首页 / 资讯中心 / 文章详情

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

IO多路复用深度解析:select、poll与epoll的原理与性能对比 ★ FEATURED ARTICLE
做网络服务的这几年我越来越觉得 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理论上仅受内存限制内核是否改写入参改写下次调用需重建只改写 reventsevents 保持查找就绪 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 的返回事件和超时分支都处理好你的服务才算真正稳了。
阅读完成 · 觉得有帮助?
咨询建站