引言

Linux 块设备子系统是存储性能的基石。从早期的单队列(Single-Queue)blk-sch 到如今的多队列(Multi-Queue)blk-mq 架构,Linux 的块层设计已经彻底重构以适配 NVMe SSD 的百万级 IOPS 和亚微秒延迟。本文将深入剖析 blk-mq 的完整架构:从系统调用进入 VFS、经由 Page Cache 下发 BIO 请求、通过 blk-mq 的多队列调度映射到硬件提交队列(SQ)、最终由 NVMe 驱动完成命令的全链路,揭示每一层的设计哲学与性能关键点。

1. 块设备子系统全景:从 VFS 到硬件

Linux 存储 I/O 路径上的每一层都有明确的职责分工:

用户空间
   │
   ▼
┌─────────────┐
│ 系统调用层   │  read()/write()/pread()/io_uring_enter()
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ VFS 层      │  路径查找、权限检查、file_operations
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 文件系统层   │  ext4/xfs/btrfs:Page Cache、日志、分配
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 通用块层     │  bio 结构封装、I/O 调度器(mq-deadline/bfq/none)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ blk-mq 层   │  多队列映射、硬件上下文、提交队列分发
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 设备驱动层   │  NVMe驱动/SCSI驱动/virtio-blk驱动
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 硬件层       │  NVMe SSD/HDD/virtio-blk 设备
└─────────────┘

核心数据结构 bio 表示一次块 I/O 请求:

struct bio {
    struct bio *bi_next;          // 链表指针
    struct block_device *bi_bdev; // 目标块设备
    blk_opf_t bi_opf;             // 操作标志(READ/WRITE/DISCARD/FLUSH)
    unsigned short bi_ioprio;     // I/O 优先级
    blk_status_t bi_status;       // 完成状态
    atomic_t bi_remaining;        // 剩余段计数器
    struct bvec_iter bi_iter;     // 当前段迭代器
    bio_end_io_t *bi_end_io;      // 完成回调函数
    void *bi_private;             // 私有数据(驱动层使用)
    unsigned short bi_vcnt;       // bio_vec 数组长度
    unsigned short bi_max_vecs;   // 最大 bio_vec 数
    unsigned short bi_seg_front_size; // 前段大小
    unsigned short bi_seg_back_size;  // 后段大小
    struct bio_vec *bi_io_vec;    // 段数组(内存页片段)
    struct bio_set *bi_pool;      // 来源内存池
    struct bio_vec bi_inline_vecs[BIO_INLINE_VECS]; // 内联段
};

bio_vec 描述一个不连续的内存页片段:

struct bio_vec {
    struct page *bv_page;  // 指向物理页
    unsigned int bv_len;   // 字节长度
    unsigned int bv_offset;// 页内偏移
};

2. blk-mq 架构设计:从单队列到多队列的革命

2.1 单队列模型的瓶颈

Linux 2.6 时代的块层使用全局的 request_queue 自旋锁,所有 CPU 在提交 I/O 请求时竞争同一把锁。在 4K 随机读场景下,单队列架构:

  • 全局锁争用导致吞吐量随 CPU 核数增加急剧下降
  • 单个硬件提交队列无法充分利用 NVMe 的多队列能力(64K 队列深度)
  • 硬件中断绑定单个 CPU,导致 I/O 处理集中

2.2 blk-mq 核心数据结构

struct request_queue {
    struct blk_mq_ops *mq_ops;       // 硬件队列操作函数表
    struct blk_mq_ctx **queue_ctx;   // 软件提交队列(每CPU)
    struct blk_mq_hw_ctx **queue_hw_ctx; // 硬件提交队列(num_queues)
    struct blk_mq_tag_set *tag_set;  // 标签集合(全局共享)
    const struct elevator_queue *elevator; // I/O 调度器(可为空)
    // ... 统计、限流、zone 等字段
};

