Linux 中 select 的用法与底层实现

在 Linux 网络编程里,select 是最早被广泛使用的 I/O 多路复用机制之一。虽然如今在高并发场景中它通常会被 epoll 替代,但 select 依然是理解 I/O 多路复用思想的重要起点。


一、什么是 select

select 的作用是:

让一个线程同时等待多个文件描述符上的事件,并在其中某些 fd 就绪时返回。

它最常见的使用场景是网络编程,比如一个服务端同时监听:

  • 监听 socket 是否有新连接到来
  • 已连接 socket 是否有数据可读
  • 某些 socket 是否可以继续发送数据

对应的系统调用原型如下:

1
2
3
4
5
6
7
8
9
10
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

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

从接口上看,它允许我们分别关注三类事件:

  • 可读
  • 可写
  • 异常

二、为什么会有 select

在没有 I/O 多路复用之前,如果一个进程要同时处理多个 socket,常见办法有两种:

  1. 阻塞式串行处理:一次只处理一个连接
  2. 每连接一个线程/进程:让每个连接由独立执行流处理

这两种方式都有明显问题:

  • 串行处理时,并发能力很差
  • 每连接一个线程会带来较高的调度、栈空间和上下文切换成本

select 的出现提供了第三种思路:

一个线程先统一等待,哪个 fd 准备好了,再去处理哪个 fd。

这就是 I/O 多路复用的基本思想。


三、select 的基本用法

1. fd_set 与相关宏

select 使用 fd_set 表示一个文件描述符集合,并配套提供了一组宏:

1
2
3
4
FD_ZERO(fd_set *set);   // 清空集合
FD_SET(int fd, fd_set *set); // 添加 fd
FD_CLR(int fd, fd_set *set); // 删除 fd
FD_ISSET(int fd, fd_set *set); // 判断 fd 是否在集合中

最常见的写法如下:

1
2
3
4
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);
FD_SET(connfd, &readfds);

2. nfds 的含义

nfds 不是 fd 的数量,而是:

所有被监视 fd 中“最大 fd 值 + 1”。

例如监视 3、5、8,那么 nfds 应该传 9

3. timeout 的含义

timeout 控制 select 的阻塞时间:

  • NULL:一直阻塞,直到有事件发生
  • {0, 0}:立即返回,非阻塞轮询
  • 其他值:阻塞指定时间

例如:

1
2
3
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;

表示最多等待 5 秒。


四、select 的典型使用流程

一个简单的 TCP 服务端使用 select 的基本流程通常如下:

  1. 创建监听 socket
  2. bind + listen
  3. 准备一个“主集合”,保存所有需要监视的 fd
  4. 每次循环前,把主集合拷贝到临时集合
  5. 调用 select
  6. 遍历所有可能的 fd,检查谁就绪了
  7. 分别处理:
    • 监听 fd:执行 accept
    • 连接 fd:执行 read / write
  8. 连接关闭时将 fd 从集合中移除

下面是一个简化示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>

#define PORT 8080

int main() {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);

bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
listen(listenfd, 128);

fd_set allset, rset;
FD_ZERO(&allset);
FD_SET(listenfd, &allset);

int maxfd = listenfd;

while (1) {
rset = allset;

int nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
if (nready < 0) {
continue;
}

if (FD_ISSET(listenfd, &rset)) {
int connfd = accept(listenfd, NULL, NULL);
if (connfd >= 0) {
FD_SET(connfd, &allset);
if (connfd > maxfd) maxfd = connfd;
}
if (--nready <= 0) continue;
}

for (int fd = 0; fd <= maxfd; fd++) {
if (fd == listenfd) continue;

if (FD_ISSET(fd, &rset)) {
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
write(fd, buf, n);
} else {
close(fd);
FD_CLR(fd, &allset);
}
}
}
}

close(listenfd);
return 0;
}

这个例子是典型的 select 版 echo server,重点在于理解结构,而不是直接用于生产环境。


五、select 返回后该怎么看结果

这是初学者很容易忽略的一个点。

select 调用前,你把关心的 fd 填进 readfds/writefds/exceptfds; 调用返回后,这几个集合会被原地修改,只保留已经就绪的那些 fd。

例如:

1
2
rset = allset;
int nready = select(maxfd + 1, &rset, NULL, NULL, NULL);

返回后需要这样判断:

1
2
3
if (FD_ISSET(fd, &rset)) {
// fd 可读
}

也正因为 select 会修改集合,所以每次调用前都需要重新拷贝一份。


六、select 能监听哪些事件

select 主要支持三类集合:

1. readfds

表示关心“可读”事件,常见情况:

  • socket 接收缓冲区中有数据
  • 监听 socket 上有新连接到来
  • 对端关闭连接,此时读可能返回 0

2. writefds

表示关心“可写”事件,常见情况:

  • socket 发送缓冲区有空间
  • 非阻塞 connect 已完成

3. exceptfds

表示关心“异常条件”,在普通 TCP 服务端里相对少用。

它不是一般意义上的“程序异常”,更多是一些带外数据等特殊情况。


七、select 的优点和局限

1. 优点

select 的优点主要有:

  • 历史悠久,资料多
  • 接口标准化,很多 Unix-like 系统都支持
  • 适合学习 I/O 多路复用的基本思想
  • 在小规模连接场景下完全够用

2. 局限

select 最大的问题并不是“不能用”,而是它在 fd 多起来后效率会明显下降。

主要体现在以下几个方面。

(1)fd 数量有限制

fd_set 通常受 FD_SETSIZE 限制,很多系统上默认是 1024

这意味着:

单次 select 可监视的 fd 数量通常不能无限增长。

(2)每次都要重新传入全集

