I/O多路复用的实现机制 - select 用法总结

一、I/O 多路复用基本概念

I/O多路复用,I/O一般指的是网络I/O,多路指的是多个连接(或者多个Channel),复用指的是一个或者少量进程/线程数。串起来理解就是多个网络I/O复用一个或少量的进程/线程来处理这些I/O事件。

因此,IO多用复用机制就是单个进程/线程通过记录跟踪每一个I/O描述符的状态来同时管理多个I/O流。

I/O多路复用的实现原理:先构造一张有关描述符的表,每一个描述符对应着一个I/O事件,然后进程将这些I/O事件告知内核,使得内核一旦发现进程指定的这些描述符中的一个或多个I/O事件就绪(一般而言是读就绪或者写就绪),内核就通知该进程进行相应的读写操作。简单来说就是,单个进程/线程,通过记录跟踪每个I/O描述符的状态,来同时管理多个I/O事件。

目前支持I/O多路复用的系统调用有select, pselect, poll, epoll, 但他们本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

二、I/O多路复用使用场景

(1)当客户处理多个描述符时(一般是交互式输入和网络套接字),必须使用I/O复用。

(2)当一个客户端同时处理多个套接字时,而这种情况是可能的,但很少出现。

(3)如果一个TCP服务端既要处理监听套接字,又要处理已连接套接字,一般也要用到I/O复用。

(4)如果一个服务端即要处理TCP,又要处理UDP,一般要使用I/O复用。

(5)如果一个服务端要处理多个服务或多个协议,一般要使用I/O复用。

与多进程/多线程技术相比,I/O多路复用技术的最大优势时系统开销小,系统不必为每一个I/O事件创建一个进程/线程,这就减少了不必要的进程/线程上下文切换的系统开销,也避免了因维护这些进程/线程带来的系统开销。

三、select() 系统调用

select 系统调用的用途是:在一段指定的时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。

例如,进程调用select函数,告知内核在下列情况发生时才返回:

  • 集合{1, 4, 5} 中的任何描述符准备好读;
  • 集合{2, 7} 中的任何描述符准备好写;
  • 集合{1, 4} 中的任何描述符有异常条件待处理;
  • 已经历了10.2秒。

也就是说,进程调用select系统调用告知内核对哪些描述符(就读、写或异常条件)感兴趣以及等待多长时间。内核监控的描述符不局限于套接字,任何描述符都可以使用select来测试。

  • select 系统调用的原型声明
//使用:man 2 select,查看select函数的使用帮助信息(CentOS-7.6)
/* According to POSIX.1-2001 */
#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout
);

void FD_CLR(int fd, fd_set *set);
int  FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

#include <sys/select.h>

int pselect(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
const struct timespec *timeout,
const sigset_t *sigmask
);

【参数说明】

1)第1个参数nfds,指定待测试描述符的个数,它的值是待测试的最大描述符加1,描述符0,1,2...一直到(nfds-1)均将被测试。

 在头文件<sys/select.h>中定义的FD_SETSIZE符号常数是结构体类型fd_set中的描述符总数,其值通常是1024,不过很少有程序用到那么多的描述符。nfds参数迫使我们计算出所关心的最大文件描述符并告知内核该值。

/* The fd_set member is required to be an array of longs.  */
typedef long int __fd_mask;

/* fd_set for select and pselect.  */
typedef struct
{
/* XPG4.2 requires this member name.  Otherwise avoid the name
   from the global namespace.  */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
//可以看到,fd_set是一个结构体类型,它的成员是一个长整型数组。

/* Maximum number of file descriptors in `fd_set'.  */
#define FD_SETSIZE      __FD_SETSIZE


//在 <bits/types.h> 头文件中
#define __FD_SETSIZE 1024

#undef __NFDBITS
#define __NFDBITS (8 * sizeof(unsigned long))

//由此可知,__NFDBITS为8*4字节=32bit,__FD_SETSIZE为1024,那么fds_bits数组大小为1024/32=32
//因此,fd_set实际上是长度为32的长整型数组,一共有1024bit。

 由以上定义可知,fd_set 结构体仅包含一个长整型数组,该数组的每个元素的每一位(bit) 标记一个文件描述符。fd_set 能容纳的文件描述符数量由 FD_SETSIZE 指定,这就限制了select能同时监听的文件描述符的总量1024。

以前面给出的打开描述符1、4和5的代码为例,其nfds值就是6,而不是5,原因在于:nfds参数指定的是描述符的个数而非描述符的最大值,而描述符是从0开始的。即{0,1,2,3,4,5}这个描述符集合中,最大描述符的值是5,而描述符的个数却是6,所以nfds参数值应是6。

<说明> 存在这个参数以及计算其值的额外负担纯粹是为了效率原因,每个fd_set都有表示大量描述符(典型数量是1024)的空间,然而一个普通进程所用到的描述符数量却少得多。内核正是通过在进程与内核之间不复制描述符集合中不必要的部分,从而不测试总为0的那些位来提高效率(TCPv2的16.13节)。

2)中间的3个参数:readfds、writefds和exceptfds,都是(fd_set *)结构体指针类型,指定进程要让内核测试读、写和异常条件的描述符。