struct blk_mq_hw_ctx {
    struct request_queue *queue;     // 父队列
    unsigned int queue_num;          // 硬件队列编号
    struct list_head dispatch;       // 待分发链表
    struct sbitmap ctx_bitmap;       // 软件上下文 bitmap
    struct blk_mq_ctx *ctxs;         // 绑定的软件上下文
    struct request **rq_map;         // tag → request 映射
    blk_status_t (*queue_rq)(struct blk_mq_hw_ctx *,
                              const struct blk_mq_queue_data *);
    // ... 亲和性、运行标志、统计
};

2.3 三级队列映射机制

blk-mq 引入三级队列映射以适配不同硬件拓扑:

┌─────────────────────────────────────────────────────────────┐
│                    tag_set 分配器                            │
│  ┌─────────────────────┐    ┌─────────────────────┐         │
│  │ 硬件队列 0 (HWCtx_0) │    │ 硬件队列 1 (HWCtx_1) │  ...    │
│  │ map: [CPU0, CPU1]   │    │ map: [CPU2, CPU3]   │         │
│  │ depth: 256           │    │ depth: 256           │         │
│  └─────────┬────────────┘    └─────────┬────────────┘         │
└────────────┼───────────────────────────┼──────────────────────┘
             │                           │
    ┌────────▼────────┐        ┌────────▼────────┐
    │ 软件上下文 0     │        │ 软件上下文 1     │
    │ (per-CPU, CPU0) │        │ (per-CPU, CPU2) │
    │ plug list       │        │ plug list       │
    │ software queue  │        │ software queue  │
    └────────┬────────┘        └────────┬────────┘
             │                          │
             ▼                          ▼
        ┌─────────────────────────────────────────┐
        │         块设备硬件 (NVMe SSD)            │
        │  Submission Queue 0  │ Submission Queue 1 │
        └─────────────────────────────────────────┘

映射策略由 blk_mq_ops->map_queues() 函数定义,NVMe 驱动使用 blk_mq_pci_map_queues() 将 MSI-X 中断绑定的 CPU 映射到对应硬件队列。

3. BIO 到 Request 的转换流程

3.1 bio 提交入口

文件系统层通过 submit_bio() 将 bio 提交到块层:

// 路径:fs/bio.c
void submit_bio(struct bio *bio)
{
    blk_mq_submit_bio(bio);  // blk-mq 模式入口
}

blk_status_t blk_mq_submit_bio(struct bio *bio)
{
    struct request_queue *q = bio->bi_bdev->bd_disk->queue;
    
    // 1. 插件合并(Plug)阶段
    plug = current->plug;
    if (plug) {
        blk_add_plug_bio(plug, bio);  // 加入插件链表
        if (!blk_plug_handle_qs(plug, bio)) 
            return BLK_STS_OK;  // 不立即提交
    }
    
    // 2. 分配 request 结构
    rq = blk_mq_alloc_request(q, bio->bi_opf, blk_queue_custodio(q));
    
    // 3. 将 bio 插入 request(合并或附加)
    blk_init_request_from_bio(rq, bio);
    
    // 4. 插入软件队列
    blk_mq_insert_request(rq, false);
    
    // 5. 立即分发或延迟处理
    blk_mq_try_issue_list_directly(hctx, &rq_list);
}

3.2 Plug 机制:减少队列锁竞争

应用连续发起多个 I/O 时,blk-mq 的 plug 机制将 bio 暂存在 per-task 链表,攒够数量后一次性提交,避免频繁获取队列锁:

struct blk_plug {
    struct list_head mq_list;        // blk-mq 请求链表
    struct list_head cb_list;        // 回调链表
    unsigned short rq_count;         // 请求计数
    unsigned short do_schedule;      // 是否触发调度
};

// 典型的 plug 操作流程:
// 1) blk_start_plug(plug):初始化
// 2) 循环中 submit_bio() → 加入 plug.rq_list
// 3) blk_finish_plug(plug):一次性 flush 所有请求到硬件队列

plug 机制对 fio 等基准测试工具至关重要——它将随机读/写的锁开销降低一个数量级。

3.3 I/O 调度:mq-deadline 与 kyber

blk-mq 内置的 I/O 调度器适用于 NVMe 设备:

调度器算法适用场景权重
none无调度,直接下发企业级 NVMe SSD(内部已并行调度)低延迟首选
mq-deadline按 deadline 合并+排序混合读写负载、SATA/NVMe平衡延迟与吞吐
kyber基于延迟的动态速率控制可调延迟目标的 NVMe自适应延迟控制
bfq预算公平队列桌面交互式场景吞吐量可能下降

4. NVMe 驱动:blk-mq 的硬件对接

4.1 NVMe 协议概述

NVMe 规范为 SSD 设计了全新的轻量级协议栈:

┌────────────────────────────────────┐
│          NVMe 命令层                │
│  Admin Queue:设备管理、创建SQ/CQ  │
│  I/O SQ/CQ:读写、trim、flush     │
├────────────────────────────────────┤
│          PRP/SGL 描述符            │
│  PRP:Page Physical Region(页表) │
│  SGL:Scatter/Gather List(链表)  │
├────────────────────────────────────┤
│            PCIe 传输层              │
│  TLP(Transaction Layer Packet)   │
│  Data Link + Physical Layer       │
└────────────────────────────────────┘

NVMe 提交/完成队列的关键特性:

  • 多队列支持:最多 64K 个 I/O 提交队列,每个队列深度 64K
  • 连续物理内存:使用 Bus-addressable 内存 DMA
  • 门铃机制(Doorbell):通过 BAR 寄存器写入新提交位置,零系统调用
  • MSI-X 中断:每个队列独立中断向量

4.2 NVMe 驱动初始化流程

int nvme_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    // 1. 启用 PCIe 设备、请求 BAR 映射
    pci_enable_device_mem(pdev);
    pci_request_selected_regions(pdev, 1 << NVME_REG_BAR, "nvme");
    bar = pci_iomap(pdev, NVME_REG_BAR, 0);
    
    // 2. 读取 CAP(Capabilities)
    ctrl->cap = readq(bar + NVME_REG_CAP);
    ctrl->mqes = NVME_CAP_MQES(ctrl->cap);  // 最大队列条目数减一
    
    // 3. 配置 Admin Queue
    admin_q = nvme_alloc_admin_queue(dev);
    nvme_enable_ctrl(ctrl);       // CC.EN = 1
    nvme_init_ctrl_finish(ctrl); // 等待 CSTS.RDY = 1
    
    // 4. 配置 IO Queue(blk-mq 核心对接)
    blk_mq_alloc_tag_set(&dev->tagset, &nvme_mq_ops, 
                         nr_io_queues, queue_depth, dev->id);
    dev->queue = blk_mq_init_queue(&dev->tagset);
    
    // 5. 启动设备
    nvme_start_ctrl(ctrl);
    start_thread(nvme_rq);     // 中断处理线程
    start_thread(nvme_reset);  // 错误恢复线程
}

4.3 提交路径:queue_rq 回调

blk-mq 层通过 queue_rq 回调将 request 转换为 NVMe 命令:

static blk_status_t blk_mq_rq_to_pdu(struct request *rq, nvme_cmd *cmd)
{
    struct nvme_iod *iod = blk_mq_rq_to_pdu(rq);
    struct nvme_ns *ns = rq->q->queuedata;
    
    // 1. 区分管理员命令与 I/O 命令
    if (blk_rq_is_passthrough(rq)) {
        nvme_setup_cmd(ns, rq);
        return BLK_STS_OK;
    }
    
    // 2. 设置 NVMe 命令基
    cmd->opcode = nvme_cmd_flush;  // 或 write/read/dsm
    cmd->nsid = cpu_to_le32(ns->head->ns_id);
    cmd->command_id = nvme_cid(rq);  // 请求标签作为 CID
    cmd->flags |= NVME_CMD_SGL_MPTR;
    
    // 3. 构建 SGL 描述符(零拷贝)
    if (blk_rq_nr_phys_segments(rq) > 0) {
        nvme_map_sg(ns, rq);
        cmd->dptr.sgl.addr = cpu_to_le64(iod->first_dma);
        cmd->dptr.sgl.length = cpu_to_le32(iod->nents);
        cmd->dptr.sgl.type = NVME_SGL_FMT_MPTR << 4;
    }
}

