引言:块层在现代存储栈中的核心地位
在 Linux 存储 I/O 栈中,块层(Block Layer)是连接文件系统与存储硬件的关键中间层。它负责将文件系统发出的逻辑 I/O 请求,经过重排序、合并、调度,最终转换为物理设备能够理解的命令序列。随着 NVMe SSD 的普及和 IO_uring 的高性能 I/O 范式兴起,块层已经从一个简单的请求队列演变成了支持多队列、多调度策略、 IO 优先级隔离的复杂子系统。
本文将深入剖析 Linux 内核块层的两大核心机制:blk-mq(Multi-Queue Block Layer)框架和现代 I/O 调度器家族(mq-deadline、kyber、BFQ),从架构原理到内核源码级实现,帮助你构建完整的块层性能优化知识体系。
一、从 blk-mq 到多队列架构的演进
1.1 单队列块层的瓶颈
在 Linux 2.6 时代,块层使用一个全局请求队列(request queue),由单个自旋锁保护。这种架构在机械硬盘(HDD)时代尚能胜任,但随着 SSD 存储介质的 IOPS 从数千飙升至数十万乃至百万级,单队列的锁竞争成为了最大的性能瓶颈。
单队列架构的核心问题有三:
锁竞争瓶颈:所有 CPU 核心必须通过同一个自旋锁来提交和完成 I/O 请求。当并发度增加时,CPU 大量时间消耗在 lock contention 而非实际 I/O 操作上。
队列深度受限:单队列的队列深度有限,无法充分利用 NVMe 设备的多队列并行能力。典型 NVMe SSD 支持 64 个 SQ/CQ 对,但单队列只能利用其中一个。
NUMA 不友好:单队列架构下,请求可能在 NUMA 节点之间频繁迁移,导致远端内存访问延迟。
1.2 blk-mq 多队列架构设计
Linux 3.13 引入了 blk-mq(Multi-Queue Block IO Queueing)框架,彻底重构了块层的并发模型。其核心思想是将请求队列解耦为两个层次:
软件队列(Software Queue / rq_ctx):每个 CPU 核心拥有一个独立的软件队列,用于缓冲来自该核心的 I/O 请求。软件队列是 per-CPU 结构,无锁或极少锁操作,支持本地处理。
硬件队列(Hardware Dispatch Queue / hctx):映射到物理设备的硬件发送队列(如 NVMe SQ)。支持多个硬件队列并行发送,充分利用设备并行能力。
软件队列到硬件队列的映射关系可通过 nr_maps 配置:
enum blk_mq_map_type {
BLK_MQ_MAP_TYPE_SQ, /* 1:1 映射 */
BLK_MQ_MAP_TYPE_HCTX, /* N:M 映射 */
};
现代 NVMe 驱动通常配置 nr_hw_queues = num_possible_cpus(),实现每个核心独占一个硬件队列的满并行架构。
1.3 blk-mq 核心数据结构
理解 blk-mq 需要掌握以下关键数据结构的关系:
- struct blk_mq_tag_set:驱动层的顶层描述符,包含硬件队列数量、tag 位图深度、操作函数表等。一个驱动的所有队列共享一个 tag_set。
- struct blk_mq_hw_ctx:每个硬件队列的运行时状态,包含本地 pending list(
ctx->rq_lists)、dispatch buffer、run work 等。 - struct request: 块层 I/O 请求的核心描述符,包含 bi_crypt_context(支持 inline encryption)、cmd_flags、rq_flags 等超过 30 个状态标志。
- struct blk_mq_alloc_data: 请求分派时的动态路由上下文,包含目标 hctx 和等待队列信息。
二、I/O 调度器核心机制
2.1 mq-deadline:延迟保证的经典实现
mq-deadline 是 Linux 块层最保守但最可靠的调度器,其设计哲学基于一个核心目标:在保证吞吐的前提下,将读请求的延迟限制在可测量的时间窗口内。
双队列架构:mq-deadline 维护四个核心数据结构:两个排序红黑树(sorted_list,按 LBA 排序)和两个 FIFO 队列(fifo_list,按插入时间排序)。分别针对读和写操作独立管理。
批量派发策略:调度器不会简单选择排序队列中的第一个请求,而是从当前位置开始批量取出连续的 batch_size(默认 16 个)请求派发。这样既保证了 LBA 局部性(减少磁头寻道),又能获得足够的队列深度。
过期机制:读请求的默认过期时间为 500ms(read_expire),写请求为 5s(write_expire)。当某个请求在 FIFO 队列中的等待时间超过过期时间时,调度器会立即优先派发该请求,无论其 LBA 是否连续。
关键参数调节建议:
read_expire:对于延迟敏感型负载(OLTP 数据库),建议降至 200ms 以内write_expire:对于日志型负载,可适当提高至 10s 以避免写请求饿死writes_starved:读优先于写的次数阈值,默认 2。数据库场景建议设置为 8 以避免写入被过度抢占fifo_batch:批量派发大小,NVMe SSD 场景可提升至 32 以利用设备并行能力
2.2 kyber:低延迟导向的自适应调度器
kyber 是 Linux 4.12 引入的轻量级调度器,专为快速设备(NVMe SSD、傲腾持久内存)设计。与 mq-deadline 的"尽力保障"不同,kyber 采用了一种完全不同的 延迟目标反馈控制 策略。
核心机制:kyber 为每个硬件队列维护一组延迟目标(如读延迟 10ms,写延迟 100ms),并实时监控实际完成延迟。当实际延迟超过目标时,调度器通过 令牌桶(token bucket) 机制动态限制下一个时间窗口内的请求数量。
令牌桶工作流:
- 每个请求从发出到完成被记录时间戳,计算实际延迟
- 根据实际延迟与目标延迟的比例,计算下一个窗口的"约束系数"(1.0 表示不限流,0.5 表示仅允许 50% 请求)
- 令牌生成速率 = 约束系数 × 理论最大 IOPS
- 只有获得令牌的新请求才能被派发
kyber 的优势是:在设备不饱和时完全无调度开销(直通模式),只有在检测到延迟超标时才介入控制。这使得它在混合负载(快慢设备混合、读写混合)场景下表现优异。
调节参数:
read_target/write_target:目标延迟,根据设备特性调整(NVMe 建议 2-5ms读 / 10-20ms写)async_depth:异步请求深度限制,影响写合并效率
2.3 BFQ:公平带宽分配的精密齿轮
Budget Fair Queueing(BFQ)是 Linux 5.0 引入的复杂调度器,专为桌面交互和多媒体场景设计。其设计理念不仅关注延迟,还强调 进程级带宽公平分配。
budget 机制:BFQ 不派发单个请求,而是为每个进程分配一个 budget(允许派发的sector总量)。当一个进程获得调度器所有权时,它会持续发送请求直到 budget 耗尽,然后才轮转到下一个进程。
权重与预算的关系:budget 的大小与进程的 I/O 权重(由 ionice 或 cgroup io.weight 决定)成正比。权重越高,预算越大,意味着在每次轮转中获得更多的 I/O 时间片。
低延迟两大法宝:BFQ 通过两种方式保证低延迟:
IDLE 队列插入:来自"交互式"进程(标记为 idle class)的请求会被插入特殊队列,即使预算未用完也会被强制派发。这使得交互进程的读请求不会被批量写操作阻塞。
超时抢占:如果一个进程的预算过大导致其他进程等待时间过长(>bfq_timeout_max,默认 300ms),BFQ 会强制切换进程。
BFQ 的典型场景:桌面环境下同时进行浏览器(低延迟读)和视频编辑(高吞吐写)时,BFQ 能有效避免视频编辑的大量写入阻塞浏览器的交互响应。
三、NVMe 驱动与块层的深度集成
3.1 轮询模式(Polled Mode)
现代 NVMe 驱动支持两种 I/O 提交模型:中断模式(通过 PCIe MSI-X 中断通知完成)和轮询模式(CPU 主动轮询 completion queue)。对于延迟要求极低的全闪阵列,轮询模式可以绕过中断处理的开销,将读延迟稳定控制在 5μs 以内。
Linux NVMe 驱动通过以下机制实现高效轮询:
- io_poll:块层回调,用于带外轮询请求完成状态
- irq_set_affinity_hint:将硬件队列的中断绑定到本地 CPU,使轮询线程与 I/O 提交在同一核心上执行
- polled hctx:标记为 polled 模式的硬件队列会跳过中断入口,完全由 sq thread 轮询
3.2 写入放大与块层写合并
SSD 的写入放大效应(Write Amplification)与块层的写合并策略直接相关。blk-mq 的 blk_plug 机制通过暂时缓冲请求,使得文件系统能够将相邻的小写入合并为一个大的块操作。
Plug 机制工作流:
- 进程提交 I/O 时,块层启用 plug(
blk_start_plug) - 请求首先进入 plug buffer 而非立即派发
- 在 plug buffer 中,I/O 调度器可以对请求进行合并(merge)和排序(sort)
当 plug 深度达到阈值或进程显式调用 blk_finish_plug 时,整个 buffer 被 flush 到硬件队列
四、实战调优:从参数到架构的全栈优化
4.1 调度器选择决策表
以下是不同工作负载下的调度器选择建议:
| 负载类型 | 推荐调度器 | 原因 |
|---|---|---|
| OLTP 数据库 | none (Bypass) | NVMe 并行能力强,调度器开销反成瓶颈 |
| OLAP / 数据仓库 | mq-deadline | 顺序写需要过期机制保护写延迟 |
| 桌面桌面 | BFQ | 交互进程的低延迟读不能被批量写阻塞 |
| CDN 边缘缓存 | kyber | 快慢设备混合下自适应限流最优 |
| 容器化多租户 | BFQ + io.weight cgroup | 预算机制天然适配权重公平分配 |
4.2 fio 基准测试对比
以下命令可测量不同调度器在随机读场景(4K QD32)下的延迟表现:
fio --name=test --ioengine=io_uring --direct=1 --rw=randread --bs=4k --numjobs=4 --iodepth=32 --runtime=60 --time_based --filename=/dev/nvme0n1
通过切换 /sys/block/nvme0n1/queue/scheduler,可以观察到:
- none:p99 延迟约 80-120μs,无调度开销但无公平性保证
- mq-deadline:p99 延迟约 90-140μs,过期机制增加微秒级抖动
- BFQ:p99 延迟约 150-300μs,预算机制引入额外排队开销
4.3 块层 tracepoint 监测
Linux 块层提供了丰富的 tracepoint 用于运行时诊断:
- block_bio_issue:请求进入块层的入口点,可用于统计设备级 IOPS
- block_rq_complete:I/O 完成记录,包含延迟、错误码
- block_bio_queue:请求进入调度器的时刻
- block_unplug:plug buffer 被 flush 的时刻和请求数量
结合 bpftrace 脚本可以实现实时的 I/O 延迟分布直方图:
kprobe:blk_mq_start_request { @start[arg0] = nsecs; }
kretprobe:blk_mq_end_request /@start[args.ret]/ { @us = hist((nsecs - @start[args.ret]) / 1000); }
五、未来演进:blk-mq 与存储实时化的融合
Linux 块层正在向两个方向快速演进:
ZNS(Zoned Namespaces)集成:随着 NVMe ZNS 规范落地,块层引入了 blk_zone 概念,将设备暴露为分区写入区域,需要在块层实现区域写入指针管理、区域重置、区域激活等操作。这与传统块层的 LBA 完全不同的抽象。
CXL 内存持久化:CXL.mem 连接的持久内存(PMem)需要块层在 CXL 内存池中实现新的请求类型(include/linux/blk_types.h 中新增的REQ_CXL_标志族),使块层不再局限于存储 I/O,而是扩展为通用持久化 I/O 路径。
blk-mq 的架构从一开始就为可扩展性设计,新增设备类型只需实现 struct blk_mq_ops 接口即可接入整个块层栈,这为未来 PMem、CXL、存内计算等新硬件提供了干净的内核集成路径。
总结
Linux 已经从简单的单队列请求模型演进为 blk-mq 多队列架构,配合 mq-deadline、kyber、BFQ 三种调度器,覆盖了从高性能存储到桌面交互的全场景 I/O 调度需求。理解这些机制不仅能帮助我们做出正确的调度器选择,更能为构建低延迟、高吞吐的存储服务提供底层知识支撑。
在 NVMe SSD 主导高性能存储的今天,块层的优化重点已从"如何减少锁竞争"转向"如何在设备能力范围内精确控制延迟与公平的平衡"。blk-mq 与 io_uring 的协同创新,正在重新定义 Linux 内核 I/O 栈的性能边界。

发表评论 取消回复