select函数允许进程指示内核等待多个事件中的任何一个发生,并仅在一个或多个事件发生或者经历一段指定的事件后唤醒它。而内核是根据描述字的状态来判断事件的发生的,也就是说,我们调用select告知内核对那些描述字(就读、写或异常条件)感兴趣以及等待多长时间。一般select函数多用于网络编程中,但其实我们感兴趣的套接字不局限于套接字描述字;任何描述字都可以使用select来测试。

1. 函数介绍

select函数原型如下:

#include <sys/select.h>

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

下面对其参数进行介绍,为了更方便描述因果关系,我们从最后一个参数开始介绍。

1.1 第5个参数

最后一个参数是一个timeval结构体:

struct timeval
{
    long    tv_sec;     /* seconds */
    long    tv_usec;    /* microseconds */
}

这个参数指定一段时间,它告知内核等待所指定的描述字中的任何一个就绪的最大等待时间。该参数有以下几种可能:

  • 永远等待下去:仅在有一个描述字准备好I/O时才返回。此时,我们把该参数设置为空指针。
  • 等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。如果超时,返回0.
  • 不等待:检查描述字后立刻返回,这称为轮询(polling)。此时,timeval结构体重的秒数和微秒数都必须设置为0.

1.2 第2、3、4个参数

中间3个参数readfds、writefds、errorfds分别指定我们要让内核测试读、写、异常条件的描述字。目前支持的异常条件只有两个:

  • 某个套接口的带外数据到达。
  • 某个已置为分组方式的伪终端存在可从其主端读取的控制状态信息。

那么如何给一个描述字指定这几个参数值呢?select函数使用描述字集,用fd_set结构体表示。该结构体一般为一个整形数组,其中每个整数的每一位对应一个描述字。比如,假设使用32位整数,那么该数组中的第一个元素对应于描述字0~31,第二个元素对应于描述字32~63,以此类推。当然我们不用去关注这些细节,因为系统提供了四个宏用于对fd_set结构体进行操作:

void FD_CLR(int fd, fd_set *fdset);     /* 清除位(bit)设置 */
int FD_ISSET(int fd, fd_set *fdset);    /* 测试位设置 */
void FD_SET(int fd, fd_set *fdset);     /* 设置位 */
void FD_ZERO(fd_set *fdset);            /* 初始化描述字集 */

假如我们此刻有一个监听套接字fd,我们要打开该描述字读集合中的位(bit),可用如下代码实现:

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

fd_set  rmask;
FD_ZERO(&rmask);
FD_SET(fd, &rmask);
...
selres = select(fd+1, &rmask, NULL, NULL, &timeout);
...

如果我们对某个条件不感兴趣,就将其设置为NULL,比如上面的代码中,我们不关心fd的写和异常,就将其设置为了NULL。

1.3 第1个参数

第一个参数为nfds,它指定了待测试的描述字个数(不是最大值),它的值是待测试的最大描述字加1(这里加1的原因是因为描述字是非负整数,是从0开始的,而nfds表示的是个数,所以需要加1,比如我们要检查{1,4,5}这个描述字集,那我们的nfds就要设置为6)。描述字0、1、2、3、...、nfds-1都将被测试。

这里可能会让人容易产生两个个疑问:

  1. 为什么系统使用的0(stdin)、1(stdout)、2(stderr)也会被测试?如果是在网络编程中,那这三个描述字肯定不需要去被检测。但是select函数并不一定全用在网络编程的场景中,在一些输入输出流的场景中,这三个系统预留使用的描述字也是需要被检测的。
  2. 这里nfds虽然含义是表示描述字的个数,而不是最大值,但其实是使用最大值来计算的,比如我们要检查{1,4,5}这个描述字集,那我们的nfds就要设置为6。此时select就会去检查0~5这六个描述字,但其实我们却只需要去检测1、4、5.那为什么会这样呢?首先,一个进程中可能会有好多描述字,如果我们准确的将需要被检测的描述字一一作为参数的话,那select的参数就会很多(虽然原型可以采用变长来实现,但是具体使用的时候还是很麻烦),所以干脆就设置一个要检测的上限。虽然select也会去检测我们不感兴趣的,比如上面假设中的0、2、3.但因为它们的描述字集并没有设置,所以也不影响。而这里要设置一个上限也纯粹是基于效率的考虑:每个fd_set都有表示大量描述字(典型的为1024)的空间,然而一个普通的进程所用的描述字数量一般远小于这个值。内核正是通过在进程与内核之间不拷贝描述字集中不必要的部分(nfds~最大值),从而不用测试那些总为0的那些位,以此提高效率。