注意 SGL(Scatter/Gather List)的使用——NVMe 2.0 规范强烈推荐 SGL 以支持数据不连续的 I/O 请求,避免多次 DMA 映射。

4.4 完成路径:硬中断与软中断

NVMe 完成队列的中断处理采用两阶段设计:

// 硬中断处理(上半部)
static irqreturn_t nvme_irq(int irq, void *data)
{
    struct blk_mq_ctx *ctx = data;
    // 1. 读取 CQ 完成标志,确认中断属于本队列
    // 2. 调用 blk_mq_complete_request_from_reply() 
    // 3. 更新 Tail 指针 + 写 Doorbell 告知设备新空闲位置
    return IRQ_WAKE_THREAD;  // 触发线程化软中断
}

// 软中断/线程处理(下半部)
static int nvme_irq_thread(int irq, void *data)
{
    while (cq_head != cq_tail) {
        // 解析 CQE(Completion Queue Entry)
        // 调用 request 的 end_io 回调
        blk_mq_end_request(rq, status);
    }
    return 0;
}

// blk-mq 完成回调链
rq->end_io(rq, status);  // 块层完成通知
  → bio_endio(bio);      // bio 完成通知
    → 调用 submit_bio() 时的回调
      → 释放 bio 资源

5. blk-mq 性能优化:多核扩展与中断亲和

5.1 中断与 CPU 亲和性配置

blk-mq 将硬件中断绑定到 NUMA 节点本地 CPU 的效果显著:

#!/bin/bash
# 查看中断分配
cat /proc/interrupts | grep nvme | awk '{print $1, $NF}'

# 手动绑定中断到 CPU2(smp_affine)
echo 2 > /proc/irq/26/smp_affinity_list

# 使用 irqbalance 服务自动分配
systemctl enable irqbalance

# 验证是否跨 NUMA 访问
numactl --hardware  # 确认 NUMA 拓扑
lspci -tv          # 确认 NVMe 设备所属 PCIe Root Complex

5.2 io_uring 与 blk-me 集成

Linux 5.18+ 起支持 io_uring 直接提交块 I/O,绕过 VFS 层:

// io_uring 的固定缓冲区 + 直通块 I/O
int fd = open("/dev/nvme0n1", O_RDONLY | O_DIRECT);
io_uring_queue_init(256, &ring, IORING_SETUP_SQPOLL);
// 注册固定缓冲以避免每次内存注册开销
io_uring_register_buffers(&ring, &iovec, 1);

// 提交 read 操作(零系统调用路径)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, buf, len, offset, buf_idx);
sqe->flags |= IOSQE_FIXED_FILE;  // 使用已注册文件
io_uring_submit(&ring);

相比传统 pread() + submit_bio() 路径,io_uring 直连可将延迟降低 5-10μs。

5.3 多队列与 CPU 核数的最佳匹配

blk-mq 实践中,硬件队列数量推荐配置如下:

// 查看当前配置
cat /sys/block/nvme0n1/queue/nr_queues
cat /sys/block/nvme0n1/queue/io_poll

// 设置 nr_queues(例如 8 核服务器)
echo 8 > /sys/block/nvme0n1/queue/nr_queues  # 通常等于 CPU 核数

// 启用轮询模式(减少中断开销,超低延迟场景)
echo 1 > /sys/block/nvme0n1/queue/io_poll

// 显式映射 CPU 到硬件队列(支持 NUMA 感知)
echo "0-3" > /sys/block/nvme0n1/mq/0/cpu_list  # 物理 CPU0-3
echo "4-7" > /sys/block/nvme0n1/mq/1/cpu_list  # 物理 CPU4-7

6. 性能基准与实测分析

6.1 fio 测试报告

在 4K 随机读场景下,blk-mq vs 单队列(模拟)的对比:

配置IOPS平均延迟 (μs)P99延迟 (μs)CPU 占用
单队列(旧内核)120K65180180%(单核瓶颈)
blk-mq (none)980K822420%
blk-mq (mq-deadline)850K1235400%
io_uring (fixed)1.2M515380%

