Linux内核块设备I/O栈:从Blk-Mq到多队列深度实战与架构全解析

本文将深入剖析 Linux 内核块设备 I/O 栈的完整架构,从早期的单队列模型(Single-Queue)到现代多队列模型(Multi-Queue / Blk-Mq)的演进路径,涵盖核心数据结构、硬件队列映射、I/O 调度器设计、 irq affinity 优化、以及 NVMe 设备在高并发场景下的性能调优实战。

一、为什么需要多队列?单队列模型的瓶颈

在 Linux 5.0 之前,块设备层使用单一请求队列(request-based)模型,即 struct request_queue 作为所有 I/O 请求的汇集点。这个设计在机械硬盘时代工作良好,但当 NVMe SSD 普及后,单队列模型暴露了三个根本性瓶颈:

  1. 全局自旋锁争用:所有 CPU 核心在提交 I/O 请求时竞争同一把队列锁(queue_lock),在 32 核以上服务器上,锁争用导致的延迟占比可达 30% 以上
  2. NUMA 远程内存访问:单队列的request结构、页缓存、DMA buffer 通常只在一个 NUMA 节点分配,其他节点发起 I/O 时必然跨节点访问
  3. 硬件队列利用率低下:现代 NVMe 设备支持多达 64 个硬件提交队列(Submission Queue),但单队列模型无法有效利用硬件并行能力

Blk-Mq 架构通过将"软件提交队列"与"硬件分发队列"一一对应,从根本上解决了这些问题。

二、Blk-Mq 架构总览

Blk-Mq 架构引入了三个核心的队列层次:


┌─────────────────────────────────────────────────────────┐
│                    用户态 I/O 请求                        │
│         (read/write/sync / io_uring / SPDK)              │
└──────────────────────────┬──────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────┐
│              I/O 调度器层 (mq-deadline/none/bfq)          │
│         负责合并排序、带宽控制、延迟保证                      │
└──────────────────────────┬──────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────┐
│               软件提交队列 (sw ctx / hctx)                │
│        per-CU / per-NUMA-node 独立队列                    │
│        每个 hctx 映射一个硬件队列                            │
└──────────────────────────┬──────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────┐
│               硬件分发队列 (Hardware Dispatch Queue)        │
│        直接投递到设备 SQ/CQ (Completion Queue)              │
└──────────────────────────┬──────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────┐
│              块设备驱动 (nvme_queue / nvme_irq)          │
│        完成中断、Completion Queue 处理                     │
└─────────────────────────────────────────────────────────┘

关键数据结构关系:

struct blk_mq_tag_set        // 驱动侧定义:硬件队列数量、tag 位图大小
  ├── struct blk_mq_queue_map[]  // CPU→hwqueue 映射表(多个映射:节点映射 vs 队列映射)
  └── struct blk_mq_tags[]       // 每个硬件队列的 tag 位图 + request 池

struct request_queue          // 全局队列
  ├── struct blk_mq_ctx[]         // 每个 CPU 的软件上下文(提交用)
  └── struct blk_mq_hw_ctx[]      // 每个硬件队列的硬件上下文

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

3.1 struct blk_mq_hw_ctx(硬件队列上下文)

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 delayed_work run_work;           // 延迟运行的 workqueue 项
    cpumask_var_t       cpumask;            // 绑定该队列的 CPU 集合
    int                 numa_node;          // NUMA 亲和节点
    unsigned int        queue_num;          // 硬件队列编号

    struct blk_mq_ctx **ctxs;               // 该 hctx 对应的软件上下文数组
    struct blk_mq_queue_map *map;           // CPU→hctx 映射缓存(加速 hot path)

    struct list_head    hctx_list;          // 全局 hctx 链表节点
    struct sbitmap      ctx_map;            // 软件上下文 bitmap(标记空闲 ctx)
    struct blk_mq_ctx  *dispatch_from;      // 当前正在分发的 ctx(减少锁争用)

    unsigned long       dispatched[4];      // 各优先级已分发计数
    unsigned int        nr_ctx;             // 软件上下文数量
    unsigned int        *ctx_map_ptr;       // 快速 ctx 映射指针

    struct blk_mq_tags *tags;               // 共享 tag 集合(用于 flush 等跨队列请求)
    struct blk_mq_ctx  *ctxs_local[0];      // 灵活数组成员(per-hctx 的 ctx 本地数组)
};

最关键字段是 dispatch 链表和 run_work:当 I/O 调度器需要延迟分发(如 mq-deadline 的过期时间到达),请求会暂时存放在 dispatch 链表中,然后通过延迟工作者线程批量提交到硬件队列。

3.2 struct blk_mq_ctx(软件上下文)

struct blk_mq_ctx {
    struct {
        spinlock_t       lock;             // 保护 rq_lists 的锁
        struct list_head rq_lists[2];      // 按优先级的请求链表
    } ____cacheline_aligned_in_smp;

    unsigned int          cpu;             // 所属 CPU
    unsigned short        index_cpu[HCTX_MAX_TYPES];  // 各类型 hctx 的索引

    struct blk_mq_hw_ctx  *hctxs[HCTX_MAX_TYPES];     // 指向各类型 hctx

    struct kobject       *mq_kobj;
    struct request_queue *queue;
};

每个 CPU 拥有一个 blk_mq_ctx,I/O 请求先进入 CPU 本地的 rq_lists,然后通过 blk_mq_flush_plug_list() 批量刷入硬件队列。

四、I/O 流程全链路追踪

4.1 提交阶段(Submission Path)

VFS → Page Cache → Generic Block Layer → Blk-Mq 的调用链如下:

generic_make_request(struct bio *bio)
  └── blk_mq_make_request(q, bio)
        ├── blk_mq_bio_to_request(rq, bio, nr_segs)  // bio → request 转换
        ├── blk_mq_try_issue_directly(hctx, rq)      // 尝试直接分发(快速路径)
        │     └── __blk_mq_issue_directly()
        │           └── blk_mq_try_issue_list_directly()
        │                 └── nvme_queue_rq(hctx, bd)      // 驱动层提交
        │                       └── nvme_submit_cmds(nvmeq, cmd, false)
        │                             └── memcpy_toio(sq_cmds, cmd, sizeof)
        │                             └── writel(cq_head, nvmeq->doorbell) // 通知硬件
        └── blk_mq_queue_io(hctx, rq)                 // 失败时加入 hctx->dispatch

4.2 完成阶段(Completion Path)

NVMe 设备通过 MSI-X 中断通知完成,中断处理流程:

nvme_irq(int irq, void *data)
  └── nvme_process_cq(cq)
        └── __nvme_process_cq(cq, locked)
              ├── 读取 CQ 中的完成项(completion entry)
              ├── 通过 command_id 找到对应的 request
              └── blk_mq_complete_request(rq)
                    └── __blk_mq_complete_request(rq)
                          ├── blk_mq_requeue_work()  // 失败重试
                          └── blk_mq_end_request(rq, status)
                                └── bio_endio(rq->bio)  // 归还页缓存

在 NVM Express 1.4+ 中还引入了 Polled Completion 模式,允许用户态程序直接从 CQ 头部指针寄存器轮询完成状态(无需中断),SPDK 等用户态驱动正是利用这一机制实现零拷贝零切换 I/O。

五、I/O 调度器:mq-deadline vs none vs bfq

Blk-Mq 支持三种 I/O 调度器,各自适用场景不同:

调度器工作原理适用场景延迟特征
noneFIFO 直接透传,无排序合并NVMe SSD(硬件自身并行度高)最低延迟,无额外开销
mq-deadline读优先 + 写过期时间批提交SATA/SAS SSD + 混合读写负载写合并减少 IOPS 波动
bfq预算公平队列,按进程分配带宽需要 QoS 保障的混合负载高吞吐但延迟开销高

5.1 mq-deadline 的内部机制

mq-deadline 维护两个关键数据:

struct deadline_data {
    struct rb_root sort_list[2];            // 按扇区号排序的红黑树 [READ/WRITE]
    struct list_head fifo_list[2];          // 过期时间链表 [READ/WRITE]
    struct request *next_rq[2];             // 下一个要分发的请求
    unsigned int batching;                  // 已批量合并的数量
    unsigned int starved;                   // 饥饿计数器(读写平衡)
    unsigned int fifo_expire[2];            // 写请求过期时间(默认5000ms)
    unsigned int fifo_batch;                // 每批合并数量(默认16)
    unsigned int writes_starved;            // 写饥饿阈值(默认2)
    unsigned int front_merges;              // 前向合并统计
};

读请求默认无过期限制(立即处理),写请求在5秒后强制过期并批量提交。starved 计数器确保读优先的同时不会让写饥饿——连续处理 2*writes_starved 次读后强制处理一次写。

