ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IO多路复用深度解析:select、poll与epoll的原理与性能对比

IO多路复用深度解析:select、poll与epoll的原理与性能对比

做网络服务的这几年,我越来越觉得 IO 多路复用是绕不过去的一道坎。尤其是当你手握一台服务器,上面挂着几千个网络连接,有聊天的、有秒杀请求、有物联网设备的心跳,看着 CPU 占用率报表却不知道瓶颈在哪的时候,你迟早会回来研究这三个名字:select、poll、epoll。

IO 多路复用说白了就是让一个线程同时盯着多个文件描述符,哪个就绪了就去处理哪个,而不是为每个连接单独开一个线程去傻等。如果你正在学网络编程、写高并发服务,或者只是好奇 Linux 为什么同时支持三种看起来功能差不多的接口,这篇文章就是写给你的。内容不绕弯子,直接从原理、数据结构、性能边界到源码级的行为差异一层层扒开,最后附上我自己踩过的坑。

1. 从阻塞 IO 说起:为什么非要多路复用不可

1.1 阻塞等待的本质代价

先看最简单的网络模型。你写一个 socket,然后调用 recv() 去收数据。如果客户端迟迟不发数据,这个调用就一直卡在那里,线程一动不动。此时这个线程干的事就只有一件:等。它占着内存、占着线程描述符,但什么产出都没有。如果连接数不多,比如几十个,你大可以为每个连接分配一个线程,让它们各自等各自的,勉强能凑合。

连接数一上来就不行了。一台普通的云主机能开的线程数大概也就几百到几千,每个线程默认栈空间 8MB,光内存开销就让人头疼。更关键的是,线程切换是有成本的,上下文切换要保存寄存器、刷新 TLB、更新调度队列,这些开销在高频切换时能把 CPU 的可利用时间吃掉一大截。所以线程模型的天花板很快就能摸到,这时候你需要的是一种“用一个等待者替所有连接等待”的机制。

1.2 IO 多路复用的出现

IO 多路复用的思路很简单:把所有的 socket 都交给一个监视者,这个监视者调用一个阻塞函数,让内核替它观察这些 socket。一旦其中任何一个 socket 有数据可读、可写,或者发生了异常,这个函数就返回,并且告诉应用程序“是哪些 socket 有事”。应用层收到通知后,逐个去处理就绪的 socket。

这样,等待从 N 个线程变成了 1 个线程,资源占用大幅下降。而 select、poll、epoll 就是内核提供的三种“监视者”接口。它们解决的是同一个问题,但各自的实现方式差别非常大,这些差别直接决定了它们适用的场景和性能上限。

提示:理解三者的差异,本质上就是理解“怎么把一堆 fd 交给内核”“内核怎么通知你哪些 fd 就绪”“就绪之后你怎么知道是哪个 fd”这三个问题的不同答案。

2. select 详解:第一代多路复用接口

2.1 接口形态与数据结构

select 是三者中资历最老的,接口长这样:

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

调用前,你要把想监视的 fd 分成三组塞进 fd_set 结构体,分别表示关注可读、可写和异常事件。fd_set 的本质是一个位图,用每一位表示一个 fd 是否在监视集合里。Linux 下 fd_set 默认大小是 1024 位,这也正是 select 最大支持 1024 个 fd 的直接原因。当然可以用编译选项修改 FD_SETSIZE,但每改一次都要重新编译所有引用方,而且内核里还有对应的拷贝上限限制,实用性有限。

调用之后,内核会把位图拷进去,逐个 fd 去检查状态。就绪的 fd 在位图中对应的位会被保留,未就绪的位会被清掉。所以 select 返回后,应用层要用 FD_ISSET 去遍历位图,逐个判断哪个 fd 还在集合里,然后就知道谁就绪了。

2.2 三个无法回避的硬伤

select 有几个问题很要命。

第一个问题是监视数量受限。前面提到 fd_set 位图大小是 1024,如果你的服务需要同时观察 2000 个连接,直接用 select 是办不到的。你当然可以改宏重新编译,但实际工程里很少这么干,因为后面的问题更致命。

第二个问题是就绪 fd 的查找是 O(n) 的。select 返回后,应用层必须从 0 到 nfds-1 全部扫一遍,逐个 FD_ISSET 判断。无论实际就绪的是 1 个还是 1000 个,遍历成本都一样。连接越多,每次返回后的空转越严重。

