epoll 用了八年,我换成 io_uring 又换回来了:4C8G 上压了三轮,结论和网上不太一样

🔑 关键词:epoll,io_uring,网络编程,高并发,Reactor

📖 摘要:一个长连接网关的改造翻车记录。对比 epoll 和 io_uring 在容器、内核版本、调试成本上的真实差异,附实测参数和一份我自己的选型清单。

先说结论,免得有人看到一半想打我:在并发连接低于 5 万、服务跑在容器里的情况下,把 epoll 换成 io_uring,大概率是负优化。

图片

这句话要是三个月前说出来,我自己第一个不信。

我维护一个长连接网关,2000~3000 台设备常驻在线,每台 5 秒发一次心跳。原来是自己写的 epoll + 4 线程 Reactor,跑在腾讯云 4C8G 轻量服务器上,CPU 常年 15% 上下。但我那阵子刷到一堆文章,说 io_uring 是 Linux 异步 IO 的终极答案,能把 syscall 开销干掉一大半,性能翻倍。手就痒了,花了两个周末重写,然后花了三周时间和它对线。

一、epoll 的开销到底在哪,先把账算清楚

不把账算清楚,后面所有对比都是瞎扯。

一次典型的 epoll 读事件处理是这样:epoll_wait() 返回一批就绪 fd,然后你对每个 fd 调一次 recv(),返回 EAGAIN 才算读完。也就是说每个就绪事件至少两次系统调用。我在这台机器上用 perf stat -e raw_syscalls:sys_enter 粗估过,一次 syscall 大概 0.8~1.5 微秒(受 Spectre 补丁、CPU 型号影响,别当绝对值用)。

算一下:3000 个连接、每 5 秒一次心跳 = 每秒 600 次事件,大约是 1200 次 syscall,撑死 2 毫秒的 CPU 时间。这点开销在总 CPU 里连零头都算不上。真正吃 CPU 的是协议解析、JSON 编解码、内存拷贝和锁竞争,不是 epoll_wait。 这一点我踩坑之前根本没意识到。

图片

二、io_uring 好在哪,我改了哪些参数

io_uring 的思路,是把「提交请求」和「收割结果」从系统调用变成两个共享内存环形队列:SQ(提交队列)和 CQ(完成队列)。用户态往 SQ 里塞 SQE,内核把结果放进 CQ,你只需要偶尔 io_uring_enter 一次。

我实际用到的参数和用法,都是踩过一遍的:

  • io_uring_queue_init(4096, &ring, 0) —— 队列深度。别贪大,单个 SQE 是 64 字节,4096 深度光 SQ 就 256KB,加上 CQ 翻倍,多线程一人一个 ring 的话内存涨得很快。我最后降到 1024。
  • IORING_SETUP_SQPOLL —— 内核起一个 poller 线程替你做 enter,理论上做到 0 syscall。代价是它常驻吃一个核,而且需要 CAP_SYS_ADMIN(或者内核 5.13+ 的 IORING_SETUP_SQ_AFF 配合调优),容器里基本别想。
  • IORING_SETUP_SINGLE_ISSUER —— 6.0 才有的单提交者优化,能省掉一些原子操作。
  • io_uring_prep_multishot_accept() —— 5.19+。一次提交能接多个新连接,不用重复 prep,短连接场景提升明显。
  • IOSQE_BUFFER_SELECT + provided buffer ring —— 也是 5.19+。buffer 由内核从你预先注册的池子里挑,不用每次自己 malloc,省了一次分配。

看出来了吧,这一堆好东西全挂在 5.19 和 6.0 上。 我的生产环境是 Ubuntu 20.04,内核 5.4。

三、实测数据:长连接几乎没差别,短连接差得离谱

图片

测试机是 Debian 12,内核 6.1.0-13,压测客户端和被测服务在同一台物理机上(这点不严谨,我知道,但我只有这一台)。

场景 A:长连接回显。 8 个压测线程保持 3000 条 TCP 长连接,每条连接每秒发 100 个 64 字节小包。

  • epoll 版:CPU 32%,P99 延迟 1.8ms
  • io_uring 版(无 SQPOLL):CPU 27%,P99 延迟 1.6ms
  • io_uring 版(开 SQPOLL):CPU 35%,P99 延迟 1.5ms