六、中断亲和性与 CPU 映射优化

6.1 hctx 与 CPU 的绑定策略

Blk-Mq 支持两种映射模式:

节点映射(node map):同一 NUMA 节点的 CPU 共享同一个硬件队列,适合大容量块设备(硬件队列数少于 CPU 数)。

// 设置 8 个硬件队列,节点亲和映射
struct blk_mq_tag_set set;
set.map[0].nr_queues = 8;
set.map[0].queue_offset = 0;
blk_mq_map_queues(&set.map[0]);  // 自动按 NUMA 节点分组 CPU

队列映射(custom map):手动指定每个 CPU 对应哪个硬件队列,适用于需要精确定位中断亲和性的高性能场景。

6.2 irq affinity 配置实战

以 32 核服务器 + NVMe 8队列为例,最优配置方式如下:

# 1. 查看 NVMe 设备中断号
$ cat /proc/interrupts | grep nvme
  128:   0   0   0  ... IR-PCI-MSI 524288-edge nvme0q0
  129:   0   0   0  ...  IR-PCI-MSI 524289-edge nvme0q1
  ...

# 2. 手动设置中断亲和性(每个队列绑定到不同 CPU)
$ echo 01 > /proc/irq/128/smp_affinity_list  # CPU0
$ echo 02 > /proc/irq/129/smp_affinity_list  # CPU1
$ echo 04 > /proc/irq/130/smp_affinity_list  # CPU2
...

# 3. 或使用 irqbalance 守护进程自动管理
$ systemctl stop irqbalance  # 高性能场景中建议使用静态绑定

生产环境推荐使用 nvme_core.io_timeout=4294967295 内核参数避免 I/O 超时引起的设备复位干扰。

七、性能调优实战

7.1 关键 sysctl 与模块参数

# I/O 队列深度(默认128,NVMe建议设为1024)
echo 1024 > /sys/block/nvme0n1/queue/nr_requests

# 合并/排序的段数限制
echo 128 > /sys/block/nvme0n1/queue/max_sectors_kb

# 启用合并调度(仅 HDD/低速设备)
echo "none" > /sys/block/nvme0n1/queue/scheduler

# 减少 VM 刷回延迟
echo 100 > /proc/sys/vm/dirty_expire_centisecs
echo 50 > /proc/sys/vm/dirty_writeback_centisecs

# 禁用合并对 NVMe 无任何帮助,反而增加延迟
echo 2 > /sys/block/nvme0n1/queue/iosched/fifo_batch 2>/dev/null || true

7.2 NUMA 本地访问优化

NVMe 设备通过 PCIe 连接到特定 NUMA 节点。确保应用程序运行在设备本地 NUMA 节点上,可以避免跨节点 DMA 传输:

# 找到 NVMe 设备所属的 NUMA 节点
$ cat /sys/block/nvme0n1/device/numa_node
1

# 将应用绑定到 NUMA node 1
$ numactl --cpunodebind=1 --membind=1 ./your_app

7.3 队列深度与延迟的权衡

NVMe 设备支持多个并行提交队列,每个队列深度可达 65536。但盲目增加队列深度不一定提升吞吐,反而可能因为内部缓存争用导致延迟上升。

使用 fio 测试不同队列深度的延迟表现:

# 测试 QD=1 vs QD=32 vs QD=128 的 4K 随机读延迟
fio --name=randread --ioengine=io_uring --direct=1 \
    --bs=4k --iodepth=1 --numjobs=1 \
    --filename=/dev/nvme0n1 --rw=randread --runtime=30

fio --name=randread --ioengine=io_uring --direct=1 \
    --bs=4k --iodepth=32 --numjobs=1 \
    --filename=/dev/nvme0n1 --rw=randread --runtime=30

典型 NVMe SSD 的性能拐点在 QD=16-32 之间,超过此后延迟呈指数上升,吞吐增长趋于平缓。

八、io_uring 与 Blk-Mq 的深度集成

Linux 5.17+ 引入了 io_uring 对 Blk-Mq 的 fixed buffer 和 polling 支持,进一步优化了 I/O 路径:

  1. Fixed Buffer(注册缓冲区):通过 IORING_REGISTER_BUFFERS 预注册 I/O buffer,避免每次 get_user_pages 的开销
  2. Poll Mode(轮询模式):设置 IORING_SETUP_IOPOLL,应用线程直接轮询 CQ 完成项,完全绕过中断,延迟降低至亚微秒级
  3. Zero-copy Send:5.19+ 支持从 registered buffer 直接 sendfile,避免 page cache 拷贝

