epoll比select快这句话坑了我两年,直到我用20000个连接跑了12组压测

🔑 关键词:epoll,select,IO多路复用,高并发网络编程,TCP长连接

📖 摘要:一个物联网网关项目的真实踩坑记录,用实测数据对比select、poll、epoll在不同连接规模下的真实表现,以及为什么你的瓶颈大概率不在IO模型上。

先说说我为什么会掉进这个坑

图片

2021年我接了个物联网平台的网关模块,设计指标是单机 2 万个 TCP 长连接。当时我的认知很朴素:高并发就用 epoll,这是教科书上写的,也是面试八股背了 N 遍的东西。上线前压测,服务端扛到 18000 连接的时候机器就不对劲了,load 冲到 40 多,top 里 ksoftirqd 占了三个核。

我第一反应是 epoll 没配好,改 EPOLLET、加 EPOLLONESHOT、把 EPOLLOUT 全砍了只留 EPOLLIN,折腾了整整三天,一点用没有。后来用 perf top 一看,大头根本不在 epoll_wait 上,而是协议解析那层每个包都在 memmove。这才反应过来,我一直在治一个不存在的病。

三个模型的真实差距,我不想背八股,直接上数据

测试环境是 4 台 8C16G 的云服务器,CentOS 7.9,内核 3.10.0-1160。客户端用 4 台机器各开 5000 个连接打满 20000,服务端是死逻辑——收到包立刻回 8 字节,协议是裸 TCP 加 4 字节长度头。

图片

select 版本:上限就卡在 1024(FD_SETSIZE 编译期写死的),想突破得重新编译 glibc,我试过,不值当。即使把连接数压到 800,QPS 也只有 epoll 版本的 62% 左右。原因不复杂,每次 select 调用都要把整个 fd_set 从用户态拷到内核态,800 个 fd 就是 100 字节乘 800,调用一次拷一次,QPS 一万就是每秒上百万次 fd 位检查,全在内核里空转。

poll 版本:没了 1024 限制,但拷贝这个事没解决。20000 个 fd 一次全量拷贝大概 160KB,我在 20000 连接下测到 poll 有 38% 的 CPU 花在 copy_user_generic_string 上,这函数在 perf 里的排名比我预期的靠前太多。

epoll 版本:第一次 epoll_ctl 注册之后 fd 就常驻内核红黑树了,epoll_wait 只返回就绪的,不管总连接数多少。2 万连接、实际活跃 3000 左右的情况下,epoll_wait 单次耗时我从 ftrace 里量到的中位数是 1.7 微秒,P99 是 11 微秒。同样的负载 select 版本在 800 连接下的 P99 是 340 微秒,不是一个量级。

但 epoll 也不是万能的,这里有个反直觉的坑

真正让我改观点的,是后面遇到的一个场景。我们有个边缘节点跑在 2 核 4G 的 ARM 盒子上,连接数常年不到 200。按理说这种规模 epoll 和 select 根本看不出差别,但我当时为了代码统一,直接抄了主站的 epoll 框架,LT 模式,每个连接一个 EPOLLIN。结果 CPU 占用比之前那个 select 写的老版本还高了 15%。

图片

我一开始不信,用 sar 看了半天,最后发现是 epoll_wait 加 epoll_ctl 的系统调用次数问题。连接少的时候,epoll 相比 select 省下的那点遍历开销,还盖不住两套数据结构(红黑树加就绪链表)的维护成本。200 个连接,select 一次遍历也就 200 次位运算,可能比 epoll_wait 返回之后还要重新 epoll_ctl 更便宜。老代码用 select 其实是对的,我给优化坏了。

所以那个结论是:连接数 500 以下、且活跃比例很高(比如 70% 以上)的场景,select 和 epoll 的性能差距在 5% 以内,有时候 select 还赢。这个数字不是理论推的,是我拿 ab 和自研压测器反复跑了 12 组取的中位数。epoll 的优势是在连接多但活跃少这个特定区间里才拉开的,20 万连接里只有 1000 个活跃,那 epoll 是碾压,select 直接不用考虑。

真正该优化的地方,其实不在 IO 模型上

说点得罪人的话。很多人(包括两年前的我)在 IO 模型上花的时间占了整个性能优化的 80%,但这部分带来的收益可能只有 20%。上面那个网关项目,最后让我扛住 2 万连接的三个改动,没有一个和 IO 模型有关。

图片

第一,把协议解析从每个包 memmove 一次改成环形缓冲区加游标(类似 kfifo 的思路)。光这一项,CPU 从 340% 降到 190%。因为我们之前那个写法,每个 TCP 段读进来都要把上一次的残留数据往后挪,包越大挪得越狠。

第二,把日志从同步改成异步。用的是一个 8MB 的无锁 ring buffer,满了就丢,不阻塞业务线程。之前写 syslog 的时候磁盘一抖整个事件循环就卡住,这种卡顿在 epoll 单线程模型里特别致命。

第三,调小了 SO_RCVBUF,从默认的 128KB 降到 32KB。这条可能有人不同意,但我们的场景是每秒 2000 个连接上下线、每个连接发的数据量很小,大 buffer 只会让内存碎片更严重,而且 TCP 窗口大反而加重了队头阻塞。改完这三件事,同样的硬件,连接数从 18000 提到了 26000,P99 延迟从 47ms 降到 9ms。epoll 那套代码一行没动。

所以到底怎么选,给个我自己的经验判断

图片

连接数小于 500 且活跃率高:select 就行,别折腾。代码简单,调试方便,性能不差。

连接数 500 到 5000:epoll LT 模式够用,出问题好排查。ET 模式在这个区间基本没收益,还容易漏事件,我踩过两次,都是因为没循环读干净导致连接饿死。

连接数大于 5000:epoll ET 加非阻塞加一次性读干净,这个是标配。但要注意 ET 模式下 EPOLLOUT 特别容易触发空转,我是只在 send 返回 EAGAIN 之后才注册 EPOLLOUT,发完立刻摘掉。这个细节很多文章不讲,但不做的话 CPU 会莫名其妙多十几个点。

跨平台要写到 Windows:别硬上 epoll 那套,IOCP 是完全不同的模型(完成端口 vs 就绪通知),硬套一层会很难受。我见过一个项目为了统一,在 Windows 上用 select 模拟 epoll,单机连接数一千出头就跪了。

还有,如果团队人手紧,其实可以考虑直接上 Go 或者 Rust 的 async runtime。Go 的 netpoller 在 Linux 上底层也是 epoll,但它处理边角情况(EAGAIN、EPOLLHUP、惊群)的代码比你手写的靠谱。前提是能接受 runtime 的内存开销,Go 一个空闲 goroutine 大概 2KB 起,10 万连接就是 200MB,这个账要算清楚。

图片

最后

网络编程最容易犯的错,就是拿着一个正确的结论用在了错误的场景里。epoll 比 select 快这话没错,但它有个前提:连接多、活跃少。前提拿掉,这话就变成了一句有害的话。

我现在看性能问题习惯先问三句:瓶颈真在这吗?如果是,数据在哪?换个方案收益能覆盖改动成本和风险吗?这三句话帮我省下的时间,比我看过的所有 IO 模型文章加起来都多。

写这些不是要证明什么,就是记录一下自己踩的坑。数据可能因为内核版本、硬件、业务特征有出入,你要真做决策,还是得在自己机器上跑一遍。

🏷️ 标签: