服务器并发一上来就卡顿这几乎是每个写网络程序的人都会撞上的墙。前几年我做某内部网关服务时连接数从几十涨到几百进程直接被打满CPU 飙到 70% 还全是无效消耗。排查到最后问题就出在一个连接一个线程 阻塞读写这套朴素模型上。这篇文章是 Linux 系统编程系列的第九篇专门拆解多路 IO 复用也就是 select、poll、epoll 这一整套解决思路。如果你正被多客户端连接怎么扛这个问题困扰或者已经写出了一个能跑但不敢上线的服务端这篇文章就是给你准备的。我会先讲清楚阻塞模型为什么撑不住再逐个拆解三种复用方案的设计逻辑和实际用法最后把我这几年在真实项目里积累的选型经验和踩坑记录一并拿出来。1. 一次性处理上百连接阻塞模型最先崩塌的地方1.1 每连接一线程朴素方案的隐性成本一个最简单的网络服务往往长这样主进程 accept 一个连接然后 fork 一个子进程或者 pthread_create 建一个线程去处理这个连接的读写。在连接数只有个位数、十几个的时候这套代码几乎不会出问题。但连接数一上来麻烦就接踵而至。首先是线程/进程本身的开销。Linux 下创建一个线程默认栈大小往往是 8MB 的虚拟内存虽然大部分不会立刻被物理分配但你手里几万个文件描述符的规模一旦压上去光是地址空间和内核调度实体就是一笔不小的账。上下文切换更是耗 CPU 大户当线程数远超 CPU 核数时每次切换都要保存和恢复一堆寄存器、栈指针、浮点状态实际业务还没怎么处理时间就耗在切换上了。其次是阻塞模型自身的尴尬。每个线程的 read() 调用一旦碰上客户端不发数据就直接挂起。这本身不算错但问题在于这些挂起的线程占着资源却什么也干不了。假设三千个连接同时存在哪怕只有五十个连接在活跃读写你也要为另外两千多个线程付出调度和内存代价。更难受的是进程能创建的线程数是有限制的默认 ulimit 和 cgroup 限制一旦碰上服务就直接报错拒绝新连接。我在做那个网关服务时就亲测过400 个连接48 个线程并发读写CPU 负载平均已经到了 15 左右。表面看是 CPU 不够实际把耗时的调用栈拉出来大半时间消耗在 futex 等待和线程切换上。真正的业务计算占比不到两成。这就是阻塞模型在连接密集场景下最典型的死法——资源全部浪费在等待和切换而不是干活。1.2 轮询非阻塞套接字的忙等噩梦既然阻塞不行那换个思路把 socket 设为 O_NONBLOCKread() 没数据就立刻返回代码自己去轮询每个连接。这个方案理论上是可行的但实际写出来又是一条死路。非阻塞模式下read() 返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK 时代表当前没有数据。如果这时你用一个 for 循环去遍历所有连接挨个做非阻塞 read那么只要连接数一多问题就变成另一个极端每个循环周期里绝大多数 read 都是在做无用功白白消耗 CPU因为大部分 socket 上根本没有数据可读。我见过有人把这种方式当多路复用来吹理由是代码简单、不用线程。但你算一笔账就知道1000 个连接每轮循环就算每个 read 只要 1 微秒一轮就是 1 毫秒。如果真有客户端数据到达你平均要等半轮周期才能读到这延迟完全不可控。更尴尬的是网络数据到达是突发性的忙等busy polling实际上是在拿 CPU 换响应规模一大就是灾难。所以问题的本质很清楚我们希望有一种机制能让内核告诉我们哪些 fd 真的准备好了而不是用户态挨个去试。同时我们也不想让一个 fd 的等待阻塞住整个进程。多路 IO 复用正是为了这个目的诞生的把等待多个 fd 变成可读可写事件这件事交给内核内核有事件了再通知用户态来处理。2. select位图方案以及三个绕不开的瓶颈2.1 fd_set 的用法和它的效率假象select 是最古老也最容易理解的多路复用方案。它的核心数据结构是 fd_set本质上就是一张位图bitmap每一位代表一个文件描述符。你用 FD_SET 把感兴趣的 fd 加进集合调用 select 后内核会把有事件发生的 fd 对应的位保留其余清零。一个典型的 select 调用长这样fd_set readfds; struct timeval timeout {5, 0}; // 5秒超时 FD_ZERO(readfds); FD_SET(listen_fd, readfds); FD_SET(client_fd1, readfds); FD_SET(client_fd2, readfds); int max_fd client_fd2 client_fd1 ? client_fd2 : client_fd1; max_fd max_fd listen_fd ? max_fd : listen_fd; int ready select(max_fd 1, readfds, NULL, NULL, timeout); if (ready 0) { perror(select); } else if (ready 0) { printf(timeout, no data\n); } else { if (FD_ISSET(listen_fd, readfds)) { // 处理新连接 } if (FD_ISSET(client_fd1, readfds)) { // 处理 client_fd1 可读 } }select 的参数里第一个是要监视的最大 fd 加一后面三个 fd_set 分别对应可读、可写、异常事件。timeout 如果是 NULL 就无限等待。从调用方式上你已经能嗅到一些不对劲的地方为什么每次都要传 max_fd 1因为内核需要从头到尾扫描这张位图扫到 max_fd 为止。这就是 select 性能模型的第一颗定时炸弹——它和最大 fd 值强相关扫描成本是线性的。2.2 1024 上限、重新传入、线性扫描这三个坑怎么来的第一个硬限制是 fd_set 的大小。在 Linux 上FD_SETSIZE 默认是 1024也就是说一组 fd_set 最多只能表达 0 到 1023 这 1024 个 fd。你就算把进程的文件描述符上限调到了 65535select 也管不过来因为位图本身只有那么大。网上有人通过重新定义 FD_SETSIZE 来绕过内核确实也支持更大的位图但这种做法需要重新编译所有相关代码而且不能保证所有 libc 实现都买账生产环境这么折腾不如直接换方案。第二个问题是每次都重新传入。select 返回后内核会把没有事件的位清零所以你需要保留一份用户态的 fd 备份每轮循环开始时重新 FD_SET 一遍。如果你要监视上千个描述符这轮拷贝高频发生又给瓶颈添了一把柴。第三个问题是线性扫描。核心时间复杂度是 O(n)n 是最大 fd 值。哪怕里面只有 3 个 fd 加入了集合只要有一个 fd 的编号是 2040select 也得把位图扫到大约 2040 这个位置。这样一个看似简单的 API在高 fd 编号场景下效率非常难看。从个人经验来看select 唯一让我感觉还算能用的场景是网络监视端口的简单工具fd 数量少轮询频率低逻辑简单。一旦把它放进高并发的服务主循环性能曲线会非常陡峭地向下走。我做某个模拟项目 X 时一开始图省事用 select连接数超过两千后单次 select 耗时从几十微秒涨到一毫秒以上整机性能肉眼可见地拉胯。果断换成 epoll 之后同样的连接规模下事件返回延迟降了两个数量级。3. poll 的进步与未竟的问题3.1 从 fd_set 到位图poll 从 API 设计上比 select 顺眼了不少它把监听的描述符和事件都用结构体数组表达不再受 FD_SETSIZE 1024 的限制。核心结构是struct pollfd { int fd; /* 文件描述符 */ short events; /* 要监听的事件掩码 */ short revents; /* 返回的实际发生事件 */ }; int poll(struct pollfd *fds, nfds_t nfds, int timeout);用法上你先填一个 pollfd 数组每个元素写明 fd 和需要的 events然后一次 poll 调用把整个数组传给内核。内核返回后每个元素的 revents 就会被填上实际发生的事件。你要判断哪些 fd 就绪就遍历这个数组逐个看 revents 是否对应 POLLIN / POLLOUT / POLLERR 等。跟 select 相比poll 的几个进步非常实质数组长度可以做到很大理论上能监视的连接数只受系统文件描述符上限约束不需要再维护最大 fd 值 1这种别扭的传参事件字段和返回事件字段分离用户不需要做备份因为 events 不会被内核覆盖。写代码的体验也直观不少struct pollfd fds[3]; fds[0].fd listen_fd; fds[0].events POLLIN; fds[1].fd client_fd1; fds[1].events POLLIN | POLLOUT; fds[2].fd timer_fd; fds[2].events POLLIN; int ret poll(fds, 3, 5000); if (ret 0) { for (int i 0; i 3; i) { if (fds[i].revents POLLIN) { // 这个 fd 上有数据可读 } } }3.2 poll 尚未解决的线性扫描和重复操作poll 没有解决的核心问题还在性能模型上。每次 poll 调用时内核依然要把你传入的整个 pollfd 数组从头到尾检查一遍看哪些 fd 上注册的事件已经就绪。这个检查的复杂度是 O(n)n 是数组长度和 select 的 O(max_fd) 殊途同归。还有一个隐藏的重复开销每次调用 poll你都要把完整的 pollfd 数组从用户态拷贝到内核态。这个拷贝成本在数组很大时会非常明显因为每次调用都是全量拷贝。等到 poll 返回内核又把 revents 写回。一来一回内存带宽和锁开销全花在搬运数据上。在实际压力测试里我用 poll 管 5000 个空闲连接的时候单次 poll 循环空转也要几十微秒。这已经比 select 好多了但离高并发还有距离。而且每次循环你都得把 pollfd 数组整个填一遍如果你还要动态增删 fd那维护数组的腾挪操作也会成为另一类麻烦。我想再补充一个 poll 的细节坑timeout 为 0 时poll 立即返回适合配合非阻塞逻辑做轮询timeout 为 -1 时无限阻塞。这个参数语义不复杂但很多人会忘记 0 和 -1 的区别导致写出来的服务要么忙等烧 CPU要么一旦没有事件就永远卡住。select 的 timeout 是 struct timeval可以精确到微秒而 poll 的 timeout 是毫秒实时性敏感的场景要注意这个粒度差距。4. epoll 的回调式设计把事件列表放进内核4.1 三个系统调用完成的完整数据流epoll 是 Linux 2.6 之后内核专门为高并发网络场景设计的事件通知机制它的设计和 select/poll 有本质区别。select/poll 是每次调用之前用户把关心的 fd 列表交给内核内核扫描后返回哪些 fd 有事件而 epoll 是用户预先在内核里注册一批 fd内核有事件时主动回调记录用户调用 epoll_wait 时只取就绪列表。注册和等待分离这是关键。使用 epoll 通常涉及三个系统调用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 创建一个 epoll 实例返回一个文件描述符。注意它的 size 参数在内核 2.6.8 之后已经被忽略了早期版本用来提示内部哈希表大小你现在传 1 或者 1024 效果一样。epoll_ctl 负责增删改op 可以是 EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL分别对应注册、修改、删除一个 fd 的监听。epoll_wait 则是真正的等待操作它把就绪的事件列表拷贝到用户传入的 events 数组里最多拷贝 maxevents 个。如果某个 fd 上发生过事件epoll 内部维护的就绪链表会把它挂上来用户态拿到的就是一份已经过滤好的、只包含就绪 fd 的列表。这个设计对性能的影响是决定性的。select/poll 每次调用都要全量扫描用户传入的所有 fdepoll_wait 返回时只搬运有事件的那一小撮 fd。当连接数以万计、活跃连接只有几百的时候差距是几十倍甚至上百倍。4.2 水平触发与边缘触发的本质区别使用 epoll 时你会在注册事件时设置一个类型EPOLLLT 或 EPOLLET。先说结论默认是水平触发Level-TriggeredLT它和 poll 的语义几乎一样——只要 fd 上还有数据未读或仍可写epoll_wait 就会一直把它放进就绪列表。边缘触发Edge-TriggeredET则只在状态变化的那一刻通知一次如果你没把数据读完下一次只有新数据再来时才会再次通知。用生活化的类比LT 就像快递柜提醒你有个快递在柜子里你没取它就一直短信提醒你ET 就像门铃快递员按一次门铃你没听到他也不会再按只有下次他再来才会再响一次。LT 的优点是不容易丢事件写起来安全就算读了一半忘了继续读也没关系下次 epoll_wait 还会带上它。代价是每次 epoll_wait 可能反复返回同一个 fd导致多几次无效遍历不过这个代价相对小。ET 则更高效因为它要求用户必须把数据一次性处理完避免反复唤醒。但 ET 有严格的使用前提必须配合非阻塞 IO 使用。原因是 ET 只通知一次你不知道数据是否已经读尽必须一直 read 到 EAGAIN 才算处理完如果用的是阻塞 socket最后一次 read 会卡死整个事件循环。ET 模式下还有几个容易翻车的细节。比如读数据时如果缓冲区不够大你一次读不完那残余数据就会一直躺在内核缓冲区里而你又等不到下一次通知客户端看到的是你的服务没反应。这正是很多人骂 ET难用的原因。用 ET 的正确姿势是把用户态缓冲区开得足够大或者实现协议层的分包和续读逻辑确保 read 到 EAGAIN 为止。我在实际项目里LT 和 ET 都用过。简单转发代理、小数据量的协议用 LT 足够稳做高吞吐框架时我会用 ET 配合非阻塞 IO避免大量空闲 fd 反复出现在就绪列表里。4.3 惊群问题与 EPOLLONESHOT多线程环境下用 epoll有一个著名的坑叫惊群thundering herd。简单说多个线程同时阻塞在同一个 epoll_fd 的 epoll_wait 上当一个事件到达时所有线程都可能被唤醒但最终只有一个线程能成功处理这个事件其他线程醒来后发现没自己什么事又回去继续等。这些无意义的唤醒会对 CPU 造成冲击。老版本内核里这个问题很明显后来虽然做了优化内核只唤醒等待队列里的一个线程但当你用 ET 模式时由于事件只通知一次多个线程抢同一个 fd 仍然可能造成混乱。最稳妥的办法就是一个 fd 只被一个线程处理。实现方式有好几种要么各线程维护自己的 epoll_fd 和 fd 集合分片模型要么用 EPOLLONESHOT 标志让事件触发后自动把这个 fd 从监听集合中摘掉处理后重新加回去防止其他线程在事件未处理完时再次触发。EPOLLONESHOT 的用法是在 epoll_ctl 注册时把 EPOLLONESHOT 写进 events 掩码ev.events EPOLLIN | EPOLLET | EPOLLONESHOT; ev.data.fd client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, ev);事件触发一次后这个 fd 就不会再上报事件你需要在该事件的处理流程里完成读写后重新 epoll_ctl 用 EPOLL_CTL_MOD 把 fd 加回并重新设置事件。这实际上就是给一个 fd 一次只交给一个线程上了一把锁防止线程间竞争。5. 把多路复用写进真实服务一个事件循环的演化过程5.1 典型的主循环骨架纸上谈兵到这里差不多了我直接给一个基于 epoll 的单线程主循环骨架这段代码是我实际项目的简化版注释里包含工程上不能省的关键逻辑。#define MAX_EVENTS 1024 int epfd epoll_create(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]; 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) { // 接受新连接并注册到 epfd int cfd accept(listen_fd, NULL, NULL); if (cfd 0) { if (errno ! EAGAIN errno ! EWOULDBLOCK) { perror(accept); } continue; } set_nonblocking(cfd); ev.events EPOLLIN | EPOLLET; ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); } else { // 处理普通 fd 的可读事件注意 ET 需要 read 到 EAGAIN handle_client(fd); } } }这个骨架的核心思想是事件循环把所有 IO 等待合并进一次 epoll_wait从而在一个线程里管理大量 fd。listen_fd 关心 EPOLLIN新连接到达时 accept再把新 socket 注册进 epoll 实例。客户端 fd 关心 EPOLLIN数据到达时读入并处理业务。注意一个细节在 accept 接受新连接时如果用到的是非阻塞 listen socket那么 accept 返回 -1 且 errno 为 EAGAIN 是正常现象——这可能发生在循环连续 accept 很多个连接后或者有客户端在连接建立前就断开的情况。妥善处理这个 errno 可以避免你错误地中断主循环。5.2 超时、空闲连接处理、事件拆分真实服务不可能像骨架那么简单业务层的超时管理就是很多新手最容易搞砸的地方。你不可能让每条连接无限期挂在那里占着内存。最常用的手段是维护一个最小堆或者时间轮记录每条连接的最后活跃时间。每次事件循环开始时检查堆顶的连接是否超时超时就直接关闭。epoll_wait 的 timeout 参数也能帮上忙把 timeout 设为距离最近一条连接超时的时间毫秒这样 epoll_wait 既不会无限阻塞也不会过早空转。举个例子你维护了一个 idle_timeout 30 秒的规则最小堆顶连接的最后活跃时间是 10 秒前那 epoll_wait 的 timeout 可以设成 20000保证最迟 20 秒后这个循环必然会醒过来处理超时。另一个容易忽略的是半包问题。TCP 是流协议客户端发来的数据可能被内核拆成多次到达一次 epoll 事件并不代表一整条业务消息。如果你按收到一次事件就解析一条消息的方式写大概率会碰到消息被拆开、拼装错误的情况。这也是为什么企业级的网络框架都会在 IO 层之上再封装一层粘包/拆包协议处理比如按长度前缀拆帧。多路复用解决的只是什么时候能读读出来之后怎么拼成完整的业务消息是完全独立的一层工作。还有一类事件值得重视EPOLLERR 和 EPOLLHUP。它们不是正常读写事件却常常因为代码只关心 EPOLLIN 而被忽略。连接出现 RST、半关闭、对端异常退出时如果不处理这些异常事件fd 会一直残留在 epoll 实例里造成 fd 泄漏。所以注册 fd 时建议在 events 里带上 EPOLLERR 和 EPOLLHUP并在事件处理分支里把这它们当作连接异常来处理直接关闭 fd。5.3 从多线程到线程池如何和谐单线程事件循环能扛很高 QPS但 CPU 密集型的业务逻辑会把循环拖死因为一个请求的计算卡住了后续所有 fd 的事件都没人处理。解决办法自然是在 IO 事件循环后面挂线程池主线程负责 epoll_wait 和收发数据业务计算丢进工作队列由若干工作线程并发处理。这个时候你就需要格外注意连接的所有权问题。主线程读完请求后把请求体交给工作线程工作线程计算完再把响应写回 socket。但主线程可能同时又在 epoll_wait 里等这个 fd 的 EPOLLOUT 事件两边同时写同一个 fd就可能出现数据交叉。工程上常见的做法是每个连接维护一个写缓冲区所有写操作都在 IO 线程里完成工作线程只负责往这个缓冲区追加数据然后主线程统一 flush。这样并发压力就落在了缓冲区锁竞争上而不是 fd 的读写竞争上。如果某个连接的响应很长而客户端读得很慢你的写缓冲区就会积压。这时写事件必须注册进 epoll用 EPOLLOUT 通知主线程缓冲区有空位了再写。但 EPOLLOUT 是水平触发的只要 send buffer 没满它就会持续触发导致忙等。如果频繁出现这种现象通常意味着对端处理能力跟不上要考虑主动断开或降速而不是硬扛。6. 老项目迁移与新项目选型我的几条经验6.1 select/poll/epoll 的最终对比把三种方式放在同一张表里看选型思路会清晰很多项目selectpollepollfd 数量上限FD_SETSIZE默认 1024由系统限制决定由系统限制决定事件注册方式每次调用重新填充 fd_set每次调用重新提供 pollfd 数组注册一次可增删改每次调用的复杂度O(max_fd)O(n)有事件时只返回就绪项复杂度近似 O(就绪数)用户态数据拷贝全量位图全量 pollfd 数组仅就绪列表触发模式水平水平水平 边缘可移植性各种平台都支持POSIX 通用Linux 专属结论很直接新写的 Linux 服务如果预期连接数会上千甚至上万用 epoll 是唯一合理的选择。select 和 poll 在现代服务端最大的价值是兼容和教学它们让你理解事件通知模型是怎么一步步演进的。但有几个特殊场景 epoll 不适合。比如需要跨平台运行的程序——Windows 上没有 epollselect 反而是通用度最高的方案很多跨平台库内部就是用 select 做底层轮询兼容。再比如 fd 数量极少的程序select 的开销反而更低因为不需要调用 epoll_create 和 epoll_ctl 这类额外系统调用代码也更简洁。记住一条原则多路复用的性能收益体现在大量连接、少量活跃的场景如果本身就是三五个连接没必要上 epoll。6.2 老项目迁移与新项目选型几条经验与隐藏的坑迁移老项目的过程中我总结了几条实操经验先说明是按我自己的项目经历来的不同的环境可能会有些差异。最容易被忽略的是 EINTR。epoll_wait 返回 -1、errno 为 EINTR通常是被信号中断导致的这是正常现象不是致命错误。但很多新手的错误处理代码会把 EINTR 当成异常直接退出循环导致服务程序收到一个 SIGINT 或 SIGTERM 就莫名其妙挂掉。正确做法是遇到 EINTR 就 continue 继续等。其次是监听套接字的可写事件。有人会把 accept 出来的新连接立刻设置 EPOLLOUT 来测试可写性这种做法非常容易被误用。可写事件在大多数时候都是成立的因为新建立的 socket 发送缓冲区通常是空的EPOLLOUT 会立刻触发。如果你的业务不准备马上写数据注册 EPOLLOUT 只会带来无意义的唤醒。正确的做法是默认只监听 EPOLLIN等到确实要写数据且一次写不完时再用 EPOLL_CTL_MOD 临时加上 EPOLLOUT。还有一个关于 fd 上限的坑。即使你用了 epoll进程的文件描述符上限默认也才 1024 左右。想撑上万连接必须在启动前调大 ulimit -n或者在程序里调用 setrlimit 把 RLIMIT_NOFILE 提高。很多人在压测时发现 epoll_wait 明明写得没问题但连接数一到某个值就突然 accept 失败查到最后往往是这个限制没调。连接数调大之后还有个隐患是内存。每个 TCP 连接内核里都要维护收发缓冲区默认值通常在几十 KB 到上百 KB一万个连接加起来就是几百 MB 到上 GB 的物理内存。如果你用的还不是 io_uring 这类新机制光靠 epoll 并不代表你不需要关注内存上限。连接池的闲置超时、半关闭回收必须有配套策略否则再高的并发最终也会因为内存泄漏而崩掉。至于惊群问题前面提到过 EPOLLONESHOT我再补充一点如果你的进程是多进程模型每个进程各自创建一个 epoll_fd再把同一个监听 fd 放进去那新连接到来时多个进程会同时被唤醒。这种监听 socket 的惊群在较新内核里已经有了一定程度的缓解但直接采用 SO_REUSEPORT 让每个进程监听独立端口是最干净的回避方案。我个人的做法是新项目一律 socket 设置为非阻塞 epoll 的 ET 模式配合自研的读写缓冲区和线程池。这套组合在被压测工具打到两万并发连接同时保持每秒几千次业务交互时CPU 占用只有单核的六成左右。这些数字在几年前的 select 方案下是想都不敢想的。写这篇内容时我把 select 到 epoll 的演进脉络重新梳理了一遍。我一直觉得理解多路 IO 复用不能只背 API更重要的是理解每一次设计选择背后的动机为什么会有 fd 数量上限为什么每次调用都要重新注册为什么边缘触发必须配合非阻塞 IO。搞懂了这些为什么你在真实项目里踩坑的概率会大幅下降遇到怪异的行为也能更快定位到问题根源。如果你正在用 epoll 做自己的网络服务我最后再分享一个小建议把事件循环和业务逻辑严格分层事件循环只关心 fd 状态和数据的搬进搬出业务逻辑放在独立模块里。这样哪怕哪天你想把 epoll 换成 io_uring或者把单线程循环改成多线程分片代价都会被控制在很小的范围里。IO 模型是一个服务的地基地基只要够稳上面的业务层怎么折腾都不太会散架。