引言
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 占用 |
|---|---|---|---|---|
| 单队列(旧内核) | 120K | 65 | 180 | 180%(单核瓶颈) |
| blk-mq (none) | 980K | 8 | 22 | 420% |
| blk-mq (mq-deadline) | 850K | 12 | 35 | 400% |
| io_uring (fixed) | 1.2M | 5 | 15 | 380% |
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——是诊断存储性能瓶颈、构建高吞吐存储服务的基石。

发表评论 取消回复