Q先生的世界

面朝大海,春暖花开

经典系统对比|epoll 与 io_uring:从高并发网络编程和事件循环视角看两代 Linux I/O 模型

上一篇文章,我们把 open/read/write/fsync/mmap/epoll/io_uring 放在同一张语义地图里讲了一遍。

其中有一个结论非常关键:

  1. epoll 主要是 readiness notification
  2. io_uring 更接近 submission/completion model

但如果你真的开始写高并发网络服务,这个结论虽然正确,却还不够落地。

因为现实里工程师真正要解决的问题并不是:

  1. 背出两个接口的定义

而是:

  1. 事件循环应该怎么写
  2. 为什么 epoll 下程序常常长成一大堆状态机
  3. io_uring 到底是在减少什么复杂度,又引入了什么新复杂度
  4. 为什么很多成熟网络服务现在仍然大量使用 epoll
  5. 为什么 io_uring 又会被很多人认为代表了 Linux I/O 模型的一次明显前进

也就是说,如果你真的从高并发网络编程角度去看,这两个东西的差异远不止“接口长得不一样”。

它们其实代表了两种不同的系统组织方式。

所以这篇文章单独把这个话题拎出来,专门从下面几个视角讲:

  1. 高并发网络编程
  2. 事件循环
  3. readiness model
  4. submission/completion model
  5. 状态机复杂度
  6. 工程取舍

这篇的目标不是做一份 API 手册,而是帮你真正回答:

如果我在写一个高并发 Linux 网络服务,应该怎样把 epoll 和 io_uring 放在同一张脑图里看。


1. 先给一张总图:两者分别在事件循环里扮演什么角色

先别陷进函数名,先看事件循环结构。

epoll 世界里的典型循环

while true:
    events = epoll_wait(epfd)
    for ev in events:
        if ev.fd is listening socket:
            accept until EAGAIN
        if ev.fd is readable:
            read until EAGAIN
        if ev.fd is writable:
            write pending buffers until EAGAIN
        update connection state machine

这里的核心是:

内核告诉你“谁现在可以做事了”,接下来真正做事还是你自己。

io_uring 世界里的典型循环

while true:
    submit pending accepts/reads/writes/timeouts to SQ
    enter kernel if needed
    completions = consume CQEs
    for cqe in completions:
        inspect result
        schedule next operation
        update connection state machine

这里的核心是:

你先把要做的事描述给内核,内核做完后把结果回给你。

这两者最本质的差别,实际上就是:

  1. epoll 以“就绪事件”为中心
  2. io_uring 以“操作完成事件”为中心

2. 为什么高并发网络编程首先会撞到“等待模型”这个问题

如果你只有一个连接,事情其实很简单:

  1. accept
  2. read
  3. 处理请求
  4. write

同步阻塞写法就能跑。

真正的问题出在连接数上来以后。

如果你有一万个连接,就会立刻遇到两个问题:

  1. 我怎么知道下一步该处理哪个 fd
  2. 我怎么避免为每个连接都配一个阻塞线程

这就是事件循环存在的原因。

epollio_uring,都在试图回答同一个大问题:

当系统里有大量并发 I/O 活动时,应用应该如何高效地等待、调度和推进这些连接。

只是它们给出的答案不同。


3. epoll 的世界观:先问“谁准备好了”

如果把 epoll 压缩成一句话,那就是:

它不是替你做 I/O,而是替你高效地等 I/O 变得值得你去做。

这句话特别重要。

很多人第一次学 epoll,会误以为它是“高性能网络 API”。

更准确地说,它是:

  1. 一个就绪事件分发器
  2. 一个大型 fd 集合上的等待优化器
  3. 一个事件循环的内核侧观察器

也就是说,在 epoll 模型下,你真正的程序结构通常是:

  1. 所有 socket 设成 non-blocking
  2. 把它们注册到 epoll interest list
  3. epoll_wait() 告诉你谁可读、谁可写
  4. 然后自己调用 read/recv/write/send

所以 epoll 解决的是“等谁”的问题,不是“替谁干活”的问题。


4. epoll 为什么会天然长出状态机

只要你把上面那条路径认真写一遍,就会发现一个现实:

epoll 世界里,内核只告诉你‘现在可以尝试’了,但一次操作未必能完整做完。

例如一个 HTTP 连接,你可能要处理:

  1. 连接建立
  2. 请求头读取
  3. 请求体读取
  4. 业务处理
  5. 响应头写出
  6. 响应体写出
  7. keepalive 或关闭

而这些阶段中,任意一步都可能出现:

  1. 只读到一半
  2. 只写出一半
  3. 暂时又不可读/不可写了
  4. 需要等下一轮事件

于是应用就不得不维护一套连接状态机:

READING_HEADERS
READING_BODY
PROCESSING
WRITING_HEADERS
WRITING_BODY
KEEPALIVE
CLOSED

这不是因为程序员喜欢写状态机,而是因为 readiness model 天然会把“半完成操作”的复杂度留在用户态。


5. epoll 的真正优势:不是神秘,而是简单、成熟、可预测

说到这里,epoll 看起来好像有很多包袱:

  1. 要非阻塞 fd
  2. 要手写状态机
  3. 要处理 EAGAIN
  4. 要小心边沿触发和水平触发

这些都是真的。

但它为什么这么多年仍然大量存在?

因为它也有几个非常扎实的优点:

  1. 模型清楚
  2. 成熟稳定
  3. 和 socket 网络编程天然契合
  4. 生态和最佳实践非常丰富
  5. 出问题时相对容易分层定位

对很多高并发网络服务来说,epoll 的吸引力不只是“能扛很多连接”,更在于:

它的复杂度虽然暴露在应用层,但暴露得比较诚实。

你知道自己在哪处理 read、在哪处理 write、在哪维护状态机、在哪做 backpressure。

这对工程可控性非常重要。


6. io_uring 的世界观:先问“我要做什么”

如果再把 io_uring 压缩成一句话,那就是:

它希望应用不只是等就绪,而是能直接把操作请求交给内核,再回来收完成结果。

这就是 submission/completion model 的核心。

于是程序的重心就从:

  1. 哪个 fd 可读可写了

转向:

  1. 我要提交一个 accept
  2. 我要提交一个 recv
  3. 我要提交一个 send
  4. 我要提交一个 timeout
  5. 我要等这些操作的 completion

也就是说,在 io_uring 的设计目标里:

应用不只是事件消费者,更像一个向内核提交工作描述的调度者。


7. 从事件循环角度看,io_uring 最想减少什么复杂度

它最想减少的,通常有三类。

7.1 反复系统调用的边界开销

epoll 模型下,常见路径往往是:

  1. epoll_wait
  2. 收到就绪
  3. read
  4. 可能下一轮再 write
  5. epoll_wait

io_uring 试图让:

  1. 批量提交操作
  2. 批量消费完成结果

变得更自然。

7.2 readiness 状态机里“试一下又不一定成”的复杂度

epoll 告诉你“可以试”,但不保证你这次逻辑上就能完全推进完。

io_uring 则更倾向于:

  1. 你先说清楚要做什么
  2. 内核做完后给你一个明确结果

这会把一部分“半完成操作”的思维负担转移到 completion model 里。

7.3 不同操作类型的组织割裂

在高并发服务里,你经常会混着处理:

  1. accept
  2. recv
  3. send
  4. timeout
  5. file I/O
  6. 某些其他辅助事件

io_uring 的吸引力之一,就是希望用更统一的框架来承载这些操作,而不只是盯着“哪个 fd 可读可写”。


8. 但 io_uring 没有消灭状态机,它只是改变了状态机长出来的位置

这一点非常关键。

很多人第一次听 io_uring,会产生一种过度乐观的期待:

  1. 有了 completion model,就不用自己维护复杂状态了

这不对。

HTTP、RPC、数据库协议、长连接、流控这些业务复杂度并不会凭空消失。

你仍然需要知道:

  1. 一个连接现在在读 header 还是 body
  2. 响应是不是还没发完
  3. 下一个应该提交 recv 还是 send
  4. timeout 和 cancel 怎么关联

也就是说,状态机还在,只是从:

  1. “收到可读可写后我要怎么推进”

更多转成了:

  1. “某个操作完成后,我接下来该提交什么”

这两者的心智负担不同,但都不是零。


9. 从网络服务角度,epoll 更像反应式循环,io_uring 更像提交式流水线

如果用更工程化的话来概括:

epoll 更像反应式循环

  1. 内核发来就绪信号
  2. 应用被动响应
  3. 每轮处理能推进多少算多少
  4. 然后回到下一轮等待

io_uring 更像提交式流水线

  1. 应用主动安排一批操作
  2. 内核执行
  3. 应用回收结果
  4. 再提交下一批操作

这也是为什么:

  1. epoll 世界里常见的是 event loop + callback/state machine
  2. io_uring 世界里更容易长出 submission queue orchestration + completion dispatch

两者都能做高并发网络编程,但程序骨架会明显不同。


10. epoll 的一个现实优势:网络生态就是围着它长大的

这件事不能低估。

很多成熟系统之所以继续大量使用 epoll,并不是因为大家没听过 io_uring,而是因为:

  1. 现有网络框架、事件库、经验文章、调优手册都高度围绕 epoll
  2. 故障模式大家熟
  3. backpressure、写缓冲、边沿触发、惊群等问题都有比较成熟的认知
  4. 运行多年后的可预测性很高

对生产系统来说,这种成熟度本身就是巨大价值。

也就是说,epoll 的工程优势不只是 API 本身,而是:

整个 Linux 高并发网络编程世界,长期以来就是围着它构建经验库的。


11. io_uring 的一个现实吸引力:它想把“等待”和“操作”重新整合起来

epoll 时代,一个典型网络程序通常要把两件事分开想:

  1. 等谁就绪
  2. 然后对谁做什么操作

io_uring 的魅力之一就在于,它试图让这件事更连贯:

  1. 直接提交要做的网络操作
  2. 等完成结果
  3. 继续提交下一步

于是某些场景下,程序结构会更像一条操作流,而不是“先等,再试,再等,再试”的循环。

这对一些需要大量小操作、批量推进、统一 completion 处理的系统来说,确实很有吸引力。


12. 但 io_uring 带来的新复杂度,也不能假装不存在

很多介绍 io_uring 的文章容易只讲优点,不讲代价。

从工程上看,它也有自己的复杂度来源。

例如:

  1. SQ/CQ 生命周期管理
  2. user data 关联与 completion 反查
  3. 操作链式依赖组织
  4. timeout、cancel、partial completion 的语义处理
  5. 内核版本能力差异
  6. 某些特性和行为在不同版本上的成熟度问题

也就是说,io_uring 不是“更现代所以自动更简单”,而是:

把一部分 readiness 世界里的复杂度换成了 submission/completion 世界里的复杂度。

你只是换了一种复杂,而不是进入了无复杂世界。


13. 从 CPU 与系统调用视角,两者的优化目标也不完全一样

如果再往底层一点看,epollio_uring 优化的重点也不同。

epoll

更像是在优化:

  1. 大量 fd 的等待与事件分发成本
  2. 避免 select/poll 那种线性扫描开销

io_uring

更像是在优化:

  1. I/O 提交与完成路径的系统调用边界
  2. 批量化处理
  3. 把不同类型操作统一塞进一个 completion-driven 框架

所以你不能简单说:

  1. io_uring 是“更快的 epoll”

更准确的说法是:

  1. 两者解决的问题有交集
  2. 但出发点和程序组织模型并不完全相同

这也是为什么很多 benchmark 容易误导人,因为它们把不同模型压成了一个“吞吐谁高”的问题。


14. 在网络协议栈里,epoll 的思维方式为什么特别契合长连接服务

像 HTTP/1.1 keepalive、HTTP/2 连接、多路 RPC、WebSocket 这些场景,本质上都很强调:

  1. 连接长期存在
  2. 每次推进一点点状态
  3. 可读可写事件交错发生

这和 epoll 的反应式模型天然很搭:

  1. 哪个连接有事了就处理哪个
  2. 哪一步推不动了就挂回去等下一次就绪

所以很多长连接服务器在 epoll 下长得很自然。

当然,自然不等于没有复杂度,而是:

复杂度和协议状态推进方式比较同构。

这也是 Nginx、Redis、很多 RPC 框架和自研事件循环长期稳定采用这条路线的重要原因之一。


15. 那 io_uring 在网络服务里最适合哪些思路

从网络编程角度,它特别适合这类思路:

  1. 希望把 accept/recv/send/timeout 等操作统一纳入同一 completion 流
  2. 希望减少频繁的 syscall 往返
  3. 希望更自然地批量提交和批量收割
  4. 愿意围绕 completion 结果重新组织调度逻辑

也就是说,它往往更吸引那些:

  1. 对系统调用边界和调度效率特别敏感
  2. 愿意重新设计事件循环骨架
  3. 能接受较新内核能力依赖

