Linux 内核 blk-mq 多队列块层深度剖析:从 NVMe 硬件并行到生产环境 IO 性能实战

在 Linux 内核存储子系统中,blk-mq(Block Multi-Queue)是彻底改变 IO 栈性能格局的核心框架。自 Linux 3.13 引入以来,它从根本上解决了传统单队列块层在多核 CPU 与 NVMe SSD 时代的扩展性问题,到 Linux 5.0 成为默认块层实现,Linux 6.0 更彻底移除了传统单队列代码路径。本文将从架构演进历史、两级队列模型、核心数据结构、请求全链路流程、标签分配机制、I/O 调度器适配、NVMe 驱动集成以及生产环境调优实战等多个维度,系统性地剖析 blk-mq 的工作原理与工程实践。

一、从单队列到多队列:架构演进的历史必然

1.1 传统单队列块层的致命瓶颈

在 Linux 2.6 时代,内核块层采用单队列(single-queue)架构。每个块设备维护一个全局请求队列(request_queue),由一个全局自旋锁(request_queue->queue_lock)保护。所有 CPU 核心提交的 IO 请求必须经过这个全局队列,在多核场景下带来了三个致命问题:

全局锁争用:多核 CPU 同时发起 IO 时,保护队列的自旋锁成为最激烈的竞争点。实测表明,8 核以上场景中锁开销占总 IO 延迟的 30% 以上,16 核以上甚至超过 50%。CPU 算力大量浪费在锁等待而非实际 IO 处理上。

缓存行弹跳(Cache Line Bouncing):全局队列及其锁的数据结构在不同 CPU 核心的 L1/L2 缓存之间反复失效和同步,造成巨大的缓存一致性流量。当一个核心修改队列头指针时,其他核心的缓存行全部失效。

无法利用硬件并行性:现代 NVMe SSD 在硬件层面支持 64 到 128 个独立的提交队列(Submission Queue),每个队列可直达一个 CPU 核心。单队列软件栈完全无法发挥这种硬件多队列能力,百万级 IOPS 的设备被锁争用限制在十万级。

1.2 blk-mq 的诞生与演进里程碑

blk-mq 框架由内核块层维护者 Jens Axboe 主导开发,其演进历程标志着 Linux IO 栈的彻底重构:

Kernel 3.13(2014 年 1 月):blk-mq 首次合并入主干内核,初期仅支持 virtio-blk 等少量实验性驱动。此时的 blk-mq 还需要用户通过 scsi_mod.use_blk_mq=1 内核参数手动启用。

Kernel 3.16(2014 年 8 月):blk-mq 达到功能完备状态,NVMe 驱动(nvme.ko)正式基于 blk-mq 实现,成为首批生产级多队列块设备驱动。SCSI 子系统逐步增加 scsi-mq 支持。

Kernel 4.0+(2015 年起):更多存储驱动迁移到 blk-mq 框架,包括 USB 存储(usb-storage)、 loop 设备、DM(Device Mapper)、MD(Multiple Devices)等。

Kernel 5.0(2019 年 3 月):blk-mq 成为内核默认的块层实现。新编译的内核默认启用多队列模式,传统单队列代码仍可通过编译选项保留兼容。

Kernel 6.0(2022 年 10 月):内核彻底移除传统单队列块层的代码路径(block/blk-core.c 中的 legacy 路径),所有块设备驱动必须基于 blk-mq 实现。单队列时代正式终结。

二、两级队列架构:软件队列与硬件队列的解耦设计

2.1 整体架构概览

blk-mq 的核心设计思想是将 IO 路径分离为两个独立的阶段:软件暂存阶段(software staging)和硬件派发阶段(hardware dispatch)。这两个阶段通过两组队列(queue)协作完成请求的高效传递。

软件暂存队列(Software Staging Queue):每个 CPU 核心拥有一个独立的软件队列(blk_mq_ctx),请求首先到达这里。软件队列负责 IO 合并(merge)、排序(sort)和调度(schedule),避免多核之间的锁争用。

硬件派发队列(Hardware Dispatch Queue):数量由硬件能力决定(通常等于 NUMA 节点内的 CPU 核心数),请求从这里被直接提交给设备驱动。硬件队列与设备端的提交队列(NVMe SQ)一一对应。

两级队列之间是多对多的映射关系:多个软件队列可以向同一个硬件队列提交请求,一个软件队列也可以轮转使用多个硬件队列。blk-mq 通过 blk_mq_hw_ctx->ctxs 数组维护这种映射。

2.2 请求投递的两种路径

blk-mq 支持两种请求投递路径,根据是否启用 I/O 调度器自动选择:

最短路径(Direct Insert / Bypass Path):当设备未配置 I/O 调度器(如使用 "none" 调度器的 NVMe 设备),或者请求可以直接合并时,blk-mq 尝试将请求直接插入硬件队列,跳过软件队列的调度开销。这是 NVMe 等高速设备的默认优化路径。

调度路径(Scheduler Path):当设备配置了 I/O 调度器(如 mq-deadline、kyber、bfq),或者需要合并相邻扇区的请求时,请求会先进入软件队列,由 I/O 调度器进行排序和合并,然后再派发到硬件队列。

三、核心数据结构深度解析

3.1 struct blk_mq_ctx — 软件队列

每个 CPU 核心对应一个 blk_mq_ctx 实例,代表一个独立的软件暂存队列。其核心字段如下:

struct blk_mq_ctx {
    struct list_head    rq_lists[HRTYPES_MAX];  // 请求链表(按优先级分)
    unsigned int        cpu;                      // 绑定的 CPU 编号
    unsigned int        index_hw[HRTYPES_MAX];    // 硬件队列索引
    struct blk_mq_hw_ctx  *hctxs[HRTYPES_MAX];    // 指向硬件队列的指针数组
    struct request_queue  *queue;                 // 所属的请求队列
    struct blk_mq_ctxs    *ctxs;                  // 父级上下文(per-tagset)
};

rq_lists 是 IO 请求的临时存放链表,按硬件队列类型(HRTYPES_MAX = 2,分别对应普通 IO 和轮询 IO)分类。每个 CPU 核心上的提交直接将请求挂入自己的 rq_lists,无需与其他 CPU 争抢全局锁。

3.2 struct blk_mq_hw_ctx — 硬件队列

硬件队列是请求从软件层传递到设备驱动的关键中间层:

struct blk_mq_hw_ctx {
    struct request_queue    *queue;               // 所属的请求队列
    unsigned int            hctx_idx;             // 硬件队列 index
    unsigned int            nr_ctx;               // 映射到此 hctx 的软件队列数量
    struct blk_mq_ctx      **ctxs;                // 软件队列指针数组
    struct blk_mq_tags      *tags;                // 标签集合(per-hctx)
    struct sbitmap_queue    __rcu *sched_bitmap;  // 调度器用的标签位图
    struct blk_mq_ops       *ops;                  // 硬件操作函数表
    unsigned int            nr_map;               // 队列映射数量
    struct blk_mq_queue_map  map;                 // CPU→hctx 映射表
    void                    *driver_data;         // 驱动私有数据
    struct dentry           *debugfs_dir;         // debugfs 入口
    struct cpumask          cpumask;              // 允许使用的 CPU 掩码
    unsigned int            flags;                // 状态标志位
    atomic_t                nr_active;            // 活跃请求数量
    struct delayed_work     run_work;             // 延迟运行 work
};

关键的设计细节:cpumask 字段定义了哪些 CPU 可以向此硬件队列提交请求,这是实现 NUMA 亲和性调优的基础。driver_data 字段供设备驱动存储私有数据(如 NVMe 的 queue 结构),实现 blk-mq 框架与具体驱动的解耦。

3.3 struct blk_mq_tags — 标签管理

标签(tag)是 blk-mq 分配 request 对象的唯一标识符,其生命周期管理直接关系到 IO 的并发能力:

struct blk_mq_tags {
    unsigned int        nr_tags;             // 标签总数(= 队列深度)
    unsigned int        nr_reserved_tags;    // 保留标签数(关键 IO 专用)
    struct request     **static_rqs;          // 静态 request 对象指针数组
    struct sbitmap_queue bitmap_tags;        // 动态标签分配位图
    struct sbitmap_queue sched_bitmap_tags;  // 调度器标签位图
    struct list_head    page_list;            // slab 分配的页面列表(vmalloc 回退)
};

每个标签对应一个预分配的 struct request 对象(保存在 static_rqs 数组中)。标签分配使用 sbitmap(scalable bitmap)实现高效并发分配——sbitmap 采用 per-CPU 缓存位图策略,将全局锁争用降到最低。

reserved tags(保留标签)的设计确保在高负载下系统仍有能力处理 IO 完成回调、错误恢复等关键操作,避免因标签耗尽导致的系统僵局。

3.4 struct blk_mq_ops — 驱动操作函数表

blk-mq 通过函数表实现框架与驱动的解耦。设备驱动需要实现以下核心回调:

struct blk_mq_ops {
    void (*queue_rq)(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd);
    int  (*commit_rqs)(struct blk_mq_hw_ctx *hctx);
    void (*complete)(struct request *rq);
    void (*cleanup_rq)(struct request *rq);
    int  (*init_hctx)(struct blk_mq_hw_ctx *hctx, void *driver_data, unsigned int hctx_idx);
    void (*exit_hctx)(struct blk_mq_hw_ctx *hctx, unsigned int hctx_idx);
    int  (*init_request)(struct blk_mq_tag_set *set, struct request *rq, unsigned int hctx_idx, unsigned int numa_node);
    void (*show_rq)(struct seq_file *m, const struct request *rq);
    int  (*map_queues)(struct blk_mq_tag_set *set);
    enum blk_eh_timer_return (*timeout)(struct request *rq);
    int  (*poll)(struct blk_mq_hw_ctx *hctx);
};

其中最关键的是 queue_rq 回调——blk-mq 通过此函数将请求提交给设备驱动。NVMe 驱动在此回调中将请求转化为 NVMe 命令(nvme_command),写入提交队列(SQ)的尾门铃寄存器(Tail Doorbell)。

四、IO 请求全链路流程追踪

以下以一个 NVMe SSD 上的文件读取为例,追踪 IO 请求从用户空间发起到底层硬件完成的完整路径:

4.1 请求发起阶段:VFS 到通用块层

用户空间的 read() 系统调用经过 VFS→文件系统(如 ext4/xfs)→页缓存层(page cache)。若缓存未命中,文件系统构造 bio(Block I/O)结构体,描述需要的磁盘扇区范围和目标内存页,然后调用 submit_bio() 进入通用块层。

submit_bio()→generic_make_request()→blk_mq_make_request() 是进入 blk-mq 框架的入口。

4.2 请求入队阶段:标签分配与队列选择

blk_mq_make_request() 函数的执行流程:

第一步:通过 blk_mq_get_tag() 分配一个空闲标签。标签分配使用 sbitmap 的 per-CPU 缓存策略——先从本地 CPU 的缓存位图分配,失败再从全局位图获取。若标签已满(设备队列被塞满),调用 blk_mq_get_driver_tag() 尝试分配驱动层标签(driver tag),或者阻塞等待标签释放。

第二步:选择目标硬件队列(blk_mq_hw_ctx)。调用 rq_qos_prepare() 进行 QoS 准入控制(blk-iocost),然后通过 blk_mq_map_queue() 根据当前 CPU 编号查找对应的硬件队列映射(map->mq_map[cpu])。

第三步:将 bio 封装为 request,挂入软件队列的 rq_lists。此时请求以 struct request 的形式存在,包含合并后的 bio 链表。

4.3 调度与派发阶段:从软件队列到硬件队列

blk_mq_run_hw_queue() 是驱动硬件队列的核心函数,被多种场景触发:

立即触发:blk_mq_request_bypass_insert() 将请求直接插入硬件队列(bypass 路径);blk_mq_try_issue_directly() 在插件刷新时尝试直接向硬件队列分发。

延迟触发:blk_mq_delay_run_hw_queue() 通过 schedule_delayed_work() 延迟触发硬件队列运行,合并短时间内的多次触发请求。

中断触发:在 IO 完成中断中,如果检测到请求队列中有待处理请求(plug list flush),触发硬件队列运行。

4.4 驱动派发阶段:queue_rq 回调