当然,select可检测的最大描述字个数是有上限的,一般为1024.不过,在有的实现中,没有上限或者上限可以设置。

1.4 返回值

该函数有3种返回值:

  • 就绪描述字的数目
  • 0:超时
  • -1:出错

调用该函数时,我们指定所关心的描述字的值(其实就是将描述字的某个位利用宏FD_SET置为1),函数返回时,结果指示哪些描述字已就绪。函数返回后,我们使用宏FD_ISSET来测试fd_set数据类型中的描述字。描述字集中任何与未就绪的描述字对应的位返回时被清为0.为此,每次重新调用select函数时,我们都必须再次把所有描述字集中所关心的位均置为1.

2. 描述字就绪条件

2.1 套接字准备好读

下列四个条件中的任何一个满足时,一个套接字准备好读:

  • 有数据可读:该套接字接收缓冲区中的字节数大于等于套接字接收缓冲区低潮标记的当前大小。对这样的套接字读操作将不阻塞并返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低潮标记。对于TCP和UDP套接字而言,其缺省值为1.
  • 关闭连接的读一半:该连接的读这一半关闭(也就是接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF)。
  • 给监听套接字准备好新连接:该套接字是一个监听套接字且已完成的连接数不为0.对这样的套接字的accept通常不会阻塞。
  • 待错误处理。其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并且返回-1,同时把errno设置成确切的错误条件。这些待处理的无措也可以通过制定SO_ERROR套接口选项调用getsockopt获取并清除。

2.2 套接字准备好写

下列四个条件中的任何一个满足时,一个套接字准备好写:

  • 有可用于写的空间:该套接字发送缓冲区中可用空间字节数大于等于套接字发送缓冲区低潮标记的当前大小,并且或者(i)该套接字已连接,或者(ii)该套接字不需要连接(比如UDP套接字)。这意味着我们把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(例如由传输层接受的字节数)。我们可以使用SO_SNDLOWAT套接字选项来设置该套接字的低潮标记。对于TCP和UDP套接字而言,其缺省值通常为2048.
  • 关闭连接的写一半:该连接的写这一半关闭。对这样的套接字的写操作将产生SIGPIPE信号。
  • 该套接字早先使用非阻塞式connect已建立连接,并且连接已经异步建立,或者connect已经以失败告终。
  • 待处理错误。其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并且返回-1,同时把errno设置成确切的错误条件。这些待处理的无措也可以通过制定SO_ERROR套接口选项调用getsockopt获取并清除。

2.3 套接字异常

如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。

接收和发送低潮标记的目的在于:允许应用进程控制在select返回可读或可写条件之前,有多少数据可读或者有多少空间可用于写。比如,如果我们知道除非至少存在64个字节的数据,否则我们的应用进程没有任何有效的工作可以做,那么我们可以把低潮标记设置为64,以防少于64个字节的数据准备好读时,select就唤醒我们。

任何UDP套接字只要其发送低潮标记小于等于发送缓冲区大小(缺省应该总是这种关系)就总是可写的,这是因为UDP套接字不需要连接。

3. pselect

该函数与select很像,函数原型如下:

#include <sys/select.h>

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

除了以下三种情况,select和pselect函数是一样的:

  • 超时时间的结构体不同:select是timeval结构,而pselect是timespec结构,后者可以精确到纳秒。
  • pselect有一个sigmask参数,如果该参数设置为NULL,则和select等价。
  • 函数返回时,select可能更改timeout的值,但是pselect不会。所以对于select,我们应该假设timeval结构在select返回时未被定义,因而每次调用select之前都得对它进行初始化。