I/O 复用之select 函数
程序员文章站
2022-03-12 11:26:54
...
select 函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。感兴趣的描述符不局限于套接字,任何描述符都可以使用 select 来测试。
这里从 select 函数的最后一个参数 timeout 开始介绍,它告知内核等待所指定描述符中的任何一个就绪可花多长时间。该参数值有以下三种可能:
(1)为空指针,表示永远等待下去,仅在有一个描述符准备好 I/O 时才返回。
(2)其中的 tv_sec 和 tv_usec 不全为 0,表示最多等待 I/O 就绪的时间。
(3)其中的 tv_sec 和 tv_usec 都为 0,表示不等待,这称为轮询。
前两种情形的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数返回。要注意的是,各种实现不一定会自动重启被中断的 select,这意味着如果在捕获信号,那么为了可移植性,必须做好处理 select 返回 EINTR 错误的准备。
尽管 POSIX 规定该参数带有 const 限定词,但有些 Linux 版本可能会修改这个结构,因此应该假设该结构在 select 返回时未被定义,因而每次调用 select 之前都应该对它重新初始化。
select 中间的三个参数 readset、writeset 和 exceptset 指定要让内核测试读、写和异常条件的描述符。如果对其中某一个条件不感兴趣,就可以把它设为空指针。事实上,当这三个指针均为空时,就得到了一个比 sleep 函数更为精确的定时器。
目前支持的异常条件只有两个:
(1)某个套接字的带外数据的到达。
(2)某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息。
如何给这三个参数中的每一个指定一个或多个描述符值是一个设计上的问题。select 使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符,不过所有这些实现细节都与应用程序无关,它们隐藏在名为 fd_set 的数据类型和 FD_ZERO、FD_SET、FD_CLR 和 FD_ISSET 这四个宏中。可以使用这四个宏设置或测试描述符集合中的每一位,也可以使用赋值语句把它赋值成另一个描述符集。
描述符集的初始化非常重要,因为作为自动变量分配的一个描述符集如果没有初始化,那么可能发生不可预期的后果。FD_ZERO 宏就可以用来初始化描述符集。
select 的第一个参数 maxfdp1 指定待测试的描述符的最大个数,它的值是待测试的最大描述符加 1,描述符 0 到 maxfdp1-1 均将被测试。存在该参数纯粹是为了效率原因,因为每个 fd_set 可以表示大量描述符(头文件 <sys/select.h> 中定义的 FD_SETSIZE 常量是数据类型 fd_set 中的描述符总数,其值通常是 1024),然而一个普通进程所用的数量却很少。内核正是通过在进程与内核之间不复制描述符集合中不必要的部分,从而不测试总是为 0 的那些位来提高效率的。
select 函数会修改 readset、writeset 和 exceptset 所指向的描述符集,因此每次重新调用该函数时都应该再次把所有描述符集内所关心的位置为 1。调用该函数时,我们指定所关心的描述符的值,该函数返回后,结果将指示哪些描述符已就绪,可以使用宏 FD_ISSET 来测试其中的描述符。描述符集内任何与未就绪描述符对应的位返回时均清成 0。
那么怎样才算达到了“就绪”条件呢?
(1)满足下列四个条件中的任何一个时,一个套接字准备好读。
a)该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记。对这样的套接字执行读操作不会阻塞并将返回一个大于 0 的值。可以使用 SO_RCVLOWAT 套接字选项设置该套接字的低水位标记。对于 TCP 和 UDP 套接字,其默认值为 1。
b)该连接的读半部关闭(也就是接收了 FIN 的 TCP 连接)。对这样的套接字的读操作不会阻塞并返回 0(也就是返回 EOF)。
c)该套接字是一个监听套接字且已完成的连接数不为 0。对这样的套接字的读操作通常不会阻塞。
d)其上有一个套接字错误待处理。对这样的套接字的读操作不会阻塞并返回 -1,同时设置 errno 为确切的错误条件。这些待处理错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。
(2)满足下列四个条件中的任何一个时,一个套接字准备好写。
a)该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记,并且或者该套接字已连接,或者该套接字不需要连接(如 UDP 套接字)。这意味着如果把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值。可以使用 SO_SNDLOWAT 套接字选项来设置该套接字的低水位标记。对于 TCP 和 UDP 套接字,其默认值为 2048。
b)该连接的写半部关闭。对这样的套接字的写操作将产生 SIGPIPE 信号。
c)使用非阻塞式 connect 的套接字已建立连接,或者 connect 失败。
d)其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回 -1,同时设置 errno 为确切的错误条件。这些待处理的错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。
(3)如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。
下表汇总了上述导致 select 返回某个套接字就绪的条件。
注意,当某个套接字上发生错误时,它将由 select 标记为既可读又可写。
下面是用 select 实现的回射服务器中客户端用来处理连接部分的代码,它可以读取用户输入并发送到服务端,也可以从服务端接收数据并显示到标准输出。它会阻塞于 select 调用,直到用户输入或套接字可读。下图展示了调用 select 所处理的各种条件。
客户的套接字的三个条件处理如下:
(1)如果对端 TCP 发送数据,那么该套接字变为可读,并且 read 返回读入数据的字节数。
(2)如果对端 TCP 发送一个 FIN(对端进程终止),那么该套接字变为可读,并且 read 返回 0(EOF)。
(3)如果对端 TCP 发送一个 RST(对端主机崩溃并重新启动),那么该套接字变为可读,并且 read 返回 -1,而 errno 中含有确切的错误码。
除 select 外,POSIX 还提供了一个 pselect 变种。
该函数相对于 select 有两个变化。
(1)pselect 使用 timespec 结构而不是 timeval 结构。
(2)pselect 增加了一个指向信号掩码的指针 sigmask 作为参数。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止信号的信号处理函数设置的全局变量,然后调用 pselect,告诉它重新设置信号掩码。
#include <sys/select.h> #include <sys/time.h> int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout); /* 返回值:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1 */ #define FD_SETSIZE 1024 void FD_ZERO(fd_set *fdset); // clear all hits in fdset, initialize 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); // is the bit for fd on in fdset ?
这里从 select 函数的最后一个参数 timeout 开始介绍,它告知内核等待所指定描述符中的任何一个就绪可花多长时间。该参数值有以下三种可能:
(1)为空指针,表示永远等待下去,仅在有一个描述符准备好 I/O 时才返回。
(2)其中的 tv_sec 和 tv_usec 不全为 0,表示最多等待 I/O 就绪的时间。
(3)其中的 tv_sec 和 tv_usec 都为 0,表示不等待,这称为轮询。
前两种情形的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数返回。要注意的是,各种实现不一定会自动重启被中断的 select,这意味着如果在捕获信号,那么为了可移植性,必须做好处理 select 返回 EINTR 错误的准备。
尽管 POSIX 规定该参数带有 const 限定词,但有些 Linux 版本可能会修改这个结构,因此应该假设该结构在 select 返回时未被定义,因而每次调用 select 之前都应该对它重新初始化。
select 中间的三个参数 readset、writeset 和 exceptset 指定要让内核测试读、写和异常条件的描述符。如果对其中某一个条件不感兴趣,就可以把它设为空指针。事实上,当这三个指针均为空时,就得到了一个比 sleep 函数更为精确的定时器。
目前支持的异常条件只有两个:
(1)某个套接字的带外数据的到达。
(2)某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息。
如何给这三个参数中的每一个指定一个或多个描述符值是一个设计上的问题。select 使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符,不过所有这些实现细节都与应用程序无关,它们隐藏在名为 fd_set 的数据类型和 FD_ZERO、FD_SET、FD_CLR 和 FD_ISSET 这四个宏中。可以使用这四个宏设置或测试描述符集合中的每一位,也可以使用赋值语句把它赋值成另一个描述符集。
描述符集的初始化非常重要,因为作为自动变量分配的一个描述符集如果没有初始化,那么可能发生不可预期的后果。FD_ZERO 宏就可以用来初始化描述符集。
select 的第一个参数 maxfdp1 指定待测试的描述符的最大个数,它的值是待测试的最大描述符加 1,描述符 0 到 maxfdp1-1 均将被测试。存在该参数纯粹是为了效率原因,因为每个 fd_set 可以表示大量描述符(头文件 <sys/select.h> 中定义的 FD_SETSIZE 常量是数据类型 fd_set 中的描述符总数,其值通常是 1024),然而一个普通进程所用的数量却很少。内核正是通过在进程与内核之间不复制描述符集合中不必要的部分,从而不测试总是为 0 的那些位来提高效率的。
select 函数会修改 readset、writeset 和 exceptset 所指向的描述符集,因此每次重新调用该函数时都应该再次把所有描述符集内所关心的位置为 1。调用该函数时,我们指定所关心的描述符的值,该函数返回后,结果将指示哪些描述符已就绪,可以使用宏 FD_ISSET 来测试其中的描述符。描述符集内任何与未就绪描述符对应的位返回时均清成 0。
那么怎样才算达到了“就绪”条件呢?
(1)满足下列四个条件中的任何一个时,一个套接字准备好读。
a)该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记。对这样的套接字执行读操作不会阻塞并将返回一个大于 0 的值。可以使用 SO_RCVLOWAT 套接字选项设置该套接字的低水位标记。对于 TCP 和 UDP 套接字,其默认值为 1。
b)该连接的读半部关闭(也就是接收了 FIN 的 TCP 连接)。对这样的套接字的读操作不会阻塞并返回 0(也就是返回 EOF)。
c)该套接字是一个监听套接字且已完成的连接数不为 0。对这样的套接字的读操作通常不会阻塞。
d)其上有一个套接字错误待处理。对这样的套接字的读操作不会阻塞并返回 -1,同时设置 errno 为确切的错误条件。这些待处理错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。
(2)满足下列四个条件中的任何一个时,一个套接字准备好写。
a)该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记,并且或者该套接字已连接,或者该套接字不需要连接(如 UDP 套接字)。这意味着如果把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值。可以使用 SO_SNDLOWAT 套接字选项来设置该套接字的低水位标记。对于 TCP 和 UDP 套接字,其默认值为 2048。
b)该连接的写半部关闭。对这样的套接字的写操作将产生 SIGPIPE 信号。
c)使用非阻塞式 connect 的套接字已建立连接,或者 connect 失败。
d)其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回 -1,同时设置 errno 为确切的错误条件。这些待处理的错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。
(3)如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。
下表汇总了上述导致 select 返回某个套接字就绪的条件。
注意,当某个套接字上发生错误时,它将由 select 标记为既可读又可写。
下面是用 select 实现的回射服务器中客户端用来处理连接部分的代码,它可以读取用户输入并发送到服务端,也可以从服务端接收数据并显示到标准输出。它会阻塞于 select 调用,直到用户输入或套接字可读。下图展示了调用 select 所处理的各种条件。
客户的套接字的三个条件处理如下:
(1)如果对端 TCP 发送数据,那么该套接字变为可读,并且 read 返回读入数据的字节数。
(2)如果对端 TCP 发送一个 FIN(对端进程终止),那么该套接字变为可读,并且 read 返回 0(EOF)。
(3)如果对端 TCP 发送一个 RST(对端主机崩溃并重新启动),那么该套接字变为可读,并且 read 返回 -1,而 errno 中含有确切的错误码。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/select.h> #include <sys/socket.h> #define MAX(n1, n2) ((n1)>(n2) ? (n1):(n2)) #define MAXLINE 4093 void str_cli(FILE *fp, int sockfd){ int n; char buf[MAXLINE]; fd_set readset; FD_ZERO(&readset); int stdineof = 0; int infd = fileno(fp); int maxfdp1 = MAX(infd, sockfd) + 1; for(;;){ if(stdineof == 0) FD_SET(infd, &readset); FD_SET(sockfd, &readset); select(maxfdp1, &readset, NULL, NULL, NULL); if(FD_ISSET(sockfd, &readset)){ // socket is readable if((n=read(sockfd, buf, MAXLINE)) <= 0){ if(n == 0 && stdineof == 1) return; // normal termination else{ printf("str_cli: server terminated prematurely\n"); return; } } write(STDOUT_FILENO, buf, n); } if(FD_ISSET(infd, &readset)){ // input is readable if((n=read(infd, buf, MAXLINE)) <= 0){ stdineof = 1; shutdown(sockfd, SHUT_WR); // send FIN FD_CLR(infd, &readset); continue; } write(sockfd, buf, strlen(buf)); } } }
除 select 外,POSIX 还提供了一个 pselect 变种。
#include <sys/select.h> #include <time.h> #include <signal.h> int pselect(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timespec *timeout, const sigset_t *sigmask); /* 返回值:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1 */
该函数相对于 select 有两个变化。
(1)pselect 使用 timespec 结构而不是 timeval 结构。
(2)pselect 增加了一个指向信号掩码的指针 sigmask 作为参数。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止信号的信号处理函数设置的全局变量,然后调用 pselect,告诉它重新设置信号掩码。