blk_mq_dispatch_rq_list() 从硬件队列的派发链表(dispatch list)中取出请求列表,调用驱动提供的 queue_rq() 回调函数。

以 NVMe 驱动为例,nvme_queue_rq() 回调的执行流程:

1. 将 struct request 转换为 struct nvme_command,设置 PRP(Physical Region Page)/SGL(Scatter Gather List)指向数据缓冲区

2. 将命令写入 NVMe SQ(Submission Queue)的尾指针位置

3. 更新 SQ 尾门铃寄存器(NVMe 寄存器写入),通知控制器有新命令待处理

4. NVMe 控制器通过 PCIe DMA 读取 SQ 中的命令并由硬件处理

4.5 完成中断阶段:hardirq 到 softirq 的路径

NVMe 控制器通过 MSI-X 中断通知 IO 完成。blk-mq 的中断处理涉及两种模式:

传统中断模式:NVMe 完成队列(CQ)产生 MSI-X 中断→硬中断 handler(nvme_irq)读取 CQ 完成项,释放标签,调用 request 的 end_io 回调→软中断处理文件系统层的 IO 完成(unlock page 等)。

轮询模式(Polling):对于极低延迟要求的应用(如 SPDK、高性能数据库),可配置 NVMe 队列使用轮询模式,在指定的 CPU 核心上轮询 CQ 完成项,绕过中断处理的上下文切换开销,可将延迟从数十微秒降至微秒级。

五、标签分配机制:并发性能的基石

5.1 sbitmap 可扩展位图

blk-mq 使用 struct sbitmap 实现标签的高效分配与释放。sbitmap 相比传统 bitmap 的优势在于:它使用两级结构——per-CPU 缓存位图(第一级)+ 全局位图(第二级)。分配时先尝试 per-CPU 缓存,成功无需加锁;失败时再同步到全局位图,避免每次分配都触发全局原子操作。

sbitmap 的设计使得在百万级 IOPS 的场景下,标签分配的开销被分摊到各个 CPU,实现了近乎线性的扩展能力。

5.2 静态请求与动态分配

blk-mq 的每个标签预分配一个 struct request 对象(保存在 static_rqs 数组),运行时直接使用,无需在 IO 路径上执行内存分配。这是高性能 IO 路径的关键优化——kmalloc/kmem_cache_alloc 的延迟在百万 IOPS 场景下是不可接受的。

reserve tags 机制确保即使所有标签都被占用,系统仍有能力处理 core IO 完成(如 flush 请求、请求队列恢复操作),避免死锁。

5.3 标签分配在 NUMA 架构下的优化

在多 NUMA 节点系统中,blk-mq 的标签分配追踪当前 CPU 所在的 NUMA 节点,优先在本地节点分配资源。这确保了 request 对象的内存位于提交 IO 的 CPU 所在节点,避免跨 NVMA 节点访问带来的延迟惩罚。

六、NVMe 驱动深度集成

6.1 NVMe 多队列模型

NVMe 规范原生支持多队列架构:一个 Admin 队列(用于控制器管理命令) + 最多 65535 个 IO 队列(IO Submission/Completion Queue pairs)。每个 IO 队列独立运行,可绑定到不同的 CPU 核心,实现完全的硬件级并行。

NVMe 驱动在初始化时通过 nvme_alloc_tagset() 向 blk-mq 注册 tag set,设置硬件队列数量(通常等于 min(在线 CPU 数量, 设备支持的最大队列数))和队列深度(通常 256 或 1024)。

6.2 io_queue 到 hctx 的映射

NVMe 驱动中,struct nvme_queue 可以直接嵌入到 blk_mq_hw_ctx 的 driver_data 中。每个 NVMe 硬件队列(nvme_queue)包含:

- SQ(Submission Queue):命令提交环形缓冲区,驱动写入、硬件读取

- CQ(Completion Queue):完成通知环形缓冲区,硬件写入、驱动读取

- 门铃寄存器(Doorbell):MMIO 寄存器,用于驱动通知硬件有新命令

- MSI-X 中断向量:每个 IO 队列独立的中断向量,实现 CPU 亲和中断分发

6.3 中断亲和性与队列映射

