资讯动态

深入理解 I/O 多路复用:从 select/poll 到 epoll 高并发实战

发布时间:2026/10/17 7:46:06 来源:尧图企业网站定制
如果你写过网络服务器迟早会被这么一个问题卡住一个进程到底怎么同时照顾几百上千个连接很多初学者第一反应是开线程一个连接一个线程结果连接一多CPU先飙了内存也吃不消。这个问题的标准答案就是 I/O 多路复用。它让一个线程同时盯着多个文件描述符哪个 fd 上有数据可读、可写就处理哪个而不是傻等某一个连接。这篇文章我想从底层原理讲到实战把 select、poll、epoll 这三兄弟掰开揉碎对比一遍然后用 epoll 手写一个高并发回声服务器最后聊聊那些文档里不会写的坑。适合正在学网络编程、准备面试或者想理解高并发框架底层逻辑的朋友参考。1. 先搞清楚I/O 多路复用到底解决了什么问题1.1 从阻塞 I/O 的限制说起一个传统的服务端程序核心代码往往就是 socket、bind、listen、accept然后进入 while 循环accept 阻塞等待新连接拿到连接后又阻塞地 read 等待对方数据。问题出在哪儿如果第一个客户端连上之后一直不说话read 就一直卡在那里第二个客户端即使已经连上了也没人 accept更别提处理数据了。这就像只有一个客服窗口第一个顾客进来之后把问题问了一半就停住不走后面排队的顾客全部被堵死。阻塞式网络模型天然就是“一对一”的处理完一个连接才能处理下一个。早期网络环境连接数少问题不明显但到了高并发场景这种模型完全撑不住。要想让一个进程同时服务大量连接核心就是要打破“一个人占住一个位置就卡死整条流水线”的僵局。I/O 多路复用正是为了解决这个僵局而生的进程先把自己的关注列表交给内核然后阻塞等待内核告知“哪个连接上有事发生”。注意这里的“多路”指的是一堆 socket 连接“复用”则是指用一个线程统一管理这堆连接而不是一个连接对应一个线程。1.2 多线程方案为什么不是银弹最容易想到的解决办法是来一个连接就开一个线程连接被阻塞了就让它阻塞在自己的线程里反正互不影响。这个方法在连接数几百个的时候确实能跑但再往上走就难受了。每条线程需要独立的栈空间Linux 上默认的线程栈大小往往是 8MB即使按虚拟内存算几千条线程也是几十 GB 级别的地址空间消耗。更麻烦的是上下文切换线程数量一多CPU 时间大量花在保存、恢复寄存器上真正干活的时间比例就下来了。再加上锁竞争、线程创建销毁的开销一个连接一个线程的模型在面对 C10K 问题时基本是死路。还有个不可忽略的点大量线程阻塞在各自的 read 上每次有数据进来操作系统要唤醒对应线程如果同一时间有一千个连接有数据就有一千次唤醒和调度。这种“无差别唤醒”本身就是巨大的性能浪费。所以高并发场景下大家最终都会回到同一个思路上来用少量线程甚至一个线程去管理大量连接只处理那些真正有事件发生的连接。1.3 “让内核替我们盯着”的非阻塞思路如果把 socket 改成非阻塞模式read 不会傻等没有数据就立刻返回错误。看起来很美好但问题立刻转到应用层你怎么知道该对哪个 socket 调 read唯一简单的办法就是遍历所有连接挨个尝试读一次。假如有一万个连接大部分时间其实没有数据但你仍然要一万一万地轮询每次都要走一遍系统调用CPU 全浪费在“确认没事发生”上了。所以理想的机制是内核提供一种“批处理订阅”能力应用把自己关心的 fd 一次性注册给内核然后内核负责监视这些 fd一旦某个 fd 有读、写或异常事件内核主动通知应用。应用只需要在通知里取事件、处理事件。这才是 I/O 多路复用的本质也解释了为什么它比“多线程 阻塞”和“非阻塞 轮询”都更适合高并发。理解了这一点再看 select、poll、epoll 的历史演进就顺了它们都是“让内核帮我们盯着 fd”的方案差别在于内核的实现方式、可扩展性和性能。2. select、poll、epoll三种监视方式的前世今生2.1 select用一个位图赌每次轮询select 是最老的多路复用接口核心思路是让人维护三个 fd_set 位图分别表示可读、可写、异常事件。调用 select 前把关心的 fd 在位图里置 1调用时把这三个位图从用户态拷贝到内核态内核遍历所有 fd发现有事件就保留对应位返回后用户再用 FD_ISSET 逐个检查哪个 fd 还在位图里。这套逻辑有两个硬伤。第一是 fd 数量上限FD_SETSIZE 通常定义成 1024也就是说 select 能同时监视的 fd 绝大多数情况下最多也就 1024 个。第二个问题更致命每次调用 select不管你有没有事件内核都要把三个位图整体拷一遍然后全部 fd 从头到尾扫一遍复杂度是 O(n)。连接越多空扫的浪费越大。还有一个特别容易踩的坑fd_set 是值传递语义内核会修改位图所以每次调用 select 前都必须重新把所有 fd 加到集合里。很多人第一次写多路复用代码在循环里只初始化一次 fd_set结果第二次 select 之后集合被内核改了后面的事件全乱套排查半天才发现是没有重新赋值。2.2 poll去掉上限但内核仍然“全量扫”poll 是 select 的改进版核心改动是把 fd_set 换成了一个 pollfd 数组每个数组项里有 fd、事件掩码和返回事件掩码。用户用 events 字段声明关注的事件内核用 revents 字段返回实际发生的事件互不干扰所以不需要像 select 那样每次重新构造集合。另外poll 没有 1024 的连接上限只要内存装得下监视几万个 fd 理论上也没问题。看起来解决了 select 的痛点但性能瓶颈还在每次 poll 调用内核还是要遍历这个数组检查每个 fd 的状态。连接数到了几千、几万以后一次 poll 的 CPU 开销会跟着线性上升而且每次 poll 都要把整个 pollfd 数组从用户态拷到内核态这部分拷贝开销也不小。很多人以为有了 poll 就可以放心干高并发实际上它在几千连接的时候已经明显吃力。我自己曾在一台普通服务器上做过对比同样一万个空闲连接poll 的一次空调用扫描时间比事件驱动的方案高出好几个数量级。所以 poll 更像是一个“修正了 select 表面问题”的过渡方案并不是高并发的终点。2.3 epoll内核帮你维护监视名单来了事件才通知epoll 是 Linux 上的事件驱动方案也是目前主流高并发服务器的底层基础。它从根本上改变了工作方式不再每次调用都全量扫描所有 fd而是由内核维护一棵红黑树来管理用户注册的 fd同时用一条就绪链表保存真正发生了事件的 fd。关键点在于“注册”和“通知”是分离的。注册用的 epoll_ctl 只在 fd 状态变化时调用比如新连接加入、连接关闭、需要修改关注事件而主循环里的 epoll_wait 只负责等待并返回就绪链表。一个 fd 上发生事件时内核通过回调机制把它挂到就绪链表上epoll_wait 返回时只需要把“已经就绪的 fd 列表”拷贝给用户不需要再遍历所有的 fd。这就把复杂度从 O(n) 降到了近似 O(就绪事件数)连接再多只要同时就绪的事件不多epoll_wait 的代价就非常小。同时epoll 还有一个额外优势它返回的是“哪些 fd 就绪了”的结果数组用户直接遍历结果处理即可不需要像 select 那样对所有 fd 做实打实的轮询检查。所以高并发场景下epoll 几乎成了 Linux 上的默认答案。2.4 一张表看三者的核心差异对比项 | select | poll | epoll 数据结构 | fd_set 位图 | pollfd 数组 | 红黑树 就绪链表 fd 上限 | 受 FD_SETSIZE 限制通常 1024 | 无明确上限 | 无明确上限 每次调用拷贝 | 拷贝全部 fd_set | 拷贝全部 pollfd 数组 | 只拷贝就绪事件 内核扫描范围 | 全部 fd | 全部 fd | 只处理就绪 fd 触发方式 | 水平触发 | 水平触发 | 支持水平触发和边沿触发 主要开销 | 随 fd 数线性增长 | 随 fd 数线性增长 | 与活跃连接数相关这个差异直接决定了选型如果只是几十个连接select 完全够用代码也简单如果数量到了几千但业务逻辑简单poll 可以撑一撑想在 Linux 上做上万甚至上百万并发epoll 基本是唯一务实的选择。3. 手写一个基于 epoll 的回声服务器3.1 三个关键 API 和两个容易看漏的细节epoll 的核心调用只有三个epoll_create1、epoll_ctl、epoll_wait。epoll_create1 负责创建 epoll 实例参数一般传 EPOLL_CLOEXEC作用是让 epoll 文件描述符在 fork 之后自动关闭避免子进程意外继承导致 fd 泄漏。很多人用 epoll_create 但忽略了这一点我自己就在一个常驻后台服务里踩过这个坑跑了一段时间后发现 fd 数缓慢上涨排查好久才定位到是 fork 的子进程没有清理 epoll fd。epoll_ctl 负责增删改。真正决定性能的不是 epoll_wait而是 epoll_ctl 的调用频率。如果每次有事件就反复 ADD 和 DEL红黑树的插入删除也会变成瓶颈。合理做法是在连接建立时 ADD 一次连接关闭时 DEL 一次平时只需要用 EPOLL_CTL_MOD 修改关注的读、写事件。epoll_wait 负责等待事件。timeout 参数是毫秒设为 -1 表示永久阻塞设为 0 表示立即返回适合需要非阻塞检查的场景设为具体数值可以支持定时任务。很多人把它当成常规阻塞调用却忽略了它在中途被信号打断的情况返回值可能是 -1 且 errno 为 EINTR需要继续等待。3.2 C 语言核心实现每行代码背后的原因下面是一个用 C 语言实现的简易回声服务器只保留了核心逻辑你可以直接拿来跑#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static void set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(listen_fd, 1024) 0) { perror(listen); return 1; } set_nonblocking(listen_fd); int epfd epoll_create1(EPOLL_CLOEXEC); if (epfd 0) { perror(epoll_create1); return 1; } struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[MAX_EVENTS]; char buf[BUF_SIZE]; while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) { continue; } perror(epoll_wait); break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { while (1) { int client_fd accept(listen_fd, NULL, NULL); if (client_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { break; } perror(accept); break; } set_nonblocking(client_fd); ev.events EPOLLIN; ev.data.fd client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, ev); } } else { ssize_t rn read(fd, buf, sizeof(buf)); if (rn 0) { if (rn 0 (errno EAGAIN || errno EWOULDBLOCK)) { continue; } epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); continue; } ssize_t wn write(fd, buf, rn); if (wn 0) { if (errno EAGAIN || errno EWOULDBLOCK) { continue; } epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } close(epfd); close(listen_fd); return 0; }这里有几个设计点值得解释listen_fd 本身也设成非阻塞同时用 while 循环 accept直到返回 EAGAIN。原因是 accept 的可读事件只能说明“当时至少有一个连接可以接受”如果有多个连接排队系统只会拿到第一个剩下的交由下一次事件触发效率是够的但既然都读到这里直接用 while 循环把当前能 accept 的都处理掉可以减少内核事件重发和无效唤醒。客户端 fd 一 accept 就立刻设成非阻塞因为后续 read、write 都可能因缓冲区满、无数据而返回 EAGAIN如果不是非阻塞某一方不发送数据时线程就卡住了。read 返回 0 表示对方关闭连接这种情况必须从 epoll 里删掉 fd 并 close否则会一直触发可读事件形成死循环。这个判断漏掉是新手最容易犯的错误。这里没有处理 EPOLLOUT因为回声场景几乎不需要主动塞数据真正要推送消息的业务需要把写事件和发送队列配合使用避免一次 write 没写完导致数据丢失。3.3 为什么没有用 SIGPIPE 处理默认情况下向一个已经关闭的连接写数据进程会收到 SIGPIPE 信号如果不处理进程会直接退出。在高并发服务器里这种情况非常常见客户端断开了但服务端还没来得及关闭 fd然后写事件触发了。所以线上代码里一般都要设置 signal(SIGPIPE, SIG_IGN)或者用 send 的 MSG_NOSIGNAL 选项。上面 demo 里没加是因为它在主线程直接处理即使收到 SIGPIPE 也会终止不适合久跑你作为练习项目跑完就关问题不大真正要部署第一件事就得把 SIGPIPE 屏蔽掉。3.4 用 Python selectors 快速搭建原型如果 C 语言不是你的主要语言或者只是想验证 epoll 的行为可以考虑 Python 的 selectors 模块。它把 epoll、poll、select 封装成了统一接口默认会用系统里最好的实现在 Linux 上就是 epoll。import selectors import socket sel selectors.DefaultSelector() def accept_conn(sock): conn, _ sock.accept() conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, handle_read) def handle_read(conn): data conn.recv(4096) if data: conn.sendall(data) else: sel.unregister(conn) conn.close() server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 9000)) server.listen(1024) server.setblocking(False) sel.register(server, selectors.EVENT_READ, accept_conn) while True: events sel.select(timeoutNone) for key, mask in events: key.data(key.fileobj)这段代码思路和上面 C 版本完全一致注册监听 socket新连接再注册客户端 fd数据到了就回显。select 出来的是一个事件列表事件发生时通过回调分发到对应处理函数。原型阶段用 Python 验证逻辑生产再用 C 或 C 重新实现是很多项目团队的常规节奏。4. 实战中绕不开的坑LT、ET、惊群与并发模型4.1 LT 和 ET 怎么选epoll 支持两种触发模式长见识的选择题在这里才真正出现。水平触发Level-Triggered简称 LT下只要一个事件没有处理完内核就会反复提醒你。比如缓冲区里有 100 字节数据你只读了 20 字节下次调用 epoll_wait 时该 fd 还是会出现在就绪列表里提醒你继续读。边沿触发Edge-Triggered简称 ET则只在状态变化的那一刻通知一次之后即使缓冲区还有数据也不会再通知你。打个比方LT 像客服每隔一段时间就打电话催你“有包裹到驿站了记得取”你会不断收到提醒直到取完ET 像驿站只在包裹第一次入柜时发一条短信之后不管你怎么忘记它都不会再提醒。ET 的问题是如果收到通知后没有把数据一次读完剩下的数据可能永远漏掉。所以 ET 模式下socket 必须是非阻塞的并且读操作要循环执行一直读到 read 返回 EAGAIN 为止写操作也要一直写完包括处理部分写和缓冲区满的情况。基于我自己的经验初学者和大多数业务场景直接用 LT 就好。LT 的语义符合直觉很多边缘情况都不容易出 bugET 省的是“重复通知”的开销但前提是你在代码里真的用非阻塞 循环读把这点优势换回来。如果业务逻辑默认读到一点数据就处理然后依赖内核再次通知那 LT 显然更合适。4.2 accept 惊群与 EPOLLEXCLUSIVE当多个线程或进程同时阻塞在同一个 epoll 实例的 epoll_wait 上一个新的连接事件到达时可能出现多个线程同时被唤醒但最终只有一个线程能成功 accept其他线程只能空跑一圈。这就是经典的惊群现象。内核针对这个问题提供了 EPOLLEXCLUSIVE 选项在注册事件时加上这个标记内核会尽量只唤醒一个等待者来处理该事件降低惊群概率。不过要注意EPOLLEXCLUSIVE 是给“多线程共享同一个 epoll fd”的场景用的如果你已经使用主线程单独 accept再把 fd 分发给 worker就不需要它。另一种更常见的做法是完全不用共享 epoll主线程负责 accept然后把新的 fd 直接交给某个 worker 线程worker 有自己独立的 epoll 实例。这样每个 worker 只处理属于自己的连接不仅能避免惊群还天然避免多线程并发操作同一个 fd 的问题。代价是编码更重一些需要设计好线程模型和 fd 传递方式。4.3 EPOLLONESHOT保证同一时刻只有一个线程在碰这个 fd在 Reactor 模式里事件循环通常会把读到的数据交给线程池处理。问题来了一个 fd 可能同时有读事件和写事件或者业务处理较慢还没来得及重新注册下一次读事件又来了。如果多个线程同时对同一个 fd 执行读或写数据顺序会乱甚至引发竞态条件。EPOLLONESHOT 的作用是该 fd 被唤醒一次后除非你显式重新注册否则它不会再触发事件。这保证了同一 fd 同一时刻只被一个线程处理。处理完业务后再用 epoll_ctl 的 EPOLL_CTL_MOD 重新把 fd 加回来。使用 EPOLLONESHOT 时一定要在重新注册前确认所有读写都结束否则 fd 会一直处于休眠状态连接就像卡死了一样。4.4 线程模型要避开的一个致命习惯基于 epoll 的 I/O 密集服务最常见的问题不是 epoll 本身而是事件处理里面藏了“阻塞”操作。比如在 epoll_wait 返回后的回调里执行 sleep、同步读磁盘、请求下游数据库这些操作会阻塞当前线程。如果只有一条事件循环线程这一个连接卡住其他所有连接全部停摆。正确做法是事件循环只负责收发数据和解析协议把耗时业务放到独立线程池。不要让任何一个慢操作进入事件循环线程的同步路径这是 epoll 高并发架构的原则性约束也是很多号称“用了 epoll 但性能还是上不去”的案例背后真正的原因。5. 常见问题排查实录5.1 为什么我的服务同时只能处理那么点连接先检查系统对文件描述符数量的限制。Linux 下用 ulimit -n 查看默认可能只有 1024。意味着同时最多只能打开 1024 个 fd其中还要算上标准输入输出和监听 socket。在高并发测试中第一个症状通常就是 accept 返回 EMFILE。解决办法是调高 ulimit或在系统配置里把 nofile 的上限调大同时确认进程的实际限制。一个容易漏掉的点即使主进程调高了限制如果服务 fork 了子进程子进程也会继承这个限制记得一起检查。5.2 数据收到了但业务没触发问题大多出在 ET 漏读客户端发来几 KB 数据服务端第一次触发可读事件时只读了一点剩余数据还在缓冲区里。LT 模式下内核会继续提醒所以没问题ET 模式下由于只通知一次如果不循环读剩下的数据就永远躺在缓冲区里直到对端再次发送新的数据才可能继续触发。排查时看两处一是注册事件时是否用了 EPOLLET二是读代码里是否在 read 返回 EAGAIN 之前一直循环。不要只在代码上怀疑内核丢事件绝大多数所谓“丢事件”都是 ET 模式自己没读完。5.3 epoll_wait 返回 0 是出 Bug 了吗不是。timeout 时间到了没有任何事件发生epoll_wait 就会返回 0这是正常行为。真正要小心的是另一种情况timeout 设置成了 0返回 0 就成了“立刻空转一次”如果主循环加了个忙循环CPU 会瞬间打满。很多高性能服务希望做定时检测应该用合理的超时时间比如 100ms 或 1s然后在返回后检查系统时间判断是否有定时任务需要执行。5.4 定位 CPU 100% 的实用方法epoll 模型下 CPU 打满往往不是系统负载高而是某些 fd 被反复标记为就绪。最常见的原因是fd 已经关闭但没有从 epoll 中删除内核以为它还可读于是每轮都通知或者 read 循环没有正确退出EAGAIN 没有被识别。排查时可以在事件处理开头打印 fd 和事件类型同时记录该 fd 最近几秒被触发的次数。如果一个 fd 每秒触发几千次且 read 都返回 EAGAIN那就是典型的“事件风暴”。另外记得检查是否忘了对 socket 设置非阻塞阻塞 fd 在某些异常情况下也会触发非常奇怪的调度行为。6. 从踩坑到顺手多年使用 epoll 的一些心得我个人现在的习惯是凡是刚开始做的新项目一律先用 LT 模式配合 epoll_create1 和清晰的事件分发把代码跑稳再把热点路径改成 ET 做性能优化。原因很简单LT 模式的代码更容易被后来接手的人理解也不容易因为漏读数据而产生诡异的线上问题。真正面对百万级连接且单连接数据吞吐偏大的场景时再仔细处理 ET 的循环读写收益会非常明显。另外一个被低估的技巧是把 epoll 的 timeout 用起来让它兼职做定时器。比如 timeout 设置为 200ms每次 epoll_wait 返回后检查当前时间发现超时连接就主动清理。这样既不用单独起线程做心跳又能减少无效唤醒。虽然是老办法但实现简单、效果直接很多框架的定时器底层都是这个概念。最后分享一个小建议排查 epoll 问题时别急着怀疑内核先把你自己的事件处理逻辑彻底读一遍。按我的观察九成以上的问题都出在用户态代码——ET 没读干净、EPOLLONESHOT 忘了重新注册、关了 fd 没删除注册、EAGAIN 判断错这些都踩过。理解 epoll 的触发语义比记住 API 参数重要得多。把这几个模型装进脑子里再写高并发服务就能少走很多弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