Storforge
编码预计 55 分钟

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)写进环里 —— 你收结果也是纯内存读

数据流是这样的:

  1. 应用填好 N 个 SQE(读哪个 fd、什么偏移、读到哪块 buffer)
  2. 一次 io_uring_enter,告诉内核「有 N 个新请求」
  3. 内核异步执行这些 IO,完成一个就往 CQ 里放一个 CQE
  4. 应用从 CQ 收割结果 —— 可以顺手再提交下一批

一次系统调用摊给 N 个 IO,批越大摊得越薄。如果你觉得这个双环结构眼熟 —— 没错, NVMe 协议本身就是这么设计的(硬件提交队列/完成队列,下一课细讲)。 io_uring 相当于把 NVMe 的接口哲学向上复制了一层,两层队列首尾相接,天生登对。

i和 tokio 是什么关系

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 模式等于把这笔账摆上台面:与其被中断打扰所有核,不如指定一个核专职干这事。

Checkpoint单选

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 的读引擎。

AI 结对:io_uring 随机读压测原型pair with ai

这个原型里 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,可达标称性能八九成,教学性价比最高