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 函数接口
函数原型
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 操作宏
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 参数
struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* 微秒 */
};timeout 的三种设置方式:
| 设置方式 | 行为 |
|---|---|
NULL | 无 I/O 事件时永久阻塞 |
| 非零值 | 等待指定时间后返回(见 第12讲 超时示例) |
tv_sec = tv_usec = 0 | 立即返回,非阻塞轮询 |
select 工作流程
程序示例
以下示例演示 select 同时监控标准输入和套接字描述符:
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. 每次循环重置集合
readmask = allreads;这是初学者最易忽略的关键步骤。select 调用完成后,内核会修改传入的描述符集合,仅保留就绪的描述符位。因此每次调用 select 之前,必须从保存的原始集合中恢复待检测集合。
3. 描述符基数
int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);描述符基数为最大描述符编号加 1,此处 socket_fd + 1 确保内核扫描范围覆盖所有待检测描述符。
套接字描述符就绪条件
可读就绪条件
当 select 返回某套接字可读时,表示以下情况之一:
| 条件 | 说明 |
|---|---|
| 接收缓冲区有数据 | read 操作不会阻塞,直接返回数据 |
| 对端发送 FIN | read 操作返回 0(EOF) |
| 监听套接字有已完成连接 | accept 操作不会阻塞,直接返回新连接 |
| 套接字有错误待处理 | read 操作返回 -1 |
总结:内核通知套接字可读,意味着此时调用 read 不会阻塞。
可写就绪条件
需要注意的是,套接字可写是基于套接字自身的缓冲区状态,而非应用程序是否有数据需要发送。具体条件:
| 条件 | 说明 |
|---|---|
| 发送缓冲区有足够空间 | write 操作不会阻塞 |
| 连接的写半边已关闭 | 继续写将产生 SIGPIPE 信号 |
| 套接字有错误待处理 | write 操作返回 -1 |
总结:内核通知套接字可写,意味着此时调用 write 不会阻塞。
select 的局限性
select 作为最早的 I/O 多路复用机制,存在以下固有缺陷:
- 描述符数量限制:
FD_SETSIZE通常为 1024,无法在运行时扩展,无法支持高并发场景 - 线性扫描:内核需要遍历 0 到
maxfd - 1的所有描述符,时间复杂度 O(n) - 数据拷贝开销:每次调用需将
fd_set从用户态拷贝到内核态,返回时再拷贝回用户态 - 集合修改:每次返回后内核修改
fd_set,应用程序必须重置
这些局限性正是 poll 和 epoll 机制被引入的原因。
总结
select 函数提供了最基本的 I/O 多路复用能力。使用 select 时,需掌握以下关键要点:
- 描述符基数为当前最大描述符编号加 1
- 每次 select 调用完成后,必须重置待检测集合
- select 受
FD_SETSIZE限制,无法支持超过 1024 个描述符的高并发场景 - select 采用线性扫描,性能随描述符数量增长而线性下降
思考题
-
select 能否对 UNIX 管道(pipe)这样的描述符进行检测?如果可以,其就绪条件是什么?
-
既然
fd_set中哪些描述符被设置为 1 是可以确定的,select 函数是否一定需要传入描述符基数参数?分析此设计的目的。
版本信息
| 项目 | 说明 |
|---|---|
| 更新日期 | 2026-06-09 |
| 目标内核 | Linux 7.0 |
| 关键接口 | select() — POSIX.1-2001, POSIX.1-2008 |
| FD_SETSIZE | 1024(编译时固定,自 Linux 2.0 未变更) |