经典系统对比|epoll 与 io_uring:从高并发网络编程和事件循环视角看两代 Linux I/O 模型
上一篇文章,我们把 open/read/write/fsync/mmap/epoll/io_uring 放在同一张语义地图里讲了一遍。
其中有一个结论非常关键:
epoll主要是 readiness notificationio_uring更接近 submission/completion model
但如果你真的开始写高并发网络服务,这个结论虽然正确,却还不够落地。
因为现实里工程师真正要解决的问题并不是:
- 背出两个接口的定义
而是:
- 事件循环应该怎么写
- 为什么
epoll下程序常常长成一大堆状态机 io_uring到底是在减少什么复杂度,又引入了什么新复杂度- 为什么很多成熟网络服务现在仍然大量使用
epoll - 为什么
io_uring又会被很多人认为代表了 Linux I/O 模型的一次明显前进
也就是说,如果你真的从高并发网络编程角度去看,这两个东西的差异远不止“接口长得不一样”。
它们其实代表了两种不同的系统组织方式。
所以这篇文章单独把这个话题拎出来,专门从下面几个视角讲:
- 高并发网络编程
- 事件循环
- readiness model
- submission/completion model
- 状态机复杂度
- 工程取舍
这篇的目标不是做一份 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
这里的核心是:
你先把要做的事描述给内核,内核做完后把结果回给你。
这两者最本质的差别,实际上就是:
epoll以“就绪事件”为中心io_uring以“操作完成事件”为中心
2. 为什么高并发网络编程首先会撞到“等待模型”这个问题
如果你只有一个连接,事情其实很简单:
acceptread- 处理请求
write
同步阻塞写法就能跑。
真正的问题出在连接数上来以后。
如果你有一万个连接,就会立刻遇到两个问题:
- 我怎么知道下一步该处理哪个 fd
- 我怎么避免为每个连接都配一个阻塞线程
这就是事件循环存在的原因。
而 epoll 和 io_uring,都在试图回答同一个大问题:
当系统里有大量并发 I/O 活动时,应用应该如何高效地等待、调度和推进这些连接。
只是它们给出的答案不同。
3. epoll 的世界观:先问“谁准备好了”
如果把 epoll 压缩成一句话,那就是:
它不是替你做 I/O,而是替你高效地等 I/O 变得值得你去做。
这句话特别重要。
很多人第一次学 epoll,会误以为它是“高性能网络 API”。
更准确地说,它是:
- 一个就绪事件分发器
- 一个大型 fd 集合上的等待优化器
- 一个事件循环的内核侧观察器
也就是说,在 epoll 模型下,你真正的程序结构通常是:
- 所有 socket 设成 non-blocking
- 把它们注册到 epoll interest list
- 等
epoll_wait()告诉你谁可读、谁可写 - 然后自己调用
read/recv/write/send
所以 epoll 解决的是“等谁”的问题,不是“替谁干活”的问题。
4. epoll 为什么会天然长出状态机
只要你把上面那条路径认真写一遍,就会发现一个现实:
在 epoll 世界里,内核只告诉你‘现在可以尝试’了,但一次操作未必能完整做完。
例如一个 HTTP 连接,你可能要处理:
- 连接建立
- 请求头读取
- 请求体读取
- 业务处理
- 响应头写出
- 响应体写出
- keepalive 或关闭
而这些阶段中,任意一步都可能出现:
- 只读到一半
- 只写出一半
- 暂时又不可读/不可写了
- 需要等下一轮事件
于是应用就不得不维护一套连接状态机:
READING_HEADERS
READING_BODY
PROCESSING
WRITING_HEADERS
WRITING_BODY
KEEPALIVE
CLOSED
这不是因为程序员喜欢写状态机,而是因为 readiness model 天然会把“半完成操作”的复杂度留在用户态。
5. epoll 的真正优势:不是神秘,而是简单、成熟、可预测
说到这里,epoll 看起来好像有很多包袱:
- 要非阻塞 fd
- 要手写状态机
- 要处理
EAGAIN - 要小心边沿触发和水平触发
这些都是真的。
但它为什么这么多年仍然大量存在?
因为它也有几个非常扎实的优点:
- 模型清楚
- 成熟稳定
- 和 socket 网络编程天然契合
- 生态和最佳实践非常丰富
- 出问题时相对容易分层定位
对很多高并发网络服务来说,epoll 的吸引力不只是“能扛很多连接”,更在于:
它的复杂度虽然暴露在应用层,但暴露得比较诚实。
你知道自己在哪处理 read、在哪处理 write、在哪维护状态机、在哪做 backpressure。
这对工程可控性非常重要。
6. io_uring 的世界观:先问“我要做什么”
如果再把 io_uring 压缩成一句话,那就是:
它希望应用不只是等就绪,而是能直接把操作请求交给内核,再回来收完成结果。
这就是 submission/completion model 的核心。
于是程序的重心就从:
- 哪个 fd 可读可写了
转向:
- 我要提交一个 accept
- 我要提交一个 recv
- 我要提交一个 send
- 我要提交一个 timeout
- 我要等这些操作的 completion
也就是说,在 io_uring 的设计目标里:
应用不只是事件消费者,更像一个向内核提交工作描述的调度者。
7. 从事件循环角度看,io_uring 最想减少什么复杂度
它最想减少的,通常有三类。
7.1 反复系统调用的边界开销
在 epoll 模型下,常见路径往往是:
epoll_wait- 收到就绪
read- 可能下一轮再
write - 再
epoll_wait
而 io_uring 试图让:
- 批量提交操作
- 批量消费完成结果
变得更自然。
7.2 readiness 状态机里“试一下又不一定成”的复杂度
epoll 告诉你“可以试”,但不保证你这次逻辑上就能完全推进完。
io_uring 则更倾向于:
- 你先说清楚要做什么
- 内核做完后给你一个明确结果
这会把一部分“半完成操作”的思维负担转移到 completion model 里。
7.3 不同操作类型的组织割裂
在高并发服务里,你经常会混着处理:
acceptrecvsend- timeout
- file I/O
- 某些其他辅助事件
io_uring 的吸引力之一,就是希望用更统一的框架来承载这些操作,而不只是盯着“哪个 fd 可读可写”。
8. 但 io_uring 没有消灭状态机,它只是改变了状态机长出来的位置
这一点非常关键。
很多人第一次听 io_uring,会产生一种过度乐观的期待:
- 有了 completion model,就不用自己维护复杂状态了
这不对。
HTTP、RPC、数据库协议、长连接、流控这些业务复杂度并不会凭空消失。
你仍然需要知道:
- 一个连接现在在读 header 还是 body
- 响应是不是还没发完
- 下一个应该提交
recv还是send - timeout 和 cancel 怎么关联
也就是说,状态机还在,只是从:
- “收到可读可写后我要怎么推进”
更多转成了:
- “某个操作完成后,我接下来该提交什么”
这两者的心智负担不同,但都不是零。
9. 从网络服务角度,epoll 更像反应式循环,io_uring 更像提交式流水线
如果用更工程化的话来概括:
epoll 更像反应式循环
- 内核发来就绪信号
- 应用被动响应
- 每轮处理能推进多少算多少
- 然后回到下一轮等待
io_uring 更像提交式流水线
- 应用主动安排一批操作
- 内核执行
- 应用回收结果
- 再提交下一批操作
这也是为什么:
epoll世界里常见的是 event loop + callback/state machineio_uring世界里更容易长出 submission queue orchestration + completion dispatch
两者都能做高并发网络编程,但程序骨架会明显不同。
10. epoll 的一个现实优势:网络生态就是围着它长大的
这件事不能低估。
很多成熟系统之所以继续大量使用 epoll,并不是因为大家没听过 io_uring,而是因为:
- 现有网络框架、事件库、经验文章、调优手册都高度围绕
epoll - 故障模式大家熟
- backpressure、写缓冲、边沿触发、惊群等问题都有比较成熟的认知
- 运行多年后的可预测性很高
对生产系统来说,这种成熟度本身就是巨大价值。
也就是说,epoll 的工程优势不只是 API 本身,而是:
整个 Linux 高并发网络编程世界,长期以来就是围着它构建经验库的。
11. io_uring 的一个现实吸引力:它想把“等待”和“操作”重新整合起来
epoll 时代,一个典型网络程序通常要把两件事分开想:
- 等谁就绪
- 然后对谁做什么操作
而 io_uring 的魅力之一就在于,它试图让这件事更连贯:
- 直接提交要做的网络操作
- 等完成结果
- 继续提交下一步
于是某些场景下,程序结构会更像一条操作流,而不是“先等,再试,再等,再试”的循环。
这对一些需要大量小操作、批量推进、统一 completion 处理的系统来说,确实很有吸引力。
12. 但 io_uring 带来的新复杂度,也不能假装不存在
很多介绍 io_uring 的文章容易只讲优点,不讲代价。
从工程上看,它也有自己的复杂度来源。
例如:
- SQ/CQ 生命周期管理
- user data 关联与 completion 反查
- 操作链式依赖组织
- timeout、cancel、partial completion 的语义处理
- 内核版本能力差异
- 某些特性和行为在不同版本上的成熟度问题
也就是说,io_uring 不是“更现代所以自动更简单”,而是:
把一部分 readiness 世界里的复杂度换成了 submission/completion 世界里的复杂度。
你只是换了一种复杂,而不是进入了无复杂世界。
13. 从 CPU 与系统调用视角,两者的优化目标也不完全一样
如果再往底层一点看,epoll 和 io_uring 优化的重点也不同。
epoll
更像是在优化:
- 大量 fd 的等待与事件分发成本
- 避免
select/poll那种线性扫描开销
io_uring
更像是在优化:
- I/O 提交与完成路径的系统调用边界
- 批量化处理
- 把不同类型操作统一塞进一个 completion-driven 框架
所以你不能简单说:
io_uring是“更快的 epoll”
更准确的说法是:
- 两者解决的问题有交集
- 但出发点和程序组织模型并不完全相同
这也是为什么很多 benchmark 容易误导人,因为它们把不同模型压成了一个“吞吐谁高”的问题。
14. 在网络协议栈里,epoll 的思维方式为什么特别契合长连接服务
像 HTTP/1.1 keepalive、HTTP/2 连接、多路 RPC、WebSocket 这些场景,本质上都很强调:
- 连接长期存在
- 每次推进一点点状态
- 可读可写事件交错发生
这和 epoll 的反应式模型天然很搭:
- 哪个连接有事了就处理哪个
- 哪一步推不动了就挂回去等下一次就绪
所以很多长连接服务器在 epoll 下长得很自然。
当然,自然不等于没有复杂度,而是:
复杂度和协议状态推进方式比较同构。
这也是 Nginx、Redis、很多 RPC 框架和自研事件循环长期稳定采用这条路线的重要原因之一。
15. 那 io_uring 在网络服务里最适合哪些思路
从网络编程角度,它特别适合这类思路:
- 希望把 accept/recv/send/timeout 等操作统一纳入同一 completion 流
- 希望减少频繁的 syscall 往返
- 希望更自然地批量提交和批量收割
- 愿意围绕 completion 结果重新组织调度逻辑
也就是说,它往往更吸引那些:
- 对系统调用边界和调度效率特别敏感
- 愿意重新设计事件循环骨架
- 能接受较新内核能力依赖
的系统。
它不是“自动更适合所有网络程序”,而是更适合那些愿意围绕它的世界观来重构程序的人。
16. 真实工程里,为什么“继续用 epoll”常常是一个完全合理的决定
这点值得明确说出来。
现实里你会看到很多非常成熟、性能非常强的系统,仍然主要基于 epoll。
这并不保守,而往往是一个完全理性的工程决策。
原因可能包括:
- 现有架构已经围绕 readiness model 打磨多年
- 故障处理、监控、调优和排障经验都很成熟
- 引入
io_uring需要重写大量事件循环与状态推进逻辑 - 新模型带来的收益未必足以覆盖重构成本和运维风险
所以工程上正确的问题通常不是:
- 新东西是不是一定该替换旧东西
而是:
- 新模型在你的瓶颈点上,是否真的能换来值得的收益
这是完全不同的判断方式。
17. 同样地,“想认真研究 io_uring”也完全合理
反过来,也不要因为 epoll 很成熟,就把 io_uring 当成一时热词。
它之所以被广泛关注,根本原因并不是宣传,而是它确实在尝试推进 Linux I/O 编程模型的边界。
特别是当你关心这些问题时:
- 大量异步操作统一调度
- 系统调用边界开销
- 批量提交与批量完成
- 事件等待和操作执行的重新整合
那 io_uring 就不只是“另一个 API”,而更像:
一个值得重新审视事件循环写法的系统能力。
18. 一个实用的选择框架:别先问谁先进,先问你的事件循环更像哪种骨架
如果把选择问题压缩成最实用的一句话,我会建议你这样问自己。
如果你的程序骨架天然更像:
- 维护大量非阻塞连接
- 根据可读/可写事件逐步推进协议状态
- 对 readiness 语义非常熟悉
那么 epoll 往往是非常自然的选择。
如果你的程序骨架更像:
- 希望把操作本身提交给内核
- 围绕完成事件来调度下一步
- 想把网络、超时甚至部分文件操作纳入统一 completion 流
那么 io_uring 会更值得认真考虑。
也就是说,不要先问:
- 谁更新
- 谁 benchmark 更漂亮
而要先问:
你的事件循环更像反应式循环,还是更像提交式流水线。
19. 常见误区集中拆一下
最后把几个高频误区集中拆开。
误区一:io_uring 就是 epoll 的升级版
不准确。它们有交集,但模型并不一样。
误区二:用了 io_uring 就没有状态机了
不是。状态机还在,只是组织方式改变了。
误区三:epoll 过时了
不是。它仍然是大量高并发网络服务的主力模型。
误区四:epoll 只要会调 API 就行
不是。真正的复杂度在非阻塞 I/O、状态推进、写缓冲、backpressure 和错误处理里。
误区五:io_uring 一定能直接带来显著收益
不是。收益要看 workload、架构、内核版本、实现质量和重构成本。
20. 总结:epoll 和 io_uring 的真正差异,不在名字,而在事件循环哲学
如果把这篇文章压缩成一句话,那就是:
epoll 和 io_uring 的真正差异,不只是一个老一个新,也不只是 API 形式不同,而是它们代表了两种不同的高并发事件循环哲学:前者围绕‘谁准备好了’,后者围绕‘我要提交什么、完成了什么’。
你真正该抓住的是这几条主线:
epoll是 readiness model,擅长大型非阻塞连接集合上的事件等待io_uring是 submission/completion model,擅长把操作提交与完成统一组织起来epoll的复杂度更容易暴露成状态机和EAGAIN推进逻辑io_uring的复杂度更容易暴露成队列编排、completion dispatch 和依赖管理- 两者都不是银弹,只是把复杂度放到了不同位置
所以如果你在写一个高并发网络服务,真正应该问的不是“谁更先进”,而是:
我的程序,到底更适合反应式事件循环,还是更适合提交式 I/O 流水线。
一旦这层想清楚,你再去看 Nginx、Redis、RPC 框架、新一代 Linux 网络运行时和各种 benchmark,判断就会稳很多。
如果继续沿这条线写下去,一个很自然的下一篇就是:
再单独写一篇高并发事件循环设计,专门讲 non-blocking socket、backpressure、写缓冲、超时和连接状态机该怎么组织。