开了 SQPOLL 反而更费 CPU,因为它多占一个核,而我这机器一共就 4 个核。这个数字我盯着看了很久,有点想笑。

场景 B:短连接。 20000 QPS 的 connect / 写 32 字节 / close。

  • epoll 版:CPU 45%
  • io_uring + multishot accept + provided buffer:CPU 28%

图片

这个差距是真的。每秒钟 2 万次连接,意味着 6 万次以上的 syscall,全在 accept / close 上。multishot accept 把这部分几乎抹平了,短连接密集场景才是 io_uring 真正的主场。

四、我为什么又换回去了

数据看着还行,但线上跑不起来。四个原因,按恶心程度排序:

1. 容器 seccomp 直接拦。 部署上去就 EPERM。Docker 默认的 seccomp profile 一度是屏蔽 io_uring_setup 的(CVE-2021-41073 那一波之后收紧的),后来虽然放开了,但很多托管 K8s 平台的 profile 还是没更新。我排查了一整晚,最后靠 strace 才发现压根没进到我的代码里。

2. 内核版本。 生产 5.4,想要的功能全在 5.19 和 6.0。升级内核在云主机上不是不能做,但我要为一个「大概提升 5%」的改动去动内核,收益账算不过来。

图片

3. 调试成本。 epoll 出问题,strace -f -e trace=epoll_wait,recv 一跑,谁在什么时候卡住看得清清楚楚。io_uring 出问题,strace 里基本看不到单个操作的细节,得用 bpftrace 挂 io_uring:* 的 tracepoint,学习曲线完全不是一个量级。我三次线上抖动,两次都是 CQ ring 里的结果没收干净。

4. 这东西的补丁还在飞。 2023 年 Google 在 Android 上直接对 app 禁用了 io_uring,ChromeOS 也做了类似的限制,原因就是历史漏洞太多、攻击面太大。这不代表 io_uring 不能用,但它说明这一层的安全模型还没稳定下来。对一个小团队来说,我不想当第一批吃螃蟹的人。

五、我最后想明白的一点(可能不太主流)

网上对比 epoll 和 io_uring,总是在比 syscall 次数、比 QPS、比延迟。我觉得这个比法从一开始就偏了。

它们真正的差别,是谁来维护那个状态机。

epoll 的世界里,状态机全在你用户态代码里:buffer 在哪、读了多少字节、半包怎么拼、下次超时什么时候。你的代码写得很笨,但每一行你都看得见,出问题你知道去哪个变量里翻。

图片

io_uring 是把一部分状态机下推给内核。你写的是「我提交了 32 个请求」,然后从 CQ 里捞结果。代码是短了,但你面对的是一个你看不见、也 trace 不到细节的内核状态机。省下的 syscall 开销,其实是拿调试能力和心智负担换来的。

所以我的判断标准不是并发量,而是这两个:你的瓶颈是 syscall 开销,还是你自己的代码复杂度? 如果是前者,io_uring 是解药;如果是后者,io_uring 大概率是换了一种病。

六、我现在怎么选

写下来给以后的我,也给正在纠结的人:

  • 连接数几十万但事件频率低(IoT 心跳、推送长连接)→ 继续用 epoll,瓶颈不在这。
  • 瞬间短连接洪峰、或者 accept 是主要开销(网关、代理、高频 RPC 入口)→ io_uring 值得试,但先确认内核 >= 5.19。
  • 跑在托管 K8s 上 → 先跑通再说,很多平台 seccomp 还没放行。
  • 团队里没人懂 io_uring 的内部结构 → 别上生产。你会在某个凌晨两点后悔的。
  • 只是想提升吞吐 → 先去查你的 JSON 解析器和内存分配器,收益大概率比换 IO 模型高得多。

另外补一句 epoll 自己的坑,新手特别容易栽:用了 EPOLLET 边缘触发,就必须配非阻塞 fd,而且每次事件必须循环读到 EAGAIN 为止。少读一次,那个 fd 就永远不会再通知你了,连接看着还在,但再也不回包。这种 bug 不报错、不断连,查起来极其折磨。

🏷️ 标签: