Linux内核分析之文件系统-02
This language version is unavailable; showing the other language.
35.1 块设备层架构 —— request_queue 与 bio
块层的三个核心对象分工清晰:gendisk 是设备在内核的身份卡、request_queue 是它的排队车间、bio 是一张 I/O 工单。本节拆解三者与提交路径的检查序列。
35.1.1 gendisk 与 request_queue
// include/linux/blkdev.h:144-180(节选)
struct gendisk {
int major;
int first_minor;
int minors;
char disk_name[DISK_NAME_LEN]; /* /dev/sda 的名字来源 */
...
struct xarray part_tbl; /* 分区表 (含 partition) */
struct block_device *part0; /* 整盘 bdev */
const struct block_device_operations *fops; /* ioctl/open 等 */
struct request_queue *queue; /* 本盘的队列 */
void *private_data;
struct bio_set bio_split; /* 切分用 bio 池 */
int flags;
unsigned long state; /* 健康状态位(介质错误等) */
...
};
// include/linux/blkdev.h:478
struct request_queue { ... /* 限额/调度器/hctx 数组/统计 */ };
gendisk 是驱动向块层注册的设备对象(41 章块设备驱动的注册入口);request_queue 归块层所有,持有调度器实例、硬件队列数组、nr_requests 限额与冻结/插拔状态。分区是 block_device(part_tbl 中的成员)——bio 携带 bi_bdev 指向分区,提交时块层做分区偏移换算(分区扇区 → 整盘扇区)。
35.1.2 bio:I/O 的最小工作单
// include/linux/blk_types.h:210-260(节选)
struct bio {
struct bio *bi_next; /* request queue link */
struct block_device *bi_bdev;
blk_opf_t bi_opf; /* bottom bits REQ_OP, top bits req_flags */
unsigned short bi_flags;
unsigned short bi_ioprio;
enum rw_hint bi_write_hint; /* 写提示(36.3 节 f2fs 应用) */
u8 bi_write_stream; /* 多流写入(分区写) */
blk_status_t bi_status; /* 完成状态 */
...
struct bio_vec *bi_io_vec; /* 页向量本体 */
struct bvec_iter bi_iter; /* 当前消费位置 */
...
bio_end_io_t *bi_end_io; /* 完成回调 */
void *bi_private;
...
};
bio = 操作类型(bi_opf 低位的 REQ_OP_READ/WRITE/DISCARD/ZONE_APPEND…)+ 扇区区间 + 页向量(bio_vec: 页指针+页内偏移+长度,支持页不连续)+ 完成回调。bio 直指页:文件系统的页缓存页、direct I/O 的用户页(经 pin)、swap 页都以 bio_vec 直接交给设备 DMA——这是 36 章"页缓存到块层零拷贝"的载体。超过设备最大段/最大扇区的 bio 由 bio_split(gendisk 内的 bio_set 池)切分链式下发。
35.1.3 提交路径的检查序列
// block/blk-core.c:780(要点)
void submit_bio_noacct(struct bio *bio)
{ ...
/* 顺序检查: 只读盘拒写 / 越界 / 分区偏移换算 /
zone 设备的写指针约束 / REQ_NOWAIT 支持性 ... */
...
if (blk_throtl_bio(bio)) return; /* cgroup 限速(12.5 节) */
...
submit_bio_noacct_nocheck(bio, split); /* :728 */
}
submit_bio_noacct() 是全部块 I/O 的咽喉:只读盘/越界/分区换算/zone 约束的静态检查、cgroup 限速(blk-throttle,12.5 节 io 控制器同源)、随后进入调度器与 blk-mq(35.3 节)。同步完成路径(如 discard 的即时应答)由 submit_bio_wait 包装完成等待;错误以 bi_status 回传,文件系统在 bi_end_io 里记账并唤醒等待者(36 章的 end_bio_bh_io_sync 族)。
35.1.4 与上下层的接口总表
上游消费者 提交形态
──────────────────────────────────────────────────
页缓存回写 (36.3) mpage: 多页一个 bio
DIO (O_DIRECT) 用户页 pin 后直组 bio
JBD2 (34.3) buffer_head → bio
swap (24 章) swap page 的专用路径
md/dm (设备映射) 克隆 bio 转发多下层
f2fs/btrfs 元数据 直组小 bio
下游驱动 (41 章驱动侧) queue_rq 收到的 request
NVMe / SCSI / virtio-blk / loop / zram ...
buffer_head(ext4/JBD2 的历史元数据单位)与 bio 的换算是块层长年的兼容面——submit_bh 把 bh 包装成单页 bio;新代码一律直用 bio。
小结
块层以 gendisk(设备身份+分区表+自己的 bio_split 池)、request_queue(车间:调度器+硬件队列+限额)、bio(工单:操作类型+页向量+完成回调)三对象运转;submit_bio_noacct 的检查序列(只读/越界/分区换算/zone 约束/cgroup 限速)是全部 I/O 的咽喉,切分与合并在此之上。bio 直指页的设计让页缓存、DIO、swap 以零拷贝直达设备 DMA。下一节看排在"咽喉"之后的调度策略。
35.2 I/O 调度器 —— mq-deadline 与 bfq
调度器回答"队列里的请求谁先走"。多队列时代默认调度器是 mq-deadline(低成本保延迟),交互敏感场景选 bfq(按进程公平+带宽预留),带宽比例控制则由 blk-iocost(成本模型)承担——三者分别代表延迟、公平、比例三种目标。本节对照三者的设计与适用场景。
35.2.1 mq-deadline:最简单的延迟保障
// block/mq-deadline.c:985(电梯注册)
static struct elevator_type mq_deadline = { ... };
mq-deadline 的队列与派发:
三条 FIFO: read FIFO / write FIFO / zoned 写 FIFO
读饥饿控制: dispatch 一批写后必须再查读
超时: read_expire=500ms, write_expire=5s
(读延迟敏感 — 文件系统/应用在等)
派发序: 到期者最先; 同级按 LBA 排序(合并友好)
为什么是默认: 实现 ~千行, 无锁竞争小,
SSD/NVMe 上"合并已无收益, 只需防写淹没读" —
两条 FIFO + 到期检查即可
deadline 的本质是写饥饿预防:回写风暴(36.3 节)一次排几十个写请求,若无保护则读延迟从微秒涨到秒级。读 FIFO 的短超时保证任何排队的读最多等 500ms。
35.2.2 bfq:进程级公平
BFQ (Budget Fair Queueing) 的模型:
每个 (cgroup, 进程) 一个 bfq_queue:
按 I/O "预算"(budget, 以服务时间计) 轮转调度
权重: ionice 或 cgroup weight → 预算比例
特性:
- 交互进程识别 (任务切换模式启发式) → 提预算,
打字/点击的 IO 不被后台拷贝淹没
- 带宽预留: 保证某进程 X MB/s
- 低延迟模式: 交互队列单独通道
代价: 代码量与状态远超 deadline, 单队列开销高 —
适合 机械盘/慢 SSD + 交互负载; NVMe 上多数场景
deadline 或 none 更优
none 调度器: 多队列设备 (NVMe) 硬件自身多队列深,
常规负载直接透传 — 需要限速/公平时换 iocost/bfq
35.2.3 blk-iocost:成本模型与比例控制
// block/blk-iocost.c:657-696(策略注册区)
static struct blkcg_policy blkcg_policy_iocost;
deadline/bfq 调度的都是"请求个数/时间",但现代设备的成本并非常数:顺序读 4KB 与随机写 4KB 的耗时差百倍。iocost 用成本模型(device QoS 参数标定:顺序/随机、读/写的单位耗时)把 I/O 换算成统一"成本"记账,cgroup 按权重分摊成本预算:
iocost 的运行模型:
模型标定 (自动测量): QoS 参数 = {rpct, rlat, wpct, wlat, limit}
运行: 全设备成本预算 (vrate 控制)
cgroup weight → 预算份额
超支者被延后 (latency 优先于公平的 elastic 分档)
与 12.5 节 io cgroup 控制器 (io.weight/io.latency/io.cost)
是同一设施的 cgroup 面 — 容器存储 QoS 的现代底座
三者的选型速查:
| 目标 | 调度器 | 典型场景 |
|---|---|---|
| 低开销+读延迟 | mq-deadline(默认) | 通用 SSD/NVMe |
| 进程公平+交互保护 | bfq | 机械盘、桌面 |
| 容器带宽比例 | blk-iocost | 云主机多租户 |
| 全透传 | none | 硬件队列足够深、上游自己做 QoS |
小结
多队列时代的调度器谱系按目标分化:mq-deadline 以双 FIFO+超时用最小成本防"写淹没读"(默认之选);bfq 以预算轮转与交互启发式提供进程级公平(机械盘/桌面);blk-iocost 以设备成本模型把异构 I/O 换算为可比成本、按 cgroup 权重分摊(云多租户)。none 则承认"硬件队列深时调度无用"。调度器的选择接口是每盘 sysfs 的 scheduler 属性——同机混载不同设备可以各配各的。下一节看这些调度器栖身的骨架——blk-mq 多队列框架。
35.3 多队列块层 (blk-mq)
单队列时代(每个设备一条 request_queue 自旋锁)在 NVMe 的几十万 IOPS 下成为瓶颈。blk-mq 把队列拆成两级:per-CPU 软件队列(blk_mq_ctx) 承接提交(零锁竞争)、硬件队列(blk_mq_hw_ctx) 对接设备提交环,请求以标签(tag)从预分配池取得。本节解剖这套框架的提交与派发。
35.3.1 两级队列与标签池
// block/blk-mq.h:19(软件队列)
struct blk_mq_ctx { ... /* 每 CPU 一个: 调度 FIFO/统计/热插拔状态 */ };
// include/linux/blk-mq.h:322(硬件队列, 节选)
struct blk_mq_hw_ctx {
struct sbitmap_queue *sched_bitmap; /* 调度器标签位图 */
...
struct blk_mq_tags *tags; /* :774 标签池 */
...
cpumask_var_t cpumask; /* 本 hctx 服务哪些 CPU */
...
wait_queue_entry_t dispatch_wait;
...
};
// include/linux/blk-mq.h:774(标签池, 要点)
struct blk_mq_tags {
unsigned int nr_tags; /* 池容量 = queue_depth */
struct sbitmap_queue bitmap_tags; /* 在用标签位图 */
struct request **rqs; /* tag → request 数组 */
struct request **static_rqs; /* 预分配的 request */
...
};
请求的一生 (tag 化):
提交 CPU 上的路径:
blk_mq_submit_bio
→ 分配 request: 从当前 CPU 的 ctx 对应 hctx 的
tags 池取一个空闲 tag (sbitmap 位图置位)
— 无全局锁! 不同 CPU 几乎不同池
→ 调度器入队 (ctx 的 FIFO)
派发 (提交者顺带 or 专门的 run 线程):
blk_mq_run_hw_queue
→ 调度器挑 request → 标签即请求身份
→ 驱动 queue_rq(q, hctx, rq): 填设备提交环,
踢门铃
完成:
设备完成环回报 tag → 驱动 blk_mq_complete_request
→ 回调 → request 归还池 (tag 释放)
NUMA/多硬件队列映射: cpumask 把 CPU 组分配给
不同 hctx (NVMe 多 Submission Queue 一一对应)
35.3.2 驱动接口:blk_mq_ops
// include/linux/blk-mq.h:576(驱动钩子表, 代表成员)
struct blk_mq_ops {
blk_mq_queue_rq_fn queue_rq; /* 必选: 派发执行 */
blk_mq_complete_request_fn complete_request;
blk_mq_init_hctx_fn init_hctx;
blk_mq_map_queues_fn map_queues; /* CPU→hctx 映射定制 */
...
};
驱动(41 章块设备驱动)只需实现 queue_rq(把 request 翻译成设备命令)与完成路径;map_queues 允许"IO 提交 CPU 与完成中断亲和"对齐(NVMe 驱动按 NUMA 分组)。请求的内存预分配(static_rqs)是延迟确定性之源:热路径零 GFP 分配,内存压力不影响 I/O(对比老式按需分配的"内存不足卡 I/O"事故)。
35.3.3 派发策略与挂起恢复
派发的触发点:
[1] 提交者顺带: 提交后尝试 run 本 CPU 的 hctx
(多数请求零线程切换直通设备)
[2] hctx 的 run 工作队列: 池满/资源忙时挂起,
条件满足后异步重跑 (dispatch_wait)
[3] 超时定时器: rq 超时 → blk_mq_timeout_work
→ 驱动 recover/重置 (NVMe controller reset)
冻结/停走 (q_usage_counter, percpu-ref):
dm 切换表/设备拔出/挂起: 冻结路径等待在途
请求清零 — 34.1 节 s_writers 之下的层间协作
io_uring 集成 (37 章): PASSTHROUGH 直接构造
request 提交 (nvme passthru cmd), 绕过文件系统层
观察面:/sys/block/<dev>/mq/*/ 暴露每 hctx 的标签占用与派发统计;blk-mq-debugfs(block/blk-mq-debugfs.c)提供逐请求状态。nr_requests(池深)与 queue_depth(设备环深)两个旋钮调"在途 IO 上限"——数据库主机的经典调参。
小结
blk-mq 以"per-CPU 软件队列 + 硬件队列 + 标签池"三级结构消灭了单队列锁瓶颈:请求从本 CPU 池取 tag(零分配、零全局锁)、顺带派发直通设备环、完成按 tag 回查;blk_mq_ops 把驱动工作量压缩到 queue_rq/完成两点,map_queues 对齐 NUMA。派发由"提交顺带+工作队列+超时恢复"三触发点保障,percpu-ref 冻结机制衔接上层的设备级停走。块层三章至此完整——bio 进、设备命令出;下一章回到数据面的仓库:页缓存与 BIO 的配合。