审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
从 write() 到 NVMe 只有一条路:bio → request → 硬件队列 → 完成。blk-mq 把锁从“一个大锁”拆成“每个 CPU 一份、每个硬件队列一份”;io_uring 把热路径上的系统调用绕过去了——提交和完成都在共享内存里发生,SQPOLL 模式下连“通知内核”那次调用也省了。
2026-09-26 · Block Layer: bio, blk-mq and io_uring

第 11 关 · 块层与 I/O:bio、多队列与 io_uring

定位

这一关回答 I/O 请求在内核里怎么流动。VFS 的读写请求先变成 bio——块层的基本 I/O 单元(include/linux/blk_types.h 注释原话:“the main unit of I/O for the block layer and lower layers”),以 512 字节 sector 为单位描述“哪块设备、从哪读/写到哪段内存”。bio 会被合并、组装成 request,进入 blk-mq 的多队列(per-CPU 的 software queue 映射到面向设备的 hardware queue),驱动通过 queue_rq 下发,设备完成后再上报。这一关不讲文件系统,只讲“块设备看得到的 I/O”。

三个机制支点:bio / request 的分工(描述与执行分离)、blk-mq 的双层队列(blk_mq_ctx 面向提交的 CPU,blk_mq_hw_ctx 面向硬件;tag 是硬件队列的并发凭证)、io_uring 的 SQ/CQ 共享环(提交和完成都搬进用户态-内核共享内存,SQPOLL 甚至省掉“通知内核”这一步)。

核心机制深讲

11.1 bio:块层的基本 I/O 单元

struct bio 描述的是一段 I/O,而不是一次设备执行。已核实的关键字段:bi_bdev(目标块设备)、bi_opf(操作码与标志,如 REQ_OP_READ / REQ_OP_WRITE / REQ_OP_FLUSH)、bi_iter(当前 sector 位置与剩余长度)、bi_io_vec(指向内存页的向量)、bi_end_io(完成回调)。块层的 sector 固定 512 字节(SECTOR_SHIFT 9),与文件系统的 block 大小无关——这是块层自己的地址单位。

bio 是“可合并”的:相邻 sector 的 bio 可以在 I/O 调度器层合并(block/elevator.c 的 elv_merge / elv_bio_merge_ok 路径),合并后一次下发,设备少收一条命令。这是 bio 必须独立于 request 存在的原因之一——合并发生在“描述”层,而不是“执行”层。

11.2 request 与 blk-mq:从描述到执行

struct request 是“发给驱动的一次执行”。它可以由一个或多个 bio 组成(bio / biotail 链表),并携带执行状态:mq_ctx(来自哪个 software queue)、mq_hctx(发往哪个 hardware queue)、tag(硬件队列分配的并发凭证)。

blk-mq 是双层队列。struct blk_mq_ctx 的注释写得直白:“State for a software queue facing the submitting CPUs”——它是 per-CPU 的(struct blk_mq_ctx __percpu *queue_ctx),提交路径按当前 CPU 取自己的 software queue,提交侧基本无锁。struct blk_mq_hw_ctx 则是“面向硬件块设备的硬件队列状态”,数量由设备决定(如 NVMe 的队列对数)。两者之间由映射表连接:blk_mq_map_queue_type() 按 map[type].mq_map[cpu] 把(CPU、类型)映射到 hardware queue。

驱动只实现一个入口:blk_mq_ops.queue_rq——“把这个 request 发给硬件”。tag 机制保证发给硬件的并发 request 数不超过队列深度:拿不到 tag 的提交者必须等待,硬件队列的深度就是天然的背压。

11.3 I/O 调度器:可插拔,以及默认值的选择

调度器(elevator)在 blk-mq 里是可插拔层:request 先进入调度器排队、合并、排序,再 dispatch 到 hardware queue。block/ 下同时存在 mq-deadline.c、kyber-iosched.c、bfq-iosched.c,用户可通过 sysfs 按设备切换(注意:elevator= 内核参数已经不再生效,elevator.c 里明写着警告)。

