一、为什么需要 io_uring?传统异步 I/O 的痛点
Linux 异步 I/O 长期以来是一个"众人期待却未曾真正解决"的难题。在 io_uring 出现之前,开发者面临以下困境:
1. AIO 的局限性:Linux 原生 AIO(libaio)仅支持 O_DIRECT 模式的文件 I/O,无法用于缓冲 I/O,且网络 I/O 完全不支持。其语义在设计上就有诸多限制。
2. epoll 不是真正的异步 I/O:epoll 本质上是"就绪通知"机制,告诉你 fd 可读了,真正的 read/write 调用仍然会阻塞(或至少是同步的)。在高并发场景下,系统调用的开销不可忽视。
3. 系统调用开销:每次 read/write 都需要用户态↔内核态切换,在高 IOPS 场景下,系统调用本身成为瓶颈。Intel 的研究表明,单核每秒能处理的系统调用数量是有上限的,频繁的 syscall 消耗大量 CPU 周期。
io_uring 由 Jens Axboe(Linux 内核块设备层维护者)于 2019 年提出,目标是用统一、高效、可扩展的接口解决所有类型的异步 I/O 问题。自 Linux 5.1 引入以来,已成为 PostgreSQL、Redis、Nginx、Envoy、Netty 等核心基础设施的关键依赖。
二、io_uring 核心架构:共享内存环形队列
io_uring 的设计精髓在于用户态与内核态通过共享内存环形队列通信,在特定模式下可以完全避免系统调用。
2.1 双队列结构
io_uring 维护两个环形队列(Ring Buffer):
- Submission Queue(SQ):用户态写入 I/O 请求(SQE),内核态消费
- Completion Queue(CQ):内核态写入完成事件(CQE),用户态消费
这两个队列通过io_uring_setup()系统调用创建时映射到用户态内存,因此用户态提交请求时直接写入共享内存,无需 syscall(在 SQPOLL 模式下)。
2.2 关键数据结构
io_uring 的核心由以下几个部分组成:
- io_uring:代表一个 io_uring 实例
- io_uring_sqe(Submission Queue Entry):提交的 I/O 请求
- io_uring_cqe(Completion Queue Entry):完成的 I/O 结果
- io_uring_params:配置参数,包含队列大小、特性标志等
2.3 执行流程概览
一次完整的 io_uring I/O 请求经历以下步骤:
- 用户态获取空白 SQE(
io_uring_get_sqe()) - 填充 SQE(操作码、fd、缓冲区地址、长度等)
- 写入 SQ 尾指针,将请求入队
- 内核从 SQ 取出 SQE,执行 I/O 操作
- 操作完成后将结果写入 CQ 对应 CQE
- 用户态从 CQ 读取 CQE,处理结果
在 SQPOLL 模式下,内核轮询线程持续消费 SQ,整个过程中用户态完全不需要 io_uring_enter() 系统调用。
三、io_uring 三种工作模式详解
3.1 中断驱动模式(默认)
最基础的运行模式:
- 用户态通过
io_uring_enter()系统调用通知内核处理 SQ 中的请求 - 内核处理完成后,通过 CQ 返回结果
- 优点:CPU 消耗最低,适合 I/O 频率不高的场景
- 缺点:每次提交仍需一次
io_uring_enter()syscall
3.2 轮询模式(IORING_SETUP_IOPOLL)
对存储设备启用内核侧轮询:
- 内核使用轮询方式检测 I/O 完成,而非中断驱动
- 延迟更低,特别适合 NVMe 等高性能存储设备
- 要求 fd 以
O_DIRECT模式打开 - CPU 消耗较高,但可以获得接近裸设备的性能
3.3 内核轮询模式(IORING_SETUP_SQPOLL)
io_uring 最强大的模式:
- 内核创建一个专门的轮询线程(io-wq),持续扫描 SQ
- 用户态提交 SQE 后完全不需要系统调用
- 对于极高 IOPS 场景(如数据库、NVMe 存储),性能提升显著
- 设置
sq_thread_idle参数可控制空闲时线程超时(避免空转) - 注意:需要确保在未完成请求时内核线程保持活跃
四、liburing 编程实战
4.1 环境准备
liburing 是 io_uring 的官方封装库,提供友好的 C/C++ API:
# Ubuntu / Debian
sudo apt install liburing-dev
# 编译时链接
gcc -o my_app my_app.c -luring
# 检查内核版本(需要 >= 5.1,推荐 >= 5.11)
uname -r
4.2 初始化 io_uring
#include <liburing.h>
struct io_uring ring;
struct io_uring_params params;
memset(¶ms, 0, sizeof(params));
// 配置 SQPOLL 模式
params.flags |= IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2 秒后休眠
int ret = io_uring_queue_init_params(256, &ring, ¶ms);
if (ret < 0) {
fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
return 1;
}
// 检查支持的特性
if (!(params.features & IORING_FEAT_NODROP)) {
fprintf(stderr, "NODROP not supported!\n");
return 1;
}
4.3 发起读请求
void submit_read(struct io_uring *ring, int fd, void *buf, off_t offset, size_t len) {
// 获取一个空闲 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
if (!sqe) {
fprintf(stderr, "SQ ring is full\n");
return;
}
// 填充读请求
io_uring_prep_read(sqe, fd, buf, len, offset);
sqe->user_data = (uint64_t)buf; // 用户标记,方便识别
// 提交(SQPOLL 模式下此调用可能为空操作)
io_uring_submit(ring);
}
4.4 收割完成事件
void reap_completions(struct io_uring *ring) {
struct io_uring_cqe *cqe;
unsigned head;
unsigned count = 0;
// 批量读取 CQE(无锁,因为 CQ 是单生产者单消费者)
io_uring_for_each_cqe(ring, head, cqe) {
void *buf = (void *)cqe->user_data;
if (cqe->res < 0) {
fprintf(stderr, "I/O error: %s\n", strerror(-cqe->res));
} else {
printf("Read %d bytes from %p\n", cqe->res, buf);
}
count++;
}
// 批量推进 CQ 头指针(一次性释放所有已消费的 CQE)
if (count > 0) {
io_uring_cq_advance(ring, count);
}
}
五、高级特性全景
5.1 固定文件(IORING_REGISTER_FILES)
预先注册一组文件描述符到内核,之后 I/O 请求中可以用索引替代 fd,避免每次的 fd 查找开销:
int files[] = {fd1, fd2, fd3};
io_uring_register_files(ring, files, 3);
// 之后 sqe->flags |= IOSQE_FIXED_FILE,sqe->fd 填索引 0/1/2
5.2 固定缓冲区(IORING_REGISTER_BUFFERS)
预先将用户态缓冲区映射到内核空间,读操作时内核直接传输数据,避免每次 pin/unpin 用户页面:
struct iovec iov[3];
posix_memalign((void*)&iov[0].iov_base, 4096, 4096);
iov[0].iov_len = 4096;
io_uring_register_buffers(ring, iov, 3);
// 发送读请求时指定 sqe->buf_group = 0;
// sqe->flags |= IOSQE_BUFFER_SELECT;
5.3 链式请求(IOSQE_IO_LINK)
将多个请求链接成一个顺序链,前一个完成后才执行下一个:
struct io_uring_sqe *sqe;
// 第一步:读取文件
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd, read_buf, len, 0);
sqe->flags |= IOSQE_IO_LINK;
sqe->user_data = OP_READ;
// 第二步:写入另一个文件
sqe = io_uring_get_sqe(ring);
io_uring_prep_write(sqe, out_fd, read_buf, len, 0);
sqe->user_data = OP_WRITE;
io_uring_submit(ring);
5.4 请求取消与超时
// 取消指定 user_data 的请求
sqe = io_uring_get_sqe(ring);
io_uring_prep_cancel(sqe, (void*)target_user_data, 0);
// 为请求添加超时
struct __kernel_timespec ts = {.tv_sec = 5, .tv_nsec = 0};
sqe = io_uring_get_sqe(ring);
io_uring_prep_link_timeout(sqe, &ts, 0);
5.5 Splice 零拷贝传输
// 直接将管道数据 splice 到文件(零用户态拷贝)
int pipefd[2];
pipe(pipefd);
sqe = io_uring_get_sqe(ring);
io_uring_prep_splice(sqe, fd, -1, pipefd[1], -1, 4096, SPLICE_F_MOVE);
sqe->flags |= IOSQE_IO_LINK;
sqe = io_uring_get_sqe(ring);
io_uring_prep_splice(sqe, pipefd[0], -1, out_fd, -1, 4096, SPLICE_F_MOVE);
io_uring_submit(ring);
六、io_uring vs epoll 网络编程范式对比
6.1 epoll 范式
epoll 仅管理"就绪状态",实际的 recv/send 仍需调用:
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
ssize_t r = recv(fd, buf, sizeof(buf), 0);
process(buf, r);
}
}
}
6.2 io_uring 网络范式
io_uring 直接提交异步 recv/send 请求,内核处理后通过 CQE 返回结果:
void submit_recv(struct io_uring *ring, int fd, void *buf, size_t len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, fd, buf, len, 0);
sqe->user_data = (uint64_t)fd;
io_uring_submit(ring);
// 稍后通过 wait_cqe 获取结果
}
io_uring 网络编程的优势:请求批量化减少 syscall 次数;配合 SQPOLL 实现零系统调用网络 I/O;recv 完成后才产生事件,不存在 EAGAIN 问题;与磁盘 I/O 统一接口。
七、实战案例:构建高性能 Key-Value 存储后端
7.1 核心设计
使用 io_uring SQPOLL 模式批量提交写请求,内核线程异步处理 fsync,应用层继续接收新请求:
void kv_put(struct io_uring *ring, int fd, const void *data, size_t len, off_t offset) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
// 异步写
io_uring_prep_write(sqe, fd, data, len, offset);
sqe->user_data = generate_unique_id();
sqe->flags |= IOSQE_IO_LINK;
// 链接 fsync(写入完成后刷盘)
sqe = io_uring_get_sqe(ring);
io_uring_prep_fsync(sqe, fd, IORING_FSYNC_DATASYNC);
sqe->user_data = FSYNC_MAGIC;
io_uring_submit(ring);
}
7.2 批量提交优化
void batch_submit(struct batch_ctx *ctx) {
if (ctx->queued_count == 0) return;
io_uring_submit(&ctx->ring);
ctx->queued_count = 0;
}
while (running) {
// 处理客户端请求
if (queued_count >= 32 || time_since_last_submit() > 100) {
batch_submit(&ctx);
}
reap_completions_nonblocking(ring);
}
7.3 性能对比
实验环境:Intel Xeon Gold 6338,NVMe SSD,CentOS 8.4,内核 5.15
| 方案 | P50 延迟 (μs) | P99 延迟 (μs) | 吞吐量 (K QPS) | CPU 使用率 |
|---|---|---|---|---|
| 同步 write + fsync | 45 | 320 | 18 | 15% |
| libaio | 28 | 180 | 35 | 22% |
| io_uring(默认) | 22 | 95 | 52 | 25% |
| io_uring(SQPOLL) | 8 | 32 | 128 | 45% |
| io_uring(SQPOLL + 固定缓冲区) | 5 | 18 | 210 | 50% |
io_uring + SQPOLL + 固定缓冲区相比传统同步写方案,吞吐提升 11.7 倍,P99 延迟降低 94%。
八、生产环境部署与调优指南
8.1 内核版本要求
| 内核版本 | 特性支持 | 建议 |
|---|---|---|
| 5.1 - 5.10 | 基础 io_uring | 仅开发测试 |
| 5.11 - 5.17 | SQPOLL 完善、Buffers 注册 | 生产可用 |
| 5.18+ | 所有高级特性就绪 | 推荐生产部署 |
| 6.1+ | IORING_SETUP_DEFER_TASKRUN | 最佳选择 |
8.2 关键参数调优
# /etc/sysctl.conf
fs.aio-max-nr = 1048576
# 内存锁定限制(注册缓冲区需要)
# /etc/security/limits.conf
your_user soft memlock unlimited
your_user hard memlock unlimited
8.3 常见陷阱
陷阱 1:SQPOLL 线程退出导致请求丢失
设置 sq_thread_idle 时,确保在线程休眠前所有请求已提交。否则需要再次 io_uring_submit() 唤醒内核线程。
陷阱 2:io_uring 与 libc read/write 混用同一 fd
某些场景下会导致数据不一致。建议:要么完全使用 io_uring 处理该 fd 的所有 I/O,要么完全不使用。
陷阱 3:固定缓冲区未对齐
O_DIRECT 要求缓冲区内存页对齐,使用 posix_memalign() 分配。
陷阱 4:队列溢出
如果 SQ 满了仍继续获取 SQE,io_uring_get_sqe() 返回 NULL,此时必须先提交并收割一批请求腾出空间。
九、io_uring 生态系统与未来展望
9.1 语言绑定
| 语言 | 库/项目 | 特点 |
|---|---|---|
| Rust | tokio-uring, glommio | 异步生态集成 |
| Go | liburing-go, gouring | cgo 绑定 |
| Python | liburing (pypi) | 简洁封装 |
| C++ | liburing++, iouring-asio | 现代 C++ 风格封装 |
| Node.js | io_uring (npm) | libuv 集成 |
| Java | io_uring-jni | JNI 绑定 |
9.2 生产应用
- PostgreSQL 15+:支持 io_uring 后端,提升检查点写入和 WAL 性能
- Redis:实验性引入 io_uring 加速 AOF 持久化
- Nginx:通过模块支持 io_uring AIO,静态文件服务性能大幅提升
- Envoy:使用 io_uring 处理文件系统操作,降低 proxy 延迟
- Tokio (Rust):可选 io_uring 后端,替代 epoll 的异步调度器
- MySQL/InnoDB:实验性支持,减少 fsync 延迟
9.3 内核演进方向
- IORING_SETUP_DEFER_TASKRUN(6.1+):减少 CQE 处理延迟
- io_uring 网络零拷贝(6.5+):零拷贝 sendmsg/recvmsg
- blk-mq 深度集成:进一步优化块设备 I/O 路径
- 自动 SQPOLL Idle 调优:自适应调整内核线程活跃时间
- 越来越多的子系统迁移到 io_uring:文件系统、网络、甚至 GPU 驱动
9.4 io_uring 与 SPDK 的定位
io_uring 不是 SPDK 替代品,而是互补:SPDK 追求极致延迟(微秒级,完全用户态驱动),io_uring 追求生态兼容性和合理性能(数十微秒级)。通用负载用 io_uring,极端性能场景用 SPDK。
十、总结与学习路径
io_uring 是 Linux 异步 I/O 领域近 20 年来最重要的变革。它的核心价值不是某一两个 API 的创新,而是重新定义了用户态与内核态协作的方式:通过共享内存环形队列实现高效通信,通过 SQPOLL 和固定资源注册实现接近零开销的 I/O 提交。
学习路径建议:
- 入门阶段:阅读 Jens Axboe 的 io_uring 原始论文和邮件列表讨论,理解设计动机
- 实战阶段:使用 liburing 实现一个简单的异步文件复制工具,逐步加入 SQPOLL 和链接请求
- 进阶阶段:学习固定文件、固定缓冲区和请求取消,构建生产级应用
- 深度阶段:阅读内核源码(io_uring.c),理解 SQPOLL 线程调度、CQE 批量处理等细节
- 广度阶段:探索 Rust tokio-uring、网络 io_uring 等生态项目
推荐资源:
- io_uring 官方文档:https://kernel.dk/io_uring.pdf
- liburing GitHub 仓库:https://github.com/axboe/liburing
- Efficient IO with io_uring:Jens Axboe 原始白皮书
- Lord of the io_uring:深入中文指南
如果说 epoll 解决了 C10K 问题,那么 io_uring 正在解决 C10M 问题。无论从性能数据还是生态发展来看,掌握 io_uring 都将成为 Linux 系统程序员的必备技能。

发表评论 取消回复