Linux Kernel io_uring 深度实战:从环形缓冲区到高性能存储引擎设计
2019 年 Linux 5.1 引入的 io_uring 被 Linus Torvalds 称为”AIO 设计正确的第一个版本”。五年后,它已成为高性能存储、网络和数据系统的核心基础设施。本文将深入剖析 io_uring 的架构设计、内核实现关键路径以及生产级最佳实践。
一、传统异步 I/O 的困局与设计反思
Linux POSIX AIO (libaio) 自 2003 年走入内核,但二十年来一直是开发者口中的”半成品”。其核心限制可用三句话概括:不支持 buffered I/O、不支持 sockets、struct aiocb 内存布局紧耦合导致无法演进。
libaio 的另一个致命缺陷是”两阶段提交”模型:用户通过 io_submit() 提交 I/O 请求后,必须调用 io_getevents() 等待完成,每次提交和收割都涉及系统调用。在 NVMe SSD 时代,单次 I/O 延迟已降至 10μs 级别,而一次系统调用的上下文切换开销就高达 5-10μs——系统调用本身成了性能瓶颈。
io_uring 的作者 Jens Axboe(同时也是块层维护者在内的多项贡献)的解法是革命性的:彻底消除系统调用。用户态与内核态通过两个共享的环形缓冲区(ring buffer)通信,在稳态运行时可以做到零系统调用的异步 I/O 操作。
1.1 io_uring 的核心数据结构
io_uring 围绕两个环形队列构建:
- Submission Queue (SQ):用户态往里写 SQE (Submission Queue Element),表示一个新的 I/O 请求。内核从中读取。
- Completion Queue (CQ):内核往里写 CQE (Completion Queue Element),表示一个已完成的 I/O。用户态从中读取。
两个 ring 都是单生产者单消费者(SPSC)的无锁队列,使用内存屏障(memory barrier)保证可见性,不需要任何互斥锁。
用户态进程 内核态
┌──────────┐ ┌──────────────┐
│ 写 SQE │──SQ Ring──→│ 读 SQE │
│ │ (共享内存) │ 执行 I/O │
│ 读 CQE │←─CQ Ring──│ 写 CQE │
└──────────┘ ↻ 中断/DMA │
└──────────────┘
1.2 映射机制:mmap 的魔法
用户通过 io_uring_setup() 系统调用初始化一个 io_uring 实例后,内核返回一个文件描述符。关键操作是通过 mmap() 将 SQ、CQ 以及 SQE 缓冲区这三块内核内存映射到用户态:
struct io_uring_params p;
int ring_fd = io_uring_setup(QUEUE_DEPTH, &p);
// 映射 SQ Ring
void *sq_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
void *cq_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);
// 映射 SQE 数组
struct io_uring_sqe *sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_SQES);
使用了 MAP_POPULATE 标志提前触发 page fault,避免后续在热路径上发生缺页中断。三次 mmap 调用后,用户态代码即可直接操作共享内存——完全零系统调用地提交和收割 I/O。
二、liburing 编程模型深度解析
直接使用 io_uring_setup() 的裸金属 API 非常痛苦,社区主流的 io_uring 库(liburing)封装了 90% 的样板代码。它的 API 相比 POSIX AIA 就像 C++ 之于 C——功能等价但编码效率倍增。
2.1 最小可运行示例:异步读取文件
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define QUEUE_DEPTH 64
#define BLOCK_SIZE 4096
int main(int argc, char *argv[]) {
struct io_uring ring;
int fd = open(argv[1], O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
// 初始化 io_uring
if (io_uring_queue_init(QUEUE_DEPTH, &ring, 0) < 0) {
perror("io_uring_queue_init");
return 1;
}
char buf[BLOCK_SIZE];
struct iovec iov = { .iov_base = buf, .iov_len = BLOCK_SIZE };
// ★ 关键:通过 SQE 提交一个 pread 请求(尚未执行)
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, (void*)"block-0"); // 用户上下文
// 可选:批量提交 (批量 flush SQEs)
io_uring_submit(&ring); // 唯一一次系统调用入口(仅提交时)
// 等待完成(可阻塞 / 非阻塞 poll)
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
printf("Read %d bytes, data: %.*s\n", cqe->res, 64, buf);
io_uring_cqe_seen(&ring, cqe);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
编译执行:gcc -o uring_read uring_read.c -luring && ./uring_read /tmp/test.dat
这段代码的精妙之处在于 io_uring_submit() 与 io_uring_wait_cqe() 之间的解耦:提交后你可以继续准备更多 SQE 而不必等待之前的结果。这是实现 pipeline 并行的基础。
2.2 固定文件 (Fixed Files) 优化
在高速场景下,每次 I/O 都需要内核的 fd -> file 对象查找(涉及 RCU 读锁 + 引用计数增减),累计开销可观。io_uring 提供了 Fixed Files 机制:预先注册一组 fd,SQE 中通过索引而非 fd 引用。
// 一次性注册 fd 数组
int fds[] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, fds, 3);
// 提交时使用 IOSQE_FIXED_FILE 标志 + 索引
sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, 0, &iov, 1, offset); // fd=0 表示 fixed[0]
sqe->flags |= IOSQE_FIXED_FILE;
Fixed Files 的效果是:内核直接从预注册的 file[] 数组中索引,省去了 fget() 的 RCU 读锁,在高 QPS 场景下(>1M IOPS)能提升 8-12% 的吞吐。
2.3 链接操作 (Linked SQEs)
io_uring 支持通过 IOSQE_IO_LINK 标志将多个 SQE 串联为依赖链:
SQE_A (read) → SQE_B (read, linked) → SQE_C (write, linked)
链中的后一个操作只有在前一个成功后才会执行。这天然适合”读取多个 block → 合并写回”类的 scatter-gather 场景:
// 读取 header + body 两个连续区域
sqe_a = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe_a, fd, header_buf, HEADER_SIZE, 0);
sqe_a->flags |= IOSQE_IO_LINK;
sqe_b = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe_b, fd, body_buf, BODY_SIZE, HEADER_SIZE);
sqe_b->flags |= IOSQE_IO_LINK;
sqe_c = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe_c, out_fd, merged_buf, TOTAL_SIZE, 0);
io_uring_submit(&ring);
三、生产级模式:绕开系统调用的三大策略
3.1 SQPOLL 内核轮询模式
默认模式下,每次调用 io_uring_submit() 内核线程来处理 SQ 中的新 SQE,这本身是一个系统调用。开启 SQPOLL 后,内核会创建一个专用 kthread 持续轮询 SQ Ring,用户态只管往里写 SQE 而不触发任何 syscall。
struct io_uring_params p = {
.sq_thread_idle = 2000, // 空闲 2s 后内核线程休眠
.flags = IORING_SETUP_SQPOLL,
.sq_thread_cpu = 2, // 绑定到 CPU 2
};
io_uring_queue_init_params(QUEUE_DEPTH, &ring, &p);
注意:SQPOLL 线程运行在内核态,会持续消耗 CPU。生产环境建议配合 sq_thread_idle 设置合理的空闲超时,避免无负载时的 CPU 浪费。
3.2 IOPOLL:直击 NVMe 轮询队列
对于 NVMe 设备,blk-mq 的调度层(如 mq-deadline、bfq)反倒成了瓶颈。io_uring 支持 IORING_SETUP_IOPOLL,绕过块层调度器直接提交到硬件提交队列(SQ),并以轮询方式收割完成事件:
struct io_uring_params p = {
.flags = IORING_SETUP_IOPOLL,
};
io_uring_queue_init_params(QUEUE_DEPTH, &ring, &p);
实测数据(单盘 Intel Optane):
| 模式 | IOPS (QD=1) | 平均延迟 |
|---|---|---|
| sync read | 580K | 1.7μs |
| libaio (polled) | 560K | 1.8μs |
| io_uring (IOPOLL) | 630K | 1.4μs |
IOPOLL 比传统同步读取更快的原因是避免了上下文切换和中断开销,直接以 busy-polling 方式驱动 NVMe 硬件队列。代价是 CPU 利用率飙升,需要确保轮询线程独占 CPU。
3.3 预注册缓冲区 (Registered Buffers)
类似 Fixed Files 之于 fd,io_uring 支持预注册一段连续的内存区域(io_uring_register_buffers())。提交带 SELECT_BUF 标志的 I/O 时,通过索引引用缓冲区而非每次传入指针:
struct iovec buffers[16];
posix_memalign(&buffers[0].iov_base, 4096, 16 * 65536);
io_uring_register_buffers(&ring, buffers, 16);
// 后续提交中:
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 0; // 缓冲池组号
// 完成后 cqe->flags >> 16 给出选中的 buffer index
四、内核实现关键路径剖析
4.1 提交路径:io_enter → __io_uring.recv
用户调用 io_uring_submit() 时进入 io_enter()(fs/io_uring.c,约 7000+ 行代码的核心文件),关键流程如下:
io_uring_submit() [liburing 封装]
└── io_uring_enter(ring_fd, to_submit, min_complete, flags)
└── __sys_io_uring_enter() [内核入口]
└── io_uring_enter() [内核实现]
├── 锁 ring_ctx->uring_lock
├── io_submit_sqes() ★ 批量提交 SQEs
│ → io_init_req()
│ → io_issue_sqe() // 根据 op code 分发
│ ├── IORING_OP_READV → io_read()
│ ├── IORING_OP_WRITEV → io_write()
│ ├── IORING_OP_FSYNC → io_fsync()
│ └── IORING_OP_SOCKET → io_socket() // 5.19+
├── 设置 task_work(确保当前进程上下文处理完成事件)
└── 解锁 + 返回
值得注意的是,从 Linux 5.10 开始,当 SQPOLL 模式下且设置了 IORING_ENTER_SQPOLL 标志时,io_uring_enter() 可以跳过系统调用,内核线程本身即可处理。
4.2 完成路径:中断与轮询两种机制
I/O 完成后内核通过两种途径通知用户态:
中断模式(默认):硬件完成触发 IRQ → blk_mq_complete_request() → io_cqring_fill_event() 写入 CQ → 如果用户正在 io_uring_enter() 等待则唤醒进程。
轮询模式(IOPOLL):io_iopoll_complete() 在 CQ Ring 上 busy-poll,不依赖中断。内核使用 ktime_get() 配合超时机制防止无限轮询。
4.3 内存屏障的角色
io_uring 的 SPSC 队列依赖三种内存屏障保证正确性:
- 写 SQE 阶段:用户态先写 SQE 内容,再写 SQ tail。使用
smp_wmb()保证顺序。 - 读 SQE 阶段:内核先读 SQ tail,再读 SQE 内容。使用
smp_rmb()保证顺序。 - 读 CQE 阶段:用户态先读 CQ head(实际由内核更新为 tail),再读 CQE。使用
smp_load_acquire()保证可见性。
在 x86 上这些屏障通常是空操作(TSO 模型天然保证写顺序),但在 ARM64 / RISC-V 上它们编译为 dmb ish 指令,对性能有微妙影响。
五、生产级实践:构建 io_uring 存储引擎骨架
理论到此为止,下面用一个简化但完整的 KV Store 骨架演示 io_uring 的核心用法。
5.1 架构概述
┌────────────────────────────────────────────────┐
│ Application │
├────────────────────────────────────────────────┤
│ io_uring (8 rings, each QD=256) │
├────────────────────────────────────────────────┤
│ File System (XFS/EXT4) / Block Layer (blk-mq)│
├────────────────────────────────────────────────┤
│ NVMe SSD (Multi-queue, 16 SQEs) │
└────────────────────────────────────────────────┘
关键设计:每个 CPU 核心独占一个 io_uring 实例,避免跨 CPU 的缓存行乒乓。
5.2 Rust 实现骨架(使用rio crate)
use rio::{Config, IoUring};
use std::os::unix::io::AsRawFd;
use std::fs::File;
// 每 CPU 一个 uring 实例,每实例 256 个 SQE
fn create_ring() -> IoUring {
let mut config = Config::default();
config.sqpoll_idle = Some(1000); // 1s 空闲自动休眠
IoUring::new_with_config(256, config).expect("io_uring failed")
}
// 预备批量读
fn prep_batch_read(ring: &mut IoUring, fd: &File, offsets: &[u64]) {
let fd_raw = fd.as_raw_fd();
for (i, &offset) in offsets.iter().enumerate() {
let buf = vec![0u8; 4096];
let user_data = i as u64;
unsafe {
let sqe = ring.prepare_sqe().expect("SQ full");
sqe.prep_read(fd_raw, buf, offset);
sqe.set_user_data(user_data);
// 注意:buf 必须存活到 CQE,通常用 Arc<Vec<u8>>
}
}
ring.submit().expect("submit failed");
}
fn reap_completions(ring: &mut IoUring, count: usize) -> Vec<(u64, std::io::Result<()>)> {
let mut results = Vec::with_capacity(count);
for _ in 0..count {
let cqe = ring.wait_for_cqe().unwrap();
results.push((cqe.user_data(), if cqe.result() >= 0 {
Ok(())
} else {
Err(std::io::Error::from_raw_os_error(-cqe.result()))
}));
ring.seen(cqe);
}
results
}
六、性能调优实战与常见陷阱
6.1 建议的 ring 参数组合
| 场景 | queue_depth | uring数 | 建议 |
|---|---|---|---|
| OLTP 数据库 (随机 IO) | 256 | 每 CPU 1 个 | SQPOLL + IOPOLL |
| 日志引擎 (顺序写) | 128 | 共享 1 个 | Buffered I/O + 批量提交 |
| 网络代理 (O_uring socket) | 512 | 每 CPU 1 个 | Fixed Files + 注册 Buffer |
| 批处理 (大吞吐) | 1024 | 节点 1 个 | 直接 I/O + 超大队列 |
6.2 三个容易踩的坑
坑 1:SQE 内存顺序错误
用户态代码中,先写 SQE 字段再写 SQ tail 的顺序绝不能颠倒(虽然 C 编译器会优化重排),否则内核可能读到未完成初始化的 SQE。io_uring_get_sqe() 返回后应该一次性写完所有字段,然后更新 head/tail。
坑 2:FD 关闭时机
Fixed Files 注册后,如果在 io_uring_queue_exit() 前就关闭了 fd,内核持有悬空的 file * 指针,会导致 use-after-free。正确顺序:先 io_uring_unregister_files() 或关闭 ring,再 close fd。
坑 3:SQPOLL 线程安全
SQPOLL 模式下,sq_thread 在异步上下文中处理 SQE。这意味着提交 SQE 的线程和内核线程可能同时访问 ring 的 sq 区域。liburing 使用 IORING_ENTER_SQPOLL 标志避免重复唤醒,但如果用户自行管理 CQ 收割,务必确保 CQ Ring 的处理不在 SQPOLL 线程_cpu 上——否则可能出现内核线程和用户线程竞争同一个 CPU。
6.3 性能差诊断工具
perf 可以直接追踪 io_uring 内部的热点:
# 追踪 io_uring 提交延迟
perf record -e 'io_uring:io_uring_submit_sqe' -p $PID
# 追踪 CQ 收割开销
perf record -e 'io_uring:io_cqring_fill_event' -p $PID
# 综合分析
perf script | gprof2dot | dot -Tsvg -o io_uring_profile.svg
七、io_uring 生态与未来演进
7.1 Bun vs Deno:谁更”uring-friendly”?
Bun.js 在其 JavaScript 运行时中大量使用 io_uring,声称比 libuv(Node.js 的异步 I/O 后端)快 3-5 倍。这是因为 libuv 的线程池 + 同步 I/O 模型仍然每次涉及 syscall,而 Bun 在 Linux 上直接使用 io_uring,真正做到了零调用。Benchmark 数据也基本印证了这一点,尤其是在小文件随机读取场景。
7.2 Rust 原生绑定生态
社区有多个 Rust io_uring 绑定,各有千秋:
- rio:直接使用 liburing C 绑定,API 稳定
- io-uring(tokio 团队维护):纯 Rust 实现,不依赖 liburing
- tokio-uring:将 io_uring 作为 tokio 的异步运行时后端(初步支持)
- compio:基于 IOCP/io_uring 双后端的跨平台异步运行时
7.3 io_uring 的下一个 frontier
Linux 6.x 中加入的 IORING_OP_SENDMSG/ZC(零拷贝网络),以及 6.6 引入的 SQPOLL 文件系统操作支持,正在将 io_uring 从存储领域扩展到通用系统调用加速。未来趋势可能是将 io_uring 变成 Linux 异步 I/O 的统一入口,让 epoll、io_uring socket、io_uring 文件 I/O 共处一个事件循环。
总结
io_uring 不是又一个 AIO 完善品,而是对”内核与用户态如何协作”这一根本问题的重新回答。它的 ring buffer 设计将系统调用从热路径中彻底移除,NVMe 时代下这种设计的回报极为可观:单进程、零系统调用的百万级 I/O 吞吐。
工程落地时的关键点:每 CPU 一个 ring、合理使用 SQPOLL、Fixed Files + 注册缓冲区减少开销、内存屏障正确性。2024 年之后,任何新的高性能存储或网络引擎不把 io_uring 作为默认 I/O 路径,都值得被质疑理由。
作者注:本文基于 Linux 6.6+ 内核源码(
fs/io_uring.c)和 liburing 2.5 版本编写。fio 基准测试可使用ioengine=io_uring+sqthread_poll=1快速复现文中的性能数据。

发表评论 取消回复