第三个问题是内核每次都要重新拷贝 fd_set。select 返回时,内核会修改传入的 fd_set,把未就绪的位清掉。这意味着下次调用前,应用程序又得把完整的 fd_set 重新准备一遍,再整体拷入内核。频繁的拷贝操作,再加上内核态逐个 fd 的轮询检查,在高并发下开销相当可观。

我早期在一个内部工具里用过 select 扛几百个连接,当时觉得还行。后来连接涨到一千多,CPU 一下子就上去了,排查半天发现大部分时间都耗在内核复制位图和用户态遍历上。那种“明明没事干却忙得不行”的感觉,真的只有亲身体会过才懂。

3. poll 改进了什么,又留下了什么

3.1 poll 的数据结构设计

poll 接口长这样:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

其中 pollfd 是一个数组,每一个元素描述一个 fd 及其关注的事件:

struct pollfd { int fd; short events; short revents; };

events 是调用前你设置的兴趣事件,revents 是内核返回时写回的实际发生事件。这样设计的一个好处是,内核不需要修改 events 字段,只是额外填 revents,所以下次调用时你不需要重新构造整个数组,直接把同一个数组再传一次就行,省掉了 select 里反复重建 fd_set 的麻烦。

数据规模上,poll 直接传数组长度 nfds,不再受 1024 限制,理论上可以监视任意数量的 fd,前提是内存够。

3.2 分散扫描与线性遍历依旧存在

poll 把 fd_set 换成了数组,解决了数量限制,但内核处理方式依然是线性扫描:把数组里的每个 fd 挨个检查一遍,有事件就置 revents,没有就跳过。而应用层在 poll 返回后,同样需要遍历整个数组,去看每个元素的 revents 是否为 0,以此判断哪些 fd 就绪。

所以 poll 虽然让人舒服了一点,但本质上还是 O(n) 的复杂度,而且这个 n 是全部监视的 fd 数量,不是就绪的 fd 数量。假如你监视了 10 万个连接,但某一时刻只有 5 个活跃,poll 还是要从那 10 万个连接里全部扫一遍才知道哪 5 个有事。

我在一个长连接推送服务里试过 poll,连接数大概两三万,心跳包每 30 秒来一次。大部分时间绝大多数 fd 都是空闲的,但每次 poll 返回后,应用层都得老老实实把所有 fd 扫一遍。那种感觉就像你明明只看 5 条消息,却必须把 10 万封邮件从第一封翻到最后一封,非常磨人。

3.3 poll 与 select 的快速对比

维度selectpoll
数据结构位图 fd_setpollfd 数组
最大监视数量受 FD_SETSIZE 限制,默认 1024理论上仅受内存限制
内核是否改写入参改写,下次调用需重建只改写 revents,events 保持
查找就绪 fd遍历位图 O(n)遍历数组 O(n)
可移植性极好,几乎所有平台支持很好,POSIX 标准

如果你只是连接数没过千、对性能不那么敏感,select 简单直接;poll 则适合连接数超过 1024 但活跃比例不高的传统场景。两个都够用,但都谈不上高效。

4. epoll 的降维打击

4.1 三个关键 API 和事件表机制

epoll 是 Linux 专属的接口。它一改前两者的“每次调用全量传入、内核全量扫描”思路,引入了三个独立的函数:

int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_create 创建一张事件表,返回一个文件描述符 epfd。epoll_ctl 负责向这张表里添加、修改、删除你要监视的 fd,可以理解为“注册兴趣”。epoll_wait 才是真正阻塞等待的地方,它只负责“取已经就绪的事件”。

这个设计的核心变化是:fd 集合的维护从每次调用都传,变成了常驻内核的一张表。你在第一次注册之后,后续的等待就完全不需要再把那几万个 fd 重新拷进内核了。内核拿到这张表之后,也不需要每次等待都全部扫描一遍。

4.2 就绪事件的红黑树与就绪链表

epoll 在内核里为每个 epfd 维护了一棵红黑树和一条就绪链表。红黑树用来存放所有注册过的 fd,节点上保存着该 fd 关注的事件类型和回调信息,增删改查都是 O(log n)。就绪链表则用来挂接那些检测到事件发生的 fd。

关键点在于事件回调机制。当你通过 epoll_ctl 注册一个 fd 时,内核会为这个 socket 挂上一个回调函数。当 socket 上真的有数据到达时,驱动层在把数据放入接收队列的同时,会触发这个回调,回调的任务就是把这个 fd 对应的节点加入到 epoll 的就绪链表中。也就是说,就绪信息的产生不是靠 epoll_wait 时临时去扫所有 fd,而是靠内核在事件发生的那一刻主动推送到就绪链表。

