Linux Block Layer 多队列 blk-mq 架构与 IO 调度深度实战
一、从单队列到多队列:存储 IO 架构的演进
Linux 存储子系统经历了一场深刻的架构演进。在内核 3.13 版本(2014年 1 月)之前,Block Layer 采用单一请求队列(single-queue)设计。在那个时代, SSD 尚未普及,机械硬盘的 IOPS 通常在 200 左右,单队列设计足以应对。然而随着 NVMe SSD 的兴起,单队列模型迅速成为性能瓶颈。
单队列模型的核心问题在于:
- 锁竞争严重:所有 CPU 核心共享一个请求队列,spinlock 成为瓶颈
- 缓存局部性差:请求在多个 CPU 之间跳跃,L1/L2 缓存命中率下降
- 无法水平扩展:即使硬件支持 128 个队列,软件也只能使用 1 个
- NUMA 感知缺失:跨 NUMA 节点的内存访问延迟和带宽限制
blk-mq(Multi-Queue Block Layer)的出现彻底解决了这些问题。截至 2026 年的今天,单队列模式已完全废弃,blk-mq 是唯一选择。NVMe 设备每个 CPU 核心可以拥有独立的硬件提交队列(Submission Queue),延迟从毫秒级降至微秒级。
二、blk-mq 核心数据结构与架构
理解 blk-mq 需要先掌握其核心数据结构,这些结构定义在 include/linux/blk-mq.h 和 block/blk-mq.c 中。
2.1 struct blk_mq_tag_set
这是 blk-mq 的"配置中心",每个块设备仅需一个实例,连接硬件队列与软件队列:
struct blk_mq_tag_set {
const struct blk_mq_ops *ops; // 硬件队列操作函数集
struct blk_mq_queue_map map[]; // 各类型队列的 CPU 映射
unsigned int nr_maps; // 队列映射数量(通常为 1,多分块设备可设为 2+)
unsigned int nr_hw_queues; // 硬件队列数量
unsigned int queue_depth; // 每个硬件队列的深度
unsigned int cmd_size; // 额外命令大小
int numa_node; // NUMA 节点
void *driver_data; // 驱动私有数据
struct blk_mq_tags *tags[]; // 标签集合数组
};
关键字段之间的关系值得深入理解:
- nr_hw_queues:通常等于或小于 CPU 核心数。NVMe 规范允许最多 64K 队列,但实际硬件通常支持 8-128 个队列
- queue_depth:NVMe 通常为 1024,SCSI 为 32-256。队列深度 = nr_hw_queues × queue_depth 影响总并发能力
- map[]:映射表决定哪些 CPU 共享哪个硬件队列
2.2 struct blk_mq_hw_ctx
每个硬件队列对应一个 hw_ctx,是 blk-mq 的调度核心:
struct blk_mq_hw_ctx {
struct {
spinlock_t dispatch_lock; // 出队锁
struct list_head dispatch; // 待处理的请求(优先级队列)
unsigned long state; // 状态位图(BLK_MQ_S_*)
} ____cacheline_aligned_in_smp;
struct sbitmap ctx_map; // 已分配标签的位图
struct blk_mq_ctx **ctxs; // 软件上下文数组
struct blk_mq_ctx *dispatch_from; // 当前出队的软件上下文
unsigned int numa_node; // 绑定的 NUMA 节点
unsigned int cpu; // 绑定的 CPU
struct hlist_node cpuhp_online; // CPU 热插拔处理
struct hlist_node cpuhp_dead;
};
注意 ____cacheline_aligned_in_smp 宏,这是 SMP 性能关键:dispatch lock 被强制对齐到独立缓存行,避免 false sharing。
2.3 struct request 与 struct blk_mq_ctx
struct request 是 IO 请求的描述符,每个请求拥有一个 tag(标签索引)。struct blk_mq_ctx 是每个 CPU 的软件队列上下文:
struct blk_mq_ctx {
struct {
spinlock_t lock; // 入队锁
struct list_head rq_lists[]; // 按优先级分的请求列表
} ____cacheline_aligned_in_smp;
unsigned int cpu; // CPU 编号
struct blk_mq_hw_ctx *hctx; // 关联的硬件队列
struct request_queue *queue; // 关联的请求队列
};
整个请求生命周期涉及的数据结构链:request_queue → blk_mq_tag_set → blk_mq_hw_ctx → request → bio。
三、 blk-mq 请求处理全流程
3.1 入队路径(Submit Path)
用户发起写操作后,数据经过 VFS → Page Cache → Block Layer,以下是 blk-mq 入队的核心路径:
blk_mq_submit_bio(struct bio *bio)
│
├─ blk_mq_bio_to_request() // bio 转 request
│
├─ blk_mq_get_tag() // 获取空闲 TAG
│ └─ sbitmap_get() // 位图分配(无锁,原子操作)
│
├─ blk_mq_try_issue_directly() // 尝试直接出队
│ └─ __blk_mq_try_issue_directly()
│ └─ blk_mq_hctx_has_requests()
│ └─ blk_mq_request_bypass_insert()
│
└─ blk_mq_queue_io() // 加入软件队列
└─ blk_mq_run_hw_queue() // 触发硬件队列处理
tag 分配是性能关键路径。blk-mq 使用 sbitmap(scalable bitmap)实现无锁分配,相比传统位图有显著的扩展性优势:
struct sbitmap { unsigned int depth; // 位图深度 struct sbitmap_word *map; // 每 CPU 缓存的位图数组 };
"scalable"体现在:sbitmap 使用两层结构——每个 CPU 维护本地缓存(percpu_caching),分配时优先从本地缓存获取,耗尽时再从全局池索取取一批(通常为 8 个)。这种批量全局获取 + 本地缓存分配的机制大幅减少了缓存一致性流量。
3.2 出队路径(Dispatch Path)
硬件完成中断触发后,进入软中断(软中断) Bottom Half 完成请求处理:
blk_mq_end_request(struct request *rq)
│
├─ blk_update_request() // 更新已完成的字节数
│
├─ blk_mq_requeue_work() // 失败时重新入队
│
└─ __blk_mq_end_request() // 标记完成
└─ blk_free_request() // 释放 tag
完成路径的关键优化是在 blk-mq 中引入了 blk_mq_complete_request 的批量完成模式:当一个硬件队列的 IO 完成时,通过 blk-mq 的 ipin(Inter-Processor Interrupt,处理器间中断)机制将远程 CPU 的请求拉到本地完成,减少远程缓存访问。
四、blk-mq 调度算法深度对比
blk-mq 设计了统一的调度器框架,当前内核提供四种(不含实验性调度器):
| 调度器 | 全称 | 适用场景 | 公平性 | 延迟控制 |
|---|---|---|---|---|
| none | No-op | NVMe/低延迟 SSD | 无 | 极佳(直通) |
| mq-deadline | Multi-Queue Deadline | 数据库/混合负载 | 读优先 | 有(批处理) |
| bfq | Budget Fair Queueing | 桌面交互/实时应用 | 强(按进程分配带宽) | 保证吞吐(fairness) |
| kyber | Kyber Scheduler | 多租户/延迟敏感服务 | 无 | 有(目标延迟调节) |
4.1 mq-deadline 调度器
mq-deadline 是最广泛应用的多队列调度器,继承自经典单队列 deadline 调度器。核心思想是防止写操作饿死读操作。
排序队列数据结构:
struct deadline_data {
struct sort_list fifo_batch; // 按 LBA 排序的合并队列(FIFO)
struct rb_root sort_rqs[2]; // 按递交时间排序的红黑树(读/写分离)
struct list_head fifo_list[2]; // FIFO 过期队列(读/写各一个)
unsigned int fifo_time[2]; // 读取/写入的过期时间阈值
unsigned int front_merges; // 前向合并的 IO 数量
};
读请求默认 500ms 过期,写请求默认 5s 过期。这种不对称设计源于读操作通常是同步的(用户等待读返回),而写操作可以异步执行。
内核参数调优:
/sys/block/sda/queue/iosched/fifo_batch # 每次批处理的请求数(默认16)
/sys/block/sda/queue/iosched/writes_starved # 连续处理读请求后才处理写(默认2)
/sys/block/sda/queue/iosched/read_expire # 读取过期时间ms(默认500)
/sys/block/sda/queue/iosched/write_expire # 写入过期时间ms(默认5000)
/sys/block/sda/queue/iosched/front_merges # 是否尝试前向合并(默认1)
4.2 BFQ(Budget Fair Queueing)调度器
BFQ 是当前 Linux 内核中最复杂的 IO 调度器,由 Paolo Valente 在罗马第三大学开发。其创新在于引入"budget"概念——每个进程被分配一定的 IO 预算(以扇区数衡量),用完后再轮转。
BFQ 的权重模型:
进程权重 = (进程的 I/O 权重 / 所有繁忙进程总权重) × 设备带宽
这意味着:权重 800 的进程获得的带宽是权重 200 进程的 4 倍。权重通过 ioprio_get() 获取,由 ionice 设置。
BFQ 的内部数据结构(block/bfq.h):
struct bfq_data {
struct rb_root busy_trees[2]; // 按扇区位置组织的红黑树
struct bfq_entity *active_entity; // 当前活跃实体
struct bfq_queue *in_service_queue; // 当前服务中的队列
unsigned long bfq_budgets_assigned; // 已分配的 budget 总数
/* 权重支持 */
struct bfq_gtsched *groups; // 组调度结构
/* ... */
};
BFQ 的延迟保证来自两个方面:1)进程级配额确保不会独占带宽;2)低延迟模式(low_latency)在检测到有等待进程时立即切换服务进程。
4.3 Kyber 调度器
Kyber(Facebook/Meta 贡献)是最激进的调度器,采用延迟目标反馈控制而非排序。核心思想:用户设定延迟目标(如读 2ms、写 4ms),Kyber 动态调节并发量以满足目标。
Kyber 通过两个 sched 域(SYNC 和 ASYNC)分别管理同步/异步请求:
struct kyber_queue_data {
struct sbitmap rq_map; // 请求标签位图
unsigned int cur_domain; // 当前域
unsigned int batching; // 批处理计数
unsigned int dispatching; // 正在出队的请求数
};
当实际延迟低于目标时,Kyber 增加并发量(timer百分位降低);当实际延迟高于目标时,Kyber 降低并发量避免排队。这种 PID 控制器式的自适应能力使得 Kyber 在云原生多租户场景中表现优秀——可以同时为延迟敏感和带宽密集型负载服务。
五、硬件队列映射与 NUMA 感知调度
blk-mq 通过 blk_mq_queue_map 实现硬件队列到 CPU 的映射配置:
struct blk_mq_queue_map {
unsigned int *mq_map; // CPU 到 hw queue 的映射表
unsigned int nr_queues; // 硬件队列总数
unsigned int queue_offset; // 偏移量
};
映射策略分为两种:
- 默认(内核 4.10+):
blk_mq_map_queues()——自动按 NUMA 本地性分配。如果硬件队列数 = CPU 数,每个 CPU 获得一个独占硬件队列;如果硬件队列数 < CPU 数(常见于 16 CPU + 8 硬件队列),则将 CPU 按 NUMA 节点均匀分组 - 自定义:驱动可以通过
struct blk_mq_ops.map_queues()提供自定义映射函数
实际映射示例(假设 8 硬件队列、32 CPU、2 NUMA 节点):
NUMA Node 0:
CPU 0-3 → hw queue 0
CPU 4-7 → hw queue 1
CPU 8-11 → hw queue 2
CPU 12-15 → hw queue 3
NUMA Node 1:
CPU 16-19 → hw queue 4
CPU 20-23 → hw queue 5
CPU 24-27 → hw queue 6
CPU 28-31 → hw queue 7
这种映射通过 blk_mq_pci_map_queues()、blk_mq_virtio_map_queues() 等辅助函数实现。
为了更好的性能,NVMe 驱动现代化后引入了 blk_mq_pci_map_queues() + 中断亲和性绑定的多队列中断(MSI-X with per-CPU affinity)——每个硬件队列绑定到一个 CPU 的独立中断,实现完整的从"中断 → 软中断 → 完成"的全核本地处理。
六、blk-mq 与现代驱动集成
6.1 NVMe 驱动中的 blk-mq
NVMe 驱动是 blk-mq 最成功的实例化。NVMe 规范原生支持多队列(MSI-X + 最多 64K IO 队列),驱动首先申请硬件队列:
int nvme_setup_io_queues(struct nvme_dev *dev)
{
// 解码 CAP 寄存器确定硬件最大队列数
// 分配 nr_io_queues = min(num_online_cpus(), dev->max_qid)
// 调用 blk_mq_alloc_sq_tag_set() 创建 tag_set
// 设置 queue_depth 为 typically 1024
}
NVMe 的 nvme_queue 与 blk-mq 的 blk_mq_hw_ctx 一一对应:hw_ctx->driver_data 指向 nvme_queue。
NVMe 驱动的性能关键点在于 nvme_queue_rq():
static blk_status_t nvme_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct nvme_dev *q = hctx->driver_data;
struct nvme_command *cmd = nvme_cmd(bd->rq);
blk_mq_start_request(bd->rq); // 标记开始,重置超时
// 将 NVMe 命令填入提交门铃寄存器
// 门铃写入由 writel() 保证顺序,无需额外 barrier
spin_lock(&q->sq_lock);
memcpy(q->sq_cmds + sq_tail, cmd, sizeof(*cmd));
if (++sq_tail == q->q_depth)
sq_tail = 0;
writel(q->sq_tail, q->sq_db); // 通知硬件
spin_unlock(&q->sq_lock);
return BLK_STS_OK;
}
6.2 SCSI 多队列(scsi-mq)
SCSI 子系统通过 scsi_mq 适配器层接入 blk-mq。早期的 SCSI 单队列模型受限于全局 SCSI 主机锁(host_lock),scsi-mq 通过将锁细化到 LUN 级别并使用 blk-mq 的 per-CPU 软件队列解决了这个问题。
值得注意的是:SCSI 硬件队列实际是逻辑概念——大多数 SAS/SATA 控制器只有一个物理命令 FIFO。blk_mq_tags 中的 nr_requests 和 queue_depth 会共同决定设备的并发上限。对于 SAS HBA,实际硬件并发 = nr_commands(通常在 32-1024 之间)。
七、性能调优与生产实践
7.1 块层队列参数调优
/sys/block/<device>/queue/ 下的关键参数:
logical_block_size # 逻辑块大小(通常4096)
physical_block_size # 物理块大小(通常4096或512)
max_sectors_kb # 每次IO最大传输KB(通常512-2048)
read_ahead_kb # 预读大小(通常128-1024)
rq_affinity # 请求完成CPU亲和性(1=本地完成,2=强制迁移)
io_poll # 是否启用轮询模式(NVMe延迟敏感)
io_poll_delay # 轮询模式下的忙等策略
scheduler # 当前调度器
NVMe 推荐配置:
echo none > /sys/block/nvme0n1/queue/scheduler # 禁用调度器
echo 256 > /sys/block/nvme0n1/queue/nr_requests # 降低请求队列深度
echo 2 > /sys/block/nvme0n1/queue/rq_affinity # 迁移完成至提交CPU
echo 1 > /sys/block/nvme0n1/queue/io_poll # 启用轮询
7.2 io_poll —— 轮询模式降低延迟
Linux 4.20 引入的 io_poll 模式完全绕过中断和调度器,在 CPU 上忙等完成事件:
static bool io_uring_poll(struct blk_mq_hw_ctx *hctx, struct request *rq)
{
struct io_comp cqe;
set_bit(REQ_QUIET, &rq->cmd_flags);
while (!test_bit(REQ_ATOM_COMPLETE, &rq->atomic_flags)) {
if (!blk_mq_poll_hctx(hctx, rq))
io_schedule(); // 无法轮询时让出CPU
}
return true;
}
这种模式的代价是 CPU 利用率 100%(忙等),但在延迟敏感的数据库场景下可降低尾部延迟从毫秒级到微秒级。
7.3 io_uring 与 blk-mq 的集成
io_uring(Linux 5.1+)是 Linux 的高性能异步 IO 框架,与 blk-mm完美结合:
// io_uring fixed buffer + 固定文件 + polled IO 路径
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
sqe->addr = (unsigned long)buf;
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_IO_LINK;
io_uring_submit(ring); // 批量提交,减少系统调用
io_uring 的 IORING_SETUP_SQPOLL 模式用户可以让内核线程轮询提交队列(SQPOLL),实现真正零系统调用的 IO 提交。
在 NVMe 设备上实现 io_uring + polling 的端到端延迟分布(P99):
- 传统 IO(中断模式):P99 ≈ 50-100μs
- io_uring 固定缓冲区:P99 ≈ 20-50μs
- io_uring + polling (sqpoll):P99 ≈ 5-15μs
- io_uring + polling + 轮询完成 (iopoll):P99 ≈ 2-8μs
八、blk-mq 调试与可观测性
8.1 blktrace / blkparse
blktrace 是分析 IO 问题的标准工具,跟踪块层的生命周期事件:
D(Issue)、Q(Queued)、G(Get Request)、I(Insert)、M(Back Merge)、F(Front Merge)、P(Plug)、U(Unplug)、C(Complete):
# 跟踪特定设备
blktrace -d /dev/nvme0n1 -o - | blkparse -i -
# 输出示例:
nvme0n1 0 D WS 3012608 + 8 [tar] ← 直接下发
nvme0n1 0 C WS 3012608 + 8 [0] ← 完成
8.2 BPF/eBPF 跟踪 blk-mq
使用 BCC/bpftrace 跟踪 blk-mq 内部函数:
# 跟踪请求入队延迟
bpftrace -e 'kretprobe:blk_mq_free_request { @lat = hist((nsecs - @start[arg0])); }'
# 统计各硬件队列的请求分布
bpftrace -e 'kprobe:blk_mq_start_request { @[arg0->q->id] = count(); }'
# 跟踪完成延迟(按硬件队列)
bpftrace -e 'kprobe:blk_mq_end_request {
$rq = (struct request *)arg0;
@queue_hist[$rq->mq_ctx->cpu] = hist(nsecs - $rq->start_time_ns);
}'
8.3 并发与延迟检查点
对于 NVMe 多队列驱动,可以使用 nvme-cli 查看硬件队列状态:
nvme list # 列出所有 NVMe 设备
nvme smart-log /dev/nvme0 # SMART 健康状态
nvme id-ctrl /dev/nvme0 # 控制器能力(CAP 寄存器)
查看每个硬件队列的 IO 统计:
cat /sys/block/nvme0n1/mq/0/stats # 硬件队列 0 统计
cat /sys/block/nvme0n1/mq/0/rq_list # 硬件队列 0 的请求列表
九、blk-mq 未来发展
截至 2026 年,blk-mq 仍然是 Linux 存储子系统演进的活跃区域。值得关注的趋势包括:
- Hybrid Polling:动态在轮询和中断之间切换,平衡延迟和 CPU 利用率
- io_uring 直通(io_uring passthrough):越过块层直接提交 NVMe 命令到硬件队列,预期进一步降低延迟
- ZNS/SMR 感知调度:针对分区命名空间 SSD 的写入约束优化调度策略
- CXL.mem 设备支持:CXL Type-3 设备的内存语义 IO 如何融入 blk-mq 框架
- IO 优先级继承:将调度器优先级反向传播到 NVMe IO 优先级字段
blk-mq 作为 Linux 存储子系统的基石,其演进始终朝着一个方向:在保持公平性和可扩展性的前提下,不断压榨硬件性能。理解 blk-mq 的架构和调优方法,是每个高性能存储工程师不可或缺的核心技能。
十、总结
Linux blk-mq 多队列块层是内核架构演进的优秀代表。通过将请求队列从单一全局队列拆分为 per-CPU 软件队列 + 硬件队列的映射模型,blk-mq 彻底解决了 SMP 扩展性瓶颈。在 NVMe 设备上,结合 io_uring 的轮询模式和调度器调优,可以实现百万级 IOPS 和微秒级延迟。
理解 blk-mq 的数据结构(tag_set、hw_ctx、request)和请求生命周期,是进行存储 IO 问题诊断和性能优化的基础。配合 blktrace 和 eBPF 工具,可以从请求级别精确观察每一笔 IO 在块层的行为,定位性能瓶颈。
最核心的认知是:blk-mq 不是一个简单的"多队列"实现,而是一套从头设计的 IO 提交框架,它协调硬件并发、NUMA 本地性、调度算法和 CPU 亲和性,在正确性与性能之间取得了优雅的平衡。

发表评论 取消回复