io_uring 与 epoll:Linux I/O 模型两种范式

linuxio_uringepollasync-iokernelperformance

数据源:HN + Lobsters · HN

如果你写网络服务,大概率用过 epoll——或者至少用过基于 epoll 的框架(libuv、tokio、nginx、haproxy,无一例外)。它统治 Linux 异步 I/O 长达十七年,稳定到几乎没人质疑它是不是”最好”的方案。

但 2019 年,io_uring 出现了。内核社区送给它一个极高的评价:“The most important addition to the Linux kernel I/O interface in a very long time.”

这句评价推高了期望,也埋了不少误解。一个常见的说法是:io_uringepoll 的新版本,就像 epoll 当初替代 poll 一样顺理成章。

这个类比是错的。io_uringepoll 处于完全不同的抽象层级——一个是 completion 模型,一个是 readiness 模型。这个切换的代价不是简单的 API 更换,它影响了从事件循环结构到内存管理策略再到安全模型的几乎每一层。

epoll 代价的核心:每次 I/O 两次 syscall

epoll 的工作模式可以概括为一句话:“内核告诉你什么时候可以操作,你去操作。” 对应到代码层面:

// epoll 的 syscall 账单:每次 I/O 事件至少两次
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
    char buf[256];
    ssize_t count = read(events[i].data.fd, buf, sizeof(buf));
}

epoll_wait 是一次 syscall,read/write 是第二次。加上最初注册 fd 时的 epoll_ctl(一次性的,不计入每事件成本),每笔 I/O 事件的固定开销就是 两次用户态↔内核态上下文切换

在连接数较低的场景下,这根本不是问题——现代 CPU 一次 syscall 的成本在几百纳秒到一两微秒之间。但当服务器需要处理数万甚至数十万并发连接时,这些上下文切换的累积就会变成一个真实的瓶颈。

更隐蔽的问题在于:epoll 只解决了多路复用的问题,没有解决数据搬运的问题。epoll_wait 返回告诉你 fd 就绪了,然后你必须亲自调 read——数据从内核缓冲区拷贝到用户缓冲区这个过程,本身又触发一次上下文切换。epoll 切了一半的蛋糕,把另一半(实际 I/O)留给了调用者。

这还不是问题的全部。epoll 无法优雅地处理文件 I/O。epoll_wait 对普通文件描述符的返回行为是不可预测的——对常规文件,open() 返回的 fd 在 epoll_wait 中总是显示为”就绪”,因为内核无法对非管道/非 socket 的常规文件做真正的 I/O 就绪通知。这意味着用 epoll 做磁盘 I/O 的异步化,基本只能仰赖 O_DIRECT + AIO(aio_read/aio_write)这个接口——但 Linux AIO 的局限众所周知:只支持 O_DIRECT、经常阻塞、上下文切换开销不低。

io_uring 的切入点:完成模型

io_uring 换了一个思路。它不问”可以操作了吗?“,而是说”我提交一批操作,你做完了告诉我。” 这要求一个全新的通信机制——内核和应用之间需要一个共享的数据通道,让操作请求和完成通知都不再依赖 syscall 作为唯一载体。

这个通道就是两个共享内存的环形缓冲区:

  • Submission Queue (SQ):应用将操作描述符(Submission Queue Entry, SQE)写入 SQ,内核从另一端消费
  • Completion Queue (CQ):内核将完成事件(Completion Queue Entry, CQE)写入 CQ,应用从另一端读取

关键之处在于:SQ 和 CQ 驻留在应用与内核共享的内存区域中。写入 SQ 不需要 syscall——只是内存写操作。只有当应用需要”通知内核去消费队列”时,才需要调 io_uring_enter()

// io_uring:一次 syscall 处理整批 I/O
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
// 可以再提交更多操作...
io_uring_submit(&ring);  // 调用 io_uring_enter,一次 syscall

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);  // 等待完成,再次进入内核
if (cqe->res < 0) { /* 处理错误 */ }

对比 epoll 的两次 syscall/事件,io_uring 将开销从 每事件计费 变成了 每批次计费。一次 io_uring_enter() 可以提交成百上千个 SQE,并收割同样数量的 CQE。

IORING_SETUP_SQPOLL 则更进一步:让一个内核线程持续轮询 SQ,应用连 io_uring_enter() 都不需要调。代价是这条内核线程在队列为空时仍然会空转一段可配置的时间(sq_thread_idle 毫秒)才进入睡眠。

性能对比:数据说了什么

公开的基准测试数据不多,但已经能看出大致轮廓。以下是目前可查的几组数据:

维度epollio_uring(默认模式)io_uring(SQPOLL)
每 I/O 事件 syscall 数2(epoll_wait + read/write)1/N(N 为批次大小,典型值 0.1-0.01)→ 0(稳态)
最大吞吐(简易 echo server)~900K req/s~1.2M req/s+33%
P99 延迟(同负载)25-30μs15-18μs15-18μs
文件 I/O 原生支持❌(需 fallback 到 AIO 或线程池)✅(统一接口)
零拷贝支持✅(注册缓冲区 + IORING_OP_SEND_ZC
内核版本需求2.5.44+(2002)5.1+(2019),零拷贝特性需 5.19+/6.0+
安全攻击面低(20 年审计)高(频繁的提权 CVE,seccomp 绕过)
主流运行时支持全部(nginx, haproxy, libuv, tokio, Go)有限(rust async 框架部分支持,Go 谨慎观望)

吞吐数据来源:一份 Rust/Go 网络 benchmark(echo server 场景),io_uring + Rust 约 1.2M req/s、P99 18μs,对应 epoll + Go 约 900K+ req/s、P99 ~25μs。数据仅供参考,真实场景偏差取决于消息大小、连接数、CPU 架构。

需要注意的是,io_uring 的最大优势体现在批量操作场景中。单连接的单次 I/O 操作,io_uringepoll 的差异微乎其微——甚至 epoll 可能更快,因为 io_uring 需要多一层队列管理成本。差距随批量的增大而拉开。

另外有开发者实测:将 asio 的 epoll 后端直接换成 io_uring 后,CPU 使用率反而上升了。这听起来像是反常结果,但仔细分析会发现:io_uring 降低了 I/O 操作的成本,CPU 不再花时间”等”,转而去做更多实际工作——所以 CPU 使用率升高不一定是坏事。真正的指标应该是相同吞吐下的延迟分布相同延迟下的最大吞吐

场景选择:什么时候该用,什么时候不该

优先选 io_uring 的场景

高吞吐、批量 I/O。 每秒处理数万次以上 I/O 操作的服务,batch 提交对 syscall 开销的消除效果最明显。典型例子包括反向代理、API 网关、消息队列中间件。

文件 I/O 和网络 I/O 混合。 如果服务既有磁盘读写又有网络处理,io_uring 统一的 SQ/CQ 接口比 epoll + 线程池/AIO 的组合优雅得多。

零拷贝需求。 大文件传输场景下,IORING_OP_SEND_ZC(内核 6.0+)跳过数据从内核到用户的拷贝路径,对吞吐有量级级别的提升。

暂缓 io_uring 的场景

安全敏感环境。 io_uring 在内核的 CVE 跟踪器中是”热门”攻击面。过去几年它频繁出现在提权漏洞报告中,部分漏洞直接绕过了 seccomp 的 syscall 过滤——因为 io_uring 的 SQE 操作不是传统的 syscall,seccomp 无法按 syscall number 过滤。最新内核已经引入了 cBPF 过滤器来弥补这个缺口,但这套机制在业界广泛应用之前仍然不被视为”默认安全”。主流容器运行时和云平台对 io_uring 的态度仍然是 opt-in。RHEL 9/10 已默认支持——这是一个积极的信号,但距离全面信任还有距离。

老内核部署。 如果目标环境横跨 5.1 以下的内核,为 io_uring 设计架构意味着必须保留 epoll 回退路径。而 io_uring 的高级特性(IORING_OP_SEND_ZCIORING_SETUP_SQPOLL、buffer registration 等)在不同内核版本间的表现差异很大,版本兼容性管理是现实工程中不可忽视的成本。

单连接低频 I/O。 远程管理接口、监控 agent 这类低吞吐服务,epoll 的开销可以忽略不计,切换到 io_uring 没有任何可测量的收益,只增加了依赖复杂度和安全风险。

框架层的折损

还有一个值得注意的问题——io_uring 的能力被现有框架打了折扣。当前主流的 async I/O 框架(tokio、libuv、asio)对 io_uring 的支持大多停留在”把事件循环后端换成 io_uring”这个层面。这意味着它们只利用了 io_uring 最基本的 batch 提交能力,而真正能拉开性能差距的特性——Linked Operations、buffered register、IOSQE_IO_LINK 链式依赖、multishot——几乎都没有被上层框架消化。

从这个角度看,目前 io_uring 在框架层的”真实收益”被严重低估了。等到框架能原生表达操作链和零拷贝路径,而不是把 io_uring 仅仅当作 epoll_wait 的替代品,整个 I/O 栈的优化空间才会真正释放。

SQPOLL 不是免费的

IORING_SETUP_SQPOLLio_uring 最吸引人的特性之一:零 syscall 的稳态吞吐。但它的代价在低负载下很显眼。

SQPOLL 内核线程即使在队列为空时,也会持续轮询一段时间(sq_thread_idle 毫秒,默认值取决于内核配置,通常为 1 秒级)。这意味着一个服务在空闲时段,CPU 上仍然有一个线程在空转。这对于服务器工作负载来说通常可以接受(空闲时被调度器降频),但对于容器环境或云原生场景——按 CPU 时间计费——这就是不可忽略的成本。

折中方案是在低负载时回退到默认模式,高负载时启用 SQPOLL。这种动态切换需要在应用中自行实现,io_uring 本身不提供内置的策略。

写在最后

epoll 在 2002 年替代 poll,解决的是 O(n) 轮询开销问题。io_uring 替代的不是 epoll——它替代的是整个”通过 syscall 做 I/O”的范式。

这不是一个”新版本比老版本好”的线性故事。epoll 简洁、稳定、经过二十年审计,在 90% 的场景下仍然是完全够用的方案。io_uring 提供了更底层的控制能力,但也带来了安全策略的缺口、内核版本依赖的复杂度、以及框架层的折损。

对于从头开始的新项目,运行在受控环境(已知内核版本、可接受的安全 profile)、面对高吞吐场景,io_uring 值得认真考虑。对于已有成熟架构的存量系统,除非你能测出可量化的收益,否则不必急于迁移。

调度器亲和性(CPU pinning)、内存分配器选择、网卡流量定向——这些与 I/O 模型无关的优化,有时比换到 io_uring 带来的提升更大。工具只是链条上的一环。

以上分析基于目前的公开信息和社区讨论。如果有不同视角或补充信息,欢迎交流。