{T}

I/O多路复用:select机制

什么是 I/O 多路复用(I/O Multiplexing)

第11讲 中,我们设计了一个应用程序:该程序从标准输入接收数据,通过套接字发送出去,同时通过套接字接收对端发送的数据流。

若使用 fgets 等待标准输入,则无法在套接字有数据时及时读取;若使用 read 等待套接字数据,则无法在标准输入有数据时及时处理。这种互斥等待的问题,正是 I/O 多路复用技术所要解决的。

I/O 多路复用的核心思想是:将标准输入、套接字等视为独立的 I/O 通道(路),当任何一路 I/O 发生事件时,由内核通知应用程序进行相应处理,从而使单个线程能够同时监控多个 I/O 事件。

select 函数是最早出现的 I/O 多路复用技术之一。应用程序调用 select 后,内核挂起当前进程,当一个或多个 I/O 事件就绪时,控制权返还给应用程序,由应用程序完成 I/O 操作。

I/O 事件的典型类型包括:

  • 标准输入文件描述符准备好可以读
  • 监听套接字上有新的连接已完成建立
  • 已连接套接字准备好可以写
  • I/O 等待超过指定时间,发生超时事件

select 函数接口

函数原型

c
int select(int maxfd, fd_set *readset, fd_set *writeset,
           fd_set *exceptset, const struct timeval *timeout);
/* 返回值:就绪描述符数目;超时返回0;出错返回-1 */

参数说明

maxfd:待测试的描述符基数(descriptor cardinality),其值为待测试的最大描述符编号加 1。例如,待测试的描述符集合为 {0, 1, 4},则 maxfd 为 5。原因在于内核需要遍历从 0 到 maxfd - 1 的所有描述符位,因此必须告知内核检测范围的上界。

readset / writeset / exceptset:三个描述符集合,分别告知内核在哪些描述符上检测可读、可写和异常事件。

fd_set 操作宏

c
void FD_ZERO(fd_set *fdset);      /* 将集合所有位清零 */
void FD_SET(int fd, fd_set *fdset);   /* 将 fd 对应位置1 */
void FD_CLR(int fd, fd_set *fdset);   /* 将 fd 对应位清零 */
int  FD_ISSET(int fd, fd_set *fdset); /* 检测 fd 对应位是否为1 */

fd_set 位图表示

fd_set 本质上是一个位图(bitmap),每一位对应一个文件描述符。值为 1 表示需要检测该描述符,值为 0 表示不需要。

图表渲染中…

实际实现中,fd_set 通常由整型数组表示。一个 32 位整型可表示 32 个描述符,例如第一个整型表示描述符 0-31,第二个表示 32-63,以此类推。这也是 FD_SETSIZE 限制的来源——在大多数 Linux 系统中,FD_SETSIZE 被编译时固定为 1024,意味着 select 最多只能监控 1024 个文件描述符。

内核版本注记FD_SETSIZE 在 Linux 中定义为 1024(<linux/posix_types.h>),此值自内核 2.0 以来未发生变更,且无法在运行时修改,只能在编译时通过重新定义 FD_SETSIZE 宏来调整,但不推荐此做法,应优先使用 poll 或 epoll。

timeout 参数

c
struct timeval {
    long tv_sec;   /* 秒 */
    long tv_usec;  /* 微秒 */
};

timeout 的三种设置方式:

设置方式行为
NULL无 I/O 事件时永久阻塞
非零值等待指定时间后返回(见 第12讲 超时示例)
tv_sec = tv_usec = 0立即返回,非阻塞轮询

select 工作流程

图表渲染中…

程序示例

以下示例演示 select 同时监控标准输入和套接字描述符:

c
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: select01 <IPaddress>");
    }
    int socket_fd = tcp_client(argv[1], SERV_PORT);
 
    char recv_line[MAXLINE], send_line[MAXLINE];
    int n;
 
    fd_set readmask;
    fd_set allreads;
    FD_ZERO(&allreads);
    FD_SET(0, &allreads);
    FD_SET(socket_fd, &allreads);
 
    for (;;) {
        readmask = allreads;
        int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
 
        if (rc <= 0) {
            error(1, errno, "select failed");
        }
 
        if (FD_ISSET(socket_fd, &readmask)) {
            n = read(socket_fd, recv_line, MAXLINE);
            if (n < 0) {
                error(1, errno, "read error");
            } else if (n == 0) {
                error(1, 0, "server terminated \n");
            }
            recv_line[n] = 0;
            fputs(recv_line, stdout);
            fputs("\n", stdout);
        }
 
        if (FD_ISSET(STDIN_FILENO, &readmask)) {
            if (fgets(send_line, MAXLINE, stdin) != NULL) {
                int i = strlen(send_line);
                if (send_line[i - 1] == '\n') {
                    send_line[i - 1] = 0;
                }
 
                printf("now sending %s\n", send_line);
                size_t rt = write(socket_fd, send_line, strlen(send_line));
                if (rt < 0) {
                    error(1, errno, "write failed ");
                }
                printf("send bytes: %zu \n", rt);
            }
        }
    }
}

关键实现要点

1. 初始化描述符集合

通过 FD_ZERO 清空 allreads 集合,再用 FD_SET 将标准输入(fd=0)和连接套接字加入集合。

2. 每次循环重置集合

c
readmask = allreads;

这是初学者最易忽略的关键步骤。select 调用完成后,内核会修改传入的描述符集合,仅保留就绪的描述符位。因此每次调用 select 之前,必须从保存的原始集合中恢复待检测集合。

3. 描述符基数

c
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);

描述符基数为最大描述符编号加 1,此处 socket_fd + 1 确保内核扫描范围覆盖所有待检测描述符。

套接字描述符就绪条件

可读就绪条件

当 select 返回某套接字可读时,表示以下情况之一:

条件说明
接收缓冲区有数据read 操作不会阻塞,直接返回数据
对端发送 FINread 操作返回 0(EOF)
监听套接字有已完成连接accept 操作不会阻塞,直接返回新连接
套接字有错误待处理read 操作返回 -1

总结:内核通知套接字可读,意味着此时调用 read 不会阻塞。

可写就绪条件

需要注意的是,套接字可写是基于套接字自身的缓冲区状态,而非应用程序是否有数据需要发送。具体条件:

条件说明
发送缓冲区有足够空间write 操作不会阻塞
连接的写半边已关闭继续写将产生 SIGPIPE 信号
套接字有错误待处理write 操作返回 -1

总结:内核通知套接字可写,意味着此时调用 write 不会阻塞。

select 的局限性

select 作为最早的 I/O 多路复用机制,存在以下固有缺陷:

  1. 描述符数量限制FD_SETSIZE 通常为 1024,无法在运行时扩展,无法支持高并发场景
  2. 线性扫描:内核需要遍历 0 到 maxfd - 1 的所有描述符,时间复杂度 O(n)
  3. 数据拷贝开销:每次调用需将 fd_set 从用户态拷贝到内核态,返回时再拷贝回用户态
  4. 集合修改:每次返回后内核修改 fd_set,应用程序必须重置

这些局限性正是 poll 和 epoll 机制被引入的原因。

总结

select 函数提供了最基本的 I/O 多路复用能力。使用 select 时,需掌握以下关键要点:

  • 描述符基数为当前最大描述符编号加 1
  • 每次 select 调用完成后,必须重置待检测集合
  • select 受 FD_SETSIZE 限制,无法支持超过 1024 个描述符的高并发场景
  • select 采用线性扫描,性能随描述符数量增长而线性下降

思考题

  1. select 能否对 UNIX 管道(pipe)这样的描述符进行检测?如果可以,其就绪条件是什么?

  2. 既然 fd_set 中哪些描述符被设置为 1 是可以确定的,select 函数是否一定需要传入描述符基数参数?分析此设计的目的。

版本信息

项目说明
更新日期2026-06-09
目标内核Linux 7.0
关键接口select() — POSIX.1-2001, POSIX.1-2008
FD_SETSIZE1024(编译时固定,自 Linux 2.0 未变更)