Linux内核分析之文件系统-02

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 的配合。