应用每次调用 select,都要把所有关心的 fd 集合重新准备好,再交给内核。

(3)返回后仍要线性扫描

即使只有 3 个 fd 真正就绪了,应用也通常要从 0 扫描到 maxfd,逐个调用 FD_ISSET 检查。

这使得它的处理成本与 maxfd 高度相关。

(4)用户态与内核态之间有集合拷贝成本

每次 select 调用,用户态的 fd_set 需要传入内核,返回时又需要把结果带回用户态。


八、select 的底层实现思路

理解 select 的关键是明白:

它本质上更接近“每次调用时,拿着一张完整名单去检查一遍”。

1. 位图表示 fd 集合

fd_set 在底层通常可以理解为一个位图(bitmap)

例如某个 fd 是否在集合里,本质上就是看对应 bit 是否置位。

这种表示方式很紧凑,但它也天然意味着:

  • 需要按照 fd 编号组织
  • 处理时往往按编号顺序扫描

2. 内核遍历用户给出的监视集合

调用 select 后,内核会根据 nfds,检查 [0, nfds) 范围内哪些 fd 在对应集合里被置位。

对于这些 fd,内核会进一步检查:

  • 是否可读
  • 是否可写
  • 是否存在异常条件

如果没有任何 fd 就绪,而调用者又允许阻塞,当前线程就会睡眠等待。

3. 事件发生后再重新筛选

当某个 fd 状态变化时,内核会唤醒等待中的线程;线程恢复后,select 再根据当前状态整理结果集合,把真正就绪的 fd 保留下来。

因此 select 的核心特征不是“保存一份长期关注列表”,而是:

每次调用都重新提交一份关注集合,然后让内核基于这份集合完成检查。

4. 复杂度特征

select 通常会表现出接近 O(n) 的特征,其中 nnfdsmaxfd 范围强相关。

这也是它在高并发场景下不如 epoll 的根本原因之一。


九、从内核视角看一次 select 等待过程

可以粗略理解为:

  1. 用户态准备好 readfds/writefds/exceptfds
  2. 调用 select
  3. 内核接收这些集合并复制到内核空间
  4. 内核遍历 [0, nfds) 范围内的 fd
  5. 若没有任何 fd 就绪,则线程进入等待
  6. 某个 socket 收到数据或状态发生变化
  7. 内核唤醒等待线程
  8. 内核再次整理结果集合
  9. 把就绪结果复制回用户态
  10. select 返回,应用再线性检查哪个 fd 被置位

这套流程里,最值得记住的是两个扫描:

  • 内核扫描关注范围
  • 用户态扫描结果范围

十、为什么 select 在高并发下不占优势

假设一个服务器维护 2 万连接,但某一时刻只有 50 个连接真正活跃。

select 而言,它通常仍然需要:

  • 根据 nfds 检查一大段 fd 范围
  • 返回后应用再遍历这一大段范围

这意味着:

成本更接近“跟总监视范围有关”,而不是“只和活跃连接数有关”。

因此 select 适合:

  • 连接数不多
  • 代码可移植性要求高
  • 教学或基础示例

但不太适合超大规模连接场景。


十一、使用 select 时的常见坑

1. 忘记每次重新初始化集合

因为 select 会修改传入集合,所以不能把主集合直接传进去反复用。

正确做法通常是:

1
2
rset = allset;
select(maxfd + 1, &rset, NULL, NULL, NULL);

2. nfds 传错

很多人会误传成“总共多少个 fd”,但正确的是“最大 fd + 1”。

3. 忽略监听 fd 与连接 fd 的不同语义

  • 监听 fd 可读:通常表示可以 accept
  • 连接 fd 可读:通常表示可以 read

这两类 fd 的处理逻辑不能混在一起。

4. close 后忘记 FD_CLR

关闭 fd 后如果没有把它从集合里移除,下次 select 可能出现错误或逻辑混乱。

5. 误以为可读就一定能读到业务数据

对端关闭连接时,也会表现为“可读”,但此时 read 返回 0


十二、select 与 poll、epoll 的关系

从历史和设计思路上可以这样理解:

  • select:最早期、最经典的多路复用接口
  • poll:改进了 select 的位图和 fd 数量限制问题
  • epoll:进一步优化了关注集合维护与就绪事件提取方式

一个简化对比如下:

对比项 select poll epoll
数据结构 位图 数组 红黑树 + 就绪链表
fd 上限 常受 FD_SETSIZE 影响 无明显固定上限 无明显固定上限
每次调用重复提交全集
返回后用户态扫描 通常只处理就绪项
典型场景 小规模、跨平台 中等规模 高并发 Linux

十三、select 在工程里适合什么场景

select 并不是“过时到不能用”,它仍然适合一些场景:

1. 教学和学习

如果要理解:

  • 什么是 I/O 多路复用
  • 为什么要区分可读和可写
  • 单线程如何管理多个连接

select 是很好的入门材料。

2. 连接数少的工具型程序

例如:

  • 简单代理
  • 调试工具
  • 内部服务
  • 小型 demo

3. 对可移植性要求高的代码

因为 select 是较通用的接口,很多 Unix / POSIX 环境都支持它。


十四、总结

select 的核心价值在于:

  1. 提供了最基础的 I/O 多路复用能力
  2. 允许一个线程等待多个 fd 的就绪事件
  3. 为后续理解 pollepoll 打下了基础

但它的底层方式决定了它存在天然瓶颈:

  • 用位图管理 fd 集合
  • 每次调用都要重新提交全集
  • 内核和用户态都要扫描 fd 范围

所以,select 更适合理解思想与处理中小规模连接; 而当连接数持续增长时,就需要进一步考虑 pollepoll 这类机制。