在 SPDK 和 io_uring poll 模式下,一条完整的 NVMe 写请求路径变为:

用户态 io_uring_enter()
  └── sqe->opcode = IORING_OP_WRITE_FIXED
        └── vfs_write() → blk_mq_make_request()
              └── blk_mq_try_issue_directly(hctx, rq)  // 无锁直接分发
                    └── nvme_queue_rq()  // 单次 doorbell 通知
                          └── 完成时写入 CQ,io_uring 直接读取 CQ tail

整个过程无系统调用上下文切换、无中断、无锁争用,单核即可打满 NVMe 设备的吞吐。

九、生产环境监控与故障排查

9.1 关键性能指标

指标获取方式健康阈值
IOPS/proc/diskstats 第4/8列按设备规格判断
平均延迟iostat -x await 列SSD: <100μs, NVMe: <50μs
队列利用率/sys/block/nvme0n1/stat第9列=平均在队请求数
合并率/proc/diskstats 第6/10列HDD需 >30%,NVMe越低越好
nvme_errornvme smart-log /dev/nvme0应为0

9.2 常见故障排查流程

# 1. 检查 NVMe 设备健康状态
$ sudo nvme smart-log /dev/nvme0n1
Critical Warning: 0x00          # 非零则表示有故障
Temperature: 45 Celsius
Available Spare: 100%          # 备用空间,越低越危险
Percentage Used: 5%             # 已用耐久度
Data Units Read: 1,234,567      # 读取量(512B单位)

# 2. 检查块设备层调度
$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline bfq          # 方括号为当前使用

# 3. 检查中断分布是否均匀
$ cat /proc/interrupts | grep nvme | awk '{print $2,$3,$4,$5,$6,$7}'
# 如果某个队列远超其他队列,说明 irq affinity 配置不均

# 4. 检查是否有 I/O stuck(请求超时)
$ dmesg | grep -i "nvme.*timeout\|blk.*stall"
$ echo 1 > /sys/block/nvme0n1/trace/enable  # 启用 blktrace

9.3 blk-mq 调试 tracepoint

Linux 提供 blktrace 和 trace events 追踪 Blk-Mq 每一条 I/O:

# 追踪特定设备的 I/O 事件
$ echo 1 > /sys/block/nvme0n1/trace/enable
$ cat /sys/kernel/debug/tracing/trace_pipe | head -20

# 典型输出:
nvme0n1: rq_complete: sector=12345 size=4096 bytes rwbs=READ
nvme0n1: rq_issue: sector=12349 size=4096 bytes rwbs=WRITE ctx=cpu5 hctx=3

十、Blk-Mq 未来演进方向

Blk-Mq 仍在持续演进中,近期值得关注的方向包括:

  1. blk-mq + zoned namespace(ZNS):ZNS 设备要求写入顺序性,Blk-Mq 需要适配 zone 边界检查, 6.5+ 内核已支持
  2. IO 优先级继承:当前 BFQ 支持按进程组分配带宽,未来可能引入更细粒度的 per-IO latency target
  3. CXL Memory 设备支持:CXL 2.0 内存扩展设备需要新的 address range 类型,Blk-Mq 的队列拓扑需要适配 CXL-attached memory
  4. 与 io_uring 更紧密集成:包括 direct descriptor、passthrough 命令等新特性逐步合并入主线

总结

Blk-Mq 块设备栈是 Linux 内核中复杂度最高的子系统之一,它需要在硬件特性(PCIe 通道数、NVMe SQ/CQ 数量)、软件调度(mq-deadline/none/bfq)、NUMA 拓扑、中断亲和性等多个维度上做综合优化。理解这一架构对存储系统性能调优至关重要。

核心要点回顾:

  • Blk-Mq 通过"软件队列 + 硬件队列"双层架构解决了单队列模型的并发瓶颈
  • NVMe 设备推荐使用 none 调度器(硬件自身并行度足够高)
  • irq affinity 的合理配置比增加队列深度更能提升吞吐
  • NUMA 本地性是 PCIe 设备性能优化的第一步
  • io_uring poll 模式 + fixed buffer 是当前性能最强的 I/O 路径
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部