这一关回答 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 甚至省掉“通知内核”这一步)。
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 存在的原因之一——合并发生在“描述”层,而不是“执行”层。
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 的提交者必须等待,硬件队列的深度就是天然的背压。
调度器(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 比写短一个数量级:读通常阻塞着用户进程,写大多可以异步回写——调度器的偏心是对延迟敏感度的诚实定价。
传统路径每次 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()),否则轮询线程可能睡着了没人叫醒——“省掉系统调用”换来的是“应用必须正确实现唤醒协议”。
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 占用——理解这一点,才算理解了这个设计。
往前看的连接:
mm == NULL 的 task_struct。io_uring 的“应用与内核共享内存环”也让人想起 M01 的“借来的 mm”:凡是共享地址空间信息的地方,内存序和生命周期就是真正的契约,写错的一方没有第二次机会。往后看:M12 的 fsync 最终会下沉到这一关——blkdev_issue_flush 在块层就是一条 REQ_OP_FLUSH 请求,走的正是 bio → request → hardware queue 这条路。“崩溃一致性”的最后一公里,是块层发的 flush 命令。
出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。
bi_bdev 是目标块设备,bi_opf 是操作码与标志(读 / 写 / flush 等),bi_iter 记录当前 sector 位置与剩余长度。块层 sector 固定 512 字节(SECTOR_SHIFT 9)——注意这是块层的地址单位,和文件系统的 block 大小(通常 4KB)是两回事,别混。
因为 bio 只解决“描述”,不解决“执行”。合并需要在描述层发生:多个相邻 bio 拼成一个 request,设备少收命令;并发控制需要在执行层发生:tag、队列归属(mq_ctx/mq_hctx)、驱动回调都是 request 的状态,bio 不需要也不该有。如果直接把 bio 发给驱动,合并逻辑就得每个驱动各写一遍,并发凭证也没地方放——分层让“描述可合并、执行可限流”同时成立。
提交路径按当前 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 个硬件队列的锁,提交路径就退化成“多核排队”,多队列的意义全失。映射表解耦了“谁提交”和“发到哪个硬件队列”。
read_expire = HZ/2(约 500ms),write_expire = 5*HZ(5 秒)。因为读通常阻塞着用户进程——进程在等数据回来,延迟直接转化为用户可感知的停顿;而写大多走页缓存异步回写,调用者早就返回了。调度器的偏心是对“延迟敏感度”的诚实定价:让读插队,本质是让“有人在等”的 I/O 优先。
elevator_set_default():默认名是 "mq-deadline",但只有单硬件队列(或共享 tag)才真正切换过去;多队列设备保持 "none"。因为调度器的价值在于弥补设备的不足:单队列设备一次只能收少量命令,需要内核合并、按 sector 排序、防止饥饿;而 NVMe 内部高度并行、队列又深,内核再排一次队只是多余的开销和延迟。调度器不是“越智能越好”,而是“设备越弱越需要”。
因为 SQE 的写入和 SQ tail 的更新是两次独立的内存写,CPU/编译器可能重排。smp_wmb() 保证“所有 SQE 内容先对内核可见,tail 更新后可见”,与内核侧 io_get_sqring() 的 smp_load_acquire 配对。漏掉的后果很具体:内核看到 tail 前进,去读 SQE 槽位时读到的是旧的、未初始化的、甚至上一次的 SQE 内容——可能把一次读变成一次写,或操作错文件。这不是“性能问题”,是正确性问题,且责任在应用侧。
(1) 应用更新 SQ tail 后没有检查 IORING_SQ_NEED_WAKEUP:SQPOLL 线程可能已休眠,应用必须在 tail 更新后(中间隔 smp_mb())检查该标志并在需要时唤醒——“省掉系统调用”的前提是应用正确实现唤醒协议。(2) 内存序错误:tail 更新前的 smp_wmb() 漏掉,内核看到的 tail 与 SQE 内容不一致,可能读到无效 SQE 而跳过或报错。SQPOLL 把“通知内核”的责任从系统调用转移成了“共享内存协议”,协议错则静默失败。
选 B 的场景:设备延迟已经低到中断开销占比显著(如 NVMe 4K 随机读几十微秒),且应用对尾延迟极度敏感(如高频交易、SPDK 式存储引擎)——轮询省掉中断延迟和上下文切换,延迟降到最低。选 B 亏的场景:I/O 本身不密集(轮询线程空转烧核)、或设备延迟本来就高(几百微秒的 SATA 上,省几微秒的中断开销毫无意义,却搭进去一个核的 100% 利用率)。轮询是拿确定的 CPU 成本换不确定的延迟收益,只在“延迟就是一切”时划算。
拿不到 tag 的提交者不能把 request 发给硬件,只能在 software queue / 调度器层等待(或被反压回上层)。这是刻意设计:硬件一次只能处理 64 个未完成命令,多发了硬件也吃不下——tag 把“设备真实并发能力”变成了硬约束,超出的需求在内核排队,而不是在设备侧溢出。相对“无限制排队”(如老式单队列无上限堆积):优势是背压位置明确且可观测——排队发生在内核,可被调度器排序、合并,可被上层感知(队列满 → 写者阻塞 → 应用自然降速);劣势是突发流量下延迟方差变大,且 tag 耗尽时的等待路径本身也有开销。理解 tag,就是理解“并发凭证”思想:不控制到达率,只控制同时在飞的数量。
(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 在提交侧的镜像。
blk_mq_alloc_tag_set、software/hardware 队列映射,多队列核心struct request、blk_mq_hw_ctx、tag set 定义io_uring_setup、SQ/CQ 共享环、SQPOLL 内存序契约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 的答案就藏在里面。
在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。