Linux io_uring 异步 IO 深度解析:原理、实现与工程实践
io_uring 是 Linux 5.1 引入的高性能异步 IO 框架,由 Jens Axboe 开发。它解决了 Linux 原生异步 IO(AIO)的诸多缺陷,成为现代高性能存储和网络应用的基石。本文将从设计哲学、内核实现、用户态 API 到生产实践进行全面剖析。
一、从 AIO 到 io_uring:问题的演进
1.1 Linux 原生 AIO 的局限
Linux 原生 AIO(POSIX AIO,通过 libaio 使用)存在以下核心问题:
- 仅支持 O_DIRECT:必须使用直接 IO,绕过页缓存,导致无法利用内核缓存机制
- 阻塞语义不一致:
io_submit()在描述符资源耗尽时会阻塞,违背异步初衷 - 不支持 socket IO:无法用于网络编程,应用场景受限
- API 设计僵化:每次 IO 都需要拷贝
struct iocb,系统调用开销大 - 完成事件处理复杂:通过
io_getevents()轮询,无法与事件驱动框架集成
1.2 io_uring 的设计目标
io_uring 的设计哲学可以概括为三个关键词:
- 零拷贝提交:用户态直接写入共享内存,避免系统调用时的数据拷贝
- 无锁队列:基于单生产者/单消费者(SPSC)环形队列,消除锁竞争
- 统一抽象:支持任意类型的 IO(磁盘、网络、eventfd 等)
二、io_uring 核心架构
2.1 双环队列结构
io_uring 的核心是两个共享内存的环形缓冲区(ring buffer):
- 提交队列(Submission Queue, SQ):用户态写入 SQE(Submission Queue Entry),内核读取处理 — 单生产者单消费者
- 完成队列(Completion Queue, CQ):内核写入 CQE(Completion Queue Entry),用户态读取获取结果 — 单生产者单消费者
+-------------------------------------------------------+
| 用户进程 (User Space) |
| |
| +-----------+ write +---------+ read +-----+ |
| | 应用逻辑 | ------> | SQ Ring | <------ |内核 | |
| +-----------+ +---------+ +-----+ |
| | | |
| +------+------+ +-----+ |
| | SQ Array | | CQ | |
| | (SQE entries)| | Ring | |
| +-------------+ +-----+ |
| ^ | |
| | read | v |
| +----+----+ +----------+|
| | io_uring <----+ 内核线程 ||
| | instance | 内核提交 ||
| +-------------+ +----------+|
+-------------------------------------------------------+
这种设计的精妙之处在于:用户态写入 SQE 后可以不触发系统调用,内核通过 IORING_ENTER 或自动轮询模式消费 SQ。批量提交时,只需一次 io_uring_enter() 系统调用。
2.2 内存映射机制
io_uring_setup() 系统调用通过 mmap 将内核缓冲区映射到用户空间,消除每次 IO 操作的数据拷贝:
// 内核侧:io_uring_setup()
struct io_uring_ctx *io_uring_setup(unsigned entries, struct io_uring_params *p) {
ctx = alloc_uring_ctx();
// 1. 分配 SQ Ring(单生产者/单消费者环形队列)
ctx->sq_ring = alloc_pages(SQ_RING_SIZE, GFP_USER);
// 2. 分配 SQE Array(实际的提交队列条目数组)
ctx->sqes = alloc_pages(entries * sizeof(struct io_uring_sqe), GFP_USER);
// 3. 分配 CQ Ring(完成队列环形队列)
ctx->cq_ring = alloc_pages(CQ_RING_SIZE, GFP_USER);
// 4. 配置映射信息,通过 params 返回给用户态
p->sq_off.head = offsetof(struct io_sq_ring, head);
p->sq_off.tail = offsetof(struct io_sq_ring, tail);
p->sq_off.ring_mask = offsetof(struct io_sq_ring, ring_mask);
// ... 更多偏移量
return ctx;
}
// 用户态:mmap 映射
sq_ring_ptr = mmap(0, p.sq_off.array + p.sq_entries * sizeof(unsigned),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_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,
ring_fd, IORING_OFF_CQ_RING);
sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_SQES);
2.3 SQE 与 CQE 数据结构
io_uring 的提交和完成条目设计极其精巧,64 字节的 SQE 容纳了足够丰富的操作类型:
// struct io_uring_sqe (64 bytes,cache line 对齐)
struct io_uring_sqe {
__u8 opcode; // 操作码:IORING_OP_READV/WRITEV/SEND/RECV/FSYNC...
__u8 flags; // IOSQE_* 标志位
__u16 ioprio; // IO 优先级
__s32 fd; // 文件描述符
union { // 64-bit 偏移量或 addr
__u64 off;
__u64 addr2;
};
union { // 缓冲区地址或 splice_fd_in
__u64 addr;
__u64 splice_off_in;
};
__u32 len; // 缓冲区长度
union { // 操作类型特定标志
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
__u32 sync_range_flags;
__u32 msg_flags;
__u32 timeout_flags;
__u32 accept_flags;
__u32 cancel_flags;
__u32 open_flags;
__u32 statx_flags;
__u32 fadvise_advice;
__u32 splice_flags;
__u32 rename_flags;
__u32 unlink_flags;
__u32 hardlink_flags;
__u32 mkdir_flags;
__u32 symlink_flags;
};
__u64 user_data; // 用户自定义标识,会原封不动地传递到 CQE
union { // 打包字段:addr 相关的索引/分组
struct {
__u16 buf_index;
__u16 buf_group;
__u8 personality;
__s8 splice_fd_in;
__u16 __pad;
};
__u64 __pad2[2];
};
};
// struct io_uring_cqe (16 bytes)
struct io_uring_cqe {
__u64 user_data; // 与 SQE 中的 user_data 对应
__s32 res; // 操作结果(类似返回值,>=0 成功,<0 错误码)
__u32 flags; // 标志位(如 IORING_CQE_F_MORE)
};
三、操作提交模式
3.1 基础模式:io_uring_enter()
用户态写入 SQE 后,需要显式调用 io_uring_enter() 通知内核处理:
// 批量提交模式
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
sqe->opcode = IORING_OP_READV;
sqe->fd = fd;
sqe->addr = (unsigned long)&iov;
sqe->len = 1;
sqe->off = 0;
sqe->user_data = (uint64_t)my_context;
// 通知内核处理 min_complete 个条目
int submitted = io_uring_submit(&ring); // 内部调用 io_uring_enter(ring_fd, submitted, 0, 0)
// 等待完成事件
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
printf("result: %d, user_data: %llu\n", cqe->res, cqe->user_data);
io_uring_cqe_seen(&ring, cqe);
3.2 内核轮询模式(SQPOLL)
通过设置 IORING_SETUP_SQPOLL 标志,io_uring 会创建一个内核线程自动轮询 SQ,用户态无需调用 io_uring_enter() 即可提交 IO:
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
params.sq_thread_idle = 2000; // 空闲 2ms 后内核线程休眠
params.sq_thread_cpu = 2; // 绑定到 CPU 2
io_uring_queue_init_params(QUEUE_DEPTH, &ring, params);
// 此后提交 SQE 后不需要调用 io_uring_submit()
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iov, 1, 0);
io_uring_sqe_set_data(sqe, my_ctx);
// 仅更新 SQ tail,无需系统调用!
io_uring_submit(&ring); // 只是写内存,不触发 syscall
SQPOLL 模式的风险:内核线程可能持有文件描述符引用,即使进程退出也会延迟释放。需设置 sq_thread_idle 控制空闲超时。
3.3 IO 轮询模式(IOPOLL)
结合 IORING_SETUP_IOPOLL,内核使用 blk-mq 的轮询模式(而非中断模式)完成 IO,适用于 NVMe 等超低延迟设备:
// IOPOLL + SQPOLL 组合:完全绕过中断和系统调用
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL;
params.sq_thread_idle = 100; // 更激进的轮询
延迟对比(典型 NVMe 设备):
- 中断模式 AIO:~5-7 μs
- io_uring 中断模式:~3-5 μs
- io_uring SQPOLL:~1.5-2.5 μs
- io_uring SQPOLL + IOPOLL:~0.8-1.5 μs
四、内核侧实现深度剖析
4.1 提交路径
从 io_uring_enter() 到实际 IO 提交的调用链:
io_uring_enter(2)
└── __io_uring_enter(ctx, to_submit, min_complete, flags)
├── io_uring_check_cancel() // 检查是否有取消请求
├── io_run_task_work() // 执行 task_work 回调
├── if (needs_lock) io_submit_state_start() // 需要锁时获取 inode/file 锁
└── io_submit_sqes(ctx, to_submit)
└── for each sqe:
└── __io_submit_sqe()
├── sqe->prep() // 准备 IO 请求(如 io_read/write)
└── io_issue_sqe() // 发起 IO
├── 直接下发:调用 f_op->read_iter/write_iter
├── 异步处理:将 linked SQE 加入 task_work
└── 轮询模式:io_wq_submit_work()
4.2 io-wq 工作队列
io_uring 内部实现了一个轻量级工作队列 io-wq,用于处理可能阻塞的操作:
// io-wq 工作线程管理
struct io_wq {
struct io_wq_work_node *hash_replace_wait; // HASH 替换等待队列
struct task_struct *task; // 工作线程
struct io_wq_data *wq_data;
// 工作线程池
struct io_wq_acct[2] {
struct { // BOUND 线程(绑定 CPU)
active workers; // 当前活跃数
max_workers; // 上限 = min(nr_online CPUs, 2*entries)
};
struct { // UNBOUND 线程(不绑定 CPU)
...
};
};
};
io-wq 的核心逻辑:
- 操作被标记为同步时,直接在提交线程执行
- 操作可能阻塞时(如 buffered read),通过 io-wq 异步处理
- io-wq 自动扩缩容:有活就加人,空闲就减人
- BOUND worker 绑定 NUMA 节点,UNBOUND worker 跨节点平衡
4.3 完成事件路径
IO 完成后,内核通过 task_work 或直接写入 CQ Ring 通知用户态:
// 完成回调路径
bio_endio()
└── req->end_io(req)
└── io_complete_rw() // io_uring 的请求完成回调
├── io_fill_cqe_res(ctx, res, cqflags) // 填充 CQE
└── io_cqring_add_event(ctx) // 写入 CQ Ring
└── smp_store_release(&cq_ring->tail, tail + 1);
// 等待侧唤醒路径
io_uring_io_wait()
└── wait_queue // 等待 CQ Ring 的 tail 变化
└── wake_up() on new CQE
// eventfd 通知集成
io_uring_register_eventfd()
└── ctx->cq_ev_fd = eventfd;
// 每次有 CQE 写入时触发 eventfd signal
// 可与 epoll/select 无缝集成,实现 \"io_uring + eventloop\"
五、高级特性
5.1 Linked SQE(链接操作)
通过 IOSQE_IO_LINK 标志将多个 SQE 串联为链式操作,确保串行执行:
// 场景:先 fdatasync 再读,确保数据落盘
struct io_uring_sqe *sqe;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_fsync(sqe, fd, IORING_FSYNC_DATASYNC);
sqe->flags |= IOSQE_IO_LINK; // 链接到下一个
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, 0);
sqe->flags |= IOSQE_IO_HARDLINK; // 硬链接:前一个失败则中断链接
io_uring_submit(&ring);
5.2 缓冲区注册(Buffer Registration)
通过 IORING_REGISTER_BUFFERS 预注册内存缓冲区,消除每次 IO 的 get_user_pages/pi_submit 开销:
// 预注册一组缓冲区
struct iovec iovecs[QUEUE_DEPTH];
for (int i = 0; i < QUEUE_DEPTH; i++)
posix_memalign(&iovecs[i].iov_base, 4096, BUF_SIZE);
int ret = io_uring_register_buffers(&ring, iovecs, QUEUE_DEPTH);
// 使用 registered buffer
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, buf, len, 0, buf_index);
// 内核直接引用已注册的页面,无需 pin/unpin
进一步优化:Linux 5.17+ 支持 IORING_REGISTER_BUFFERS2,允许 NUMA 感知的缓冲区注册。结合 IORING_SETUP_COOP_TASKRUN 和 REGISTERED_BUF_RING(5.19+),可实现高性能网络服务器的零拷贝 IO。
5.3 文件描述符注册(Fixed Files)
通过 IORING_REGISTER_FILES 预注册 fd 数组,避免每次 IO 的 fd get/put 开销:
// 注册文件描述符表
int fds[FILE_TABLE_SIZE] = {fd1, fd2, fd3, ...};
io_uring_register_files(&ring, fds, FILE_TABLE_SIZE);
// 使用 fixed file(fd = index, 设置 IOSQE_FIXED_FILE)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, len, 0); // fd=0 表示 use fixed_fds[0]
sqe->flags |= IOSQE_FIXED_FILE;
// Linux 5.18+: IORING_REGISTER_FILES2 支持稀疏 fd 表
io_uring_register_files2(&ring, nr_files, tags, fds);
// 结合 tag 机制,支持 fd 替换的引用计数安全
5.4 零拷贝网络(zerocopy send)
IORING_OP_SEND_ZC 支持网络零拷贝,数据从用户缓冲区直接发送到网卡,绕过内核协议栈的拷贝:
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, sockfd, buf, len, 0, 0); // zero-copy send
sqe->ioprio |= IORING_RECVSEND_FIXED_BUF; // 使用 registered buffer
sqe->addr2 = buf_index; // registered buffer 索引
io_uring_submit(&ring);
// 完成事件处理:注意 zerocopy 会有两个 CQE
// 第一个:实际发送结果
// 第二个:IORING_CQE_F_NOTIF 标记,表示缓冲区可安全释放
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (io_uring_cqe_get_data(cqe) == SEND_TAG) {
// 数据正在发送中,不要释放缓冲区
}
io_uring_cqe_seen(&ring, cqe);
io_uring_wait_cqe(&ring, &cqe);
if (cqe->flags & IORING_CQE_F_NOTIF) {
// 发送完成,可以释放/重用缓冲区
}
六、生产实践与性能优化
6.1 高性能 Web 服务器模式
使用 io_uring 构建高性能网络服务器的典型架构:
// 1. 初始化 io_uring(SQPOLL + COOP_TASKRUN + SINGLE_ISSUER)
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL
| IORING_SETUP_COOP_TASKRUN
| IORING_SETUP_SINGLE_ISSUER
| IORING_SETUP_DEFER_TASKRUN;
params.sq_thread_cpu = target_cpu;
params.sq_thread_idle = 1000; // 1ms idle
io_uring_queue_init_params(4096, &ring, params);
// 2. 注册 eventfd,集成到 epoll event loop
int evfd = eventfd(0, EFD_NONBLOCK);
io_uring_register_eventfd(&ring, evfd);
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, evfd, &ev_event);
// 3. accept 循环:批量提交 recv
while (running) {
// 批量准备 recv
for (int i = 0; i && client_count; i++) {
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, clients[i].fd,
clients[i].buf, BUF_SIZE, 0);
sqe->user_data = MAKE_TAG(CLIENT_RECV, i);
}
io_uring_submit(&ring);
// 处理 CQ
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
if (TAG(cqe->user_data) == CLIENT_RECV) {
handle_request(cqe);
}
}
io_uring_cq_advance(&ring, completed);
}
6.2 存储引擎模式
NVMe 存储引擎的 io_uring 优化方案:
// IOPOLL + SQPOLL:极致低延迟路径
params.flags = IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL;
params.sq_thread_cpu = 0;
// 预注册 IO buffer pool(NUMA 本地内存)
for (int node = 0; node < num_nodes; node++) {
io_uring_register_buffers2(ring, node, bufs, nr_bufs);
}
// 批处理 submit:一次提交 N 个 IO 请求
#define BATCH_SIZE 32
for (int i = 0; i < pending; i += BATCH_SIZE) {
count = min(BATCH_SIZE, pending - i);
for (int j = 0; j < count; j++) {
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(ring, fd, bufs[j], BLOCK_SIZE, offsets[i+j]);
}
io_uring_submit(ring); // SQPOLL: 纯内存操作,0 系统调用
}
6.3 性能调优参数对比
| 参数 | 默认值 | 优化建议 | 影响 |
|---|---|---|---|
QUEUE_DEPTH | 64 | 256-4096 | 更高的并吞吐,更多内存 |
SQPOLL_IDLE | 1000ms | 1-2000ms | 越低 CPU 占用越高,延迟越低 |
SQ_THREAD_CPU | 不绑定 | 绑定独占 CPU | 减少上下文切换,NUMA 亲和 |
COOP_TASKRUN | 关闭 | 开启 | 减少抢占,提高 SQ 写入速度 |
SINGLE_ISSUER | 关闭 | 开启时跳过 CPU 验证 | 单线程提交时节省开销 |
DEFER_TASKRUN | 关闭(6.1+默认开) | 开启 | 批量化处理 task_work |
6.4 常见陷阱与解决
- SQPOLL 线程泄漏:进程被 OOM killer 或强制 kill 后,sq_thread 可能仍在运行(持有 fd 引用)。解决:设置
sq_thread_idle上限或使用IORING_SETUP_ATTACH_WQ复用 wq - CQ Ring 溢出:高并发场景下 CQ 可能满,导致提交失败(
IORING_SETUP_CQSIZE可扩大队列,但无法根本解决)。解决:及时消费 CQE,或使用IORING_ENTER_EXT_ARG带超时等待 - 注册缓冲区与 fork:fork 后子进程复制了 ring 但注册的内核映射不共享。解决:子进程重新注册或使用
CLONE_IO - IOPOLL 不支持的文件系统:某些文件系统(如 overlayfs)不支持
iopoll。解决:运行时检测,回退到中断模式 - zerocopy 完成通知乱序:
IORING_CQE_F_NOTIF可能在其他 CQE 之间到达。解决:通过 user_data 标识匹配,不要依赖 CQE 顺序
七、io_uring 演进历程
- Linux 5.1 (2019):初始版本,基础 read/write/fsync 支持
- Linux 5.4:SQPOLL 模式,缓冲区注册
- Linux 5.6:accept/send/recv 网络支持
- Linux 5.10:
IORING_OP_SHUTDOWN、IORING_OP_RENAME - Linux 5.15:Fixed buffers ring buffer(无需 pre-register 即可速配 buffer)
- Linux 5.19:IOPOLL + SQPOLL 完善,sendmsg/recvmsg
- Linux 6.0:zerocopy send(
IORING_OP_SEND_ZC),FUTEX 支持 - Linux 6.1:async buffered read、DEFER_TASKRUN、personality 支持
- Linux 6.3:IORING_MSG_RING 跨 ring 通信、fixed buffer pool
- Linux 6.6:
IORING_OP_URING_CMD设备直通命令(NVMe passthrough) - Linux 6.10:bind/listen 用于 TCP 服务器(accept+listen 原生支持)
八、总结
io_uring 解决了 Linux 异步 IO 领域二十年的痛点,其双环队列 + 共享内存的架构将系统调用开销降到了趋近于零。从 SPDK 到 RocksDB,从 Redis 的 io-threads 到 PostgreSQL 的 IO 框架尝试,io_uring 正在重新定义 Linux 高性能 IO 的标准。
掌握 io_uring 的关键在于理解「提交」与「完成」的解耦——SQ 是生产者-消费者模型下的通知通道,不是数据通道。真正的数据通过 page cache 或 direct IO 的 page 引用流转。这种设计使得 io_uring 能在内核态和用户态之间实现真正的零拷贝、无锁通信。
在未来,随着 io_uring 对网络 bind/listen、设备 uring_cmd 等原生支持的完善,它有望成为一个统一的内核异步操作平台——不仅仅是 IO,而是「一切异步」。

发表评论 取消回复