之前的经验告诉我,聊“并发服务器”最容易踩的误区,就是把注意力全放在“多线程”上,却忽略了真正决定并发上限的那一层——多路 IO 复用。很多系统上升到几千、几万个连接之后突然打摆子,不是 CPU 不够快,也不是业务代码太烂,而是绝大部分线程都堵在某一个连接的 read 等待上。这篇文章想把这层窗户纸捅破:从阻塞模型开始讲清楚问题根源,再逐步拆开 select、poll、epoll 三代的差异,最后给出一个可以直接抄作业的 epoll 事件循环骨架,以及压测时我踩过的那些坑。
以下内容适合三类人:正在被并发连接数卡住的客户端网关开发者、想搞明白事件循环原理的学生,以及做技术选型时总在多线程与异步之间摇摆的工程师。
1. 阻塞模型为什么撑不住高并发:问题出在“等”这个字上
1.1 一次 accept 一个连接,排队等待的不仅仅是业务
我们写服务器最朴素的版本几乎都是这样:先 socket、bind、listen,然后进一个 while 循环,里面 accept 一个连接,再 read 请求、写响应、关连接。
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); bind(listen_fd, ...); listen(listen_fd, 128); while (1) { int conn = accept(listen_fd, NULL, NULL); char buf[1024] = {0}; int n = read(conn, buf, sizeof(buf)); write(conn, resp, resp_len); close(conn); }这种写法在作业和小工具里完全没问题,但一旦上线就原形毕露。关键在于accept()本身是阻塞的,同一时刻只能接受一个新连接;进入 read 之后,如果客户端迟迟不发数据,整个线程就死等在这一个连接上。结果就是:后面的连接全部建立成功,但进程根本没有机会去 accept,它们的 TCP 握手早就完成、待在完成队列里,应用层却一直不读。
一个连接空闲 10 秒,这个线程就白白等 10 秒。就算你在这段代码上下再多功夫,业务逻辑写得再高效,也改变不了“CPU 在等待期间没有任何正事可做”的事实。
1.2 “每连接一个线程”的解法,代价被严重低估
既然单线程不行,最简单的补救就是一个连接开一个线程。我以前给某内部管理系统写测试时也这么干过,连接一多就发现不对劲:
- 线程栈虽然虚拟内存不计入 RSS,但 1 万个线程就意味着上 GB 的虚拟地址空间,光线程管理的开销就很可观;
- 线程切换不是免费的,尤其是线程数远超 CPU 核数之后,调度器光切来切去就吃掉大量 CPU;
- 更本质的问题是:大多数连接在某一时刻是没有数据到达的,线程池并不能解决“忙等”的根源,只是把并发转换成了排队;
- 为了控制线程数量,你又得引入任务队列、拒绝策略、超时回收,复杂度立刻上来。
所以后来我的结论很明确:并发瓶颈的核心不是“谁在算”,而是“谁在等”。多路复用要解决的就是让一个线程去等一批连接,而不是让 N 个线程各等各的连接。
2. 多路复用接口的演进:select、poll、epoll 分别补齐了什么
2.1 多路复用的核心,是把“主动等”变成“委托内核等”
多路复用听起来很玄,本质却很简单:用户程序把一批需要监控的 socket 描述符交给内核,然后睡眠。内核负责盯着这些描述符,只要任何一个变得可读、可写、出错,就把这个描述符标记为就绪,唤醒用户线程,用户线程再去处理就绪的那几个 fd。
用服务员的类比就是:你不必站在每一张桌子旁边等客人叫点菜,而是把整片用餐区域交给服务员盯着,一旦有客人举手,他跑过来告诉你“3 号桌举手了”,你只需要处理 3 号桌。这个“替你盯着”的内核机制,就是 select、poll、epoll 存在的意义。
2.2 select:老前辈,但被 FD_SETSIZE 和线性扫描锁死
select的接口特点决定了它的边界:它用一个 fd_set 位图来标记要监控的描述符,默认上限是 1024。也就是说,一次 select 最多帮你盯 1024 个 fd,对万级连接的场景直接没戏。
就算你为了绕过上限去改内核参数,性能上也扛不住。每次调用 select,内核都要把整个 fd_set 从用户态拷贝到内核态,再从 0 扫到 max_fd,检查每一位对应的 socket 是否就绪。等 select 返回后,用户态还得再次遍历所有 fd 才能知道是谁有事件。两头都是 O(n),几百个连接还能接受,几万个连接就是灾难。
2.3 poll:取消了 1024 上限,但本质仍是轮询
poll比 select 聪明的一点,是改用struct pollfd数组来存放 fd 和事件标志,上限由内存决定,连接数不再被 1024 卡死。但它的工作机制仍然是:每次把整个 pollfd 数组拷贝进内核,内核挨个扫描,谁会变就绪就置位,返回后用户程序再遍历一遍。
所以 poll 是“能管更多了,但代价还是线性扫描”。很多人从 select 换到 poll 后连接数上去了,CPU 却居高不下,就是因为每次等待本身都消耗了 O(n) 的扫描成本。
2.4 epoll 的机制差异:内核事件表 + 就绪队列 + 回调唤醒
epoll 和前面两个有本质区别。它分三个接口:
epoll_create建立内核事件表;epoll_ctl往事件表里注册、修改、删除某个 fd 的关注事件;epoll_wait等待就绪事件。
这里最关键的三个变化:
第一,不需要每次等待都重新拷贝全部 fd 列表。注册时拷贝一次,之后靠事件表长期保存,等待返回时只带回就绪的 fd。
第二,就绪事件不是靠扫描得到的,而是内核在 socket 状态变化时通过回调机制,把对应 fd 放入一个就绪队列。epoll_wait 直接把就绪队列里的条目返回给用户,达到 O(就绪数) 的效果,而不是 O(总连接数)。
第三,它提供了 LT(水平触发)和 ET(边缘触发)两种模式。
- LT:只要缓冲区还有未读的数据,每次 epoll_wait 都会提醒你这个 fd 可读;
- ET:只在缓冲区从空变成有数据的“边缘”提醒一次,之后即使还有数据也不再通知,你必须一口气读完,直到 read 返回 EAGAIN。
一句话对比 LT 和 ET:LT 更友好、不容易漏读,但是同一个 socket 未读尽时会反复唤醒;ET 更节省系统调用,但要求事件处理代码必须循环读完缓冲区,处理不好就丢数据。
我把它们三个的关键差异排成了表:
| 维度 | select | poll | epoll |
|---|---|---|---|
| 最大 fd 数 | 受 FD_SETSIZE,通常 1024 | 主要受内存限制 | 主要受内存限制 |
| 等待代价 | 每次全量拷贝 fd 集合并扫描 | 每次全量拷贝 pollfd 并扫描 | 注册后由内核事件表维护,等待返回就绪队列 |
| 时间复杂度 | O(总 fd 数) | O(总 fd 数) | O(就绪数) |
| 触发模式 | 水平触发 | 水平触发 | 水平触发 + 边缘触发 |
| 适用场景 | fd 少、并发低 | 中等连接数 | 大量空闲长连接、高并发 |
3. 事件循环的最小可用实现:用 epoll 手写并发服务器
3.1 初始化监听 socket:非阻塞是标配
先做一个监听 socket 并放进 epoll。这里有几个固定操作不能少:设置SO_REUSEADDR避免重启时端口被占用,把监听 fd 设为非阻塞,因为 accept 在不阻塞模式下会用到 EAGAIN 处理。
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int one = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); set_nonblock(listen_fd); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_port = htons(8080); addr.sin_addr.s_addr = htonl(INADDR_ANY); bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 128); int epfd = epoll_create1(0); struct epoll_event ev = {0}; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);把监听 fd 放进事件表后,epoll_wait 一旦发现它可读,就说明有新的连接完成握手。
3.2 主事件循环:accept 与连接的读写分发
事件循环的框架是死板但清晰的:epoll_wait 返回一批就绪事件,遍历这批事件,如果是监听 fd 就 accept 新连接,否则处理已连接 socket 的读写。
struct epoll_event events[MAX_EVENTS]; while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == listen_fd) { // 用 while 循环把完成队列里的连接全部 accept 出来 while (1) { int conn = accept(listen_fd, NULL, NULL); if (conn < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; break; } set_nonblock(conn); struct epoll_event cev = {0}; cev.events = EPOLLIN | EPOLLRDHUP; cev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev); } } else { handle_io_event(events[i]); } } }注意这里有两个容易犯错的地方。
第一是 accept 要用 while 循环尽量把已完成的连接一次性取完。原因是监听 fd 变成可读之后,内核只是告诉你“完成队列里有至少一个连接”,并不保证只有一个。如果读一个就停,可能下一秒又有一次无意义的唤醒。
第二是连接 fd 必须也用非阻塞。ET 模式下尤其如此,你必须在读到最后拿到 EAGAIN 才能停下来,否则数据就留在缓冲区里。
3.3 处理读事件:读一点、解析一点、响应一点
读事件的写法取决于协议。如果只是回显示例,可以在 read 返回后直接 write 返回,但这离真实场景太远。真实场景中更常见的是:连接会持续复用,一次 read 可能只读到半个消息,需要缓冲区暂存,等消息完整后再处理。
void handle_io_event(struct epoll_event ev) { int fd = ev.data.fd; if (ev.events & (EPOLLHUP | EPOLLERR | EPOLLRDHUP)) { close(fd); return; } if (ev.events & EPOLLIN) { char buf[4096]; ssize_t n = read(fd, buf, sizeof(buf)); if (n <= 0) { close(fd); return; } // 这里先当作一次性请求处理:直接响应 char resp[] = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"; write(fd, resp, sizeof(resp) - 1); close(fd); } }上面的 close(fd) 在真实系统里应该写成“是否关闭由协议决定”。HTTP/1.1 默认长连接,你 close 了就等于放弃了 keep-alive 的优势;但如果做的是简单短连接协议,不 close 又会泄漏 fd。这里我刻意写成短连接示例,是为了让骨架代码跑起来可观测。
真正写长连接服务时,还需要做三件事:为每个连接维护一个读缓冲区;用状态机判断一次完整消息是否到达;处理半包时把剩余数据留在 per-connection 缓冲区里继续等下一个 EPOLLIN。
3.4 写事件的注册时机:别把 EPOLLOUT 一直挂着
新手最容易掉进去的坑,是无脑把所有连接的关注事件设为EPOLLIN | EPOLLOUT。
这非常危险,因为一个正常 socket 只要内核发送缓冲区没满,它几乎永远可写。你把 EPOLLOUT 一直挂在 epoll 上,就意味着 epoll_wait 每次都会无脑告诉你“可写”,事件循环就会进入忙转的空转状态,CPU 占用直接拉满,而实际上大部分时间根本没有数据要发送。
正确的做法是:默认只关注 EPOLLIN,只有当业务层要写数据且写缓冲区满了、一次 write/send 没有被完全接受时,才动态通过epoll_ctl(EPOLL_CTL_MOD)把这个 fd 加上 EPOLLOUT。等 EPOLLOUT 事件触发、发送完成后,再把 EPOLLOUT 从关注事件里摘掉。这个“按需挂 EPOLLOUT”的原则,能避免大量无意义的唤醒。
3.5 边界情况:断开、异常、超时
断开检测在事件循环里同样不能马虎。除了 EPOLLHUP 和 EPOLLERR,Linux 从 2.6.17 开始提供了EPOLLRDHUP,当对端主动关闭或半关闭时,它会提前触发。这样你可以在 read 返回 0 之前就知道对方挂了,及时回收连接资源。
超时清理一般不在 epoll_wait 内部做,而是用另一个定时器机制:每隔一段时间遍历活跃连接,把超过 N 秒没有读事件的连接踢掉。因为 epoll 不提供“事件超时”参数,连接空闲太久不清理,fd 数量会缓慢泄漏,最终把ulimit耗尽。
4. 单线程事件循环的天花板与多线程扩展思路
4.1 单线程 epoll 也有极限:CPU 核才是瓶颈
单线程事件循环能扛的并发连接数,通常比大多数人想象得高很多。几万条空闲长连接在 epoll 里几乎不占 CPU,真正消耗 CPU 的是连接事件到达后的业务处理:解析协议、访问数据库、组装响应。
等到 CPU 单核接近打满,再增加连接数就会开始明显劣化。这时候方向不是把事件循环改复杂,而是要把事件循环的数量扩到和 CPU 核数匹配。
4.2 扩展模型一:Acceptor 线程 + N 个事件循环线程
一种很经典的多线程架构是这样的:主线程只做一件事——监听端口、accept 新连接,然后把连接 fd 通过队列分发给 N 个 worker 线程;每个 worker 线程有自己独立的 epoll 实例,独立跑事件循环,只处理分配给它的连接。
好处是:
- 锁的粒度只发生在分发队列上,事件处理阶段不用抢全局锁;
- 每个 worker 内部的 epoll 数据结构独立,不会互相争用内核资源;
- 单线程事件循环写起来简单的优点被保留了下来。
代价是连接一旦分发到某个 worker,后续所有事件都由这个 worker 处理,如果不同连接负载不均,可能出现某个 worker 很忙、其他 worker 空闲。要缓解,就得在分发时做一些简单的连接级 hash 或者按当前负载动态分配。
4.3 扩展模型二:SO_REUSEPORT 多进程监听同一个端口
另一个方案是让多个进程直接监听同一个端口。Linux 3.9 之后的SO_REUSEPORT允许同一个端口被多个 socket 绑定,内核在做 TCP 握手时按负载把连接分散到这些 socket 上,最高效的负载均衡发生在内核协议栈本身。
这种模式很适合多进程部署,每个进程内部可以继续跑自己独立的 epoll,不需要人为分发连接。我在压测某个网关时试过这种方案,连接分配非常均匀,代码上也几乎没有增加复杂度,唯一要注意的是各进程的共享状态要通过外部存储或者其他 IPC 来同步。
4.4 业务处理不能阻塞事件循环
无论哪种多线程扩展,都逃不开一个铁律:事件循环线程里不能做阻塞操作。
数据库查询、远程调用、磁盘 IO 如果直接写在事件处理函数里,一旦某个请求慢了,整个 worker 后面排队的连接全部遭殃。处理方式一般两道:
- 计算密集或阻塞操作拆出去,放到单独的线程池里,线程池完成后再向事件循环投递一个“结果可写”的事件;
- 如果外部依赖的时间太长,宁可异步化:发起请求后先不等待,给这个连接挂一个 pending 状态,等结果回来再激活。
这就是很多高性能服务器的核心骨架:epoll 只管网络事件,业务线程池负责真正重活。
5. 压测中比并发数更重要的几个细节
5.1 先查系统参数:文件描述符上限
压测刚开始最常见的问题是:连接数到一两千就上不去了,socket 返回 “Cannot assign requested address” 或者进程直接报 too many open files。
第一步先看进程 fd 限制:
ulimit -n默认往往是 1024,意味着一个进程最多同时打开约 1024 个 fd。一个 TCP 连接占用一个 fd,压测客户端和服务器两边都要调大。再用cat /proc/sys/fs/file-max看系统级上限,如果不够就临时写大:
ulimit -n 655350但注意这只是 shell 层面,最终要写到启动脚本或 systemd 的 LimitNOFILE 里,否则重开后依然回落到 1024。
5.2 大量 TIME_WAIT 导致新连接失败
每次压测短连接请求,服务器状态里会堆积大量 TIME_WAIT。如果持续有大量短连接并发,而 net.ipv4.ip_local_port_range 给的端口范围不够,客户端很快会用尽本地端口,新连接全部失败。
排查方法:
ss -s看到 TIME_WAIT 数量成千上万,基本就是这个问题。临时手段可以允许回收复用:
sysctl -w net.ipv4.tcp_tw_reuse=1但生产环境我不建议靠这个掩盖问题,更好的解法是尽量使用长连接压测,或者在客户端多准备几个端口段。真正的高并发线上,数据库连接、内部 RPC 都比 HTTP 短连接依赖 keep-alive,这是架构层面要养成的习惯。
5.3 CPU 空转 100%,但吞吐量却很低
这是我在调试某个网关时特别印象深刻的场景。进程 CPU 一直满,但每秒请求数惨不忍睹,一看就是事件循环在空转。常见原因就那几个:
- 前面说的无脑挂 EPOLLOUT,导致每次 epoll_wait 都立即有可写事件返回,循环根本睡不下去;
- 读事件里没有处理完半包数据,但 LT 模式会反复唤醒,你又只处理其中一部分;
- accept 循环在没有新连接时出现空转,这往往是因为把 accept 写在了不是监听 fd 的分支里。
这种假象问题在压测中很隐蔽,因为业务代码看着完全是正常的,CPU 却全花在无意义的唤醒和扫描上。建议压测时开perf top或者 strace 看系统调用热点,循环到底是在哪一步“忙”的,一眼就能定位。
5.4 连接数上去了,但活跃连接并不多
高并发常被误认为是“最大在线连接数”,其实服务器真正消耗的资源取决于“每秒有多少活跃事件”,而不是“总共有多少条连接”。
压测时如果只测空闲长连接,一万条连接可能才占几兆内存,事件循环非常空闲。你真正该关注的是事件到达速率、读缓冲区和每连接内存占用。我一般会在压测脚本里造两类流量:一类是持续的心跳或小请求,考验事件处理能力;另一类是同时建立大量连接但长时间不发送数据,考验 fd 管理和超时回收能力。两类都得过,才能说并发能力到位。
5.5 记录“已读但未处理完成”的缓冲区大小
大流量压测下,另一个容易被忽略的指标是每个连接缓冲区的堆积情况。如果协议处理速度跟不上网络到达速度,读缓冲区会不断涨,内存很快被吃光,服务变得极其不稳定。
所以事件循环里一定要有水量控制:读缓冲区超过某个阈值就暂停读或者主动断开;补一个超时机制,定期清理那些“读进来但迟迟处理不完”的连接。这个阈值设多少,取决于单个请求的平均大小和业务处理时延,没有统一标准,但压测必须专门测这一项,不然线上突发流量一来,最先崩的往往是缓冲区内存。
写在最后的体会
我自己写并发服务器的过程,就是从“连接数上不去”一路排查到“事件循环到底怎么等”的整个过程。真正把多路复用的思路搞通之后,再去读那些高性能网络框架的源码,会觉得骨架其实都差不多:一个事件循环、一组注册到内核的 fd、一套按需触发的写注册机制,再加一个兜底的超时清理。希望这篇文章能把你在“并发服务器”上遇到的头疼问题,缩小到可以定位的范围内,哪怕只是帮你理解为什么 CPU 在空转、连接数为什么卡在某个数字上不去,也算没白写。