2019 年 3 月,Linux 5.1 合入了一个 6,000 行的新子系统。它的作者 Jens Axboe 在提交信息里写道:"This is an attempt at a new interface for async I/O."
低调到几乎不像是在宣布革命。但在接下来的几年里,io_uring 成为了 Linux 内核史上增长最快的子系统之一,被 Cloudflare、Meta、ScyllaDB 等公司用在最关键的 I/O 路径上,替代了 epoll 和 AIO 这两个统治了十年的接口。
Linux 在 io_uring 之前有两套异步 I/O 方案:epoll 做网络 I/O,POSIX AIO 做文件 I/O——两套 API,两套心智模型,而且都有根本性的缺陷。epoll 仍然是同步的(epoll_wait 阻塞,read/write 需要再调一次),POSIX AIO 实现残缺(glibc 用线程池模拟,不是真正的内核异步)。io_uring 用一对共享环形缓冲区统一了两者:提交 I/O 请求不需要系统调用,完成通知也不需要系统调用,内核和用户态通过共享内存协作。这篇文章从 epoll 的根本缺陷出发,拆解 SQ/CQ ring 的设计,解释为什么这个设计在高 IOPS 场景下能显著超越 epoll。、epoll 的问题:不是不好,是有根本限制
epoll 是 Linux 高性能网络编程的基础。nginx、Redis、Node.js 的事件循环全部基于它。它的工作模式:
- 对就绪的 fd 执行实际 I/O(
read/write,这里再次系统调用)
每次 I/O 操作最少需要 2 次系统调用:一次 epoll_wait 等通知,一次 read/write 实际读写。在高 IOPS 场景(比如每秒 500,000 次小文件读取),这个开销不可忽视。
更根本的问题:epoll 对文件系统 I/O 几乎无效。文件系统操作(read/write 到普通文件)在 Linux 的 VFS 层通常是同步的,即使用 O_NONBLOCK 打开,大多数情况下 read 仍然会阻塞等待磁盘——epoll 不能感知这种就绪状态。
POSIX AIO 填不上这个坑。glibc 的 aio_read 在内部用线程池模拟异步,线程切换开销往往比直接同步 read 还高。内核原生 AIO(io_submit)虽然有真正的内核路径,但 API 设计极其残缺:不支持 socket、不支持 buffered I/O、不支持 fsync,实际上只能用于 Direct I/O。
图中最关键的差异:io_uring 的提交和完成都不需要系统调用,内核和用户态通过共享内存直接通信。
SQ/CQ Ring:设计解析
io_uring 的核心是两个环形缓冲区,通过 io_uring_setup() 创建,映射到用户态:
- SQ(Submission Queue):用户态写入要执行的 I/O 操作描述符(SQE,Submission Queue Entry)
- CQ(Completion Queue):内核写入已完成操作的结果(CQE,Completion Queue Entry)
// SQE 的核心字段structio_uring_sqe { __u8 opcode; // IORING_OP_READ, IORING_OP_WRITE, IORING_OP_ACCEPT, ... __s32 fd; // 目标文件描述符 __u64 off; // 文件偏移 __u64 addr; // 用户态缓冲区地址 __u32 len; // 操作长度 __u64 user_data; // 用户自定义标签,原样返回到 CQE// ... 更多字段};// CQE 的核心字段structio_uring_cqe { __u64 user_data; // 对应 SQE 的 user_data,用于匹配请求 __s32 res; // 操作结果(成功时是返回值,失败时是 -errno) __u32 flags;};
零系统调用的关键
SQ 和 CQ 都是用户态和内核态共享的内存区域(通过 mmap)。写 SQE 只是写内存,不需要陷入内核。读 CQE 也只是读内存。
实际提交时,需要调用 io_uring_enter()——但这里有一个优化:SQPOLL 模式。启用后,内核会启动一个内核线程(io_uring-sq 进程)持续轮询 SQ,完全不需要用户态调用 io_uring_enter()。在高吞吐场景下,整个 I/O 路径可以做到零系统调用。
代价是那个内核线程会占用一个 CPU 核,适合延迟敏感的专用 I/O 线程场景,不适合普通 web 服务。
批量提交
即使不用 SQPOLL,io_uring 也能批量提交:用户态一次性往 SQ 里写 128 个 SQE,然后调用一次 io_uring_enter(ring, 128, 0, 0)——用 1 次系统调用提交 128 个 I/O 请求。epoll 模式下这需要 256 次系统调用(128 次 epoll_wait + 128 次 read)。
一个能跑的例子:liburing 异步读文件
直接操作 io_uring_setup 的原始 syscall 很繁琐。liburing 是 Jens Axboe 同步维护的封装库,把大部分样板代码藏起来:
#include<liburing.h>#include<fcntl.h>#include<stdio.h>#define BUFFER_SIZE 4096intmain(void) {structio_uringring;structio_uring_sqe *sqe;structio_uring_cqe *cqe;char buf[BUFFER_SIZE];// 初始化 ring,队列深度 32 io_uring_queue_init(32, &ring, 0);int fd = open("test.txt", O_RDONLY);// 获取一个 SQE,填写读操作 sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, buf, BUFFER_SIZE, 0); sqe->user_data = 42; // 自定义标签// 提交(1 次系统调用) io_uring_submit(&ring);// 等待完成(也是 1 次系统调用,或者轮询 CQ 做到零调用) io_uring_wait_cqe(&ring, &cqe);printf("read %d bytes, user_data=%llu\n", cqe->res, cqe->user_data); io_uring_cqe_seen(&ring, cqe); // 告诉内核 CQE 已消费 io_uring_queue_exit(&ring);return0;}
代码里的 user_data 字段是 io_uring 的关键设计:内核不关心它的内容,原样写回 CQE。实际工程里通常把指向请求上下文结构体的指针存在这里,CQE 回来时直接取出处理,不需要额外的哈希表查找。
性能:数字说话
Jens Axboe 在最初的提案里给出了基准测试数据(NVMe SSD,4KB 随机读):
生产环境的数据更能说明问题。Cloudflare 把 HTTP/3 的 UDP 收发路径迁移到 io_uring 后,在相同硬件上实测 CPU 使用率下降约 15%,吞吐量提升约 10%。Meta 的存储系统在 NVMe 密集读场景下测得 io_uring 比 epoll + 线程池方案提升约 30% 的 IOPS。
io_uring 的边界:不是银弹
io_uring 也有不适合用的场景:
小并发低 IOPS 场景:如果你的服务每秒只有几千个请求,epoll 的系统调用开销几乎感知不到,切换 io_uring 收益接近零,只是增加了代码复杂度。
安全敏感场景:io_uring 的攻击面大于 epoll。2022–2023 年,io_uring 被发现了多个内核权限提升漏洞(CVE-2022-29581 等)。Google 在 ChromeOS、Android 以及内部的 gVisor 环境里默认禁用了 io_uring,认为它的安全风险超过了性能收益。这是一个真实的工程权衡,不是 io_uring "差",而是它的内核路径比 epoll 复杂得多,审计成本更高。
跨平台代码:io_uring 是 Linux 专有的,macOS、Windows、FreeBSD 都没有对等接口(kqueue 和 IOCP 有类似思路但 API 完全不同)。需要跨平台的库(如 libuv)不能直接暴露 io_uring 语义。
io_uring 之后的世界
io_uring 在内核里的发展没有停止。从 Linux 5.1 到今天,它的 opcode 列表已经从最初的 6 个扩展到 50 多个,覆盖了 accept、connect、send、recv、splice、statx、openat、close、mkdir、甚至 exec。
这意味着理论上整个程序的 I/O 相关系统调用都可以走 io_uring,包括文件打开、socket 建立、数据收发全程异步化。
Tokio(Rust 异步运行时)、Glommio(Rust I/O 框架)已经深度整合 io_uring。Node.js 和 Java NIO 的 io_uring 后端也在推进中。
PostgreSQL 16(2023)为 Direct I/O 写路径引入了 io_uring 后端:事务提交时,多个 pwrite + fsync 请求批量写入 SQ,一次 io_uring_enter() 提交,等所有 CQE 返回后 commit 才算完成。相比原来每次 fsync 都是独立系统调用,在 NVMe 密集写场景下 WAL 写入吞吐提升约 15–20%。
Glommio(DataStax 开源)是目前对 io_uring 集成最彻底的 Rust 框架,强制 SQPOLL + O_DIRECT + 注册固定缓冲区,不允许 buffered I/O。ScyllaDB 的 Rust 重写版本基于它构建:
use glommio::{io::DmaFile, LocalExecutorBuilder, Placement};LocalExecutorBuilder::new(Placement::Fixed(0)) .spawn(|| asyncmove {letfile = DmaFile::open("data.bin").await.unwrap();// read_at 内部走 IORING_OP_READ;地址和长度必须 512 字节对齐(O_DIRECT 要求)letbuf = file.read_at(0, 4096).await.unwrap();println!("read {} bytes", buf.len()); }) .unwrap() .join() .unwrap();
DmaFile 只接受对齐的地址和长度——Glommio 把硬件约束暴露到 API 层,让不符合 io_uring 最优路径的代码在类型层面报错,而不是运行时失败。tokio-uring(Tokio 官方的实验性 crate)提供了兼容 Tokio 生态的 io_uring 接口,但尚未合入主线 Tokio,稳定的生产路径目前是 Glommio。
这不是在替代 POSIX,而是在 POSIX 之下挖了一条更快的隧道。
延伸阅读
- io_uring 原始邮件列表提案 — Jens Axboe,2019 年
- Efficient IO with io_uring — Axboe 写的技术文档,最好的入门材料
- liburing GitHub — 封装库 + 丰富的示例代码
- Lord of the io_uring — 详细教程,适合从零开始
- CVE-2022-29581 — io_uring 权限提升漏洞,了解安全边界