默认值的选择逻辑在 elevator_set_default(),值得逐行读:默认名字是 "mq-deadline";如果 tag_set 带了 BLK_MQ_F_NO_SCHED_BY_DEFAULT 标志,直接保持 "none";只有单硬件队列的设备(或共享 tag 的设备)才切到 mq-deadline——多队列设备默认就是 none。理由很实在:NVMe 这类设备内部已经高度并行,队列本身够深,内核再排一次队只是多余开销;单队列的 SATA 时代才需要调度器来合并、排序、防止饥饿。

mq-deadline 本体的权衡也在源码里写死了默认值:read_expire = HZ/2(约 500ms)、write_expire = 5*HZ(5 秒)、writes_starved = 2、fifo_batch = 16。每个优先级同时维护 sort_list[](红黑树,按 sector 排序——吞吐)和 fifo_list[](按到期时间——防饥饿),dd_dispatch_request() 在两者之间做选择。读的 deadline 比写短一个数量级:读通常阻塞着用户进程,写大多可以异步回写——调度器的偏心是对延迟敏感度的诚实定价。

11.4 io_uring:把提交和完成搬进共享内存

传统路径每次 I/O 至少两次系统调用(提交一次、等完成一次),系统调用的固定开销(模式切换、参数拷贝)在百万 IOPS 下成为瓶颈。io_uring(io_uring/io_uring.c 开头注释:“Shared application/kernel submission and completion ring pairs, for supporting fast/efficient IO”)的解法是:提交队列(SQ)和完成队列(CQ)都是应用与内核共享的内存环,应用 mmap 后直接读写。

入口是 io_uring_setup(entries, params)(SYSCALL_DEFINE2(io_uring_setup, …))。提交 = 往 SQ 写 SQE、更新 SQ tail;完成 = 内核往 CQ 写 CQE、应用读 CQ。共享内存的契约是内存序:源码注释明确要求应用在写 SQ tail 之前用 smp_wmb()(与内核侧 smp_load_acquire 配对),读 CQ tail 之后用 smp_rmb()——顺序错了就会读到无效的完成项,这是应用侧的责任,不是内核的 bug。

没有 SQPOLL 时,应用更新 SQ tail 后仍需一次系统调用通知内核“有活了”(并可顺带等待完成)。而 IORING_SETUP_SQPOLL 更进一步:内核起一个轮询线程不断检查 SQ,应用只管写 SQE + 更新 tail,连“通知”这个动作都省了。代价同样写在注释里:应用必须在更新 tail 之后检查 IORING_SQ_NEED_WAKEUP 标志(中间隔一个 smp_mb()),否则轮询线程可能睡着了没人叫醒——“省掉系统调用”换来的是“应用必须正确实现唤醒协议”。

11.5 完成路径:中断 vs 轮询

request 发给硬件后,完成有两种模型。默认是中断:设备做完发中断,内核在中断上下文里收尾。另一种是轮询:blk_mq_get_hctx_type() 里写得明白——带 REQ_POLLED 的 I/O 走 HCTX_TYPE_POLL 类型的 hardware queue,应用(通常是 io_uring 的 IORING_SETUP_IOPOLL)忙等完成,不经过中断。

轮询的 trade-off 极端而诚实:延迟降到最低(没有中断延迟、没有上下文切换),代价是一个 CPU 核心被烧来“等”。只有当设备延迟已经低到“中断开销占比显著”(如 NVMe 4K 随机读几十微秒)时,这笔买卖才划算——否则就是拿一个核的 100% 利用率换几微秒,亏。

为什么这样设计

为什么 bio 和 request 要分两层,而不是一层?

因为“描述一段 I/O”和“执行一次设备命令”是两种变化节奏。bio 负责描述(哪段内存、哪个 sector 区间),可以在调度器层被合并、拆分;request 负责执行(tag、队列归属、驱动回调),一旦创建就直奔硬件。分层让合并发生在描述层——多个相邻 bio 拼成一个 request,设备少收命令、吞吐上升,而执行层的并发控制(tag)不受影响。一层结构要么放弃合并,要么把合并逻辑塞进驱动,两边都不对。

为什么 blk-mq 要 software queue + hardware queue 两层,而不是每 CPU 直连设备?

因为两边的数量级不匹配:CPU 可能上百个,设备的硬件队列只有几个到几十个。per-CPU 的 software queue 让提交路径无锁(每个 CPU 只碰自己的队列,这是多核扩展性的关键);hardware queue 按设备能力设置,映射表(mq_map)解耦“谁提交”和“发到哪个硬件队列”。单层设计要么让所有 CPU 抢少数硬件队列的锁(回到单队列时代的全局锁),要么让驱动面对上百个队列(硬件不支持)。双层是“软件侧扩展、硬件侧收敛”的折中。

为什么 io_uring 用共享内存环,而不是“更快的系统调用”?

因为系统调用的开销不在“慢”,而在“固定”:无论 I/O 多小,一次模式切换的代价不变。百万 IOPS 下,固定开销就是总开销。SQ/CQ 把“提交”变成“写内存 + 内存屏障”,批量提交把一次通知的开销摊到 N 个 I/O 上;SQPOLL 连通知都省了。但省掉的东西不会消失,只会转移:内存序的正确性从内核转移到了应用(smp_wmb 配对写错就读到垃圾完成项),SQPOLL 烧掉一个 CPU 核心。io_uring 不是“免费的快”,是把内核的固定成本转嫁成应用的正确性负担和 CPU 占用——理解这一点,才算理解了这个设计。

与已学模块的关联

往前看的连接:

往后看:M12 的 fsync 最终会下沉到这一关——blkdev_issue_flush 在块层就是一条 REQ_OP_FLUSH 请求,走的正是 bio → request → hardware queue 这条路。“崩溃一致性”的最后一公里,是块层发的 flush 命令。

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。

Q1. struct bio 的 bi_opf、bi_iter、bi_bdev 各表示什么?块层的 sector 固定为多少字节?

bi_bdev 是目标块设备,bi_opf 是操作码与标志(读 / 写 / flush 等),bi_iter 记录当前 sector 位置与剩余长度。块层 sector 固定 512 字节(SECTOR_SHIFT 9)——注意这是块层的地址单位,和文件系统的 block 大小(通常 4KB)是两回事,别混。

Q2. bio 已经描述了“哪块设备、哪个 sector、哪段内存”,为什么还需要 request 这一层?不能直接把 bio 发给驱动吗?

因为 bio 只解决“描述”,不解决“执行”。合并需要在描述层发生:多个相邻 bio 拼成一个 request,设备少收命令;并发控制需要在执行层发生:tag、队列归属(mq_ctx/mq_hctx)、驱动回调都是 request 的状态,bio 不需要也不该有。如果直接把 bio 发给驱动,合并逻辑就得每个驱动各写一遍,并发凭证也没地方放——分层让“描述可合并、执行可限流”同时成立。

Q3. 64 核机器配 8 硬件队列的 NVMe:提交 I/O 的 CPU 如何找到自己的 hardware queue?per-CPU software queue 解决了什么问题?

提交路径按当前 CPU 取自己的 blk_mq_ctx(per-CPU software queue),再经 blk_mq_map_queue_type() 查 mq_map[cpu] 映射到 8 个 hardware queue 之一。per-CPU software queue 解决的是提交侧的锁竞争:64 个 CPU 各写各的队列,互不干扰;如果 64 核直接抢 8 个硬件队列的锁,提交路径就退化成“多核排队”,多队列的意义全失。映射表解耦了“谁提交”和“发到哪个硬件队列”。

Q4. mq-deadline 的 read_expire 和 write_expire 默认值各是多少?为什么读的 deadline 比写短一个数量级?

read_expire = HZ/2(约 500ms),write_expire = 5*HZ(5 秒)。因为读通常阻塞着用户进程——进程在等数据回来,延迟直接转化为用户可感知的停顿;而写大多走页缓存异步回写,调用者早就返回了。调度器的偏心是对“延迟敏感度”的诚实定价:让读插队,本质是让“有人在等”的 I/O 优先。

Q5. 单队列 SATA SSD 默认调度器是 mq-deadline,而 16 队列 NVMe 默认是 none。对照 elevator_set_default() 的逻辑解释:为什么多队列反而“不需要”调度器?

elevator_set_default():默认名是 "mq-deadline",但只有单硬件队列(或共享 tag)才真正切换过去;多队列设备保持 "none"。因为调度器的价值在于弥补设备的不足:单队列设备一次只能收少量命令,需要内核合并、按 sector 排序、防止饥饿;而 NVMe 内部高度并行、队列又深,内核再排一次队只是多余的开销和延迟。调度器不是“越智能越好”,而是“设备越弱越需要”。

Q6. io_uring 应用在更新 SQ tail 之前为什么必须执行 smp_wmb()?如果漏掉,会发生什么具体的错误?

因为 SQE 的写入和 SQ tail 的更新是两次独立的内存写,CPU/编译器可能重排。smp_wmb() 保证“所有 SQE 内容先对内核可见,tail 更新后可见”,与内核侧 io_get_sqring() 的 smp_load_acquire 配对。漏掉的后果很具体:内核看到 tail 前进,去读 SQE 槽位时读到的是旧的、未初始化的、甚至上一次的 SQE 内容——可能把一次读变成一次写,或操作错文件。这不是“性能问题”,是正确性问题,且责任在应用侧。

Q7. 开了 IORING_SETUP_SQPOLL 的应用抱怨“提交了 SQE 但内核迟迟不执行”。对照源码注释,列出至少两种可能原因。

(1) 应用更新 SQ tail 后没有检查 IORING_SQ_NEED_WAKEUP:SQPOLL 线程可能已休眠,应用必须在 tail 更新后(中间隔 smp_mb())检查该标志并在需要时唤醒——“省掉系统调用”的前提是应用正确实现唤醒协议。(2) 内存序错误:tail 更新前的 smp_wmb() 漏掉,内核看到的 tail 与 SQE 内容不一致,可能读到无效 SQE 而跳过或报错。SQPOLL 把“通知内核”的责任从系统调用转移成了“共享内存协议”,协议错则静默失败。

Q8. 同一块 NVMe,A 方案用中断完成,B 方案用 REQ_POLLED 轮询(IOPOLL)。从延迟和 CPU 代价出发,什么场景该选 B?什么场景选 B 是亏的?

选 B 的场景:设备延迟已经低到中断开销占比显著(如 NVMe 4K 随机读几十微秒),且应用对尾延迟极度敏感(如高频交易、SPDK 式存储引擎)——轮询省掉中断延迟和上下文切换,延迟降到最低。选 B 亏的场景:I/O 本身不密集(轮询线程空转烧核)、或设备延迟本来就高(几百微秒的 SATA 上,省几微秒的中断开销毫无意义,却搭进去一个核的 100% 利用率)。轮询是拿确定的 CPU 成本换不确定的延迟收益,只在“延迟就是一切”时划算。

Q9. 难题:blk-mq 里 tag 是“硬件队列深度的并发凭证”。当 200 个线程同时向同一个 hardware queue 提交、而队列深度只有 64 时,拿不到 tag 的提交者会怎样?从“背压”角度评价这种设计相对“无限制排队”的优劣。

拿不到 tag 的提交者不能把 request 发给硬件,只能在 software queue / 调度器层等待(或被反压回上层)。这是刻意设计:硬件一次只能处理 64 个未完成命令,多发了硬件也吃不下——tag 把“设备真实并发能力”变成了硬约束,超出的需求在内核排队,而不是在设备侧溢出。相对“无限制排队”(如老式单队列无上限堆积):优势是背压位置明确且可观测——排队发生在内核,可被调度器排序、合并,可被上层感知(队列满 → 写者阻塞 → 应用自然降速);劣势是突发流量下延迟方差变大,且 tag 耗尽时的等待路径本身也有开销。理解 tag,就是理解“并发凭证”思想:不控制到达率,只控制同时在飞的数量。

