0%

IO 多路复用(一) select 函数

引言

在介绍 IO 多路复用的时候,始终都会围绕 select、pselect、poll、epoll 这几个函数展开。为了对它们进行详细的说明,这里会分为几篇文章(避免篇幅过大)。

1.0 select 函数

select 是用于 I/O 多路复用的一个系统调用函数。在 C 程序中,该系统调用在 sys/select.h 或 unistd.h 中声明。它允许进程指示内核等待多个事件中的任何一个发生,当任何事件发生或者超时后它将被唤醒。

1.1 函数声明

1
2
3
4
5
6
7
8
#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.1 参数

timeout

我们先从最简单的参数入手 timeout, 该参数是一个 timeval 类型的结构体,该结构体声明如下:

1
2
3
4
5
struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
}

该参数用来指定 select 函数等待描述符就绪的最大时间,该值有三种选项:

  • 永远等待
    当设置成 NULL 的时候,该函数会一直等待关联的任一描述符就绪。
  • 等待指定时间
    当设置成具体值的时候,该函数会等待指定时间,直到超时或者描述符就绪。
  • 立即返回
    当该值设置成 0 的时候,函数会立即返回,通过轮询的方式,无阻塞地获取多个文件描述符状态。

nfds

乍一看这个参数,以为是文件描述符的数量呢,毕竟有个 n,结果惨遭打脸。查了一些资料,大体意思是当前注册的描述符最大值加 1。比如有 {1、3、5 } 三个描述符被注册,那么 nfds 的值是: 5 + 1 = 6;为什么这么设计呢?

分析(如果不对,大家可以帮我指出,谢谢):

  1. 描述符以位存储(下面会介绍)。
  2. 位数又以 0 开始(类似数组从 0 开始)
  3. 位数:7—-6—-5—-4—-3—-2—-1—-0
    开关:0—-0—-1—-0—-1—-0—-1—-0
  4. 需要遍历的描述符的索引为 5,也就是数组第 6 个元素

总结

其实 nfds 还是描述符的数量,只不过需要最大的描述符加 1,为什么加 1,其实是因为索引为 0 的缘故。数组遍历是需要知道数组元素的数量的。

在介绍接下来的参数之前,首先需要介绍另一个很重要的概念,即 fd_set。它也是一个结构体,声明如下(取自 macOS):

1
2
3
4
5
6
7
8
9
10
#define __DARWIN_FD_SETSIZE     1024
#define __DARWIN_NBBY 8 /* bits in a byte */
#define __DARWIN_NFDBITS (sizeof(__int32_t) * __DARWIN_NBBY) /* bits per mask */
#define __DARWIN_howmany(x, y) ((((x) % (y)) == 0) ? ((x) / (y)) : (((x) / (y)) + 1)) /* # y's == x bits? */

typedef int __int32_t;
typedef struct fd_set {
__int32_t
fds_bits [__DARWIN_howmany(__DARWIN_FD_SETSIZE, __DARWIN_NFDBITS)];
} fd_set;

简化之后:

1
2
3
typedef struct fd_set {
int fds_bits[32];
} fd_set;

经过简化之后,fd_set 可以理解为一个数组,这个数组中存放的是文件描述符(Unix 下任何设备、管道、FIFO 等都是文件形式,全部包括在内),而对于 fd_set,系统提供了一系列的宏进行操作,不急,稍后会对这些宏进行介绍。我们继续回到 fd_set 中来,查看它的声明,发现这个结构体只有一个字段 fds_bits,它是一个由 32 个 int 类型的数组。那它具体怎么存放呢?为了弄清这个问题,还是看看它的源码吧。

1
2
3
4
#define __DARWIN_FD_SET(n, p) do {  \
int __fd = (n); \
((p) -> fds_bits[(unsigned long)__fd /__DARWIN_NFDBITS] |= ((__int32_t)(((unsigned long)1)<<((unsigned long)__fd % __DARWIN_NFDBITS)))); \
} while(0)

老样子,我们对它进行简化:

1
2
3
4
#define FD_SET(n, fd_set)  do {                     
int fd = n;
fd_set -> fds_bits[fd / 32] |= 1 << (fd % 32);
} while(0)

分析:

  1. n 是我们想要监听的描述符,fd_set 就是那个结构体。
  2. fd_set -> fds_bits[fd/32],这个怎么理解呢?首先 fds_bits 是一个由 32 个 int 数值组成的数组,其中每个 int 占 4 个字节。fd/32 则确定 fd 这个描述符在 32 个 int 中的第几个 int 上。
  3. fd%32 确定的是 fd 在 int 的第多少位。那么就将该位置置成 1。

总结

fd_set 这个结构体在我的系统 macOS 上可以存储最多 32 * 32 = 1024 个描述符。每一个 bit 位代表一个描述符,位的编号代表具体的描述符数值。

readfds

你需要告诉内核,你想要关注对这个描述符的目标行为是什么。比如说:当这个描述符可以读取数据的时候,告诉调用者。那么这个参数就是用来告诉内核,我只关心读操作。那么问题来了,什么时候会出发读就绪呢?

触发条件

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

    writefds

    描述符的写操作就绪。

    触发条件

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

errorfds

如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。
接收和发送低潮标记的目的在于:允许应用进程控制在 select 返回可读或可写条件之前,有多少数据可读或者有多少空间可用于写。比如,如果我们知道除非至少存在 64 个字节的数据,否则我们的应用进程没有任何有效的工作可以做,那么我们可以把低潮标记设置为 64,以防少于 64 个字节的数据准备好读时,select 就唤醒我们。
任何 UDP 套接字只要其发送低潮标记小于等于发送缓冲区大小(缺省应该总是这种关系)就总是可写的,这是因为 UDP 套接字不需要连接。

1.1.2 返回值

该函数有三种返回值,分别是:

  • 0
    当函数等待超时的时候返回。
  • -1
    当函数发生错误的时候返回。
  • n
    n 为已经就绪的描述符的数量。

    1.2 宏

    为了便于操作 fd_set,系统提供相应的宏。
  • FD_ZERO(&fd_set)
    使用 fd_set 之前,必须对它进行初始化,该宏的作用就是将 fd_set 所有位清零。
  • FD_SET(n, &fd_set)
    将 n 对应的描述符添加到描述符集 fd_set 中。换句话说,注册你感兴趣的描述符到内核。
  • FD_CLR(n, &fd_set)
    将 n 对应的描述符从描述符集中移除。
  • FD_ISSET(n, &fd_set)
    判断指定的描述符 n 有没有就绪。

    1.3 注意

  • 第一个参数 nfds 是最大文件描述符 +1
  • 函数返回后,相关的未就绪描述符需要重新设置
  • 函数返回后,超时时间也需要重新设置
  • 函数可以检测远程连接断开
    如:读描述符 socket 断开时,这个 socket 变为一直可读,但是读到的是文件结尾 EOF。以此用来判断远程连接是否断开。
  • 函数阻塞时,会被信号中断
  • select 与 stdio 混合使用带来的有关缓冲区问题

参考