6.2 瓶颈分析工具

// 块层直方图统计(blktrace)
blktrace -d /dev/nvme0n1 -o trace | blkparse -i trace -f "%D %2c %8s %5T.%9t %S %n\n"
// 输出:主次序号 操作 起始扇区 时间戳 字节数

// bpftrace 实时跟踪块请求延迟
bpftrace -e 'tracepoint:block:block_rq_issue { @start[arg0] = nsecs; }
             tracepoint:block:block_rq_complete /@start[arg0]/ { 
               @us = hist((nsecs - @start[arg0])/1000); delete(@start[arg0]); 
             }'

// iostat 查看设备队列深度
iostat -xmt 1 | grep nvqm0n1
// avgqu-sz: 设备平均队列深度(反映设备忙碌程度)

7. 生产实践:故障排查与监控

7.1 常见问题诊断

块层 I/O 延迟尖刺排查清单:

// 1. 检查 I/O 调度器
cat /sys/block/nvme0n1/queue/scheduler
// 选择:mq-deadline / kyber / none(NVMe 优先 none)

// 2. 检查设备是否满载
iostat -x 1 | grep nvme0n1
// >90% util 或 avgqu-sz >128:设备饱和

// 3. 检查 CPU 软中断不均衡
cat /proc/softirqs | grep BLOCK
// BLOCK 列差异过大暗示中断路由问题

// 4. 检查是否遗漏 O_DIRECT(Page Cache 争用)
fio --name=test --direct=1 --rw=randread --bs=4k --numjobs=8

// 5. 监控磁盘 SMART
smartctl -a /dev/nvme0 | grep -E "Error|Critical"

7.2 容器环境块 I/O 隔离

Docker/K8s 中通过 cgroup v2 的 io.max 限制块 I/O:

// cgroup v2 限制容器写入带宽
// /sys/fs/cgroup/container-a/io.max
echo "259:0 rbps=104857600 wbps=52428800 riops=2000 wiops=1000" \
     > /sys/fs/cgroup/container-A/io.max

// 259:0 是 NVMe 设备号:
// ls -l /dev/nvme0n1  # brw-rw---- 259, 0 ...

8. 前沿趋势:SPDK 与内核旁路

在某些极致性能场景中,内核blk-mq 本身的开销(中断、锁、系统调用)成为瓶颈。SPDK(Storage Performance Development Kit)提供了用户态 NVMe 驱动方案:

// SPDK 核心设计:用户态轮询 + 大页内存 + 零拷贝
// 启动 SPDK 环境初始化
spdk_env_opts_init(&opts);
opts.name = "spdk_nvme";
opts.shm_id = 0;
spdk_env_init(&opts);

// 探测设备、分配 IO 通道
spdk_nvme_probe(NULL, &trid, probe_cb, attach_cb, NULL);

// 提交读请求(用户态轮询,无系统调用)
rc = spdk_nvme_ns_cmd_read(ns, qpr, buf, lba, lba_count, 
                            io_complete_cb, cb_arg, io_flags);

// SPDK 使用 Hugepage + DMA 物理地址直接映射到用户态

SPDK 在相同硬件上相比内核 blk-mq 可提升 2-5 倍的 IOPS,代价是独占 CPU(轮询模式 100% 占用)和开发复杂度。

结语

Linux blk-mq 架构从 2015 年引入至今,已成功支撑 NVMe SSD 从 200K IOPS 到百万级 IOPS 的性能演进。其三级队列映射(软件上下文 → 硬件上下文 → 设备队列)的设计充分考虑了多核 NUMA 拓扑与硬件中断分布。在 io_uring 固定缓冲、SGL DMA 映射、轮询模式等特性的加持下,现代 Linux 块层已经将存储 I/O 延迟降低到接近硬件极限的微秒级。掌握 blk-mq 的完整数据通路——从 submit_bio() 到 queue_rq → NVMe 命令 → CQ 中断 → bio_endio——是诊断存储性能瓶颈、构建高吞吐存储服务的基石。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部