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 后端。本课我们用更底层的方式直接驱动 ring, 因为你需要看清每个环上发生了什么 —— 落盘路径这条红线要求的不是你能写出这些代码, 而是你能说清每一步在做什么、失败时会怎样。

用 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 活到函数结束,中途也没有发生会导致重新分配的追加),所以是安全的。 AI 生成这类代码时,这就是你要盯的头号问题 —— 盯的办法不是逐行抠语法, 而是让它按时间顺序讲「谁在什么时候碰这块内存」,讲到你能复述。

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

这个原型里最危险的地方是内存交接:你把一块内存的地址交给内核, 内核在未来某个时刻才往里写。别去逐行抠语法,换个做法 —— 让 AI 按时间顺序讲一遍 「谁在什么时候碰这块内存」,讲到你能用自己的话复述为止;它讲不清楚的那一段, 就是这段代码真正的风险点。然后用测量收口:和 fio 的差距超过 20%, 先怀疑 O_DIRECT 没生效 —— page cache 命中会让数字虚高得离谱, 上面那两条对照实验就是专门用来验掉它的。

目标:在 forge workspace 里新起一个压测工具 forge-bench,先做出一个 io_uring 随机读原型 —— 单线程、单个 ring,把一块 NVMe 的 4k 随机读打到接近厂商标称值。这个原型后面会长成正式的压测工具,所以结构要经得起扩展。(用什么库你定,最后告诉我为什么选它。)

行为规格:
1. 命令行能指定:目标文件或块设备、队列深度(默认 128)、运行秒数(默认 10)、块大小(默认 4096);
2. 必须用 O_DIRECT 打开目标,读 buffer 的地址和长度都要按块大小对齐。对齐一旦错了,内核会悄悄回退到 buffered 路径、数字凭空虚高一个数量级,所以这条必须是可验证的 —— 传一个不对齐的块大小时,程序要明确报错,而不是照跑不误;
3. 稳态运行时,环里要始终维持住指定数量的在途请求:每收割掉一个完成事件,就立刻补进一个新请求。不要攒够一整批再一起发,那样在途请求数会周期性掉到零,实际压出来的深度远小于你设的值;
4. 随机偏移在目标容量范围内生成并对齐到块大小,同一次运行内不复用固定的随机序列;
5. 每个完成事件都要逐个检查:内核报回来的错误要变成明确的失败上报;读回来的字节数不等于块大小也算失败 —— 静默吞掉短读是这类程序最常见的错,一旦发生你会得到一个又快又假的数字;
6. 任何预期内的失败都要变成明确的错误返回给调用方,由入口统一报告后退出,不能让进程直接崩掉;
7. 结束时输出总 IO 数、IOPS、平均延迟。延迟按每个请求各自的提交时刻到完成时刻算,而不是用总时间除以总数;记录延迟这件事本身不能在热路径上引入打印或加锁。

交付:能跑的原型 + 在一块 NVMe 上跑一次的完整输出 + 同参数 fio 对照(4k 随机读、direct=1、相同队列深度)的两边 IOPS 对比,以及你对差距的解释。
你的验收动作自己跑、自己看,不用读代码
  • 同参数跑一遍 fio 对照,两边 IOPS 差距超过 20% 就先别往下走 —— 第一嫌疑人是 O_DIRECT 没真正生效
  • 压测时另开终端看 iostat -x 1:盘的 r/s 要和程序报的 IOPS 同数量级,平均队列长度要接近你设的队列深度。队列压不上去,说明在途请求数根本没维持住,你测的是空转
  • 对照实验:把队列深度设成 1 再跑,IOPS 应该掉到上一课 QD1 那一档的水平(一两万)。数字不动,说明这个参数根本没接到实际提交路径上
  • 破坏性验证:故意传一个不对齐的块大小(比如 4095),程序必须明确报错退出;再故意指定一个不存在的设备路径,同样要明确报错而不是崩溃或静默跑空
  • 同一份数据连读两遍,第二遍如果明显更快,page cache 还在插手,O_DIRECT 没生效
必须问到听懂的问题答不上来就别合并
  • 这个程序同一时刻最多有多少个请求在盘上飞?是什么东西限制住了这个数字?
  • 内核要在未来某个时刻才往我给的那块内存里写数据。这段时间里,是什么保证这块内存不会被释放或搬走?如果它被搬走了,会发生什么?
  • 如果某个请求永远不返回,整个循环会怎样 —— 是彻底卡死,还是会表现成别的样子?
  • 把提交方式改成攒够一批再一起发,IOPS 和延迟分别会怎么变?为什么?

小结

  • 系统调用的固定开销几百纳秒,对上「每微秒一个 IO」的 NVMe,传统路径喂不饱盘
  • io_uring 用共享内存的 SQ/CQ 双环批量传递请求与完成,N 个 IO 摊一次系统调用
  • registered buffers 省掉重复的页锁定,SQPOLL 烧一个核换零系统调用 —— Weka 哲学的内核版
  • unsafe 的实质是 buffer 生命周期从编译器交回你手里,这是 review 的头号关注点
  • 再往上是 SPDK 用户态驱动;forge 停在 io_uring,可达标称性能八九成,教学性价比最高