3. I/O 多路复用(上):select/poll 模型详解,文件描述符限制与性能瓶颈
聊到高并发服务端,绕不开的一个话题就是 I/O 多路复用。说白了,就是让一个线程能同时盯着几百上千个网络连接,哪个连接有数据来了,就去处理哪个。这比传统的「一个连接一个线程」或者「阻塞式读写」要高效得多。
今天我们先讲最经典的两种模型:select 和 poll。虽然现在生产环境里直接用它们的不多了,但理解它们的原理和瓶颈,是后面理解 epoll、kqueue 这些高性能模型的基础。我自己刚入行时,就在一个老旧的项目里跟 select 的 1024 限制死磕过,印象太深了。
3.1 为什么需要 I/O 多路复用?
先想想一个简单的场景:你写一个 HTTP 服务器,要同时处理多个客户端请求。
- 阻塞 I/O 模型:一个线程调用
read(),如果客户端没发数据,线程就卡在那了。其他客户端想连上来?没门,得排队等。这显然不行。 - 多线程/多进程模型:来一个连接就创建一个线程。连接数少还行,到了几千个并发,线程切换的开销就能把 CPU 拖垮,内存也扛不住。
I/O 多路复用就是来解决这个矛盾的。它让一个线程先「监视」一批文件描述符(也就是 socket 连接),内核告诉你哪些描述符「准备好了」(可读、可写、有异常),然后你再针对性地去读写。这样,一个线程就能高效处理成千上万个连接。
核心思想:用一个线程 + 一个监视机制,替代「一个连接一个线程」的笨重方案。这是服务端并发的基础设施。
3.2 select 模型:老当益壮,但力不从心
select 是 POSIX 标准定义的,几乎所有操作系统都支持。它的接口长这样:
#include <sys/select.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
参数看着多,其实逻辑很简单:你告诉内核,我想监视哪些文件描述符的可读事件、哪些的可写事件、哪些的异常事件,然后给个超时时间。内核就帮你盯着,一旦有事件发生,或者超时了,就返回。
fd_set 是一个位图结构,每个 bit 对应一个文件描述符。比如 fd_set 的第 5 个 bit 为 1,就表示我们在监视 fd=5 的这个 socket。
3.2.1 select 的工作流程
- 准备阶段:把要监视的 fd 分别加入
readfds、writefds、exceptfds集合。 - 调用 select:把这三个集合从用户态拷贝到内核态。
- 内核轮询:内核遍历所有被监视的 fd,检查每个 fd 对应的设备驱动是否有事件发生。这个过程是 O(n) 的。
- 返回结果:内核修改
fd_set集合,把没有事件的 fd 对应的 bit 清 0,有事件的保留为 1。然后返回就绪的 fd 总数。 - 用户态遍历:用户程序再次遍历
fd_set,找到那些 bit 为 1 的 fd,然后进行读写操作。
嗯,这里要注意,select 返回后,fd_set 会被内核修改。所以如果你要循环调用 select,每次调用前都必须重新把 fd 加入集合。这是一个很容易踩的坑。
避坑指南:我曾经在一个网关项目里,忘了在每次 select 循环前重新设置 fd_set,结果第二次调用 select 时,所有 fd 都被清空了,连接全部断开。排查了半天才发现是这个问题。所以,一定要用 FD_SET 重新填充集合,或者用临时变量保存原始集合。
3.2.2 select 的致命限制
select 最大的问题,就是那个 fd_set 的大小是固定的。在 Linux 上,它由 FD_SETSIZE 宏定义,默认是 1024。这意味着一个进程用 select 最多只能监视 1024 个文件描述符。
你想想看,现在随便一个 Web 服务器,并发连接数轻松上万。1024 的限制,根本不够用。虽然你可以通过修改内核参数或者重新编译程序来增大这个值,但 select 本身的性能瓶颈不止这一个。
| 限制项 | 具体描述 | 影响 |
|---|---|---|
| 文件描述符上限 | FD_SETSIZE 固定为 1024(通常) |
无法处理高并发连接 |
| 用户态/内核态内存拷贝 | 每次调用都要拷贝整个 fd_set(大小固定 128 字节) |
虽然 128 字节不大,但频繁拷贝也是开销 |
| 内核轮询 O(n) | 内核需要遍历所有 fd,不管有没有事件 | fd 数量越大,性能线性下降 |
| 用户态遍历 O(n) | 返回后,用户程序需要遍历所有 fd 来找出就绪的 | 同样,fd 越多,耗时越长 |
| fd_set 被修改 | 每次返回后,fd_set 只保留就绪的 fd | 必须重新设置,增加编程复杂度 |
注意:select 的 1024 限制是编译时决定的。如果你在代码里直接用了 FD_SETSIZE 定义的数组,即使你修改了内核的 fs.file-max,程序本身依然只能处理 1024 个 fd。这是很多新手容易忽略的地方。
3.3 poll 模型:突破了限制,但没解决本质
poll 的出现,就是为了解决 select 的 1024 限制。它不再用位图,而是用了一个 pollfd 结构体数组。这样,理论上你可以监视任意数量的文件描述符,只受系统内存限制。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监视的事件(输入) */
short revents; /* 返回的事件(输出) */
};
你看,pollfd 结构体里,events 是你想监视的事件(比如 POLLIN 可读、POLLOUT 可写),revents 是内核返回时告诉你实际发生的事件。而且,revents 是独立的,不会像 select 那样修改 events。所以,你不需要每次调用前都重新设置事件。
3.3.1 poll 的工作流程
- 准备数组:创建一个
struct pollfd数组,每个元素对应一个要监视的 fd,设置好fd和events。 - 调用 poll:把整个数组从用户态拷贝到内核态。
- 内核轮询:内核遍历数组中的每个
pollfd,检查对应 fd 是否有事件发生。依然是 O(n) 的复杂度。 - 返回结果:内核设置每个
pollfd的revents字段,然后返回就绪的 fd 数量。 - 用户态遍历:用户程序遍历数组,检查
revents字段,找到就绪的 fd 进行处理。
流程跟 select 很像,对吧?区别在于数据结构变了,而且不需要重新设置事件。
3.3.2 poll 相比 select 的改进
- 没有上限限制:基于数组,可以监视任意数量的 fd。
- 事件不丢失:
events和revents分离,不需要重新设置。 - 更精细的事件类型:支持 POLLRDHUP(对端关闭连接)等更丰富的事件。
3.3.3 poll 依然存在的性能瓶颈
虽然 poll 突破了 1024 限制,但它的性能模型跟 select 本质上是一样的。说白了,就是「遍历」。
- 用户态到内核态的内存拷贝:每次调用 poll,都要把整个
pollfd数组拷贝到内核。如果监视 10 万个连接,这个数组就很大,拷贝开销不可忽视。 - 内核态 O(n) 轮询:内核依然要遍历所有 fd,不管它们有没有事件。连接数越多,遍历越慢。
- 用户态 O(n) 遍历:返回后,用户程序还是要遍历整个数组,才能知道哪些 fd 就绪了。如果 10 万个连接里只有 10 个活跃的,你依然要遍历 10 万次。
这就是 poll 的核心问题:随着监视的 fd 数量增长,性能线性下降。它没有解决「从 O(n) 到 O(1)」的问题。
关键点:select 和 poll 的性能瓶颈,不在于它们「慢」,而在于它们「无差别遍历」。每次调用,都要把全部 fd 扫一遍,不管这些 fd 是不是活跃的。这在高并发场景下,就是灾难。
3.4 性能对比:select vs poll
我整理了一个对比表格,方便你直观地看差异:
| 对比维度 | select | poll |
|---|---|---|
| 数据结构 | 位图 (fd_set) |
结构体数组 (pollfd) |
| fd 上限 | 1024(默认,可修改但麻烦) | 无硬性限制(受系统内存限制) |
| 事件设置 | 每次调用前必须重新设置 | 只需设置一次,内核不修改 events |
| 内核遍历复杂度 | O(n) | O(n) |
| 用户态遍历复杂度 | O(n) | O(n) |
| 内存拷贝 | 固定 128 字节(fd_set 大小) | 数组大小,随 fd 数量线性增长 |
| 可移植性 | 所有 POSIX 系统 | 所有 POSIX 系统 |
| 典型应用场景 | 连接数少(几百以内)的简单程序 | 连接数中等(几千)但要求跨平台 |
从表格能看出来,poll 在「易用性」和「上限」上胜出,但在「性能模型」上,跟 select 是难兄难弟。它们都逃不过 O(n) 的遍历宿命。
3.5 核心瓶颈总结:为什么它们扛不住高并发?
我个人习惯把 select/poll 的瓶颈总结为三个字:「重复遍历」。
- 遍历 1:内核遍历所有 fd。每次系统调用,内核都要把 fd 列表从头到尾扫一遍,检查每个 fd 的状态。这个操作跟活跃连接数无关,只跟总连接数有关。
- 遍历 2:用户态遍历所有 fd。内核返回后,你作为开发者,还得再遍历一遍,才能找到哪些 fd 有数据要读、哪些可以写。
- 遍历 3:重复的拷贝。每次调用,都要把 fd 列表从用户态拷贝到内核态。poll 的数组越大,拷贝越慢。
你想想看,如果服务器有 10 万个连接,但每秒只有 1000 个活跃请求。select/poll 依然要遍历 10 万次,只为了找出那 1000 个活跃的。这效率,太低了。
我的经验:我曾经在一个旧版的推送服务里,用 poll 管理大约 5000 个长连接。CPU 使用率在低峰期还能接受,但一旦有突发流量,CPU 就飙升到 90% 以上。用 perf 分析,发现 poll 系统调用和后续的遍历占了 70% 以上的 CPU 时间。后来换成 epoll,CPU 直接降到了 20%。这就是 O(n) 和 O(1) 的差距。
3.6 本章小结
select 和 poll 是 I/O 多路复用的「古典」模型。它们解决了「一个线程处理多个连接」的问题,但受限于「遍历」的设计,在高并发场景下性能会急剧下降。select 还有 1024 的硬限制,poll 虽然突破了上限,但性能模型没有本质改变。
下一章,我们会讲 epoll——它才是真正解决这些瓶颈的现代方案。不过,在理解 epoll 之前,先把 select/poll 的「遍历之痛」刻在脑子里。这样你才能明白,epoll 的事件驱动机制,到底高明在哪里。