select使用描述符集,通常是一个整型数组,其中每个数组元素中的每一位对应一个文件描述符,即通过位图的方式来存储文件描述符的。举例来说,假如使用32位整数,那么该数组的第1个元素对应的于描述符的0~31,第2个元素对应于描述符32~63,以此类推。所有这些实现细节都与应用程序无关,它们隐藏在名为fd_set结构体类型和以下四个宏中。由于位操作过于繁琐,我们应该使用下面的四个宏来访问fd_set结构体中的位:

void FD_CLR(int fd, fd_set *set);    //清除set中相关fd的位,即将其置为0
int  FD_ISSET(int fd, fd_set *set);  //测试set中相关fd是否存在,即该位是否被置1
void FD_SET(int fd, fd_set *set);    //设置set中相关fd的位,即将其置1
void FD_ZERO(fd_set *set);           //清空set中所有的位,即全置为0

 <说明> 置1表示将一个给定的文件描述符加入到set描述符集合中;置0表示将一个给定的文件描述符从set描述符集合中删除。

使用举例,定义一个fd_set结构体类型的变量rset,然后打开描述符1、4、5的对应位,代码如下:

fd_set rset;

FD_ZERO(&rset);    //初始化rset,所有位置0
FD_SET(1, &rset);  //将描述符1对应的位置1
FD_SET(4, &rset);  //将描述符4对应的位置1
FD_SET(5, &rset);  //将描述符5对应的位置1

<Warnning> 描述符集变量的初始化非常重要,因为作为自动变量分配的一个描述符集变量如果没有初始化,那么可能发生不可预测的后果。

select函数中间的这3个参数,如果我们对某一个的条件不感兴趣,就可以将其设置为空指针(NULL)。

 (3)最后一个参数timeout,它告知内核等待所指定描述符中的任何一个准备就绪的超时时间。使用的是stuct timeval时间结构体,用于指定超时时间的秒数和微秒数。该结构体类型定义如下:

#include <sys/time.h>
struct timeval {
	long	tv_sec;   /* 秒(seconds) */
	long    tv_usec;  /* 微秒(microseconds) */
};

这个timeout参数有以下三种可能:

<1> 永远等待下去:仅在有一个文件描述符准备好I/O时才返回。为此,我们把该参数设置为空指针NULL。

<2> 等待一段固定时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。

<3> 根本不等待:检查被监控的描述符后函数立即返回,需要将select放在循环结构中,这称为轮询(polling)。为此,该参数必须指向一个timeval结构,而且其中的定时器值(由该结构指定的秒数和微秒数)必须为0。

struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 0;

【扩展】如果将select函数的中间3个参数readfds、writefds和exceptfds均置为空指针,我们就有了一个比sleep()函数更为精确的定时器(sleep睡眠以秒为最小单位)。

【返回值】

成功,返回已就绪描述符的数目;超时,返回值为0;失败,返回值为-1,并设置相应的错误码给errno全局变量。关于返回值为0的情况,如果在任何描述符就绪之前定时器到时,那么返回0,同时未就绪的描述符会被重新置为0。

【注意事项】

1)调用select函数时,我们指定所关心的描述符的值,该函数返回时,结果将指示哪些描述符已就绪。该函数返回后,我们使用FD_ISSET宏来测试set描述符集中的描述符。

2)描述符集内任何与未就绪描述符对应的位在select函数返回时均会被清成0。为此,我们每次重新调用select函数时,都得再次把所有描述符内所关心的描述符位置为1。

3)使用select时最常见的两个编程错误是:忘了对最大描述符加1;忘了描述符集是值-结果参数。第2个错误导致调用select函数时,描述符集内我们认为是1的位却被置为0了。

四、文件描述符就绪条件

        哪些情况下文件描述符可以被认为是可读、可写或者发生异常,对于 select 的使用非常关键。

  • 在网络编程中,下列情况下 socket 可读
  • socket 内核接收缓冲区中的字节数大于或等于其低水位标记 SO_RCVLOWAT。此时我们可以无阻塞地读该socket,并且读操作返回的字节数大于0。
  • socket 通信对端关闭连接。此时对该 socket 的读操作将返回 0。
  • 监听 socket 上有新的连接请求。
  • socket 上有未处理的错误。此时我们可以使用 getsockopt() 函数来读取和清楚该错误。
  •  下列情况下,socket 可写
  • socket 内核发送缓冲区中的可用字节数大于或等于其低水位标记 SO_SNDLOWAT。此时我们可以无阻塞地写该socket,并且写操作返回的字节数大于0。
  • socket 的写操作被关闭。对写操作被关闭的 socket 执行写操作将触发一个 SIGPIPE 信号。
  • socket 使用非阻塞 connect 连接成功或者失败(超时)之后。也就是,将 socket 设置为非阻塞模式下,调用 connect 系统调用建立连接。
  • socket 上有未处理的错误。此时我们可以使用 getsockopt() 函数来读取和清楚该错误。

        网络编程中,select 能处理的异常情况只有一种:socket 上接收到带外数据