blk-mq 通过 blk_mq_map_queues() 和 blk_mq_hctx_to_phys() 设置从 hctx 到 CPU 的映射。NVMe 驱动通常在 probe 阶段通过 pci_alloc_irq_vectors() 分配 MSI-X 中断,然后通过 irq_set_affinity_hint() 将中断绑定到特定 CPU。

关键配置:对于支持 64 硬件队列的 NVMe SSD,在 32 核服务器上通常将硬件队列数设为 NUMA 本地核数;对于大容量多盘场景,可以使用 nvme.io_queue_count 参数手动指定。

七、blk-mq 下的 IO 调度器

7.1 mq-deadline:延时保障型调度器

mq-deadline 是最简化的 blk-mq 调度器,按请求的扇区号排序并设置过期时间。它维护两个队列:按扇区排序的 fifo_list 和按时间排序的 expiration_list。读请求默认 500ms 过期,写请求 50 秒过期,过期请求强制下发。

适用场景:延迟敏感型应用(数据库、SSD 缓存层),需要确保请求不会在队列中饿死。

7.2 kyber:目标延迟调度器

kyber 调度器通过动态调整并发 token 数量,使 IO 延迟逼近用户设定的目标值(默认读 2ms,写 10ms)。它维护三个调度队列(同步读、同步写、其他)和两个并发限制(sync,r/w)。

当实际延迟超过目标值时,kyber 减少 token 数量(限制并发);当延迟低于目标值时,增加 token 数量(允许更多并发)。

7.3 bfq:带宽公平调度器

BFQ(Budget Fair Queueing)是通过预算(budget)机制为不同进程分配 IO 带宽的比例公平调度器。它为每个进程维护独立的队列,按时间片轮转下发请求,并为低延迟请求(如交互式应用的同步 IO)设置高优先级。

BFQ 的优势在于桌面/交互式场景下能显著降低 IO 争用导致的用户体验下降,但其 CPU 开销高于 mq-deadline 和 kyber。

7.4 none:无调度直传模式

对于 NVMe 等具备硬件级并行能力和自主调度逻辑的设备,"none" 调度器可以完全绕过软件调度层,请求直接以 bypass 路径进入硬件队列。这进一步减少了软件开销,是最速路径。

适用场景:企业级 NVMe SSD、SPDK 类用户态驱动环境。

八、内核源码关键函数映射

以下是 blk-mq 核心函数在内核源码中的位置与功能映射:

初始化路径:blk_mq_init_queue()(block/blk-mq.c)→ 初始化默认的请求队列;blk_mq_alloc_tag_set() → 分配标签集合;blk_mq_init_sq_queue() / blk_mq_init_rw_queues() → 初始化读写队列。

请求提交路径:submit_bio()(block/blk-core.c)→ blk_mq_submit_bio()(block/blk-mq.c)→ blk_mq_try_issue_list_directly() / __blk_mq_sched_bio_merge()。

调度路径:__blk_mq_run_hw_queue() → blk_mq_sched_dispatch_requests() → 各调度器的 dispatch 函数(如 kyber_dispatch_request())。

中断完成路径:nvme_pci_complete_rq()(drivers/host/pci.c)→ blk_mq_free_request() → blk_mq_put_tag()。

debugfs/sysfs 入口:/sys/block/nvme0n1/queue/ 下的 nr_requests、scheduler、io_poll 等参数,通过 blk_mq_sysfs_register() 注册。

九、生产环境调优实战

9.1 硬件队列深度调优

通过 /sys/block/nvme0n1/queue/nr_requests 可以调整每个硬件队列的深度。默认值通常为 1024(内核 5.x+)或 256(4.x 系列)。增大深度可以提高突发 IO 的吞吐能力,但会增加内存占用(每个 request 对象约占用标签对应的内核内存)。

对于高并发 OLTP 数据库,建议设置 nr_requests 2048-4096,充分利用 NVMe 硬件队列;对于低延迟交互应用,建议 512-1024,避免队列过深导致排队延迟。

9.2 中断与 NUMA 亲和性

