引言:存储性能的关键战场
在 Linux 内核的四大子系统(进程管理、内存管理、网络栈、文件/存储)中,存储 I/O 栈是与硬件交互最直接、与性能关系最紧密的子系统之一。NVMe SSD 的延迟可以低至 10μs,但 Linux 传统的单队列块层架构(Single-queue Block Layer)在高并发场景下会成为性能瓶颈。自 3.13 版本引入、4.0+ 逐步成熟的 blk-mq(Multi-Queue Block Layer)彻底重构了块层,为 NVMe 高性能 SSD 铺平了道路。本文从 bio 请求结构的自底向上生命周期出发,深入剖析 blk-mq 的多队列硬件分发机制、软件调度层设计、硬件队列映射策略,并延伸到 IO 调度器(mq-deadline/BFQ/Kyber/none)的实现差异与生产调优实践。
一、块层架构演进:从 Single-queue 到 Multi-queue
1.1 传统单队列架构的瓶颈
在 Linux 2.6 时代(现代块层由 Jens Axboe 重构),块层采用全局单一请求队列(request_queue),所有 CPU 发起的 I/O 请求通过同一个自旋锁保护的全局链表入队。在高速 NVMe SSD 上,这个全局锁成为灾难性的瓶颈:
- 锁竞争:频繁的入队/出队操作导致跨 CPU 缓存行乒乓(Cache-line Bouncing)
- NUMA 不感知:请求队列不与 NUMA 节点绑定,跨节点内存访问延迟高
- 单软队列:单一软件队列无法利用硬件并行能力
- 上下文切换开销:高 QD(Queue Depth)下合并/调度延迟急剧上升
在 Optane NVMe SSD 上,单队列架构仅能达到约 300K IOPS,而设备的物理能力可达到 500K+ IOPS,块层本身吃掉了近 40% 的硬件潜力。
1.2 blk-mq 的核心设计思想
blk-mq(Multi-Queue Block Layer)的设计哲学是"从硬件出发":充分利用 NVMe 的多硬件队列特性(最多 64K 并行队列),让 I/O 路径尽可能无锁、短路径优先。核心数据结构分层:
- Hardware Dispatch Queue(HWQ / hctx):硬件调度队列,每个映射到一个硬件中断和提交队列(SQ),由 CPU 本地化处理
- Software Queue(SWQ / ctx):软件分发队列,每个 CPU 对应一个,负责快速分发请求到硬件队列
- blk_mq_tag_set:管理 tag(请求标识符)的全局集合,是所有硬件队列共享的资源
1.3 两阶段调度:分发与分发
blk-mq 的 I/O 处理分两阶段:
- 阶段一:软件队列分发:当前 CPU 调用
blk_mq_run_hw_queue()或软中断(blk-mq hctx)触发,将软件队列中的 request 移动到硬件队列的 dispatch 列表 - 阶段二:硬件队列处理:硬件队列的提交函数(
queue_rq回调,如 NVMe 驱动中的nvme_queue_rq())将 request 转换为硬件提交队列条目(CMD),写入 SQ 门铃寄存器(Doorbell)
这种分层设计允许在不同阶段执行不同的优化策略:阶段一(软件队列)处理 I/O 合并和调度;阶段二(硬件队列)处理硬件特定的命令封装和写入。
二、Block I/O(bio)结构深度解析
2.1 bio 结构体
struct bio 是块层 I/O 请求的基本单元。一个 bio 代表一个不连续内存区域对不连续设备扇区的"分散-聚集"传输:
| 字段 | 说明 |
|---|---|
| bi_bdev | 指向块设备(block_device)的指针 |
| bi_opf | 操作类型(READ/WRITE/FLUSH/DISCARD/WRITE_ZEROES 等) |
| bi_iter.bi_sector | 起始设备扇区号(512B 为单位) |
| bi_iter.bi_size | I/O 总字节数 |
| bi_io_vec | iovec 数组,每个元素指向一个物理内存片段 |
| bi_vcnt | io_vec 数量(分段个数) |
| bi_status | I/O 完成状态码 |
| bi_end_io | I/O 完成回调函数 |
| bi_private | 私有数据(通常传递给 bi_end_io) |
2.2 bio 的内存描述:bvec_iter 与 io_vec
一次 I/O 涉及不连续的内存区域(分散-聚集 I/O),因此 bio 通过 bio_vec 数组描述:
struct bio_vec {
struct page *bv_page; /* 物理页面指针 */
unsigned int bv_len; /* 字节长度 */
unsigned int bv_offset; /* 页面内偏移 */
};
bvec_iter 则是 bio_vec 数组的迭代器,用于遍历物理扇区和对应的内存片段。一个 bio 可以包含多个 bio_vec,最大数量受 BIO_MAX_VECS 限制(通常 256),因此一个 bio 的最大 I/O 大小约为 1MB(256 × 4KB)。
2.3 bio 的生命周期
一个 bio 从创建到完成的典型路径:
- 分配:通过
bio_alloc_bioset()从 bioset 内存池预分配的 slab 缓存中分配(避免内存不足时分配失败) - 填充:调用者(文件系统或 DAX)设置 bi_opf、bi_sector、通过
bio_add_page()添加页面片段 - 提交:调用
submit_bio()(或老版generic_make_request())进入块层 - 插入/合并:在调用驱动的
.submit_bio或 blk-mq 的blk_mq_bio_to_request()前,尝试与已排队的 I/O 合并(plug/unplug 机制下延迟合并更高效) - 请求构建:blk-mq 将 bio 转换为
blk_mq_ctx所属 request 结构(blk_mq_alloc_request()) - 分发 + 提交:上述两阶段分发后,由驱动处理并写入门铃
- 完成:中断返回时调用
blk_mq_complete_request(),驱动设置req->status,通过回调链向上层层 bio_endio() - 释放:request 归还 tag 池,bio_free() 归还 objs
2.4 Plug 机制(插入/拔出)
块层引入的"塞住"(Plug)机制是合并优化关键:当一个进程在短时间内提交多个 bio 时,如果平常立即逐个提交,每次提交都需要进行锁操作和延迟调度。Plug 机制将这些 bio 临时"塞住"在进程的 current->plug 缓存列表中,等积累到一定数量或拔出时才批量提交。这样多个相邻扇区的顺序小 I/O 可以合并为一个大的 request 再进入块层,大幅减少请求数量。关键函数:blk_start_plug()、blk_finish_plug()、blk_mq_flush_plug_list()。在 writeback 和 mmap write 路径中,默认都启用 blk_plug。
三、blk-mq 核心机制深度剖析
3.1 Tag 管理与静态/动态分配
blk-mq 用"tag" 替代传统请求结构的全局分配:每个 request 对应一个唯一的 tag ID(通常 0 ~ queue_depth-1),tag 与硬件队列中的 Submission Queue Entry 一一对应。Tag 管理两种策略:
- Static tags:每个硬件队列预分配固定数量的 tag,请求从本地 bitmap 分配。低延迟但存在队列饥饿浪费
- Shared tags:全局共享 tag 池(
blk_mq_tag_set.shared_tags),多个硬件队列共享一个 bitmap 空间。适合队列负载不均衡的场景(如:某些队列空闲时,繁忙队列可借用共享 tag)
nvme_queue_tag_set),可最大限度利用设备的并行能力。
3.2 硬件队列映射:CPU 亲和与 NUMA 拓扑
blk-mq 的关键配置是硬件队列到 CPU 的映射(blk_mq_hw_ctx 与 possible_online_cpu 关系)。映射函数类型:
- blk_mq_map_queues():默认将每个 hctx 映射到一组就近 CPU(
cpu_map参数决定) - blk_mq_pci_map_queues():PCI 设备场景,以中断亲和性为映射依据(最接近中断处理 CPU)
- blk_mq_virtio_map_queues():VirtIO 场景,根据 vring 队列号顺序映射
对于 NVMe 设备(drivers/nvme/host/pci.c nvme_queue_map_irq()),中断采用 MSI-X 模式,每个硬件队列有一个独立 MSI-X 中断。blk-mq 将同一 hctx 的多个中断亲和性绑定到同一组 CPU。映射关系可通过 /sys/block/<dev>/mq/ 下的映射表文件查看。
3.3 延迟调度(Soft IRQ / HARDISPATCH / BUSY)
blk-mq 的软件队列分发在非软中断上下文有多种触发条件:
- 直接触发:
blk_mq_run_hw_queue()被调用者(如blk_mq_submit_bio())立即启动 - 软中断触发:若直接启动失败(硬件队列 busy),设置
BLD_MQ_SHOULD_RUN标记并在软中断(raise_irq_thread()或软中断处理函数)继续分发 - 定时轮询触发:对于轮询模式(polling),blk-mq 启动内核线程定时检查硬件队列状态
poll_queue 属性配置为 true),跳过中断直接进入轮询。轮询模式每个硬件队列独占一个 CPU,延迟极低(尾部延迟 <5μs)。代价是 100% CPU 占用,适用于低延迟 DB(如 Redis)和 SPDK 场景。
3.4 blk-mq 的请求合并(Front-merge 与 Back-merge)
如果两个请求的扇区相邻,块层可以将它们合并为一个更大的 request。blk-mq 中的合并发生在两个层面:
- 软件队列合并:
blk_mq_bio_list_merge()扫描当前软件队列中已排队的 request 是否可合并(通常检查 request 的尾部 or 头部) - IO 调度层合并:经过 IO 调度器(如 mq-deadline)时,调度器内部再次尝试合并
合并请求的大小合并上限:/sys/block/nvme0n1/queue/max_sectors_kb(NVMe 默认 128KB,Optane 通常允许 1MB+)。过高的大请求会导致单个请求占用硬件队列时间长,延迟其他请求,需根据场景权衡。
四、IO 调度器深度分析
4.1 调度器架构:mq-deadline 详解
mq-deadline 是多队列块层默认的简单调度器,核心设计:
- 排序队列:按扇区号排序的 RB 树(
sort_list),用于高效的顺序 I/O 合并 - 过期队列:每个 I/O 的过期时间由其类型决定(读 500ms / 写 5s),按过期时间排序
- 批量分发:每次从排序队列批量取出 16 个请求分发,兼顾合并与基数排序效率
- 写优先:写操作的优先级高于读(write_batch_factor = 8:每取 16 个读请求,额外取 2 个写请求),避免"写饿死"
mq-deadline 适合大多数常规工作负载(数据库、文件服务、容器存储)。它的实现足够简单不会引入高延迟,批量分发又提供了适中的合并效率。
4.2 BFQ:公平性与延迟控制
Budget Fair Queueing(BFQ) 是为桌面和低延迟应用设计的权重感知 I/O 调度器:
- Budget 机制:每个进程组获得固定"预算"(budget,单位已服务扇区数),用完后需等待下一轮分配
- 权重分配:通过 cgroups v2
io.weight分配不同权重(默认 100),高权重大预算获得更多 I/O 机会 - 预测模型:BFQ 预测每个进程的 I/O 模式(随机/顺序),对随机读提供更高优先级(防止随机读被顺序 IO"淹没")
- 空闲队列:空闲的进程组不会被立即放入过期队列,保留到下一个活跃周期处理
BFQ 延迟更可控但吞吐量可能不如 mq-deadline(因为不必要的预算轮转)。推荐场景:桌面系统、延迟敏感的交互式应用、多租户容器环境。
4.3 Kyber:低延迟自适应调度
Kyber 是 Facebook 贡献的超低延迟调度器,适合多队列 SSD(NVMe):
- 延迟目标:为每种 I/O 类型(同步读/写、异步读/写)设置延迟目标(默认读 2ms / 写 10ms)
- 动态调节:跟踪每个目标的当前延迟,通过 PID 控制器动态调节调度频率
- 简单队列:每个 I/O 类型一个 FIFO 请求队列,无排序开销
- 短期预测:快速识别突发 I/O 并调节流量分配
Kyber 在极高 QD 深度下延迟最稳定,但要求硬件队列延迟本身较低。推荐场景:高吞吐 NVMe 存储上的数据库工作负载(MySQL/PostgreSQL 在高 QD 环境下)。
4.4 None 调度器与 io_uring 集成
none 不是真正的调度器,而是"透传模式":bio 几乎不做额外处理直接分发到硬件队列。对于 NVMe + io_uring 的场景(io_uring 在内核态完成分发),none 是推荐选项。
io_uring 提供内核态提交/完成的 I/O 路径,但 io_uring 引入 IORING_SETUP_SQPOLL 选项后,io_uring 的提交队列(SQ)由内核线程轮询读取。bio 路径和 io_uring 路径共享底层 blk-mq 硬件队列,bio 提交的 I/O 请求(如 page cache writeback)和 io_uring 提交的 I/O 请求最终都经过 blk-mq。/sys/block/nvme0n1/queue/scheduler 显示 none 仅针对 bio 路径生效。
4.5 IO 调度器性能对比
| 调度器 | 延迟 | 吞吐量 | 公平性 | 最佳场景 |
|---|---|---|---|---|
| mq-deadline | 低(~1ms) | 高 | 基本 | 通用(数据库/文件/容器) |
| BFQ | 中(~5ms) | 中 | 优秀 | 桌面/交互/多租户 |
| Kyber | 极低(~0.5ms) | 极高 | 中 | 高 QD NVMe 数据库 |
| none | 最低 | 最高 | 无 | io_uring+NVMe+独占式 |
五、request 结构详解
5.1 request 与 bio 关系
一个 request 可以包含 1 到 n 个 bio(bio chain),实现跨 bio 的 I/O 合并后构成更大的高效工作单元。struct request 关键字段:
q:所属 request_queuemq_ctx:所属软件队列mq_hctx:所属硬件队列tag/internal_tag:硬件队列 tag / 内部保留 tagrq_flags:标记(REQ_FUA/REQ_FLUSH/REQ_PREREAD 等)bio/biotail:指向首个/尾个 biosrl:request_limit 信息(用于流量统计)part:所属分区(part)
5.2 request 操作类型(req_opf flag)
- REQ_OP_READ:读
- REQ_OP_WRITE:写
- REQ_OP_FLUSH:刷新缓存(写入持久化存储)
- REQ_OP_DISCARD:丢弃区域(告知控制器不再保存内容)
- REQ_OP_WRITE_ZEROES:写零(利用控制器快速写零能力)
- REQ_OP_SECURE_ERASE:安全擦除
- REQ_OP_ZONE_RESET:重置 ZNS(Zoned Namespace)区域
5.3 FUA(Force Unit Access)与 Write Barrier
文件系统(如 ext4)为了保证元数据一致性,会在关键Metadata 写入时设置 REQ_FUA 标志,确保本次写入直接到达持久化存储(绕过 NVMe Write Cache)。REQ_FLUSH 虽然也确保持久化,但它需要先发送 Flush 命令再发送数据,性能更差。
NVMe 驱动层,CMD_WRITE_FUA 标志映射到 NVMe 的 FUA 位(在 SQ Entry 中设置)。对于带电容保护的 NVMe(PLP,Power Loss Protection),FUA 通常会被控制器忽略(因为 DRAM writeback 电容可以保证数据写入),因为不必要的 FUA 可能降低吞吐量。
六、生产实践与 NVMe 驱动集成
6.1 NVMe 块层路径
NVMe 驱动(drivers/nvme/host/)与块层集成的关键流程:
- 探测与初始化:
nvme_probe()创建设备 →nvme_alloc_disk()创建 gendisk - tag_set 配置:填写
nvme_mq_reg操作集 →blk_mq_alloc_tag_set()分配 tag 集合 →blk_mq_init_queue()初始化 HW 队列 - 中断注册:每个 hctx 分配独立 MSI-X 向量 →
nvme_irq()中断处理 - 门铃写入:即将提交队列头写入门铃寄存器(SQxxTDBL),控制器从读取条目
- 完成/收割:中断到来 →
blk_mq_complete_request()收割 CQ 条目 → 回调通知上层
6.2 中断分发与 CPU 亲和
NVMe PCI 驱动通过 pci_alloc_irq_vectors_affinity() 为每个硬件队列分配独立中断。中断亲和性绑定到硬件队列绑定的 CPU 集合。IRQBALANCE(irqbalance 守护进程)在桌面环境下可能干扰生产存储服务器上的中断亲和性。生产存储建议关闭 irqbalance,手动通过 /proc/irq/<vector>/smp_affinity_list 固定。
6.3 多路径(Multipath)
NVMe-MI 规范中为高可用性定义了 NVMe over Fabrics (NVMe-oF) + 多路径。块层面 dm-multipath 或原生 NVMe multipath(kernel 5.4+)提供:
- 路径管理:主路径/备用路径,路径故障自动切换
- 负载均衡:在多个可用路径间分散 I/O(NVMe 原生支持 NST(Namespace Switch Time)和优化访问路径)
- 故障转移:端到端 NVMe 控制器故障时,通过发现服务重新注册
- IO 停滞检测:700ms 内未完成 IO 的路径标记为 dead,自动触发重路由
6.4 Zoned Namespace(ZNS)
ZNS SSD 是一种新型 SSD,将存储空间划分为多个 zone,每个 zone 必须按顺序写入。块层通过 blk_zoned_model 识别 ZNS 设备,设置 zone 容量/大小属性。ZNS 消除了 SSD 内部的 GC(垃圾收集),提升稳态延迟和耐久性。相关 sysfs 属性:/sys/block/nvme0n1/queue/zoned、/sys/block/nvme0n1/queue/max_open_zones、/sys/block/nvme0n1/queue/max_active_zones。
6.5 生产调优参数表
| 参数 | 默认值 | 推荐值(NVMe 生产) | 说明 |
|---|---|---|---|
| queue/scheduler | mq-deadline | none(io_uring)或 mq-deadline | IO 调度器 |
| queue/nr_requests | 128 | 1024 | 每个硬件队列最大未决请求数 |
| queue/read_ahead_kb | 128 | 256~1024 | 读预读缓存 |
| queue/max_sectors_kb | 128 | 256~1024 | 单次最大 I/O 大小 |
| queue/io_poll | 0 | 1 | 启用轮询模式(低延迟场景) |
| rq_affinity | 1 | 2 | 完成后在提交 CPU 处理(0=否,1=本地,2=本地并标记) |
| queue/io_poll_delay | -1 | 0 | 轮询模式每次轮询间隔(负值=中断模式) |
| queue/dma_alignment | 511 | 0 | DMA 对齐边界 |
| queue/discard_max_bytes | 0 | 启用 | 启用 discard/UNMAP 支持 |
七、blk-mq 性能基准测试
7.1 fio 测试工具
fio 是 Linux 存储最常用的基准测试工具,支持多引擎(io_uring、libaio、sync):
fio --name=test --ioengine=io_uring --direct=1 --size=4G
--rw=randread --bs=4k --numjobs=4 --iodepth=64
--filename=/dev/nvme0n1 --runtime=30
7.2 单队列 vs 多队列 IOPS 对比
在三星 PM983 NVMe SSD(PCIe 3.0 x4,理论 32Gbps 带宽)上的典型数据:
- 单队列块层 + IO 调度:随机读 QD1 = 35K IOPS,随机读 QD32 = 280K IOPS
- blk-mq + none + io_uring:随机读 QD1 = 60K IOPS,随机读 QD32 = 520K IOPS
- blk-mq + polling + io_uring:随机读 QD1 = 80K IOPS(尾部延迟降低 80%)
blk-mq 在双路服务器(多 NUMA 节点)服务器上提升更为显著,因为避免了全局锁带来的跨节点损耗。
7.3 延迟分布(P99 vs P99.9)
blk-mq 的关键价值不仅是更高平均吞吐,更是更可预测的延迟分布:
- blk-mq + mq-deadline:P99 = 0.4ms,P99.9 = 1.2ms,P99.99 = 5+ms
- blk-mq + polling + none:P99 = 0.1ms,P99.9 = 0.2ms,P99.99 = 0.5ms
高 QD 深度(>64)的混合工作负载下优势尤其明显,轮询模式在 NVMe 多队列设备上的延迟稳定性提升可达 5~10 倍。
八、最新进展与未来趋势
8.1 6.0+ blk-mq 内核改进
内核 6.0+ 对 blk-mq 有多项关键改进:
- io_uring block passthrough: io_uring 命令可直达块层,绕过 bio/brequest 中间层
- blk-mq 标签扩容: tag_set 支持更多队列(适配 CXL-attached 多端口设备)
- NVMe reservations/PLM: 持久化内存区域(Persistent Memory Region)的块层集成
- I/O polling 优化:减少轮询空转时的 CPU 开销
8.2 与 CXL(Compute Express Link)
CXL 2.0/3.0 让外部存储设备(内存池、DRAM/SSD)连接到 CPU 总线,块层需要适应 CXL-attached 设备(极高带宽、细粒度访问)。blk-mq 需要为 CXL 设备特别调整:延迟低至数纳秒、访问粒度 64B(而非 4KB 扇区)、多队列规模更大。Intel Xeon 第四代(Sapphire Rapids)已经支持 CXL 1.1。
8.3 io_uring Block Passthrough
内核 5.19+ 引入 io_uring block passthrough(blkdev_uring_cmd),Io_uring 可直接操作块设备,省略了 bio + blk-mq 的完整 bio 路径,使用 io_uring 提交队列条目直接发给 NVMe 驱动的 .queue_rq 后端。这是对超低延迟应用的极致优化。
九、总结
Linux 块层从单队列到多队列的演进,是操作系统不断适应硬件创新的缩影。blk-mq 通过多队列并行、tag 机制、延迟调度和 IO 调度器多样化,为 NVMe SSD 的高性能存储提供了坚实基础。理解块层的 bio/request 生命周期、硬件队列映射策略和不同调度器的内在逻辑,对于存储性能测试、数据库调优、云原生存储 KV(Container Storage Interface)等工程实践至关重要。
对于希望深入存储栈的工程师,建议学习路径:读 LWN "The multi-queue block layer" 系列文章 → Jens Axboe 的 fio 工具源码 → NVMe 1.4 规范(控制器/队列行为)→ io_uring 文档与 kernel 测试脚本。理解块层不仅是提升 I/O 效率的关键,也是掌握 Linux 内核子系统协作的绝佳入口。

发表评论 取消回复