于是 epoll_wait 返回时,它只需要检查就绪链表是否为空,然后把这几个就绪的节点拷贝到你传入的 events 数组里。应用层拿到的是精确的就绪列表,无需再对全量 fd 做无差别遍历。

提示:epoll 的效率边界可以从它的复杂度看出来。注册和删除是 O(log n),收集就绪事件是 O(k),k 是实际就绪的 fd 数量。你的服务活跃连接比例越低,epoll 相对 select/poll 的优势就越明显。

4.3 水平触发与边缘触发

epoll 支持两种事件通知模式,这是我实际工作中最容易踩坑的地方。

水平触发(LT)是默认模式。只要 fd 上还有未读数据,epoll_wait 就会反复通知你。假设你一次只读了 100 字节,但 socket 里还有 200 字节,下一次 epoll_wait 依然会把这个 fd 报给你。这种模式实现简单,不容易漏事件,适合新手,也适合那些不追求极致吞吐的场景。

边缘触发(ET)则只在状态变化时通知一次。你读完一次数据后,如果这次没有把 socket 里的数据全部读完,剩余数据不会再次触发通知,因为“新数据到来”这个边沿没有再次发生。ET 模式下你必须把数据一次性读到 EAGAIN,否则剩余数据会一直留在内核缓冲区里,直到下一个新数据包到来才有可能触发,这会导致明显的读取延迟,甚至数据饥饿。ET 的正确用法是配合非阻塞 socket,循环调用 recv/read,直到返回 EAGAIN 才收手。

我在一个网关项目里最初用 LT,后来为了减少系统调用切到 ET,结果漏读了一整段数据,查了好久才意识到是边沿触发下没有读干净的锅。从那以后,我对团队的要求一直是:能不用 ET 就别用,除非你对每一路数据的读取逻辑都非常有把握。

5. 三种模型性能差异的实测视角

5.1 时间复杂度对比

把三者放在一起看很清晰:

操作selectpollepoll
注册/删除单个 fd每次调用整体传入每次调用整体传入O(log n)
等待并获取就绪事件O(n) 全量扫描O(n) 全量扫描O(k) 就绪链表弹出
就绪后用户态查找遍历位图遍历数组直接拿到就绪数组
内核态入参拷贝每调用一次全量拷贝每调用一次全量拷贝仅 ctl 时操作

这张表基本就回答了一个问题:为什么同样的业务,从 poll 换成 epoll 后性能能差出一个数量级。epoll 省掉的不是某一次操作的开销,而是把“每次都要全量扫描”这个结构性成本直接砍掉了。

5.2 多线程场景下的表现

select 和 poll 还有一个隐蔽问题:如果多个线程同时调用,等待同一个 fd 集合,会发生惊群效应。多个线程同时阻塞在同一个 fd 的等待上,一旦事件到达,内核会把所有等待线程都唤醒,但最终只有一个线程能处理,其余线程又空转一圈回去睡。

epoll 在较新内核版本上支持 EPOLLEXCLUSIVE 标志,可以限制唤醒的线程数量,减少无谓的上下文切换。对于多线程 accept 或读写模型的优化,这个标志值得一试。

不过我得提醒一句:epoll 本身的事件通知是线程安全的,但具体到业务处理,你仍然需要自己保证临界区的正确性。epoll 解决的是等待模型,不是并发控制。

6. 如何选型:场景决定方案

6.1 小型工具与简单脚本

如果你写的是内网小工具、测试脚本、或者连接数长期不超过几百个的轻量服务,select 就绰绰有余。它的最大优势是可移植性好,代码量少,几乎不用考虑平台差异。在这个量级,select 和 epoll 的性能差距你根本感知不到,没必要为了极致性能去引入更复杂的逻辑。

6.2 高并发网关与长连接服务

如果你的目标是 C10K 级别的服务,或者你的产品是一个长连接网关、消息推送系统、实时聊天服务器,那基本不用犹豫,直接上 epoll。理由不是“epoll 更快”这种笼统的说法,而是你的场景里大量连接处于空闲状态,epoll 的就绪链表机制能确保你只为真正活跃的连接付出成本。

