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-&gt;addr = (unsigned long)buf;
sqe-&gt;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 亲和性,在正确性与性能之间取得了优雅的平衡。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论