Linux io_uring:高性能异步IO的新纪元
Linux io_uring 是内核 5.1 引入的革命性异步 IO 接口,彻底解决了长期以来 Linux 异步 IO(AIO)在性能和易用性上的种种不足。从数据库到 Web 服务器,从存储到网络,io_uring 正在重新定义 Linux 系统的高性能 IO 编程范式。
一、从 POSIX AIO 到 io_uring:一个漫长而曲折的故事
在 io_uring 出现之前,Linux 开发者进行异步 IO 主要有两条路:
- POSIX AIO(libaio):通过
io_submit()/io_getevents()实现异步 IO,但仅支持O_DIRECT模式的文件 IO,不支持网络 IO,且存在诸多设计缺陷。 - epoll + 线程池:用 epoll 管理 IO 事件,配合工作线程同步模拟异步,复杂度高,上下文切换开销大。
POSIX AIO 的核心问题包括:仅支持 direct IO(绕过页缓存,无法利用内核缓存加速);每次操作至少两次系统调用(submit + complete);不支持套接字;缓冲区对齐要求严格(必须 512 字节对齐)。
io_uring 由 Jens Axboe(Linux 内核块设备层维护者,也是 epoll 的作者)设计,目标是提供一个通用、高性能、低开销的异步 IO 接口,一统文件和网络异步 IO。
二、io_uring 的核心架构设计
io_uring 的突破性设计在于共享内存环形队列——一个用于提交(SQ,Submission Queue),一个用于完成(CQ,Completion Queue)。用户态和内核态通过这两个环形队列通信,实现批量、免系统调用的异步操作。
┌──────────────────────────────────────────────────────┐
│ 用户态进程 │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ SQ Ring │ │ CQ Ring │ │
│ │ (生产者) │ │ (消费者) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ SQE[] (提交队列项) CQE[] (完成队列项) │
│ │ │ │
└──────────┼─────────────────────────┼──────────────────┘
│ 共享内存 (mmap) │ 共享内存 (mmap)
▼ ▲
┌──────────────────────────────────────────────────────┐
│ 内核态 │
│ │
│ io_uring 引擎:批量收割 SQE,异步执行 IO 操作, │
│ 完成后将结果写入 CQE,可完全不触发系统调用。 │
└──────────────────────────────────────────────────────┘
关键数据结构:
struct io_uring {
struct io_uring_sq sq; // 提交队列
struct io_uring_cq cq; // 完成队列
unsigned int flags; // 配置标志
int ring_fd; // 通过 io_uring_setup() 返回的 fd
};
// 提交队列项 (SQE)
struct io_uring_sqe {
__u8 opcode; // 操作码:IORING_OP_READV / WRITEV / SEND / RECV...
__u8 flags;
__u16 ioprio; // IO 优先级
__s32 fd; // 目标文件描述符
__u64 off; // 偏移量
__u64 addr; // 用户缓冲区地址
__u32 len; // 缓冲区长度
__u32 personality; // 个性标识(用于共享 worker)
__u64 user_data; // 用户自定义数据,会在 CQE 中原样返回
};
// 完成队列项 (CQE)
struct io_uring_cq {
struct io_uring_cqe *cqes;
unsigned *ktail; // tail 指针(内核更新)
unsigned *khead; // head 指针(内核更新)
...
};
三、核心 API 与工作模式
3.1 基本初始化
#include <liburing.h>
struct io_uring ring;
// 初始化:队列深度 1024,默认参数
int ret = io_uring_queue_init(1024, &ring, 0);
if (ret < 0) {
fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
return 1;
}
// 清理
io_ring_queue_exit(&ring);
3.2 三种工作模式
io_uring 支持三种工作模式,适应不同场景:
| 模式 | 标志 | 特征 |
|---|---|---|
| 中断驱动(默认) | 无 | IO 完成时内核通过 CQ Ring 通知用户态,需要检查 CQ |
| 轮询模式 | IORING_SETUP_IOPOLL | CPU 轮询 CQ,零延迟但高 CPU 占用,适合 NVMe 等极速存储 |
| 内核轮询 | IORING_SETUP_SQPOLL | 内核线程自动收割 SQ,用户态完全不需要系统调用 |
3.3 SQPOLL 模式:终极零系统调用
SQPOLL(Submission Queue Poll)是最强模式:内核创建一个专用线程( io-wq-worker )自动监测 SQ Ring,当发现新的 SQE 时立即提交给用户态内核。用户态只需写入共享内存中的 SQ Ring,之后不需要任何系统调用。
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2s 后内核线程休眠
struct io_uring ring;
int ret = io_uring_queue_init_params(1024, &ring, ¶ms);
这种模式下,整个 IO 生命周期(提交 + 完成)完全不触发系统调用,上下文切换归零,延迟可达到微秒级别。
四、实战:编写一个高性能 echo server
4.1 基础版本
// 简单的 io_uring echo server 伪代码 void echo_server(struct io_uring *ring, int listen_fd) { // 先提交一个 accept 请求 struct io_uring_sqe *sqe = io_uring_get_sqe(ring); io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0); io_uring_sqe_set_data(sqe, (void*)ACCEPT_MARKER); io_uring_submit(ring); while (1) { // 等待至少一个完成事件 struct io_uring_cqe *cqe; int ret = io_uring_wait_cqe(ring, &cqe); // 批量收割完成事件 unsigned head; unsigned count = 0; io_uring_for_each_cqe(ring, head, cqe) { if ((uintptr_t)io_uring_cqe_get_data(cqe) == ACCEPT_MARKER) { // 新的客户端连接 int client_fd = cqe->res; // 为该客户端提交 recv 请求 submit_recv(ring, client_fd); // 继续 accept 下一个连接 submit_accept(ring, listen_fd); } else { // 客户端数据到达,echo 回去 int client_fd = (int)(uintptr_t)io_uring_cqe_get_data(cqe); // 先提交 write 回写 submit_send(ring, client_fd); // 再提交 recv 等待下一条数据 submit_recv(ring, client_fd); } count++; } // 一次性推进 CQ head io_uring_cq_advance(ring, count); } }
4.2 高级版本:使用 Linked SQE 实现 "accept → recv → send" 链
io_uring 的链接 SQE 特性允许将多个操作链式组合,前一个执行的返回值自动被后一个使用,无需用户态介入:
void submit_echo_chain(struct io_uring *ring, int fd) {
struct io_uring_sqe *sqe;
// Step 1: recv
sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, fd, buf, BUF_SIZE, 0);
io_uring_sqe_set_flags(sqe, IOSQE_IO_LINK); // 链接到下一个
sqe->user_data = (uint64_t)fd;
// Step 2: send(只有 recv 成功才会执行)
sqe = io_uring_get_sqe(ring);
io_uring_prep_send(sqe, fd, buf, BUF_SIZE, 0);
io_uring_sqe_set_flags(sqe, IOSQE_IO_LINK);
sqe->user_data = (uint64_t)fd;
// Step 3: 重新 recv(循环)
sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, fd, buf, BUF_SIZE, 0);
sqe->user_data = (uint64_t)fd;
io_uring_submit(ring);
}
五、性能对比:io_uring vs epoll + 线程池
以下是典型场景下的性能对比数据(参考 Jens Axboe 的基准测试):
| 指标 | epoll + 线程池 | io_uring (默认) | io_uring (SQPOLL) |
|---|---|---|---|
| 4K 随机读 IOPS(NVMe) | ~180K | ~280K | ~380K |
| 平均延迟(μs) | ~8 | ~4 | ~1.5 |
| 每 IO 系统调用次数 | 2+ | 1 (批量) | 0 |
| CPU 利用率(同吞吐) | 100% | ~70% | ~55% |
| P99 延迟(NVMe) | ~45μs | ~18μs | ~6μs |
io_uring 的性能优势在存储场景尤为明显,因为它实现了真正的异步文件 IO,而 epoll 本质上只支持网络 IO。
六、高级特性
6.1 Fixed Files 和 Fixed Buffers
默认情况下,每次 IO 操作涉及 fd 查找和缓冲区 pin/unpin。io_uring 提供 IORING_REGISTER_FILES 和 IORING_REGISTER_BUFFERS 预处理机制,将 fd 和缓冲区固定在内部表中,运行时直接使用索引号:
// 预注册缓冲区(只需一次系统调用,后续零开销)
struct iovec iovecs[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
iovecs[i].iov_base = bufs[i];
iovecs[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iovecs, BUF_COUNT);
// 使用预注册缓冲区(zero-copy提交)
sqe->flags |= IOSQE_BUFFER_SELECT; // 自动选择缓冲区
sqe->buf_group = bgid; // 缓冲组 ID
6.2 Buffer Selection:内核自动选缓冲
io_uring 支持内核侧自动选择预注册的缓冲区(IOSQE_BUFFER_SELECT),recv 操作完成后自动填充选中缓冲区,用户态只需处理完成事件:
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 0; // 缓冲组 0
// 完成后通过 cqe->flags 获取选中缓冲区编号
unsigned buf_idx = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
6.3 多核扩展:IORING_SETUP_ATTACH_WQ
在多核场景下,可以创建共享的 io-wq worker pool(IORING_SETUP_ATTACH_WQ),多个 io_uring 实例共用同一个 worker 线程池,避免每个 ring 创建过多的内核线程:
// 第一个 ring 创建 worker pool
struct io_uring_params params1 = {0};
params1.wq_fd = 0; // 创建新的
io_uring_queue_init_params(DEPTH, &ring1, ¶ms1);
// 第二个 ring 共享 worker pool
struct io_uring_params params2 = {0};
params2.wq_fd = params1.wq_fd; // 复用
params2.flags = IORING_SETUP_ATTACH_WQ;
io_uring_queue_init_params(DEPTH, &ring2, ¶ms2);
七、生产环境最佳实践
7.1 选择合适的模式
- 网络服务器:使用 io_uring 的 recv/send 配合 linked SQE,但需要同时处理 epoll 事件(可以用 eventfd + epoll 监控 io_uring 的完成事件)
- 存储引擎/数据库:使用 NVMe + IOPOLL 或 SQPOLL,最大化 IOPS
- 混合负载:SQPOLL + 适当的 sq_thread_idle,平衡延迟和 CPU 占用
7.2 与 io_uring 配合的 cgroup 调优
SQPOLL 内核线程共享当前进程的 cgroup。如果需要在容器中运行 io_uring 服务,确保 io_uring 的 worker 线程正确归属到目标 cgroup:
// 查看 io_uring worker 线程
$ cat /proc/$(pidof your_app)/task/*/comm | grep io_wq
io_wq_worker
io_wq_worker
...
// 通过 cgroup v2 进行带宽限制
$ echo "253:0 rbps=104857600" > /sys/fs/cgroup/your_group/io.max
7.3 内存序注意事项
SQ Ring 和 CQ Ring 使用共享内存,需要特别注意内存序问题。io_uring 库已处理普通场景,但有bug需要小心:
// 错误写法:未推进 SQ head 就提交
io_uring_submit(&ring); // 已提交 0 个 SQE,因为 head 没推进
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// ... 填充 sqe ...
// 此时 sqe 还没提交!除非显式调用 io_uring_submit(&ring) 再写 sqe
// 正确写法
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_sqe_set_data(sqe, user_data);
io_uring_submit(&ring); // 提交后内核才可见
八、语言生态与框架支持
| 语言/框架 | io_uring 支持方式 | 代表项目 |
|---|---|---|
| C | liburing(官方) | liburing |
| Rust | tokio-uring, glommio | glommio (Datafuse), tokio-uring |
| Go | syscall + 自定义 ring (不推荐) 或使用 liburing via cgo | liburing-go, gouring |
| Java | Project Panama (JDK 21+) | panama-uring |
| Python | ctypes / CFFI 绑定 | liburing |
| Node.js | N-API + liburing | node-io-uring |
其中 Rust 的 glommio 库尤为出色,提供了基于 io_uring 的异步运行时,自动管理缓冲区、自动处理 SQPOLL,让开发者像写普通 async Rust 代码一样使用 io_uring。
九、未来演进
io_uring 仍在快速演进中,值得关注的新特性:
- IORING_MSG_RING:跨 io_uring 实例发送消息,支持多线程协调
- io_uring 网络 zerocopy:TX zero-copy send 已经合并,进一步降低网络延迟
- FUSE Passthrough:通过 io_uring 加速用户态文件系统
- SQE128 / CQE32:扩大的 SQE/CQE 大小,允许携带更多上下文信息(已合并到 6.x 内核)
- 内存回收(deferred cqe):减少内存分配开销,支持延迟 CQE 处理
十、总结
io_uring 是 Linux 内核近十年来最重要的 IO 接口创新。它通过共享内存环形队列的设计,实现了超高的 IO 吞吐和极低的延迟,同时保持了 POSIX AIO 所不具备的通用性(支持文件和网络 IO)。随着内核版本不断迭代、语言生态日益成熟,io_uring 正在成为高性能 Linux 服务的标准基础设施。
对于追求极致 IO 性能的工程师来说,掌握 io_uring 不仅是学习一种新 API,更是理解现代操作系统异步 IO 设计哲学的绝佳入口。
// 一句话:如果用 Linux 做高性能服务,不用 io_uring 可能意味着你在浪费 30%~50% 的 IO 性能。

发表评论 取消回复