如果受限于平台必须用跨平台方案,可以考虑那些把多路复用封装得比较好的库,它们内部会自动根据平台选择最优后端,比如 Linux 上用 epoll、macOS 上用 kqueue、Windows 上用 IOCP 或 select 兼容层。这样既满足跨平台需求,又能拿到 Linux 上的高并发能力。

6.3 代码层面的建议

如果你正在写一个全新的服务,我个人建议不要直接裸用 select 或 poll 写业务,除非你真的只是做个实验。裸用 epoll 也需要小心处理内存管理、事件格式和 ET/LT 模式这些细节。工程上更稳的做法是先用封装库把模型搭稳,再深入理解底层机制,必要时再手写调优。

注意:千万不要用“连接数少就无所谓”的心态去选底层模型。一旦业务量增长,从 select/poll 切换到 epoll 不是改个函数名那么简单,你的事件循环、读缓冲、连接生命周期管理可能都要重构。选型这件事,越早做越省钱。

7. 实战中的常见误区和排查经验

7.1 误区一:fd 重复注册导致事件混乱

用 epoll_ctl 注册 fd 时,如果你对一个已经注册过的 fd 再次执行 EPOLL_CTL_ADD,内核会返回 EEXIST。很多新手不知道这个细节,在连接重连或事件循环复用场景里,同一个 fd 被加了两次,结果事件处理逻辑一下子全乱。正确做法是注册前先判断状态,或者使用 EPOLL_CTL_MOD 去更新事件,而不是重复 ADD。

7.2 误区二:LT 模式无限通知导致死循环

LT 模式下,如果 fd 一直有未读数据,epoll_wait 会一直返回这个 fd。如果你的处理逻辑里没有读取数据的动作,或者读取逻辑没有消费完缓冲区,程序就会陷入“被唤醒—不消费—又被唤醒”的死循环,CPU 直接被拉满。判断这个问题的简单办法是:看单一 fd 的就绪事件是否异常频繁,以及对应线程的 CPU 是否长时间接近 100%。

7.3 误区三:listen fd 的可读事件处理不彻底

accept 返回 EAGAIN 或者 EMFILE 时,很多人会忽略。EMFILE 表示进程的文件描述符已达上限,此时如果直接忽略,listen fd 可能一直保持可读状态,在 LT 模式下就会反复触发 accept,造成忙轮询。稳妥的做法是:accept 返回 EAGAIN 时 break 出循环,返回 EMFILE 时可以先处理这个异常,必要时短暂关掉监听,延后处理。

我自己的习惯是在 accept 循环里加一个最大处理数量限制,比如一次最多 accept 64 个连接。这样即使某个瞬时突发连接特别多,也不会让单次事件循环占用过长时间,避免其他 fd 的事件被饿死。

7.4 排查工具

遇到多路复用相关的问题,strace 是排查系统调用行为的第一选择。你用它跟踪一下进程的 epoll_wait 调用,能清楚看到每次唤醒的时间间隔、就绪事件数量。如果发现 epoll_wait 被频繁以极短的间隔调用,很可能就是 LT 模式下处理不彻底或边沿事件处理逻辑有误。

压测时我还会配合 ss 和 nstat 看连接状态和重传情况,能快速区分问题是出在应用层事件处理,还是出在内核协议栈。

8. 个人实操体会与建议

这些年写过不少网络服务,从最开始的 select 玩具,到 poll 支撑起第一版推送,再到后来全面切 epoll,踩过的坑记忆犹新。给我感触最深的一点是:多路复用的性能差距,本质上不是接口的差距,而是“全量扫描”和“事件驱动”两种思维的差距。select 和 poll 把 fd 集合当成一份静态清单,每次都要重新审视;epoll 则把它当成一张可以长期维护的注册表,只在变化发生时行动。

如果你刚开始学,我建议的顺序是:先用 select 写一个简单的回声服务,理解阻塞、就绪、遍历的概念;再用 poll 改造,体会数据结构变化带来的便利;最后用 epoll 实现同一个服务,对比三种方案的代码量和复杂度。这个过程比看十篇原理文章都管用,因为只有亲手写过,你才能理解为什么 epoll 的接口设计成三个函数,为什么 LT 和 ET 的差别会让人抓狂。

最后再分享一个小技巧:无论你最终选哪种方案,一定要在事件循环里做好超时处理。网络环境不是教科书,一个连接可能在半路上就断了,一个 fd 可能被对端悄悄关闭。把每次 epoll_wait 的返回事件和超时分支都处理好,你的服务才算真正稳了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进