五、select多路复用流程图

上图是TCP网络通信服务端的select多路复用流程图。从上图可以得知,服务端有两个socket描述符,一个是listening sockt,另一个是处理accept成功的client读写的socket。当有client连接成功后,就将这个Client描述符(client_fd)添加到描述符集set中,然后服务端接收客户端的消息,并返回消息,之后关闭tcp连接,从描述符集中删除这个Client描述符。如果select函数返回超时,则关闭所有的socket描述符,结束服务端进程。

六、select 的缺陷

1、单个进程可监视的描述符数量有限。32位机器默认是1024个,64位默认是2048。即select支持的文件描述符数量太小了。
2、对描述符进行扫描的方式是线性扫描,即采用轮询的方式,效率较低。当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个描述符来完成调度,不管那个描述符是不是活跃的,都需要遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。即每次调用select都需要在内核遍历传递进来的所有描述符,这个开销在描述符很多时会比较大。
3、需要维护一个用来存放描述符集的数据结构(即fd_set结构体)。每次调用select时,都需要把描述符集合set从用户空间拷贝到内核空间,这样会使得用户空间和内核空间在传递该结构时复制开销大。

4、每次重新调用select函数时,都得再次把所有描述符内所关心的位置为1,这个是非常不友好的。

5、当有描述符就绪时,select仅仅会返回成功,但是并不会告诉你是哪个描述符准备好了,于是你只能一个个地找:在循环结构中的if语句里使用宏函数FD_ISSET进行遍历,直到找出所有满足就绪条件的描述符。

6、select函数不是线程安全的函数。如果你在线程1中将一个描述符加入到select的描述符集中,然后在线程2中,却将这个描述符给回收了,即close掉这个描述符,这会导致不可预测的后果。

七、编程实例

/**
程序描述:在标准输入读取8个字节数据。用select函数实现超时判断。
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/types.h>
int main(int argc, char ** argv)
{ 
	char buf[10] = "";
	fd_set rset; //监视读事件
	struct timeval tv; //超时时间变量
	int ret;
	FD_ZERO(&rset); //将rset结构体变量清空
	FD_SET(1, &rset); //添加stdin事件到rset集合
	tv.tv_sec = 8;    //设置定时器8秒
	tv.tv_usec = 0;
	ret = select(1 + 1, &rset, NULL, NULL, &tv); //调用select函数
	if(ret < 0) 
		perror("\nselect");
	else if(ret == 0)
		printf("\ntimeout");
	else 
	{
		printf("\nselect ret=%d", ret);
	}

	if(FD_ISSET(1, &rset))
	{
		printf("\nreading data");
		fread(buf, 1, 8, stdin); //从标准输入端读取8个字节数据
	}
	printf("\nprint:");
	fwrite(buf, 1, strlen(buf),stdout); //从标准输出端输出数据
	printf("\ndata_len = %d\n", strlen(buf));
	return 0;
}

【编译程序】gcc select_demo.c -o select_demo

【运行结果1】./select_demo
12345678      //终端输入

select ret=1
reading data
print:12345678
data_len = 8

【结果分析】可以看到,select的返回值为1,表示有一个文件描述符就绪了,即标准输入I/O描述符。

【运行结果2】./select_demo (不输入,让其超时)

timeout
print:
data_len = 0

【结果分析】可以看到,select监控的标准输入I/O描述符由于在8秒内没有任何输入,所以超时了,同时这个描述符在rset描述符集中的bit位被重置为0了,因为if(FD_ISSET(1, &rset))语句并没有被执行,说明返回的是0值。

<说明> 标准输入描述符-0;标准输出描述符-1;标准错误描述符-2。

题外话

I/O多路复用这个概念被提出来以后, select是第一个实现 的(1983 左右在BSD里面实现的)。但是由于select多路复用存在许多问题和缺陷。于是,14年之后(1997年),一帮人又实现了poll,poll修复了select的很多问题,比如,poll去掉了最大描述符数量的限制等,在下一篇博文中,会详细介绍poll的用法。

参考

 IO多路复用之select总结

 五种IO模型透彻分析

IO多路复用之select、poll、epoll

《UNIX网络编程卷1:套接字联网API(第3版)》第6.3章节

《Linux高性能服务器编程》第9章 - I/O 复用

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值