翻译:雨丝风片@chinaunix.net
6.1 高级I/O和进程资源正如我们在前面章节中看到的,程序可以同时打开多个文件描述符。这些文件描述符并不一定就是文件,还可以是fifo、pipe或者socket。于是,如何复用这些打开的描述符就很重要了。例如,考虑一个简单的邮件阅读程序,比如pine。它显然应当允许用户在读写email的同时也能去检查是否有新邮件。这就意味着在任一给定时刻都至少能够接收两个来源的输入:一个来源是用户,另一个是用来检查新邮件的描述符。处理描述符的复用是个复杂的问题。一种方法是把所有打开的描述符都标记为非阻塞的(O_NONBLOCK),然后在它们之中循环,直到找到一个可以进行I/O操作的描述符为止。这种方法的问题是程序会一直在循环,如果长时间内没有I/O可用,进程就会一直占据CPU。当有多个进程在一组很少的描述符上循环时,你的CPU的负载就会恶化。
另一种方法就是设置信号处理器去捕获I/O变为可用的事件,然后就让进程进入休眠状态。如果你只打开了少量的描述符,而且并不经常请求I/O的话,这种方法从理论上看倒是不错。由于进程已经休眠,就不会再占用CPU,仅当I/O可用时它才恢复执行。然而,这种方法的问题在于信号处理的开销有点大。比如一个web服务器,每分钟收到100个请求,那就几乎一直都在捕获信号。每秒钟捕获上百个信号的开销是相当大的,不单是进程,对于内核发送信号的开销而言也是一样的。
到目前为止,我们看到的两种选择都有限制,效率也不高,它们需要解决的共同问题就是进程需要知道I/O究竟什么时候能用?然而,这个信息实际上只有内核才能事先知道,因为是内核在最终处理系统中的所有打开的描述符。例如,当一个进程通过fifo向另一个进程发送数据的时候,发送进程会调用write,这是一个系统调用,因此会进入内核。在发送方的write系统调用执行完毕之前接收方对此是一无所知的。于是就引出了一个更好的复用文件描述符的方法:由内核来替进程管理描述符。换句话说,就是把一个打开描述符的链表发送给内核,然后等待,直到内核发现某个或多个描述符已经准备好了或者已经超时了为止。
这就是select()、poll()和kqueue()接口采用的方法。通过这些接口,内核就会管理文件描述符,当I/O可用时就去唤醒进程。这些接口巧妙地处理了上述问题。进程不必再在打开的文件描述符中循环,也不必再去设置信号了。但进程在使用这些函数的时候还是会产生一点小问题。这是因为I/O操作是在从这些接口返回之后才去执行的。所以它至少需要两个系统调用才能完成其操作。例如,你的程序有两个用于读的描述符。你对它们使用select,然后等待它们直至有数据可读。这就需要进程首先调用select,在select返回之后,就对该描述符调用read。更妙的是,你还可以对所有打开的描述符执行一个整体的read。一旦其中有某个描述符准备好读之后,read就会返回,并把数据放在缓冲区中,同时还会给出一个标识,用来指示这个数据是从哪个描述符读进来的。
6.2 select
我首先要讲的接口是select()。格式如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
传给select的第一个参数已经造成了多年的混乱。nfds参数的正确用法是把它设成文件描述符的最大值加1。换句话说,如果你有一组文件描述符{0,1,8},nfds参数就应当被设置成9,因为你的描述符的最大值为8。有些人错误地以为这个参数的意思是文件描述符的总数加1,对于我们的例子而言就是4。记住,一个文件描述符只是一个整数而已,所以你的程序就需要指出你所想要在其上select的最大的描述符值。
select接下来会按顺序针对所有尚未完成的读、写以及异常条件检查其余的三个参数,readfds、writefds和exceptfds。(详细信息请参见man(2) select)。注意,如果readfds、writefds和execptfds中没有设置描述符,那么传给select的对应参数应当被设置成NULL。
readfds、writefds和execptfds参数通过以下4个宏进行设置。
FD_ZERO(&fdset);
FD_ZERO宏用来对指定的描述符集合中的bit进行清零。有一点需要特别注意:只要使用select,就应当调用这个宏;否则select的行为将是不可预知的。
FD_SET(fd, &fdset);
FD_SET宏用于向一组激活的描述符中添加一个描述符。
FD_CLR(fd, &fdset);
FD_CLR宏用于从一组激活的描述符中删除一个描述符。
FD_ISSET(fd, &fdset);
FD_ISSET宏是在select返回之后使用的,用于测试某个描述符是否已准备好进行I/O操作。
select的最后的参数是一个超时值。如果超时值被设置为NULL,则对select的调用将以不确定的方式被阻塞,直至某个操作已准备好为止。如果你需要一个确定的超时时间,那么超时值就得是一个非空的timeval结构体。timeval结构体如下:
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
如果select调用成功,将返回准备好的描述符的数目。如果select因为超时而返回,则返回值为0。如果有错误发生,则返回-1,同时会相应地设置errno。
6.3 poll
我们在这里对I/O的讨论主要是针对BSD的。System V支持一种特殊类型的I/O,即所谓的STREAMS。和socket一样,STREAMS也具有优先级属性,这种属性有时也被成为数据带。数据带可用来给STREAMS中的特定数据设置较高的优先级。BSD最初并不支持这一特性,不过有些人添加了System V仿真功能,可以对某些类型提供支持。由于我们并不关注System V,因此我们只会引用数据带或数据优先级带的概念。详细信息请参见System V STREAMS。
poll函数和select很相似:
int poll(struct pollfd *fds, unsigned int nfds, int timeout);
和原产于BSD的select不同,poll是由System V Unix创建的,在早期的BSD版本中并不支持它。目前主流BSD系统中都已经支持poll了。
和select相似,poll也是在一组给定的文件描述符上进行复用。在指定这些描述符的时候,你必须使用一个结构体数组,其中每个结构体代表一个文件描述符。和select相比,poll的好处就是你可以判断一些很罕见的条件,而select则无法做到。这些条件是POLLERR、POLLHUP和POLLNVAL,我们稍后讨论。尽管对于选择select还是poll的问题已经有了相当多的讨论,但这在很大程度上还是取决于你的个人爱好。poll所使用的结构体是pollfd结构体,如下:
struct pollfd {
int fd; /* which file descriptor to poll */
short events; /* events we are interested in */
short revents; /* events found on return */
};
int fd; /* which file descriptor to poll */
short events; /* events we are interested in */
short revents; /* events found on return */
};
fd
fd成员用于指定你想要poll的文件描述符。如果你想删除一个描述符,那就把那个描述符的fd成员设置成-1。通过这种方法,你可以避免对整个数组进行混洗,同时还可以清除revents成员中列出的所有事件。
events, revents
events成员是一个bit掩码,用于指定针对指定描述符所关心的事件。revents成员也是一个bit掩码,但它的值是由poll设置的,用于记录在指定描述符上发生的事件。这些事件的定义如下:
#define POLLIN 0x0001
POLLIN事件表明你的程序将选择该描述符上的可读数据事件。注意,此处的数据不包括高优先级数据,比如socket上的带外数据。
#define POLLPRI 0x0002
POLLPRI事件表明你的程序准备选择该描述符上的任何高优先级事件。
#define POLLOUT 0x0004
#define POLLWRNORM POLLOUT
#define POLLWRNORM POLLOUT
POLLOUT和POLLWRNOMR事件表明你的程序想知道什么时候可以对一个描述符执行写操作了。在FreeBSD和OpenBSD上这两个事件是相同的;你可以在你的系统头文件(/usr/include/poll.h)中查证这一点。从技术角度来说,它们之间的区别在于POLLWRNOMR仅当数据优先带等于0的时候才去检测是否可以进行写操作。
#define POLLRDNORM 0x0040
POLLRDNORM事件表明你的程序准备选择该描述符上的常规数据。注意,在某些系统上,这个事件指定的操作和POLLIN完全一样。但在NetBSD和FreeBSD上,这个事件和POLLIN并不相同。同样,请去查看你的系统头文件(/usr/include/poll.h)。严格地说,POLLRDNORM仅当数据优先带等于0的时候采取检测是否可以进行读操作。
#define POLLRDBAND 0x0080
POLLRDBAND事件表明你的程序想知道什么时候能够以一个非0的数据带值从该描述符读数据。
#define POLLWRBAND 0x0100
POLLWRBAND事件表明你的程序想知道什么时候能够以一个非0的数据带值向该描述符写数据。
专用于FreeBSD的选项
下面的选项是专用于FreeBSD的,知道的和使用的人都不是太多。但它们还是值得提一下,因为它们可以提供更多的灵活性。这些都是新的选项,poll并不保证能够检测这些条件,而且它们只能用于UFS文件系统。如果你的程序需要检测这些类型的事件,那最好使用kqueue接口,我们将在稍后介绍。
#define POLLEXTEND 0x0200
如果文件已经被执行,则设置POLLEXTEND事件。
#define POLLATTRIB 0x0400
如果有任一文件属性发生改变,则设置POLLATTIB事件。
#define POLLNLINK 0x0800
如果文件被重命名、删除或解除链接,则设置POLLNLINK事件。
#define POLLWRITE 0x1000
如果文件内容被修改,则设置POLLWRITE事件。
下面的事件并不是pollfd events成员的有效标志,poll也将忽略它们。它们是在pollfd revents中返回的,用于表明发生了某个事件。
#define POLLERR 0x0008
POLLERR事件表明有错误发生。
#define POLLHUP 0x0010
POLLHUP表明在对应的STREAMS上发生了挂起事件。POLLHUP和POLLOUT是互斥事件,因为一个发生了挂起的STREAMS就不再是可写的了。
#define POLLNVAL 0x0020
POLLNVAL表明对poll的请求是无效的。
poll的最后一个参数是超时值。可以通过这个参数告诉poll一个以微秒为单位的超时值。如果把超时值设置为-1,poll就会阻塞,直至所请求的事件发生为止。如果超时值设置为0,则poll将立即返回。
如果对poll的调用成功,则返回一个正整数。这个正整数的值表示有多少个描述符发生了事件。如果超时,poll将返回0。如果有错误发生,poll则会返回-1。
6.4 kqueue
到目前为止,poll和select已经是相当不错的复用文件描述符的方法了。但为了使用这两个函数,你需要创建一个描述符的链表,然后把它们发送给内核,在返回的时候又要再次查看这个链表。这看上去有点效率低下。一个更好一些的模型是把描述符链表交给内核,然后就等待。一旦有某个或多个事件发生,内核就把一个只包含有发生了事件的描述符的链表通知给进程,由此避免了每次函数返回的时候都要去遍历整个链表。尽管对于只打开了几个描述符的进程而言这点改进算不得什么,但对于那些打开了几千个文件描述符的程序来说,这种性能改进就相当显著了。这就是kqueue诞生背后的主要目的。同时,设计者还希望进程能够检测更多类型的事件,比如文件修改、文件删除、信号交付或者子进程退出,并提供一个包含了其它任务的灵活的函数调用。处理信号、复用文件描述符、以及等待子进程等操作都可以封装到这个单一的kqueue接口中,因为它们都是在等待某个事件的发生。
