引言:存储性能的关键战场

在 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_sizeI/O 总字节数
bi_io_veciovec 数组,每个元素指向一个物理内存片段
bi_vcntio_vec 数量(分段个数)
bi_statusI/O 完成状态码
bi_end_ioI/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 从创建到完成的典型路径:

  1. 分配:通过 bio_alloc_bioset() 从 bioset 内存池预分配的 slab 缓存中分配(避免内存不足时分配失败)
  2. 填充:调用者(文件系统或 DAX)设置 bi_opf、bi_sector、通过 bio_add_page() 添加页面片段
  3. 提交:调用 submit_bio()(或老版 generic_make_request())进入块层
  4. 插入/合并:在调用驱动的 .submit_bio 或 blk-mq 的 blk_mq_bio_to_request() 前,尝试与已排队的 I/O 合并(plug/unplug 机制下延迟合并更高效)
  5. 请求构建:blk-mq 将 bio 转换为 blk_mq_ctx 所属 request 结构(blk_mq_alloc_request())
  6. 分发 + 提交:上述两阶段分发后,由驱动处理并写入门铃
  7. 完成:中断返回时调用 blk_mq_complete_request(),驱动设置 req->status,通过回调链向上层层 bio_endio()
  8. 释放: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)
p>NVMe 驱动采用共享 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 启动内核线程定时检查硬件队列状态
p>关键优化:NVMe 驱动在轮询模式下(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_queue
  • mq_ctx:所属软件队列
  • mq_hctx:所属硬件队列
  • tag / internal_tag:硬件队列 tag / 内部保留 tag
  • rq_flags:标记(REQ_FUA/REQ_FLUSH/REQ_PREREAD 等)
  • bio / biotail:指向首个/尾个 bios
  • rl: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/)与块层集成的关键流程:

  1. 探测与初始化:nvme_probe() 创建设备 → nvme_alloc_disk() 创建 gendisk
  2. tag_set 配置:填写 nvme_mq_reg 操作集 → blk_mq_alloc_tag_set() 分配 tag 集合 → blk_mq_init_queue() 初始化 HW 队列
  3. 中断注册:每个 hctx 分配独立 MSI-X 向量 → nvme_irq() 中断处理
  4. 门铃写入:即将提交队列头写入门铃寄存器(SQxxTDBL),控制器从读取条目
  5. 完成/收割:中断到来 → 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/schedulermq-deadlinenone(io_uring)或 mq-deadlineIO 调度器
queue/nr_requests1281024每个硬件队列最大未决请求数
queue/read_ahead_kb128256~1024读预读缓存
queue/max_sectors_kb128256~1024单次最大 I/O 大小
queue/io_poll01启用轮询模式(低延迟场景)
rq_affinity12完成后在提交 CPU 处理(0=否,1=本地,2=本地并标记)
queue/io_poll_delay-10轮询模式每次轮询间隔(负值=中断模式)
queue/dma_alignment5110DMA 对齐边界
queue/discard_max_bytes0启用启用 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 内核子系统协作的绝佳入口。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论