Q10. 难题:一个程序要发 1000 个 4KB 随机读并等待全部完成。对比三条路径——(a) 循环 read() 系统调用,(b) io_uring 普通模式批量提交,(c) io_uring SQPOLL + IOPOLL——各自的系统调用次数、上下文切换和 CPU 代价,并说明 (c) 的代价转移到了哪里。

(a) 2000 次系统调用(1000 提交 + 1000 等待,忽略批量优化):每次固定模式切换开销,IOPS 瓶颈首先卡在 syscall 本身。(b) ~2 次系统调用:一次把 1000 个 SQE 批量提交(一次通知),一次等待完成——固定开销被摊薄到每个 I/O 约 1/500,是 io_uring 的核心收益。(c) 0 次“通知”系统调用:SQPOLL 线程轮询 SQ,IOPOLL 忙等完成。但代价没有消失:转移到了 CPU 占用(轮询线程 + 忙等各烧核)和应用正确性负担(SQ tail 的 smp_wmb、IORING_SQ_NEED_WAKEUP 唤醒协议、CQ 的 smp_rmb,错一处就是静默的数据错误)。(c) 只在“CPU 换延迟划算”时成立——这正是 Q8 的轮询 trade-off 在提交侧的镜像。

延伸阅读

bio → request → hardware queue → 设备:一条 I/O 的旅程

  VFS 读写
    │  bi_bdev / bi_opf / bi_iter / bi_io_vec
    ▼
 ┌──────┐  描述层:可合并            ┌─────────┐
 │ bio  │ ──────── elv_merge ──────▶ │ request │  执行层:tag / mq_ctx / mq_hctx
 └──┬───┘  相邻 sector 合并,        └────┬────┘  一个 request 可含多个 bio
    │      设备少收命令                   │
    │      per-CPU software queue         │  mq_map[cpu]
    │      (blk_mq_ctx, 提交侧无锁)       ▼
    │                              ┌────────────┐
    └─────────────────────────────▶│ hw queue   │── queue_rq ──▶ 设备
                                   │(blk_mq_hw_ctx)  中断 / 轮询完成
                                   └────────────┘
看懂这张图就抓住了本关的一半:bio 是“描述”,request 是“执行”,
双层队列是“软件侧扩展、硬件侧收敛”。
io_uring SQ/CQ:提交和完成都在共享内存里

 应用进程                          内核
 ┌─────────────────┐  mmap 共享   ┌──────────────────┐
 │ 写 SQE 到 SQ    │◀───────────▶│ SQ ring          │
 │ 更新 SQ tail    │  smp_wmb     │  取 SQE 执行     │
 │ (SQPOLL: 无需   │  配对        │                  │
 │  系统调用通知)  │              │  写 CQE 到 CQ    │
 │ 读 CQ, 更新 head │  smp_rmb    │                  │
 └─────────────────┘  配对        └──────────────────┘
 IORING_SETUP_SQPOLL: 内核轮询线程代替“通知”;
 应用必须在 tail 更新后检查 IORING_SQ_NEED_WAKEUP,
 否则线程睡着没人叫醒 —— 省掉的系统调用变成了应用的协议责任。
调度器默认值的选择(elevator_set_default 逻辑)

 tag_set 有 BLK_MQ_F_NO_SCHED_BY_DEFAULT? ──是──▶ 保持 "none"
            │
            否
            ▼
 单硬件队列 或 共享 tag? ──是──▶ "mq-deadline"(合并/排序/防饥饿)
            │
            否(多队列 NVMe)
            ▼
         "none":设备内部已高度并行,
         内核再排队只是多余开销。
记住这个判断顺序,Q5 的答案就藏在里面。

在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。