的系统。

它不是“自动更适合所有网络程序”,而是更适合那些愿意围绕它的世界观来重构程序的人。


16. 真实工程里,为什么“继续用 epoll”常常是一个完全合理的决定

这点值得明确说出来。

现实里你会看到很多非常成熟、性能非常强的系统,仍然主要基于 epoll

这并不保守,而往往是一个完全理性的工程决策。

原因可能包括:

  1. 现有架构已经围绕 readiness model 打磨多年
  2. 故障处理、监控、调优和排障经验都很成熟
  3. 引入 io_uring 需要重写大量事件循环与状态推进逻辑
  4. 新模型带来的收益未必足以覆盖重构成本和运维风险

所以工程上正确的问题通常不是:

  1. 新东西是不是一定该替换旧东西

而是:

  1. 新模型在你的瓶颈点上,是否真的能换来值得的收益

这是完全不同的判断方式。


17. 同样地,“想认真研究 io_uring”也完全合理

反过来,也不要因为 epoll 很成熟,就把 io_uring 当成一时热词。

它之所以被广泛关注,根本原因并不是宣传,而是它确实在尝试推进 Linux I/O 编程模型的边界。

特别是当你关心这些问题时:

  1. 大量异步操作统一调度
  2. 系统调用边界开销
  3. 批量提交与批量完成
  4. 事件等待和操作执行的重新整合

io_uring 就不只是“另一个 API”,而更像:

一个值得重新审视事件循环写法的系统能力。


18. 一个实用的选择框架:别先问谁先进,先问你的事件循环更像哪种骨架

如果把选择问题压缩成最实用的一句话,我会建议你这样问自己。

如果你的程序骨架天然更像:

  1. 维护大量非阻塞连接
  2. 根据可读/可写事件逐步推进协议状态
  3. 对 readiness 语义非常熟悉

那么 epoll 往往是非常自然的选择。

如果你的程序骨架更像:

  1. 希望把操作本身提交给内核
  2. 围绕完成事件来调度下一步
  3. 想把网络、超时甚至部分文件操作纳入统一 completion 流

那么 io_uring 会更值得认真考虑。

也就是说,不要先问:

  1. 谁更新
  2. 谁 benchmark 更漂亮

而要先问:

你的事件循环更像反应式循环,还是更像提交式流水线。


19. 常见误区集中拆一下

最后把几个高频误区集中拆开。

误区一:io_uring 就是 epoll 的升级版

不准确。它们有交集,但模型并不一样。

误区二:用了 io_uring 就没有状态机了

不是。状态机还在,只是组织方式改变了。

误区三:epoll 过时了

不是。它仍然是大量高并发网络服务的主力模型。

误区四:epoll 只要会调 API 就行

不是。真正的复杂度在非阻塞 I/O、状态推进、写缓冲、backpressure 和错误处理里。

误区五:io_uring 一定能直接带来显著收益

不是。收益要看 workload、架构、内核版本、实现质量和重构成本。


20. 总结:epoll 和 io_uring 的真正差异,不在名字,而在事件循环哲学

如果把这篇文章压缩成一句话,那就是:

epollio_uring 的真正差异,不只是一个老一个新,也不只是 API 形式不同,而是它们代表了两种不同的高并发事件循环哲学:前者围绕‘谁准备好了’,后者围绕‘我要提交什么、完成了什么’。

你真正该抓住的是这几条主线:

  1. epoll 是 readiness model,擅长大型非阻塞连接集合上的事件等待
  2. io_uring 是 submission/completion model,擅长把操作提交与完成统一组织起来
  3. epoll 的复杂度更容易暴露成状态机和 EAGAIN 推进逻辑
  4. io_uring 的复杂度更容易暴露成队列编排、completion dispatch 和依赖管理
  5. 两者都不是银弹,只是把复杂度放到了不同位置

所以如果你在写一个高并发网络服务,真正应该问的不是“谁更先进”,而是:

我的程序,到底更适合反应式事件循环,还是更适合提交式 I/O 流水线。

一旦这层想清楚,你再去看 Nginx、Redis、RPC 框架、新一代 Linux 网络运行时和各种 benchmark,判断就会稳很多。

如果继续沿这条线写下去,一个很自然的下一篇就是:

再单独写一篇高并发事件循环设计,专门讲 non-blocking socket、backpressure、写缓冲、超时和连接状态机该怎么组织。