io_uring:内核旁路前的最后一站
两个环形队列把系统调用摊薄到接近零。理解 SQ/CQ 模型,用 Rust 直接驱动 io_uring 做真异步的文件 IO。
学完这节你能做到
- 画出 SQ/CQ 双环模型的数据流
- 用 io-uring crate 写出注册 buffer 的随机读程序
- 对比 io_uring 与线程池方案的 IOPS 和 CPU 占用
系统调用为什么贵:上下文切换的账
先算一笔你在运维时感受过但未必算过的账。一次系统调用(比如 pread)的固定开销:
用户态陷入内核、保存和恢复寄存器、切换页表相关状态、污染 CPU 缓存和分支预测器 ——
在现代 CPU 上大约几百纳秒;自从 Spectre/Meltdown 缓解措施(KPTI 等)上线后更贵。
你可能还记得当年打完补丁后 IO 密集型业务集体掉了一截性能,就是这笔账。
单看几百纳秒不算什么,但对上 NVMe 就难看了:一块盘标称 100 万 4k 随机读 IOPS, 意味着每微秒就要完成一个 IO。如果每个 IO 要一次系统调用,光陷入内核的固定开销 就吃掉了预算的一大半;再叠加上一课说的线程池排队,传统路径根本喂不饱盘。
iostat 里你看盘明明不忙(util 上不去、延迟也不高),压测工具却打不出更高 IOPS ——
瓶颈在软件路径,不在盘。io_uring 要解决的就是这个:把「每个 IO 一次系统调用」
变成「一批 IO 摊一次系统调用」,甚至零次。
SQ / CQ 双环:和 NVMe 硬件同一个设计
io_uring 的核心是两个用户态和内核共享内存的环形队列:
- SQ(Submission Queue,提交队列):你把 IO 请求(SQE)写进环里 —— 纯内存操作,不陷入内核
- CQ(Completion Queue,完成队列):内核把完成事件(CQE)写进环里 —— 你收结果也是纯内存读
数据流是这样的:
- 应用填好 N 个 SQE(读哪个 fd、什么偏移、读到哪块 buffer)
- 调一次
io_uring_enter,告诉内核「有 N 个新请求」 - 内核异步执行这些 IO,完成一个就往 CQ 里放一个 CQE
- 应用从 CQ 收割结果 —— 可以顺手再提交下一批
一次系统调用摊给 N 个 IO,批越大摊得越薄。如果你觉得这个双环结构眼熟 —— 没错, NVMe 协议本身就是这么设计的(硬件提交队列/完成队列,下一课细讲)。 io_uring 相当于把 NVMe 的接口哲学向上复制了一层,两层队列首尾相接,天生登对。
io_uring 是内核接口,tokio 是运行时,不冲突。tokio 主线版本的 IO driver 仍基于 epoll; 社区的 tokio-uring 提供了 io_uring 后端。本课我们用更底层的 io-uring crate 直接驱动, 因为你需要看清每个环上发生了什么 —— 这是「落盘路径必须逐行读懂」红线的延伸。
用 io-uring crate 跑起来
use io_uring::{opcode, types, IoUring};
use std::fs::File;
use std::os::unix::io::AsRawFd;
fn read_4k_batch(file: &File, offsets: &[u64]) -> std::io::Result<Vec<Vec<u8>>> {
let mut ring = IoUring::new(256)?; // 队列深度 256
let mut bufs: Vec<Vec<u8>> = offsets.iter().map(|_| vec![0u8; 4096]).collect();
// 1. 填 SQE:每个偏移一个读请求,user_data 记下序号用于对账
for (i, (off, buf)) in offsets.iter().zip(bufs.iter_mut()).enumerate() {
let sqe = opcode::Read::new(
types::Fd(file.as_raw_fd()),
buf.as_mut_ptr(),
buf.len() as u32,
)
.offset(*off)
.build()
.user_data(i as u64);
// 安全性:buf 在收到对应 CQE 前必须保持存活且不被移动
unsafe { ring.submission().push(&sqe).map_err(|_| std::io::Error::other("sq full"))? };
}
// 2. 一次系统调用提交全部请求,并等待全部完成
ring.submit_and_wait(offsets.len())?;
// 3. 收割 CQE,检查每个 IO 的返回值
for cqe in ring.completion() {
if cqe.result() < 0 {
return Err(std::io::Error::from_raw_os_error(-cqe.result()));
}
}
Ok(bufs)
}
注意那个 unsafe:io_uring 的本质是把裸指针交给内核,内核会在未来某个时刻写这块内存。
buffer 的生命周期管理从编译器手里回到了你手里 —— 这正是 L0 红线里
「并发与 unsafe 必须逐行读懂」的教材级案例:上面代码里 bufs 活到函数结束,
且中途没有 push 导致的重新分配,所以是安全的;AI 生成这类代码时,这是你 review 的头号问题。
registered buffers 与 polling:再拧两圈
批量提交只是第一层,io_uring 还有两个进阶开关,压测里各值一截性能:
- registered buffers(
IORING_REGISTER_BUFFERS):把 buffer 预先注册给内核, 内核提前完成页锁定和地址翻译,每次 IO 省掉这部分重复工作。 配合ReadFixed操作码使用,4k 小 IO 下常见 10% 上下的提升。 - SQPOLL 模式:内核起一个专职线程轮询 SQ,你连
io_uring_enter都不用调了 —— 提交 IO 变成纯内存写,系统调用次数归零。代价是烧一个核在轮询上。
「烧一个核换延迟」是不是听着耳熟?这就是 Weka 独占 CPU busy polling 的思路,
io_uring 把它做成了内核选项。你运维时调过中断亲和、见过 %si 打满一个核 ——
polling 模式等于把这笔账摆上台面:与其被中断打扰所有核,不如指定一个核专职干这事。
io_uring 相比每个 IO 一次 pread 的传统路径,最根本的节省是什么?
Weka 更进一步:SPDK 一句话
顺着「减少内核参与」这条路走到头,就是 SPDK:把 NVMe 驱动整个搬到用户态, 应用直接读写 NVMe 的硬件队列,内核完全出局,零系统调用、零中断、零拷贝。 Weka 的 Drive 进程就是这个思路。代价是这块盘被独占(内核看不见它了)、 必须自带全套驱动逻辑、debug 工具链另起炉灶。
forge 停在 io_uring 这一档是清醒的选择:SQPOLL + registered buffers + O_DIRECT 能把一块 NVMe 喂到标称性能的八九成,剩下那一成多留给 Weka 们去卷 —— 你会在里程碑的数据里亲眼看到这档差距有多大(或者说,有多小)。
动手:单线程打满 4k 随机读
本课的实验:一个线程 + 一个 ring,把一块 NVMe 的 4k 随机读打到接近标称值。
关键配置:O_DIRECT 打开文件(绕过 page cache,买卖公道)、队列深度 128 以上、
buffer 4k 对齐。这个实验的产出会直接长成下一阶段 forge-bench 的读引擎。
这个原型里 unsafe 至少出现在三处:对齐分配、SQE push、裸指针交给内核。 按红线要求逐行过:AlignedBuf 的 Drop 有没有用同一个 Layout 释放? buffer 会不会在 CQE 回来前被移动或释放(提示:装 buffer 的 Vec 中途 push 会重新分配)? 和 fio 的差距如果超过 20%,先检查是不是忘了 O_DIRECT —— page cache 命中会让数字虚高得离谱。
在 forge workspace 新增 crate crates/forge-bench(bin),先实现一个 io_uring 随机读原型,作为后续压测工具的地基: 1. 依赖 io-uring、libc、rand、clap;命令行参数:--file 目标文件或块设备路径、--qd 队列深度(默认 128)、--runtime-secs 运行秒数(默认 10)、--block-size(默认 4096); 2. 用 O_DIRECT 打开目标(libc::open 或 OpenOptions + custom_flags),读 buffer 必须按 block-size 对齐分配(可用 std::alloc::alloc 配合 Layout,并封装成安全的 AlignedBuf 类型,Drop 里释放); 3. 主循环:保持环里始终有 qd 个在途读请求 —— 每收割一个 CQE 就立刻补提交一个新的随机偏移读;偏移用 rand 在文件大小内生成并对齐到 block-size; 4. CQE 的 result 必须逐个检查:负值转 io::Error 报告,短读(result 不等于 block-size)也算错误; 5. 结束时输出:总 IO 数、IOPS、平均延迟(用提交时刻的 Instant 记在一个按 user_data 索引的数组里); 6. 所有错误用 Result 传播,main 里统一打印退出,不许 unwrap; 7. 在一块 NVMe 上跑一次,同参数跑一次 fio(randread、direct=1、iodepth 相同),把两边 IOPS 贴给我对比。
小结
- 系统调用的固定开销几百纳秒,对上「每微秒一个 IO」的 NVMe,传统路径喂不饱盘
- io_uring 用共享内存的 SQ/CQ 双环批量传递请求与完成,N 个 IO 摊一次系统调用
- registered buffers 省掉重复的页锁定,SQPOLL 烧一个核换零系统调用 —— Weka 哲学的内核版
- unsafe 的实质是 buffer 生命周期从编译器交回你手里,这是 review 的头号关注点
- 再往上是 SPDK 用户态驱动;forge 停在 io_uring,可达标称性能八九成,教学性价比最高