1. 从阻塞到复用一次真实业务把我逼进 I/O 多路复用事情得从一次线上事故说起。当时我维护一个内部推送服务逻辑很简单每个客户端连上来之后服务端要维护一条 TCP 连接等客户端发心跳再偶尔推数据下去。用最原始的方式写每个连接开一个线程线程里一个阻塞的read()等数据。最初连接量只有几百一切正常。等用户量涨到两三千问题就来了——线程数量跟着连接一起涨上下文切换把 CPU 打得满负荷内存被线程栈吃掉一大块甚至连创建线程都开始报错。那会儿我意识到光靠一个连接一个线程这种模型撑不起高并发。真正需要的是 I/O 多路复用用尽可能少的线程同时监听成千上万个文件描述符只有当某个 fd 真正可读可写时才去处理它。通俗地说从前台接待员一人只能盯一个窗口I/O 多路复用是一个人同时盯几百个窗口哪个窗口有人喊他他才过去。这篇文章不打算写成 API 手册而是想把 select、poll、epoll 这套东西掰开揉碎讲清楚。适合正在写网络服务、做后端开发、或者刚接触高并发编程的人看。我会结合自己踩过的坑、跑过的压测把原理和选型逻辑都讲透。理解这套东西之后再看 Redis、Nginx、Netty 这些高性能组件的设计会发现很多思路都是相通的。2. select、poll、epoll 的底层逻辑它们到底在做什么2.1 一切从等待说起先看阻塞 I/O 的问题。一个普通 socket默认是阻塞模式当没有数据到达时read()会一直卡住直到内核把数据从网络缓冲区复制到用户态缓冲区系统调用才返回。线程一旦卡在read()上就只能等这一个 fd别的 fd 就算有数据也顾不上。要同时处理多个 fd思路无非两种一种是多线程加阻塞 I/O用线程数堆另一种是多路复用用一个线程等待多个 fd。多路复用的核心是把谁就绪了这个判断交给内核来做。用户线程把一堆 fd 交给内核内核去检查这些 fd 有没有就绪有就绪的才通知用户程序处理。这样用户程序就不用在每个 fd 上傻等。select 是这种思路里最老的实现。它需要你维护一个fd_set位图把关心的 fd 加进去然后调用select()。内核会遍历这个位图把没有就绪的 fd 从位图里剔除最后返回就绪的 fd 总数。用户程序再逐个用FD_ISSET判断哪个 fd 还在集合里。fd_set readfds; FD_ZERO(readfds); FD_SET(listen_fd, readfds); FD_SET(client_fd, readfds); int maxfd client_fd listen_fd ? client_fd : listen_fd; int ret select(maxfd 1, readfds, NULL, NULL, NULL); if (ret 0) { if (FD_ISSET(listen_fd, readfds)) { // 有新连接 } if (FD_ISSET(client_fd, readfds)) { // 客户端 socket 可读 } }这段代码非常简单但简单背后藏着三个致命问题第一fd_set是固定大小的位图Linux 下默认上限是 1024连接多了一旦超过这个数就没法用了第二每次调用 select 都要把完整 fd 集合从用户态拷贝到内核态fd 多了之后这个拷贝开销非常明显第三内核也不知道你到底关心哪些 fd它只能每次从头到尾全量扫描一遍O(n) 的复杂度跑不掉。2.2 poll 只是在边界上改善poll 的出现解决了一部分问题。它不再用位图而是用一个struct pollfd数组来存放 fd 和关心的事件这样就没有 1024 的限制了fd 数量可以更大。每次调用poll()的时候同样需要把整个数组从用户态拷贝到内核态内核遍历数组检查每个 fd 的状态然后在revents字段里标记实际发生的事件。struct pollfd fds[1024]; fds[0].fd listen_fd; fds[0].events POLLIN; fds[1].fd client_fd; fds[1].events POLLIN; int ret poll(fds, 2, -1); if (fds[0].revents POLLIN) { // 有新连接 } if (fds[1].revents POLLIN) { // 客户端 socket 可读 }但仔细看poll 只是把 fd 的表示方式改得更灵活一点本质上还是每次全量拷贝 内核全量扫描。连接数到了几千、几万之后每次 poll 调用都变成一次大规模遍历性能瓶颈依然很明显。它适合中小规模并发场景但撑不起十万连接这种量级。2.3 select 和 poll 的通病反复拷贝与全量遍历我见过不少刚接触这块的人会觉得 select 和 poll 的区别只是 API 不同。其实它们的共同病根更值得关注每次调用都要把全部 fd 集合从用户空间传到内核空间内核再从头到尾扫一遍所有 fd然后在用户空间再扫一遍返回结果。这来回折腾的开销是随 fd 数量线性增长的。打个比方你有 1 万个快递柜格子每次想知道哪个格子里有包裹都得把 1 万个格子的号码全部报给管理员管理员一个个查完再告诉你哪几个格子有包裹。如果包裹没变化这个过程也要重复一万次。select 和 poll 就是这种工作方式。所以真正改变游戏规则的是后来的 epoll。3. 为什么 epoll 能承载十万连接事件驱动与就绪链表3.1 从每次都问到有事再通知epoll 在思路上做了一个根本转变。它不再让用户每次都把所有 fd 甩给内核扫描而是让你先把关心的 fd 注册到内核里维护一棵红黑树。内核在 fd 就绪的时候把对应的回调函数挂到一个就绪链表中。用户调用epoll_wait()时内核只需要把就绪链表里的 fd 拷回用户空间就行。这个机制有两个关键收益。第一注册过的 fd 是长期存在的不需要每次调用都重新传全量集合拷贝开销大幅下降第二内核不再全量扫描只有真正就绪的 fd 才会出现在结果里。换句话说epoll 是有事说事没事别打扰属于事件驱动模型。int epfd epoll_create1(0); 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[1024]; while (1) { int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // accept 新连接并 epoll_ctl 注册到 epfd } else { // 处理已连接 socket 的可读事件 } } }注意epoll_wait()返回的n代表就绪事件个数。大多数情况下n远小于注册的 fd 总数。这个设计让 epoll 的实际开销从 O(n) 降到了 O(就绪数量)两者差别在连接数大、活跃连接少的时候尤其巨大。3.2 水平触发与边缘触发一个很容易搞混的细节epoll 支持两种触发模式水平触发Level-TriggeredLT和边缘触发Edge-TriggeredET。水平触发是默认模式行为接近 poll。只要 fd 还有数据没有读完epoll_wait()返回过一次之后下次再调用仍然会返回这个 fd提醒你继续处理。这种模式写起来省心不容易漏事件但如果你每次只读一点数据同一个 fd 会被反复通知很多次浪费不必要的唤醒。边缘触发只在状态变化时才通知一次。比如缓冲区内从没有数据变为有数据这时通知一次之后如果还有剩余数据内核不会再主动通知。你必须用非阻塞 fd并且要把数据读到EAGAIN为止否则很容易漏掉后面来的数据。我一开始用 ET 的时候因为偷懒没做循环读取只read()了一次就返回主循环结果客户端发来的一大包数据只处理了一半剩下的一半直到下次新数据到来时才被“顺带”读出来。这是 ET 模式最常见的坑。while (1) { ssize_t nread read(fd, buf, sizeof(buf)); if (nread -1) { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 本次数据读完了等下一次边缘触发 } // 其他错误处理 } else if (nread 0) { // 对端关闭 close(fd); break; } // 处理 nread 字节的数据 }3.3 epoll 背后的红黑树、回调链表和 ready 列表epoll 之所以性能好除了事件驱动之外数据结构的选择也很讲究。每个注册的 fd 会挂到内核里的一棵红黑树上epoll_ctl()增删改查都是 O(log n)。当 fd 上有事件发生时内核协议栈会触发回调把这个 fd 对应的epitem加入到就绪链表中。所以 fd 再多的场景下就绪链表也只会记录真正发生了事件的那批。epoll_wait()的本质就是把就绪链表里的一部分内容拷贝到用户传入的events数组里。拷贝完成之后还涉及是否清空链表的工作如果是 ET 模式事件只触发一次标记会被清除直到下一次状态变化。如果用一句话概括 epoll 的厉害之处它把查一遍谁就绪做成了谁就绪就自己报名把内核和用户态之间的无效流量降到最低。4. 从系统调用到业务代码一次可复现的压测与选型依据4.1 测试场景与压测方法光讲原理不够我实际跑过一次对比压测用的是一台 4 核 8G 的虚拟机内核版本比较新。服务端分别用 select、poll、epoll 实现同一个原型服务监听 TCP 端口收到客户端请求后返回一个固定字符串。压测工具是自写的客户端程序一次性建立大量连接然后持续发送小包请求。测试连接数分别设置为 2000、10000、50000。每个连接建立后客户端发一条消息服务端回一条消息记录每秒完成请求数和延迟分布。因为只是原型业务逻辑几乎没有所以测下来的差距基本能反映多路复用机制本身的差异。结果和预期一致连接数在 2000 时select 勉强能用CPU 占用已经不低到 10000 时select 的单线程模型开始明显抖动因为有大量无效扫描。poll 比 select 略好但同样逃不脱全量遍历的宿命。epoll 在 10000 和 50000 连接下都能保持稳定单次epoll_wait()返回的就绪事件数量较少且非常集中CPU 消耗远低于另外两者。4.2 各方案的关键参数对照对比项selectpollepollfd 数量上限受 FD_SETSIZE 限制一般 1024受系统文件描述符限制受系统最大 fd 数量限制每次调用的拷贝量全量 fd_set全量 pollfd 数组增量注册 就绪列表内核工作方式全量遍历全量遍历红黑树 回调 就绪链表触发模式水平触发水平触发支持 LT 和 ET适合规模几百连接几千连接万级以上连接代码复杂度低中中高这个表是我在实际使用中的感受不是唯一标准。如果你的服务就几百个连接用 select 完全没问题甚至代码更直白。但如果你的目标是支撑万级以上长连接epoll 基本是目前 Linux 平台上的最优解。4.3 选型不能只看性能还得看线程模型多路复用机制选好之后线程模型同样重要。常见做法是单线程跑epoll_wait()拿到就绪事件后立刻处理如果业务处理耗时很短这种单线程事件循环就能支撑很高的吞吐Redis 就是这类模型的代表。如果处理业务需要查询数据库、调用外部服务等耗时操作就不能在事件循环里同步做否则一个慢请求会堵住后面所有事件。我当时的做法是 event loop 只负责收包和解包把解析出来的任务丢到线程池里执行。线程池的结果再通过另一个 epoll 实例或 eventfd 唤醒主循环来发送。这种IO 线程 Worker 池的模型兼顾了高并发连接和耗时业务处理也是很多网络框架的通用结构。这里要特别提一个细节不要把发送操作也放进 worker 线程里直接操作 socket。如果多个线程同时往同一个 fd 上写数据会出现数据交错和缓冲竞争。稳妥的做法是由一个专门的发送线程负责写或者每个连接加锁或者暂存到每个连接的发送队列再由 IO 线程统一 flush。5. 实战中容易踩的坑与排查思路5.1 fd 泄漏连接数缓慢上涨然后雪崩我接手过一个服务看起来用的 epoll 也没有问题运行几天后内存和 CPU 突然飙升。排查到最后发现是accept()之后忘记把新连接通过epoll_ctl加进去同时又没设置超时关闭导致大量连接处于半开状态文件描述符一路涨到进程上限。排查 fd 泄漏最直接的方法是监控/proc/pid/fd目录的数量或者每隔几秒打印一次当前进程的 fd 总数。如果总数只增不减基本可以认定有地方只打开了 fd 却没有 close。另外一个隐蔽点close()一个 fd 之后如果它还在 epoll 实例里注册内核会自动移除所以不会造成 epoll 实例泄漏。倒是反过来容易犯错已经close()的 fd又拿去填充epoll_event.data.fd导致复用后的 fd 被误操作。ls /proc/pid/fd | wc -l这个命令在排查时非常管用。看到数量持续上涨就逐个时代线程看它在哪一步分配了 fd通常能很快定位。5.2 惊群问题多线程同时 epoll_wait 同一个 epfd很多新手让多个线程共用一个 epoll fd大家同时调epoll_wait()。当有事件就绪时内核会唤醒所有等待的线程但只有一个线程能拿到事件其他线程白醒一场。在连接很多、唤醒频繁的场景下这种惊群现象会浪费不少 CPU。Linux 内核后来加入了EPOLLEXCLUSIVE标记可以让事件只唤醒其中一个线程。如果不需要多个线程同时反应建议加上这个标记。更干净的做法是每个 CPU 核心跑一个独立的 epoll 实例然后用 SO_REUSEPORT 让内核把连接分散到多个监听 socket 上这样每个线程只管自己那份连接互相不干扰。我当时遇到的现象是四线程共享一个 epfdCPU 总使用率不算高但epoll_wait()的调用次数异常多很多次返回 0 或者返回少量事件。用perf top看了内核热点确认是在唤醒路径上。改成 SO_REUSEPORT 分片之后同样的连接数唤醒开销明显下降。5.3 LT 模式下忘了读完数据导致忙轮询水平触发模式下如果 fd 一直有数据可读而你又没有把它读完那么epoll_wait()会立刻再次返回这个 fd形成忙轮询白白消耗 CPU。这种情况和 ET 漏数据正好是两个极端。我见过某个服务 CPU 瞬时飙到 100%最终定位到原因是业务层收到请求后只按固定长度读取了一部分数据剩下的包体留在内核缓冲区里代码认为一个请求已经处理完了就把事件标记清掉继续循环。由于 LT 模式会不断提醒你还有数据没读主线程就在这个 fd 上打转。解决方式取决于你的协议设计。要么保证每次read()都读到EAGAIN要么按照报文长度精确读完一个完整请求。万万不要在事件回调里只读一次、吞掉剩余数据。5.4 误用 EPOLLIN 和 EPOLLOUT只关注EPOLLIN也算常见坑。一个 socket 刚建立时发送缓冲区一般是空的如果你把EPOLLOUT也注册进去它会立刻变成可写事件因为能写在大多数时候是成立的。于是主循环被可写事件刷爆又没真正写数据白白消耗 CPU。正确的做法是一开始只关心EPOLLIN当你确实要把数据写出去并且发现write()返回EAGAIN时才把EPOLLOUT挂上去等写完了再把EPOLLOUT摘掉。这样可写事件只出现在具备意义的时刻避免忙唤醒。5.5 回调里做重活拖垮整个事件循环最后这个坑属于架构层面。事件循环本质上是一个不能阻塞的线程。任何耗时的操作比如磁盘读、远程调用、数据库查询如果直接写在事件回调里都会把整个事件循环卡住其他连接全部延迟。排查这类问题可以从延迟曲线入手如果偶发请求延迟极高但 CPU 占用不高大概率是有同步阻塞调用。我当时加了一个简单的环状历史缓冲记录每个事件回调的开始时间和结束时间发现有一个回调平均耗时 800ms才追溯到了某次外部请求的重试逻辑。把重试和等待放入线程池之后问题立刻消失。6. 我个人的取舍经验什么场景用什么模型做技术选型最怕手里有锤子看什么都是钉子。I/O 多路复用确实强但它不是万能的更不是所有网络服务都必须上 epoll。我想给几条比较实际的建议。如果你的服务主要是短连接比如简单的 API 网关请求处理完就关闭连接那么连接数可能只有几百用多线程阻塞 I/O 反而更简单代码可读性高出问题也好排查。只有当连接数高、连接空闲时间长、或者单机需要支撑上万并发时多路复用的优势才真正体现出来。如果选多路复用优先上 epoll。除非你在老系统上做维护否则没必要为了兼容 select 写那套FD_SETSIZE也不够用的代码。poll 可以作为中间过渡方案但长期看并不推荐。事件模型上大多数人应该从 LT 模式起步。ET 模式性能上限更高一点但要求你处理逻辑非常仔细包括非阻塞 fd、循环读取、正确处理EAGAIN任何一环疏忽都会带来诡异的 bug。把服务跑稳之后再根据瓶颈去考虑换 ET。最后想再说一句关于技术深度的体会。I/O 多路复用本身只是内核提供的一个能力用得好不好更多取决于你对事件驱动模型的理解以及对业务特性的把握。先搞清楚你的连接是密集活跃还是稀疏长连接再决定线程模型、缓冲策略和消息协议比盲目追新技术有用得多。这套底层知识看明白之后再学任何网络框架都会觉得轻松很多。