Linux内核学习轨迹第七部: 多队列块层blk-mq深度拆解(第四节)
4. 多队列块层blk-mq深度拆解
Linux 5.0内核开始,blk-mq(Multi-Queue Block IO Queueing Mechanism,多队列块IO排队机制)已成为块层的默认实现,Linux 6.0内核完全移除了传统单队列块层框架,所有块设备驱动必须基于blk-mq开发。
blk-mq是为适配多核CPU、NVMe SSD等多队列硬件量身设计的,彻底解决了传统单队列的全局自旋锁竞争瓶颈,实现了IO请求的全链路并行处理,把NVMe设备的IOPS和延迟性能提升了一个数量级,是现代Linux IO栈的核心基石。
4.1 传统单队列块层的致命痛点
传统单队列块层的核心设计是单个全局IO请求队列+全局自旋锁,所有CPU核心发起的IO请求都要进入同一个队列,必须拿到全局锁才能操作队列,在多核场景下存在三个致命缺陷,完全无法适配现代硬件:
- 严重的全局锁竞争:多核CPU同时发起IO时,全局自旋锁会导致大量CPU时间浪费在锁等待上,CPU核心数越多,锁竞争越严重。实测8核以上CPU场景下,单队列的锁开销会超过30%,16核以上场景锁开销甚至超过50%,CPU算力被严重浪费。
- 无法适配多队列硬件:现代NVMe SSD通常支持64/128个独立硬件提交队列,每个队列可以直接和一个CPU核心交互,完全并行处理IO。单队列框架无法发挥硬件的多队列并行能力,硬件性能被严重浪费,NVMe设备的百万级IOPS能力被锁竞争限制到十万级。
- NUMA架构性能损耗:多NUMA节点的服务器上,跨节点的内存访问、全局锁竞争会导致IO延迟急剧增加。单队列框架无法做NUMA亲和性优化,跨节点的IO访问延迟比同节点高2倍以上。
blk-mq的核心设计目标就是彻底解决这三个问题,通过「Per-CPU软件队列 + 多硬件队列」的全并行架构,消除全局锁,最大化发挥多核CPU和多队列硬件的性能。
4.2 blk-mq的核心两层架构
blk-mq的架构分为上下两层,彻底解耦了IO的调度分发与硬件提交,中间通过IO调度器连接,完整架构如下:
┌─────────────────────────────────────────────────────────────────┐ │ 通用块层:BIO提交、合并、封装为request请求 │ └───────────────────────────────┬─────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 【软件队列层】Per-CPU软件队列 │ CPU0 → blk_mq_ctx[0] CPU1 → blk_mq_ctx[1] ... CPUn → blk_mq_ctx[n] │ 每个CPU核心对应一个独立软件队列,独立自旋锁,多核完全无锁竞争 └───────────────────────────────┬─────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ IO调度器层:mq-deadline/kyber/bfq/none,对request请求调度优化 │ └───────────────────────────────┬─────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 【硬件队列层】硬件提交队列 │ 硬件队列0 → blk_mq_hw_ctx[0] 硬件队列1 → blk_mq_hw_ctx[1] ... │ 每个硬件队列对应存储设备的一个物理提交队列,可绑定CPU核心 └───────────────────────────────┬─────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 块设备驱动:NVMe/ahci/RAID卡驱动,把IO请求下发给物理硬件 │ └─────────────────────────────────────────────────────────────────┘各层核心职责详解
1.软件队列层(struct blk_mq_ctx):
每个CPU核心对应一个独立的软件队列,每个队列有自己的自旋锁,完全消除了多核之间的全局锁竞争。发起IO的CPU核心只会操作自己对应的软件队列,不会和其他CPU核心产生锁冲突,多核场景下的并行能力得到了质的提升。
软件队列的核心职责:缓存当前CPU核心发起的IO请求,做批量合并、统计,然后提交给IO调度器。
2.IO调度器层:
blk-mq框架原生支持可插拔的IO调度器,调度器可以选择在软件队列层(每个CPU独立调度)或硬件队列层(全局调度)执行,对IO请求做排序、合并、优先级调度、公平性控制,适配不同的硬件和业务场景。
3.硬件队列层(struct blk_mq_hw_ctx):
每个硬件队列对应存储设备的一个物理提交队列,现代NVMe SSD通常有64/128个硬件队列,每个队列可以独立和PCIe总线交互,完全并行处理IO。硬件队列可以和指定的CPU核心绑定,实现中断亲和性,避免跨CPU/跨NUMA节点的访问开销,最大化降低IO延迟。
硬件队列的核心职责:接收调度器分发的IO请求,下发给块设备驱动,处理硬件中断,完成IO闭环。
4.3 blk-mq核心数据结构拆解
本章节基于Linux 6.6 LTS内核源码(定义在include/linux/blk-mq.h),拆解blk-mq最核心的4个数据结构,剔除调试、统计类非核心字段,聚焦生产环境与原理理解的核心内容。
4.3.1 驱动操作函数集:struct blk_mq_ops
这是块设备驱动必须实现的blk-mq标准接口,是blk-mq层和驱动之间的桥梁,驱动通过实现这套接口,接入blk-mq框架,核心函数如下:
struct blk_mq_ops { // 【核心入口】驱动处理IO请求的入口函数,blk-mq把request请求下发给驱动 blk_status_t (*queue_rq)(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd); // IO完成回调函数,硬件IO完成后,驱动调用blk_mq_complete_request()触发这个回调 void (*complete)(struct request *rq, blk_status_t error); // 初始化硬件队列上下文,设备初始化时调用 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_ctx)(struct blk_mq_ctx *ctx, unsigned int ctx_idx); // 清理软件队列上下文 void (*exit_ctx)(struct blk_mq_ctx *ctx, unsigned int ctx_idx); // 检查硬件队列是否繁忙,用于调度分发 int (*busy)(struct blk_mq_hw_ctx *hctx); // 设备暂停时的处理函数 void (*quiesce)(struct request_queue *q); // 设备恢复时的处理函数 void (*unquiesce)(struct request_queue *q); };核心函数说明:
- queue_rq是驱动最核心的函数,blk-mq层把准备好的request请求通过这个函数下发给驱动,驱动把请求转换为硬件指令,下发给物理设备;
- complete是IO完成的回调函数,驱动在硬件IO完成后,通过这个函数通知blk-mq层,blk-mq层再通知上层通用块层和文件系统。
4.3.2 IO请求队列:struct request_queue
struct request_queue是块设备IO请求的核心管理结构,每个gendisk对应一个request_queue,是整个块设备IO栈的核心枢纽,包含了blk-mq的所有配置、队列、调度器、驱动操作函数集,核心字段如下:
struct request_queue { // blk-mq驱动操作函数集 const struct blk_mq_ops *mq_ops; // 硬件队列上下文数组,每个元素对应一个硬件队列 struct blk_mq_hw_ctx **queue_hw_ctx; // 硬件队列的总数量 unsigned int nr_hw_queues; // 软件队列上下文数组,每个元素对应一个CPU核心的软件队列 struct blk_mq_ctx __percpu *queue_ctx; // 软件队列的总数量,等于CPU核心数 unsigned int nr_queues; // 块设备的IO调度器 struct elevator_type *elevator; // 调度器的私有数据 void *elevator_data; // 所属的gendisk实例 struct gendisk *disk; // 队列的引用计数 refcount_t refs; // 队列的读写信号量 struct rw_semaphore rq_rwsem; // 设备的IO参数:最大IO大小、逻辑块大小、物理块大小 unsigned int max_sectors; unsigned int logical_block_size; unsigned int physical_block_size; // 队列深度:每个硬件队列的最大请求数 unsigned int nr_requests; // 队列的标志位:只读、非阻塞、可插拔等 unsigned long queue_flags; // 队列的冻结计数,用于设备热插拔、暂停IO int mq_freeze_depth; // 所属的NUMA节点 int numa_node; // 驱动私有数据 void *queuedata; };4.3.3 硬件队列上下文:struct blk_mq_hw_ctx
struct blk_mq_hw_ctx是blk-mq对硬件提交队列的抽象,每个硬件队列对应一个实例,管理该硬件队列的所有状态、调度器、CPU亲和性、统计信息,核心字段如下:
struct blk_mq_hw_ctx { // 所属的request_queue struct request_queue *queue; // 硬件队列的编号 unsigned int hctx_idx; // 该硬件队列对应的驱动私有数据 void *driver_data; // 该硬件队列的调度器实例 struct elevator_queue *sched; // 待下发给驱动的请求链表 struct list_head dispatch; // 保护硬件队列的自旋锁 spinlock_t lock; // 该硬件队列绑定的CPU核心掩码 const struct cpumask *cpumask; // 该硬件队列所属的NUMA节点 int numa_node; // 该硬件队列的中断处理函数 irq_handler_t handler; // 硬件队列的队列深度 unsigned int queue_depth; // 硬件队列的繁忙标记 atomic_t busy; // 该硬件队列的IO统计信息 struct blk_mq_stats stats; // 延迟下发的工作队列 struct delayed_work run_work; // 超时处理工作队列 struct work_struct timeout_work; };4.3.4 软件队列上下文:struct blk_mq_ctx
struct blk_mq_ctx是Per-CPU软件队列的抽象,每个CPU核心对应一个实例,管理该CPU核心发起的所有IO请求,完全独立的自旋锁,多核之间无锁竞争,核心字段如下:
struct blk_mq_ctx { // 所属的request_queue struct request_queue *queue; // 所属的CPU核心编号 unsigned int cpu; // 所属的NUMA节点 int numa_node; // 该CPU核心的IO请求链表,待提交给调度器 struct list_head rq_list; // 保护该软件队列的自旋锁 spinlock_t lock; // 该软件队列对应的硬件队列,实现软件队列到硬件队列的映射 struct blk_mq_hw_ctx *hctx; // 该CPU核心的IO统计信息 struct blk_mq_ctx_stats stats; // 引用计数 refcount_t refs; // 随机数种子,用于IO调度 unsigned int rand; };4.4 blk-mq IO请求的完整执行流程
衔接第3章的BIO生命周期,BIO被封装为request请求后,进入blk-mq层的完整执行流程分为6个阶段,全链路无全局锁,实现完全并行处理:
1. request请求入队Per-CPU软件队列 → 2. IO调度器处理 → 3. 软件队列到硬件队列的映射分发 → 4. 硬件队列下发给驱动 → 5. 驱动下发硬件执行 → 6. IO完成与请求释放阶段1:request请求入队Per-CPU软件队列
- 通用块层把BIO封装为struct request请求,根据发起IO的CPU核心,找到对应的Per-CPU软件队列struct blk_mq_ctx;
- 拿到该软件队列的独立自旋锁,把request请求加入到软件队列的rq_list链表尾部;
- 释放自旋锁,整个过程仅操作当前CPU的软件队列,和其他CPU核心完全无锁竞争。
阶段2:IO调度器处理
- blk-mq层调用对应IO调度器的入队函数,把request请求加入到调度器的队列中;
- 调度器对request请求进行排序、合并、优先级调度、延迟处理,优化IO访问模式;
- 调度器把处理完成的request请求,加入到待分发的调度队列中,等待下发到硬件队列。
阶段3:软件队列到硬件队列的映射分发
blk-mq通过软件队列到硬件队列的映射机制,把软件队列的request请求分发到对应的硬件队列,核心映射规则:
- 默认映射规则:每个CPU核心的软件队列,固定映射到一个硬件队列,实现CPU核心和硬件队列的绑定,避免跨CPU/跨NUMA节点的访问;
- NUMA优化映射:多NUMA节点场景下,节点内的CPU核心的软件队列,仅映射到同节点的PCIe设备对应的硬件队列,完全避免跨NUMA节点的IO访问,大幅降低延迟;
- 负载均衡映射:如果某个硬件队列过于繁忙,blk-mq会自动把请求分发到空闲的硬件队列,实现负载均衡,避免单个硬件队列成为瓶颈。
映射完成后,request请求被加入到对应硬件队列的dispatch分发链表中,等待下发给驱动。
阶段4:硬件队列下发给驱动
- 拿到硬件队列的自旋锁,遍历dispatch链表中的request请求;
- 调用驱动实现的queue_rq函数,把request请求逐个下发给驱动;
- 驱动返回处理结果:
- 成功下发:请求从dispatch链表中移除,等待硬件执行完成;
- 设备繁忙:请求保留在dispatch链表中,等待后续重试;
- 释放自旋锁,完成请求下发。
阶段5:驱动下发硬件执行
- 驱动解析request请求中的所有BIO,获取IO的起始扇区、大小、内存缓冲区、操作类型;
- 把request请求转换为硬件可识别的指令,比如NVMe的SQE提交队列项、SCSI的CDB命令;
- 把指令写入硬件的提交队列,通过PCIe总线下发给物理存储设备;
- 硬件执行IO操作,完成后触发MSI-X硬件中断,通知CPU IO完成。
阶段6:IO完成与请求释放
- 驱动在中断处理函数中,解析硬件的完成队列,检查IO执行结果,设置request的状态;
- 调用blk_mq_complete_request()函数,触发blk-mq层的IO完成处理;
- blk-mq层调用驱动的complete回调函数,然后调用BIO的bi_end_io回调函数,通知上层通用块层和文件系统IO完成;
- 释放request请求的引用计数,当引用计数为0时,释放request结构体和对应的BIO资源,IO请求的生命周期结束。
4.5 blk-mq的核心设计优势
- 全链路无锁并行设计:Per-CPU软件队列+独立硬件队列的设计,彻底消除了传统单队列的全局锁竞争,多核CPU场景下,IO性能随CPU核心数线性扩展,16核以上场景性能提升5倍以上。
- 原生适配多队列硬件:完全匹配NVMe SSD的多队列硬件设计,每个硬件队列可以独立和CPU核心交互,完全并行处理IO,把NVMe设备的百万级IOPS能力完全释放出来。
- NUMA架构原生优化:支持NUMA节点亲和性,同节点的CPU核心、硬件队列、PCIe设备绑定,完全避免跨NUMA节点的内存访问和锁竞争,多节点服务器场景下,IO延迟降低50%以上。
- 灵活可插拔的调度器框架:支持在软件队列层、硬件队列层灵活插入IO调度器,适配不同的硬件和业务场景,比如NVMe SSD用none无调度器,机械硬盘用mq-deadline调度器,桌面场景用bfq调度器。
- 低延迟高IOPS:减少了上下文切换和锁等待的开销,IO平均延迟降低30%以上,99.9%长尾延迟降低10倍以上,完美适配低延迟高并发的业务场景,比如数据库、高频交易、分布式存储。
- 热插拔与高可用支持:原生支持设备热插拔、队列动态调整、硬件故障处理,适配企业级存储的高可用需求。
4.6 工程实践与调优指南
4.6.1 核心调优参数
所有调优参数都在/sys/block/[设备名]/queue/目录下,对应struct request_queue的核心字段:
参数路径 | 核心作用 | 调优建议 |
nr_requests | 每个硬件队列的最大队列深度 | NVMe SSD建议设置为1024~2048,机械硬盘建议设置为256~512,过大会导致延迟升高,过小会导致IOPS上不去 |
hw_sector_size | 硬件扇区大小 | 只读,不可修改,4K对齐的核心依据 |
max_sectors_kb | 单个IO的最大大小 | NVMe SSD建议设置为2048,机械硬盘建议设置为1024,提升顺序IO吞吐量 |
read_ahead_kb | 预读大小 | 机械硬盘顺序读场景建议设置为1024,NVMe SSD随机读场景建议设置为0,关闭预读 |
scheduler | IO调度器 | NVMe SSD建议设置为 none / kyber ,机械硬盘建议设置为 mq-deadline ,桌面/安卓场景建议设置为 bfq |
rq_affinity | 中断亲和性 | 设置为2,强制IO完成中断在发起IO的CPU核心上执行,降低延迟,提升缓存命中率 |
4.6.2 生产环境最佳实践
1.NVMe SSD多队列优化:
- 开启NVMe设备的多队列支持,确保硬件队列数量等于CPU核心数,实现每个CPU核心对应一个独立硬件队列;
- 关闭irqbalance服务,手动绑定每个硬件队列的中断到对应的CPU核心,避免中断在CPU核心之间漂移,导致缓存失效,延迟升高;
- 设置rq_affinity=2,保证IO发起和完成都在同一个CPU核心上,最大化L1/L2缓存命中率,降低延迟。
2.NUMA架构优化:
- 多NUMA节点服务器上,把存储设备的PCIe控制器绑定到对应的NUMA节点,仅该节点的CPU核心可以访问对应的硬件队列,完全避免跨节点IO访问;
- 业务进程绑定到同NUMA节点的CPU核心,确保进程发起的IO都在同节点内处理,跨节点IO延迟会升高2倍以上。
3.队列深度调优:
- 随机IO场景(数据库、KV存储):队列深度不宜过大,建议1024以内,过大会导致IO排队延迟升高,99.9%长尾延迟恶化;
- 顺序IO场景(大数据、视频存储):队列深度可以调大到2048,提升IO合并的概率,最大化吞吐量;
- 低延迟场景(高频交易):队列深度建议设置为256~512,减少排队延迟,保证低延迟。
4.IO调度器选型:
- NVMe SSD:绝大多数场景用none无调度器,因为NVMe设备内部有自己的FTL调度算法,内核调度器会带来额外开销;低延迟随机读写场景用kyber调度器,控制IO延迟;
- 机械硬盘HDD:必须用mq-deadline调度器,通过排序减少磁头寻道开销,提升吞吐量;
- 桌面/嵌入式场景:用bfq调度器,保证前台应用的IO优先级,提升交互体验。
4.6.3 常见避坑指南
- irqbalance服务的坑:irqbalance会自动调整硬件中断的CPU亲和性,导致中断在CPU核心之间漂移,缓存频繁失效,IO延迟升高。NVMe SSD场景建议关闭irqbalance,手动绑定中断到固定CPU核心。
- 硬件队列数量设置不当:硬件队列数量超过CPU核心数,会导致多个CPU核心竞争同一个硬件队列的锁,反而带来锁竞争开销。最佳实践是硬件队列数量等于CPU核心数,或者等于NUMA节点内的CPU核心数。
- 跨NUMA节点IO访问:多NUMA节点服务器上,进程在节点0的CPU核心上运行,却访问节点1的PCIe存储设备,会导致跨节点内存访问,IO延迟升高2倍以上。必须保证进程、CPU核心、存储设备在同一个NUMA节点内。
- 队列深度过大的坑:很多人误以为队列深度越大,IOPS越高,实际上过大的队列深度会导致IO在队列中排队,延迟急剧升高,尤其是99.9%长尾延迟会恶化10倍以上。必须根据业务场景合理设置队列深度,不是越大越好。
- NVMe SSD用传统调度器的坑:很多老教程建议NVMe SSD用deadline调度器,实际上现代NVMe SSD用none无调度器性能最好,因为设备内部的FTL已经做了IO调度,内核调度器只会带来额外的CPU开销,没有任何收益。
