参考文献:《Unix网络编程》
一个输入操作通常包含两个阶段:
- 等待数据准备好。
- 从内核向进程复制数据。
Unix下有5种可用的I/O模型:
- 阻塞式I/O;
- 非阻塞式I/O;
- I/O复用;
- 信号驱动式I/O;
- 异步I/O(POSIX的aio_系列函数);
以下例子中,我们把recvfrom
函数视为系统调用(以区分进程和内核)。不论它如何实现(在源自Berkeley的内核上是作为系统调用,在System V内核上是作为调用系统调用getmsg
的函数),一般都会从进程空间运行切换到内核空间运行,再切换回来。
阻塞式I/O
如图,进程调用recvfrom
,该系统调用知道数据报到达且被复制到应用进程的缓冲区中,或者发生错误(如:被信号中断)才返回。
非阻塞式I/O
把一个套接字设置成非阻塞式,是在通知内核:当所请求的I/O操作非得把本金诚投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。
前三次调用recvfrom
时没有数据可返回,因此内核转而返回一个EWOULDBLOCK
错误。直到第四次调用时数据准备完成,被复制到进程缓冲区,则recvfrom
成功返回。
I/O复用模型
我们可以调用select
或poll
,阻塞在多个系统调用的某一个上,而不是阻塞在真正的具体一个I/O系统调用上。
信号驱动式I/O
信号驱动式I/O模型是要求内核在描述符就绪时发送SIGIO
信号通知使用者。
具体来说,需要先开启套接字的信号驱动式IO功能,通过sigaction
安装一个对应的信号处理函数。在本例中,我们可以考虑在信号处理函数中调用recvfrom
读取数据,也可以通知主循环来发起读取动作。
异步I/O模型
我们调用aio_read
函数,给内核传递描述符、缓冲区指针、缓冲区大小与文件偏移,并告诉内核当整个操作完成时通知我们。也就是说,我们的进程不会被阻塞,直到数据已被复制到进程缓冲区。
与之相对的是,信号驱动式IO,是由内核通知我们何时可以启动一个IO操作,而异步IO模型由内核通知我们IO操作何时完成。
各种IO模型对比
POSIX定义同步/异步如下:
- 同步I/O操作**导致请求进程阻塞,直到I/O操作完成;
- 异步I/O操作不导致请求进程阻塞。
根据定义,前4种模型,都在真正的I/O操作(recvfrom
)时阻塞进程,只有第五种与POSIX定义的异步I/O匹配。
select函数
select
函数声明如下:
int select(int maxfdp1, fd_set* readset, fd_set* writeset, fd_set* exceptset, const struct timeval* timeout);
先从最后一个参数timeval结构说起
struct timeval {
long tv_sec; // seconds
long tv_usec; // microseconds
};
该参数有三种可能:
- 设置为空指针,即仅在有一个描述符准备好I/O时才返回,不然永远等待下去。
- 可能是等待一段固定时间,在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval 结构中指定的秒数和微秒数。
- 不等待,检查描述符后立即返回,这称为轮询(polling),为此,该参数必须指向一个timeval结构,而且其中的字段都必须是0。
前2种的往往有可能进程等待期间被被捕获的信号中断,并从信号处理函数返回。不同系统对被中断的select的是否自动重启不同,因此为了可移植性考虑,如果我们在捕获信号,那么必须做好select返回EINTR错误的准备。
如果该
timeval
结构的值超过1亿秒(3年多,虽然不太可能被使用),有些系统的select
函数将以EINVAL
错误失败返回。
中间三个参数readset
、writeset
和exceptset
按字面意思就可以知道,分别表示要监视的可读、写、或异常的描述符。
如何给这三个参数的每一个指定一个或多个描述字值是一个设计上的问题。select使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述字。举例来说,假设使用32位整数,那么该数组的第一个元素对应描述字0-31,第二个元素对应描述字32-63,以此类推。具体实现隐藏在fd_set
的类型以及以下四个宏中:
void FD_ZERO(fd_set *fdset); /* clear all bits in fdset */
void FD_SET(int fd, fd_set *fdset); /*turn on the bit for fd in fdset*/
void FD_CLR(int fd, fd_set *fdset); /*turn off the bit for fd in fdset*/
int FD_ISSET(int fd, fd_set *fdset); /*test if the bit for fd is turned on in fdset*/
对于其中不感兴趣的条件,可以将相应描述符集指针设置为空指针。如果三个指针都为空,则变成一个类似sleep
的计数器。
我们讨论的每个描述符占用整数数组中的一位的策略仅仅是
select
可能的实现方式之一,但却是很常见的办法。后续可以看到poll
函数使用另一个办法,一个可变长度的结构数组,每个结构代表一个描述符。
第一个参数maxfdp1
指定待测试的描述符个数,它的值是待测试的最大描述符加1。这里有两个值得注意的点:
- 这里前半句“指定待测试的描述符个数”稍有不精确,
maxfdp1
是指描述符编号的最大值加1。例如我们需要观察1,4,5三个描述符,则maxfdp1是6(这是因为描述符从0开始计数,一共需要至少6bit来表示0-5号的描述符)。定义在<sys/select.h>
中的FD_SETSIZE
常数是fd_set
中的描述符总数,通常是1024——不过很少有程序用到这么多描述符。maxfdp1
参数迫使我们计算出所关心的最大的描述符并告知内核。 - 在上一条中有所提及,
maxfdp1
是指描述符编号的最大值加1。
由指针传入的readset
、writeset
、exceptset
都是值-结果参数。传入函数以指定我们关心的描述符,返回后可以通过FD_ISSET
来测试集合,如果该描述符未就绪,则被清为0,否则保持为1。也因此,每次重新调用select函数时,都得重新设置描述符集的各个位。
select
函数的返回值表示已就绪的描述符总个数。如果在任何描述符就绪前计时器耗尽,则返回0。另外返回-1表示出错(如被信号中断)。