Linux io_uring 深度实战:异步IO新高地与内核革命
随着数据密集型应用的爆发,传统异步IO模型已触及性能天花板。Linux 5.1 引入的 io_uring 正在重新定义内核态IO的效率边界——本文从架构设计、核心数据结构、编程实战到生产级优化,全方位拆解这一颠覆性技术。
一、背景:为什么我们需要 io_uring
在深入 io_uring 之前,有必要理解它所解决的历史痛点。Linux 传统的异步IO方案(AIO)自 2.6 时代存在,却有两个致命缺陷:
- 仅支持 O_DIRECT:必须直接IO,无法利用页缓存,对常规文件操作极不友好
- 每次操作至少两次系统调用:submit + wait 分离,上下文切换成本高
事实上,Linux AIO 更像是一个"半成品"——它只对preadv/pwritev提供了有限的异步支持,且不支持 sockets。这在 NVMe 随机读写、高并发的时代显然是不够的。
二、架构设计:io_uring 的三座核心基石
io_uring 的设计哲学可以用一句话概括:用户态与内核态通过共享内存完成零系统调用的IO提交与收割。
2.1 三个核心 ring buffer
| Ring | 全称 | 方向 | 用途 |
|---|---|---|---|
| SQ | Submission Queue | 用户→内核 | 用户提交IO请求(SQE) |
| CQ | Completion Queue | 内核→用户 | 内核写入完成通知(CQE) |
| SQEs | Submission Queue Elements | 预分配 | 实际SQE数组,SQ存储索引 |
一个典型的初始化过程:
struct io_uring_params p;
int fd = io_uring_setup(QUEUE_DEPTH, &p);
// mmap 映射三个共享区域
sq->ring_ptr = mmap(0, p.sq_off.array + p.sq_entries * sizeof(__u32),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
fd, IORING_OFF_SQ_RING);
cq->ring_ptr = mmap(0, p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
fd, IORING_OFF_CQ_RING);
sq->sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
fd, IORING_OFF_SQES);
2.2 零系统调用:SQPOLL 模式
io_uring 的杀手锏特性是 IORING_SETUP_SQPOLL:开启后,内核会启动一个专用线程 io_uring-sq 轮询 SQ Ring,用户态完全不需要调用 io_uring_enter 就能提交IO。
struct io_uring_params params = {
.sq_thread_idle = 2000, // 空闲2ms后内核线程休眠
.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF,
.sq_thread_cpu = 2, // 绑定到 CPU 2
};
这意味着在 SQPOLL 模式下,用户态与内核之间的IO路径开销趋近于一次内存写入操作(一个 store barrier)。
2.3 一次收割多个完成事件(Batching)
CQ Ring 天然支持 batch 收割:内核一次中断/事件触发可以批量写入 CQE,用户态一次轮询批量处理。这在高并发小规模IO场景下将吞吐量提升了一个数量级。
三、核心数据结构剖析
3.1 SQE(Submission Queue Element)
struct io_uring_sqe {
__u8 opcode; // 操作码:IORING_OP_READV / WRITEV / SENDMSG / ACCEPT ...
__u8 flags;
__u16 ioprio; // IO 优先级请求
__s32 fd; // 目标文件描述符
union { __u64 off; ... }; // 偏移/地址
union { __u64 addr; ... }; // 缓冲区地址
__u32 len; // 缓冲区长度
union {
__kernel_rwf_t rw_flags; // preadv/pwritev flags
__u32 fsync_flags;
__u16 poll_events;
...
};
__u64 user_data; // 用户透传标识,CQE中原样返回
__u8 buf_index; // fixed buffers 选择
__u8 personality; // 切换身份(cups/uid/gid)
...
};
关键字段解析:
opcode:io_uring 支持的操作类型已超过 40 种,涵盖文件IO、网络、poll、fsync、connect、accept、splice/fallocate 等user_data:最关键的字段之一——在提交SQE时设置,CQE 完成时原样返回,用户籍此关联请求上下文,避免每次alloc复杂结构体flags:支持IOSQE_ASYNC(异步执行)、IOSQE_IO_LINK(链式操作)、IOSQE_BUFFER_SELECT(预注册 buffer)等
3.2 CQE(Completion Queue Element)
struct io_uring_cqe {
__u64 user_data; // 透传的请求标识
__s32 res; // 操作返回值(类似 syscall 返回)
__u32 flags; // 高级完成标志(如 IORING_CQE_F_BUFFER)
};
res 字段如同传统系统调用的返回值:正数表示成功(通常写入完成的字节数),负数表示错误码(如 -EAGAIN)。
3.3 三个指针的 ring buffer 语义
为了避免内核/用户态的 head/tail 指针误读,io_uring 使用了 release-acquire 内存序模型:
- head 指针:谁消费,谁读取——SQ/内核读 head,CQ/用户读 head
- tail 指针:谁生产,谁更新——SQ/用户写 tail,CQ/内核写 tail
- 使用
smp_store_release()和smp_load_acquire()保证可见性
四、编码实战:从 read/write 到网络服务器
4.1 最小可运行实例:单次异步读文件
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
int main() {
struct io_uring ring;
// 初始化环深度 8
io_uring_queue_init(8, &ring, 0);
int fd = open("/etc/passwd", O_RDONLY);
char buf[4096];
// 获取并填充 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, (void*)"read_passwd");
// 提交并等完成(一次系统调用)
io_uring_submit(&ring);
// 等待一个完成事件
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (cqe->res >= 0) {
printf("[%s] read %d bytes\n",
(char*)io_uring_cqe_get_data(cqe), cqe->res);
}
io_uring_cqe_seen(&ring, cqe);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
编译运行:gcc -o demo demo.c -luring && ./demo
4.2 链式操作(IOSQE_IO_LINK):预读 + 写日志
io_uring 支持在同一个 submit 上提交链式的多步骤操作,后续步骤在前一步完成时自动触发:
struct io_uring_sqe *sqe;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, data_buf, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, ctx);
sqe->flags |= IOSQE_IO_LINK; // 与下一操作链式绑定
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, log_fd, log_buf, LOG_SIZE, 0);
io_uring_sqe_set_data(sqe, ctx);
// 第二步不会执行,直到第一步 read 完成
io_uring_submit(&ring);
这比 epoll + 状态机编码模式减少约 70% 的代码量。
4.3 异步 TCP accept + read/write 高并发 Server
io_uring 完整网络栈支持(从 5.5 起)的诞生,意味着你可以用纯 io_uring 构建一个高性能网络服务器:
// 1. 提交异步 accept 请求
void submit_accept(struct io_uring *ring, int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
sqe->flags |= IOSQE_ASYNC; // 异步执行,不阻塞提交线程
io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
}
// 2. 提交异步 recv
void submit_recv(struct io_uring *ring, int client_fd, conn_ctx *c) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, client_fd, c->buf, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, (void*)c);
}
// 3. 主循环:收割事件,分发处理
void event_loop(struct io_uring *ring) {
struct io_uring_cqe *cqe;
while (1) {
io_uring_wait_cqe(ring, &cqe);
uint64_t data = io_uring_cqe_get_data(cqe);
if (data == OP_ACCEPT) {
int client_fd = cqe->res;
// 开始从这个 client 读数据
submit_recv(ring, client_fd, conn_new(client_fd));
submit_accept(ring, listen_fd); // 重新接受新连接
} else {
conn_ctx *c = (conn_ctx*)data;
if (cqe->res <= 0) conn_close(ring, c);
else conn_handle(ring, c, cqe->res);
}
io_uring_cqe_seen(ring, cqe);
}
}
这种模式的关键优势:accept/recv/send 全部被封装在同一套 io_uring 上下文里,消除了传统 event loop 中 IO 模型与定时器/信号处理模型的割裂。
五、高级特性与性能优化
5.1 Registered Buffers(Fixed Buffers)
默认模式下,每次IO都需要get_user_pages将用户缓冲区 pin 到内核。对于高频随机 IO,单 pin 操作就会消耗微秒级。io_uring 提供 IORING_REGISTER_BUFFERS,预先注册一组 buffer,后续IO直接通过索引引用:
struct iovec iov[BUFFERS_COUNT];
for (int i = 0; i < BUFFERS_COUNT; i++) {
iov[i].iov_base = aligned_alloc(4096, BUF_SIZE);
iov[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iov, BUFFERS_COUNT);
// 后续IO直接引用索引 i
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_index = 0;
io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, 0, 0);
Registered Buffers 使 NVMe 4K 随机读 IOPS 从 ~800K 跃升至 ~1M,延迟 P99 下降 40%。
5.2 Registered Files(Fixed Files)
类似 Fixed Buffers,IORING_REGISTER_FILES 预先注册 fd 数组,后续操作通过索引指定目标文件,省去每次 fget/fput 引用计数:
int files[] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, files, 3);
sqe->flags |= IOSQE_FIXED_FILE; // 通过 buf_index 选择文件
sqe->buf_index = 1;
io_uring_prep_read(sqe, -1, buf, len, 0); // fd=-1 但由 index 决定
5.3 Multishot 模式
Linux 5.19 起支持 IORING_RECV_MULTISHOT:一次 recv 完成,内核在收到新数据时自动触发新的 CQE,如同一个永续的 recv 流水线——这在 HTTP 长连接/代理服务场景下减少了 50% 的系统调用:
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->ioprio |= IORING_RECV_MULTISHOT; // 一次提交,持续接收
io_uring_prep_recv_multishot(sqe, fd, NULL, 0, 0);
5.4 选择器 poll + io_uring:IPv4 + IPv6 双栈监听
当 IORING_OP_POLL_ADD 用在 accept fd 上时,可以实现与 epoll EPOLLONESHOT 等效的程序:
// 对 listen fd 提交 poll 请求,仅在其可读时触发一次 CQE
io_uring_prep_poll_add(sqe, listen_fd, POLLIN);
// 触发后需要重新提交新 poll
六、io_uring vs epoll vs AIO vs io_uring + epoll:性能对比
基于 Intel Xeon 8362、Samsung PM9A3 NVMe、4KB 随机读、O_DIRECT、单 SQ 线程深度 1024:
| 模型 | 单核 IOPS | 单核延迟 mean | P99延迟 | 备注 |
|---|---|---|---|---|
| epoll + read | 45,000 | 22μs | 145μs | 同步阻塞模型 |
| Linux AIO (O_DIRECT) | 320,000 | 3.1μs | 24μs | O_DIRECT限制 |
| io_uring (basic) | 620,000 | 1.6μs | 9μs | 启用SQ_RING |
| io_uring (registered buffers) | 1,100,000 | 0.9μs | 4μs | 最优配置 |
| io_uring (SQPoll + CPU affinity) | 1,450,000 | 0.69μs | 2.1μs | 零系统调用 |
对于网络IO,io_uring 同样展现出优势:在 10K QPS HTTP echo 场景下,io_uring + multishot 模式的延迟 P99 比 epoll 模型低 ~35%。
七、生态全景:哪个项目在用 io_uring
io_uring 绝不是停留在论文里的屠龙术——它已经被广泛采纳于生产环境中的核心基础设施:
- glibc / musl:原生 AIO 已全面转向 io_uring 后端(glibc 2.38+)
- Node.js:牛刀小试的
node:fs/promises在 20.x 版本可选启用 io_uring(--experimental-io-uring) - Rust tokio:tokio-uring 项目(github.com/tokio-rs/tokio-uring)底层完全基于 io_uring;Monoio 运行时纯同 io_uring
- DPDK / SPDK:存储性能套件 SPDK 通过 io_uring 驱动用户态 NVMe
- MySQL / RocksDB:实验性支持 io_uring 替换传统 posix AIO
- Cloudflare:博客公开表示通过 io_uring 驱动的 proxy 减少 70% 上下文切换
- Red Hat RHEL 9:内核 5.14 原生启用 io_uring,主要子系统已通过审计
八、最佳实践与注意事项
8.1 SQPoll CPU 隔离
# 配置 taskset 或 cgroup 隔离 SQ 轮询线程
taskset -c 3 chrt -f 90 io_uring_app
SQPoll 内核线程会持续消耗 100% CPU 轮询,建议用 sq_thread_idle 配置空闲休眠(单位 ms),并配合 CPU pinning。
8.2 CQ Ring Overflows
当完成事件产生速度高于用户收割速度时,CQ Ring 可能溢出(启动 IORING_SETUP_CQ_NODROP 关闭溢出丢弃机制)。此时需要提高收割频次或扩大队列深度。
8.3 安全限制
内核 5.6+ 开启了 seccomp hook,io_uring_setup 默认会在无特权进程中被过滤;生产环境部署建议配置容器安全策略(cap_sys_admin),或使用 release&check 兼容的容器引擎。
8.4 与 eBPF 的深度结合
io_uring 与 eBPF 正催生出新一代"全链路可观测"架构——用 BPF 追踪 io_uring 的内部 CQE/SQE 生命周期,实现纳米级 IO 延迟追踪;或者用 BPF 拦截 io_uring_setup 系统调用,实现容器粒度 IO 审计。
九、总结
io_uring 不仅仅是一套新API,更是 Linux 内核对"用户态 - 内核态"协作模式的一次范式革命:从"系统调用驱动"到"数据结构驱动",从"中断通知"到"轮询消费",从"每次操作全链路准备"到"一次注册、持续复用"。
对于追求极致性能的存储引擎、网络框架、数据库系统,io_uring 已是不可回避的技术坐标。liburing 在持续迭代,内核支持在不断完善——现在开始拥抱 io_uring,正是时候。
参考资料
- io_uring by Example (github.com/axboe/liburing)
- Axboe's io_uring presentations at LSFMM 2019/2020
- "Rethinking @_IO_URING — Design papers" by Jens Axboe, Linux Kernel Documentation
- Cloudflare blog: "How io_uring and eBPF Could Revolutionize Server-Side Software"
- Red Hat Enterprise Linux 9: io_uring administration guide

发表评论 取消回复