高级I/O之I/O多路转接——pool、select
当从一个描述符读,然后又写到另一个描述符时,可以在下列形式的循环中使用阻塞I/O:
while ((n = read(STDIN_FILENO, buf, BUFSIZ)) > 0) if (write(STDOUT_FILENO, buf, n) != n) err_sys("write error");
这种形式的阻塞I/O到处可见。但是如果必须从两个描述符读,又将如何呢?如果仍旧使用阻塞I/O,那么就可能长时间阻塞在一个描述符上,而另一个描述符虽有很多数据却不能得到及时处理。所以为了处理这种情况显然需要另一种不同的技术。
让我们观察telnet(1)命令的结构。该程序读终端(标准输入),将所得数据写到网络连接上;同时读网络连接,将所得数据写到终端上(标准输出)。在网络连接的另一端,telnetd守护进程读用户在终端上所键入的内容,并将其送给shell,这如同用户登录在远程机器上一样。telnetd守护进程将执行用户键入命令,而产生的输出通过telnet命令回送给用户,并显示在用户终端上。图14-6显示这种工作情景。
图14-6 telnet程序概观
telnet进程有两个输入、两个输出。对于这两个输入中的任一个都不能使用阻塞read,因为我们永远不知道哪一个输入有我们需要的数据。
(参考方法一)处理这种特殊问题的一种方法是,用fork将一个进程变成两个进程,每个进程处理一条数据通路。图14-7显示了这种安排。
图14-7 使用两个进程实现telnet程序
如果使用两个进程,则可使每个进程都执行阻塞read。但是这也产生了问题:操作什么时候终止?如果子进程接收到文件结束符,telnetd守护进程使网络连接断开,那么该子进程终止,然后父进程接收到SIGCHILD信号。但是,如若父进程终止(用户在终端上键入了文件结束符),那么父进程应通知子进程停止。为此可以使用一个信号(例如SIGUSR1),但这使程序变得更加复杂。
(参考方法二)我们可以不使用两个进程,而是用一个进程中的两个线程。这避免了终止的复杂性,但却要求处理线程之间的同步,在减少复杂性方面可能会是得不偿失。
(参考方法三)另一个方法是仍旧使用一个进程执行该程序,但使用非阻塞I/O读取数据。基本方法是将两个输入描述符都设置为非阻塞的,对第一个描述符发一个read。如果该输入上有数据,则读数据并处理它;如果无数据可读,则read立即返回。然后对第二个描述符作同样的处理。在此之后,等待若干秒,然后再读第一个描述符(这里用一个无限循环)。这种形式的循环称为轮询(polling)。这种方法的不足之处是浪费CPU时间。因为大多数时间实际上是无数据可读的,但是仍花费时间不断反复执行read系统调用。在每次循环后要等多长时间再执行下一轮循环也很难确定。虽然轮询技术在支持非阻塞I/O的系统上都可使用,但是在多任务系统中应当避免使用这种方法。
(参考方法四)还有一种技术称之为异步I/O(asynchronous I/O)。其基本思想是进程告诉内核,当一个描述符已准备好可以进行I/O时,用一个信号通知它。这种技术有两个问题。第一,并非所有系统都支持这种机制(在Single UNIX Specification中这是一个可选择的设施)。系统V为此技术提供了SIGPOLL信号,但是仅当描述符引用STREAMS设备时,此信号才能工作。BSD有一个类似的信号SIGIO,但也有类似的限制,仅当描述符引用终端设备或网络时才能工作。其次,这种信号对每个进程而言只有1一个(SIGPOLL或SIGIO)。如果使该信号对两个描述符都起作用,那么在接到此信号时进程无法判别是哪一个描述符已准备好可以进行I/O。为了确定是哪一个,仍需将这两个描述符都设置为非阻塞的,并顺序试执行I/O。
(有效方法)一种比较好的技术是使用I/O多路转接(I/O multiplexing)。先构造一张有关描述符的列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时,该函数才返回。在返回时,它告诉进程哪些描述符已准备好可以进行I/O。
poll、pselect和select这三个函数使我们能够执行I/O多路转接。注意基本POSIX.1标准定义了select函数,而poll则是对该基本部分的XSI扩展。
POSIX指定,为了在程序中使用select,必须包括<sys/select.h>。但是历史上,为了在程序中使用select,还要包括另外三个头文件,而且某些实现至今还落后在标准之后。为此,要查看select手册页,弄清楚你所用的系统对它支持到何种程度。较老的系统要求在程序中包括<sys/types.h>、<sys/time.h>和<unistd.h>。
1、select和pselect函数
在所有依从POSIX的平台上,select函数使我们可以执行I/O多路转接。传向select的参数告诉内核:
- 我们所关心的描述符。
- 对于每个描述符我们所关心的状态。(是否读一个给定的描述符?是否想写一个给定的描述符?是否关心一个描述符的异常状态?)
- 愿意等待多长时间(可以永远等待,等待一个固定量时间,或完全不等待)。
从select返回时,内核告诉我们:
- 已准备好的描述符的数量。
- 对于读、写或异常这三个状态中的每一个,哪些描述符已准备好。
使用这些返回信息,就可调用相应的I/O函数(一般是read或write),并且确知该函数不会阻塞。
#include <sys/select.h> int select(int maxfdpl, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict tvptr); 返回值:准备就绪的描述符数,若超时则返回0,若出错则返回-1
先说明最后一个参数,它指定愿意等待的时间:
struct timeval { long tv_sec; /* seconds */ long tv_usec; /* and microseconds */ };
有三种情况:
tvptr==NULL
永远等待。如果捕捉到一个信号则中断此无限期等待。当所指定的描述符中的一个已准备好或捕捉到一个信号则返回。如果捕捉到一个信号,则select返回-1,errno设置为EINTR。
tvptr->tv_sec==0 && tvptr->tv_usec==0
完全不等待。测试所有指定的描述符并立即返回。这是得到多个描述符的状态而不阻塞select函数的轮询方法。
tvptr->tv_sec!=0 || tvptr->tv_usec!=0
等待指定的秒数和微秒数。当指定的描述符之一已准备好,或当指定的时间值已经超过时立即返回。如果在超时时还没有一个描述符准备好,则返回值是0(如果系统不提供微秒分辨率,则tvptr->tv_usec值取整到最近的支持值)。与第一种情况一样,这种等待可被捕捉到的信号中断。
POSIX.1允许在实现中修改timeval结构中的值,所以在select返回后,你不能指望该结构仍旧保持调用select之前它所包含的值。
中间的三个参数readfds、writefds和exceptfds是指向描述符集的指针。这三个描述符集说明了我们关心的可读、可写或处于异常条件的各个描述符。每个描述符集存放在一个fd_set数据类型中。这种数据类型为每一可能的描述符保持了一位,其实现可如图14-8中所示。
图14-8 对select指定读、写和异常条件描述符
对fd_set数据类型可以进行的处理是:分配一个这种类型的变量;将这种类型的一个变量值赋予同类型的另一个变量;或对于这种类型的变量使用下列四个函数中的一个。
#include <sys/select.h> int FD_ISSET(int fd, fd_set *fdset); 返回值:若fd在描述符集中则返回非0值,否则返回0 void FD_CLR(int fd, fd_set *fdset); void FD_SET(int fd, fd_set *fdset); void FD_ZERO(fd_set *fdset);
这些接口可实现为宏或函数。调用FD_ZERO将一个指定的fd_set变量的所有位设置为0。调用FD_SET设置一个fd_set变量的指定位。调用FD_CLR则将一指定位清除。最后,调用FD_ISSET测试一指定位是否设置。
声明了一个描述符集后,必须用FD_ZERO清除其所有位,然后在其中设置我们关心的各个位。这种操作序列如下所示:
fd_set rset; int fd; FD_ZERO(&rset); FD_SET(fd, &rset); FD_SET(STDIN_FILENO, &rset);
从select返回时,用FD_ISSET测试该集中的一个给定位是否仍旧设置:
if (FD_ISSET(fd, &rset)) { ... }
select的中间三个参数(指向描述符集的指针)中的任意一个或全部都可以是空指针,这表示对相应状态并不关心。如果所有三个指针都是空指针,则select提供了较sleep更精确的计时器。(回忆http://www.cnblogs.com/nufangrensheng/p/3517365.html中,sleep等待整数秒,而对于select,其等待的时间可以小于1s;其实际分辨率取决于系统时钟。)
select的第一个参数maxfdpl的意思是“最大描述符加1”。在三个描述符集中找出最大描述符编号值,然后加1,这就是第一个参数值。也可将第一个参数设置为FD_SETSIZE,这是<sys/select.h>中的一个常量,它说明了最大的描述符数(经常是1024)。但是对大多数应用程序而言,此值太大了,多数应用程序只使用3-10个描述符。(某些应用程序使用更多的描述符,但这种UNIX程序并不具代表性。) 如果将第一个参数设置为我们所关注的最大描述符编号值加1,内核就只需在此范围内寻址打开的位,而不必在三个描述符集中的百位内搜索。
例如,若编写如下代码:
fd_set readset, writeset; FD_ZERO(&readset); FD_ZERO(&writeset); FD_SET(0, &readset); FD_SET(3, &readset); FD_SET(1, &writeset); FD_SET(2, &writeset); select(4, &readset, &writeset, NULL, NULL);
那么,下图显示了这两个描述符集的情况。
因为描述符编号从0开始,所以要在最大描述符编号值上加1。第一个参数实际上是要检查的描述符数(从描述符0开始)。
select有三个可能的返回值。
(1)返回值-1表示出错。出错是有可能的,例如在指定的描述符都没有准备好时捕捉到一个信号。在此种情况下,将不修改其中任何描述符集。
(2)返回值0表示没有描述符准备好。若指定的描述符都没有准备好,而且指定的时间已经超过,则发生这种情况。此时,所有描述符集皆被清0。
(3)正返回值表示已经准备好的描述符数,该值是三个描述符集中已准备好的描述符数之和,所以如果同一描述符已准备好读和写,那么在返回值中将其记为2。在这种情况下,三个描述符集仍旧打开的位对应于已准备好的描述符。
对于“准备好”的意思要作一些更具体的说明:
- 若对读集(readfds)中的一个描述符的read操作将不会阻塞,则此描述符是准备好的。
- 若对写集(writefds)中的一个描述符的write操作将不会阻塞,则此描述符是准备好的。
- 若异常状态集(exceptfds)中的一个描述符有一个未决异常状态,则此描述符是准备好的。
现在,异常状态包括(a)在网络连接上到达的带外数据(http://blog.chinaunix.net/uid-27164517-id-3275870.html),或者(b)在处于数据包模式的伪终端上发生了某些状态。
- 对于读、写和异常状态,普通文件描述符总是返回准备好。
应当理解,一个描述符阻塞与否并不影响select是否阻塞。也就是说,如果希望读一个非阻塞描述符,并且以超时值为5s调用select,则select最多阻塞5s。相类似地,如果指定一个无限超时值,则在该描述符数据准备好或捕捉到一个信号之前,select一直阻塞。
如果在一个描述符上碰到了文件结尾处,则select认为该描述符是可读的。然后调用read,它返回0,这是UNIX系统指示到达文件结尾处的方法。(很多人错误地认为,当到达文件结尾处时,select会指示一个异常状态。)
POSIX.1也定义了一个select的变体,它被称为pselect。
#include <sys/select.h> int pselect(int maxffdpl, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, const struct timespec *restrict tsptr, const sigset_t *restrict sigmask); 返回值:准备就绪的描述符数,若超时则返回0,若出错则返回-1
除下列几点外,pselect与select相同:
select的超时值用timeval结构指定,但pselect使用timespec结构。(回忆http://www.cnblogs.com/nufangrensheng/p/3521654.html中timespec结构的定义。) timespec结构以秒和纳秒表示超时值,而非秒和微秒。如果平台支持这样精细的粒度,那么timespec就提供了更精准的超时时间。
pselect的超时值被声明为const,这保证了调用pselect不会改变此值。
对于pselect可使用一可选择的信号屏蔽字。若sigmask为空,那么在与信号有关的方面,pselect的运行状况和select相同。否则,sigmask指向一信号屏蔽字,在调用pselect时,以原子操作的方式安装该信号屏蔽字。在返回时回复以前的信号屏蔽字。
2、poll函数
poll函数类似于select,但是其程序员接口则有所不同。我们将会看到,虽然poll函数可用于任何类型的文件描述符,但它起源于系统V,所以poll与STREAMS系统仅仅相关。
#include <poll.h> int poll(struct pollfd fdarray[], nfds_t nfds, int timeout); 返回值:准备就绪的描述符数,若超时则返回0,若出错则返回-1
与select不同,poll不是为每个状态(可读性、可写性和异常状态)构造一个描述符集,而是构造一个pollfd结构数组,每个数组元素指定一个描述符编号以及其所关心的状态。
struct pollfd { int fd; /* file descriptor to check, or <0 to ignore */ short events; /* events of interest on fd */ short revents; /* events that occurred on fd */ };
fdarray数组中的元素数由nfds说明。
应将每个数组元素的events成员设置为表14-6中所示的值。通过这些值告诉内核我们对该描述符关心的是什么。返回时,内核设置revents成员,以说明对于该描述符已经发生了什么事件。(注意,poll没有更改events成员,这与select不同,select修改其参数以指示哪一个描述符已准备好了。)
表14-6 poll的events和revents标志
表14-6中头四行测试可读性,接着三行测试可写性,最后三行则是测试异常状态。最后三行是由内核在返回时设置的。即使在events字段中没有指定这三个值,如果相应条件发生,则在revents中也会返回它们。
当一个描述符被挂断(POLLHUP)后,就不能在写向该描述符。但是仍可能从该描述符读取到数据。
poll的最后一个参数说明我们愿意等待多少时间。如同select一样,有三种不同的情形:
timeout == –1 永远等待。(某些系统在<stropts.h>中定义了常量INFTIM,其值通常是-1。)当所指定的描述符中的一个已准备好,或捕捉到一个信号时则返回。如果捕捉到一个信号,则poll返回-1,errno设置为EINTR。
timeout == 0 不等待。测试所有描述符并立即返回。这是得到很多个描述符的状态而不阻塞poll函数的轮询方法。
timeout > 0 等待timeout毫秒。当指定的描述符之一已经准备好,或指定的时间值已超过时立即返回。如果已超过但是还没有一个描述符准备好,则返回值是0.(如果系统不提供毫秒分辨率,则timeout值取整到最近的支持值。)
应当理解文件结束与挂断之间的区别。如果正从终端输入数据,并键入文件结束字符,POLLIN被打开,于是就可读文件结束指示(read返回0)。POLLHUP在revents中没有打开。如果正在读调制解调器,并且电话已挂断,则在revents中将接到POLLHUP通知。
与select一样,不论一个描述符是否阻塞,都不影响poll是否阻塞。
select和poll的可中断性
中断的系统调用的自动再启动是由4.2BSD引进的(见http://www.cnblogs.com/nufangrensheng/p/3515035.html) ,但当时select函数是不再启动的。这种特性在大多数系统中一直延续了下来,即使指定了SA_RESTART也是如此。但是,在SVR4之下,如果指定了SA_RESTART,那么select和poll也是自动再启动的。为了在将软件移植到SVR4派生的系统上时防止这一点,如果信号可能终端对select或poll的调用,则总是使用signal_intr函数(见http://www.cnblogs.com/nufangrensheng/p/3515945.html中的程序清单10-13)。
本篇博文内容摘自《UNIX环境高级编程》(第二版),仅作个人学习记录所用。关于本书可参考:http://www.apuebook.com/。