服务器并发一上来就卡顿,这几乎是每个写网络程序的人都会撞上的墙。前几年我做某内部网关服务时,连接数从几十涨到几百,进程直接被打满,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_NONBLOCK,read() 没数据就立刻返回,代码自己去轮询每个连接。这个方案理论上是可行的,但实际写出来又是一条死路。
非阻塞模式下,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。你就算把进程的文件描述符上限调到了 65535,select 也管不过来,因为位图本身只有那么大。网上有人通过重新定义 FD_SETSIZE 来绕过,内核确实也支持更大的位图,但这种做法需要重新编译所有相关代码,而且不能保证所有 libc 实现都买账,生产环境这么折腾不如直接换方案。
第二个问题是"每次都重新传入"。select 返回后,内核会把没有事件的位清零,所以你需要保留一份用户态的 fd 备份,每轮循环开始时重新 FD_SET 一遍。如果你要监视上千个描述符,这轮拷贝高频发生,又给瓶颈添了一把柴。
第三个问题是线性扫描。核心时间复杂度是 O(n),n 是最大 fd 值。哪怕里面只有 3 个 fd 加入了集合,只要有一个 fd 的编号是 2040,select 也得把位图扫到大约 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 每次调用都要全量扫描用户传入的所有 fd;epoll_wait 返回时只搬运有事件的那一小撮 fd。当连接数以万计、活跃连接只有几百的时候,差距是几十倍甚至上百倍。
4.2 水平触发与边缘触发的本质区别
使用 epoll 时,你会在注册事件时设置一个类型:EPOLLLT 或 EPOLLET。先说结论,默认是水平触发(Level-Triggered,LT),它和 poll 的语义几乎一样——只要 fd 上还有数据未读或仍可写,epoll_wait 就会一直把它放进就绪列表。边缘触发(Edge-Triggered,ET)则只在状态变化的那一刻通知一次,如果你没把数据读完,下一次只有新数据再来时才会再次通知。
用生活化的类比: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 的最终对比
把三种方式放在同一张表里看,选型思路会清晰很多:
| 项目 | select | poll | epoll |
|---|---|---|---|
| fd 数量上限 | FD_SETSIZE(默认 1024) | 由系统限制决定 | 由系统限制决定 |
| 事件注册方式 | 每次调用重新填充 fd_set | 每次调用重新提供 pollfd 数组 | 注册一次,可增删改 |
| 每次调用的复杂度 | O(max_fd) | O(n) | 有事件时只返回就绪项,复杂度近似 O(就绪数) |
| 用户态数据拷贝 | 全量位图 | 全量 pollfd 数组 | 仅就绪列表 |
| 触发模式 | 水平 | 水平 | 水平 + 边缘 |
| 可移植性 | 各种平台都支持 | POSIX 通用 | Linux 专属 |
结论很直接:新写的 Linux 服务,如果预期连接数会上千甚至上万,用 epoll 是唯一合理的选择。select 和 poll 在现代服务端最大的价值是"兼容"和"教学",它们让你理解事件通知模型是怎么一步步演进的。
但有几个特殊场景 epoll 不适合。比如需要跨平台运行的程序——Windows 上没有 epoll,select 反而是通用度最高的方案,很多跨平台库内部就是用 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 模型是一个服务的地基,地基只要够稳,上面的业务层怎么折腾都不太会散架。