使用 irqbalance 或手动设置 /proc/irq/XX/smp_affinity_list,将 NVMe 中断绑定到与队列相同的 NUMA 节点 CPU。对于双 NVMe SSD 双路服务器,最佳配置是:SSD1 队列绑定 NUMA0 CPU,SSD2 队列绑定 NUMA1 CPU,避免跨节点中断处理。

关闭 irqbalance 在中高负载场景下通常是最佳实践——irqbalance 的动态调整可能破坏已优化好的中断绑定。

9.3 IO 调度器选择

echo "mq-deadline" > /sys/block/nvme0n1/queue/scheduler — 通用数据库负载

echo "none" > /sys/block/nvme0n1/queue/scheduler — NVMe 纯性能优先

echo "kyber" > /sys/block/nvme0n1/queue/scheduler — 延迟目标可控

echo "bfq" > /sys/block/nvme0n1/queue/scheduler — 带宽公平分配

内核 5.x+ NVMe 设备通常默认使用 "none" 调度器。如果在高负载下遇到延迟尖峰,尝试切换到 "mq-deadline" 是否更稳定。

9.4 写回缓存与 volatile write cache

对于配备掉电保护电容(PLP,Power Loss Protection)的企业级 SSD,建议启用 volatile write cache 以降低写延迟:echo 0 > /sys/block/nvme0n1/queue/write_cache。无 PLP 的消费级 SSD 应禁用写缓存以保证数据安全。

NVMe 也支持 VWC(Volatile Write Cache)控制命令:nvme set-feature -f 0x06 -v 1 /dev/nvme0。

9.5 多队列映射可视化

通过 /sys/block/nvme0n1/mq/ 目录可以查看 blk-mq 的队列拓扑。每个 XX 子目录代表一个硬件队列(hctx0, hctx1, ...),其中 cpu_list 文件展示了该 hctx 绑定的 CPU 列表。

示例输出:/sys/block/nvme0n1/mq/0/cpu_list → "0-3"(该硬件队列绑定 CPU 0-3)

9.6 blktrace + ftrace 组合分析

使用 blktrace 可以追踪 IO 从下发到完成的完整路径,配合 ftrace 的函数图分析可精确定位延迟瓶颈:

blktrace -d /dev/nvme0n1 -o trace.blk
blkparse -i trace.blk | head -100

关注 D(Dispatch)到 C(Complete)之间的时间差。如果 D2C 时间异常长(>10ms),说明硬件或驱动层存在瓶颈;如果 Q2Q(提交到调度)时间过长,说明 blk-mq 层有排队阻塞。

十、blk-mq 最佳实践总结

1. 队列深度与硬件对齐:确保 nr_requests 与设备支持的队列深度匹配。NVMe 设备通常在 identfy controller 数据中报告最大队列深度(0-based),可通过 nvme id-ctrl /dev/nvme0 查看。

2. NUMA 感知配置:在 NUMA 系统中,确保 blk-mq 硬件队列与 IO 来源 CPU 在同一 NUMA 节点。使用 numactl --cpunodebind=X --membind=Y 绑定 IO 进程,配合对应节点的 NVMe 设备。

3. 中断隔离:将 IO 中断绑定到专用 CPU 核心,避免与业务进程争抢。可以使用 tuned 配置集的 cpu-partitioning 或手动修改 smp_affinity。

4. 避免 IO 合并器争用:在 plug 机制下,批量 IO 会被合并后一次性派发。合理调整 nr_requests 和 batch IO 的 plug 时长(通过 blk-mq submit_bio 的 plug 逻辑),平衡合并收益与延迟。

5. 升级内核以获取最新 blk-mq 优化:Linux 5.x 系列持续对 blk-mq 进行优化,包括 shared tagset 支持(多 namespace 共享标签)、io_poll 加速(轮询路径优化)、blk-iocost IO 控制等。

6. 监控关键指标:通过 /proc/diskstats 和 /sys/block/*/stat 监控 IOPS、await(平均延迟)、avgqu-sz(平均队列长度)。高 await + 低 IOPS 通常意味着队列深度不足或调度器不合适。

blk-mq 不仅是 Linux IO 栈的一次架构重构,更是系统软件跟上硬件演进速度的典范。理解和掌握 blk-mq 的多队列模型,是在现代存储基础设施上进行性能优化的基础必备技能。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.349946s