摘要:
当 Page Cache 发生 miss,需要从磁盘读取数据时,文件系统会调用 submit_bio() 将 IO 请求提交到块设备栈。这一层是 Linux 存储系统中最贴近硬件的内核子系统,也是近十年变化最大的部分。传统的单队列块层(single-queue block layer)在面对 NVMe SSD(每秒百万级 IOPS)时成为严重瓶颈——一把大锁保护整个请求队列,多核 CPU 之间的锁竞争使得高速 SSD 的 IOPS 只能发挥出一小部分。Linux 3.13 引入的 blk-mq(multi-queue block layer)从根本上重新设计了块设备栈:每个 CPU core 有独立的软件提交队列(software staging queue),硬件有多个硬件调度队列(hardware dispatch queue),彻底消除了锁竞争。本文从 bio 数据结构出发,解析一个 IO 请求从文件系统到磁盘控制器 DMA 的完整路径,重点剖析单队列时代的瓶颈、blk-mq 的设计思想与数据结构,以及 io_context(进程 IO 上下文)如何支持 IO 调度器对进程级 IO 优先级的管理。
第 1 章 块设备的基本概念
1.1 块设备 vs 字符设备
Linux 将设备分为两大类:
字符设备(Character Device):以字节流为单位,顺序访问,不支持随机 seek(如串口、终端、键盘)。
块设备(Block Device):以固定大小的”块”为单位,支持随机访问任意块(如 HDD、SSD、NVMe、USB 存储)。块设备是文件系统的载体,也是本文的主角。
块设备的核心特征:
- 可寻址:每个逻辑块有唯一的 LBA(Logical Block Address,逻辑块地址),可以随机访问
- 缓冲 IO:内核的通用块层在设备驱动之上统一管理 IO 请求的排队、合并、调度
- 块大小:物理扇区(Sector)通常 512 字节或 4096 字节;文件系统的块(Block)通常 4096 字节(= 8 个 512 字节扇区)
# 查看系统中的块设备
lsblk
# NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
# sda 8:0 0 500G 0 disk
# ├─sda1 8:1 0 1G 0 part /boot
# └─sda2 8:2 0 499G 0 part /
# nvme0n1 259:0 0 1.8T 0 disk
# └─nvme0n1p1 259:1 0 1.8T 0 part /data
# 查看块设备的扇区大小
cat /sys/block/sda/queue/physical_block_size # 物理扇区大小(HDD 通常 512 或 4096)
cat /sys/block/sda/queue/logical_block_size # 逻辑扇区大小(通常 512)
cat /sys/block/nvme0n1/queue/physical_block_size # NVMe 通常 5121.2 通用块层的职责
通用块层(Generic Block Layer,GBL) 位于文件系统和具体设备驱动之间,承担三个核心职责:
- IO 请求的标准化封装:用
bio结构体统一描述所有 IO 请求,文件系统不需要了解设备的具体接口(SCSI、NVMe、virtio-blk) - IO 请求的合并与排序:将多个相邻的小 IO 合并为大 IO(减少磁盘寻道次数和请求处理开销)
- IO 调度:根据调度策略(deadline、mq-deadline、bfq 等)决定 IO 请求的提交顺序,优化总体吞吐量或延迟
第 2 章 bio:IO 请求的基本单元
2.1 bio 是什么
bio(Block I/O)是通用块层的核心数据结构,描述一个或多个不连续内存区域(pages)到磁盘上一段连续区域的 IO 映射:
struct bio {
struct bio *bi_next; /* 链表:指向下一个 bio(合并时使用)*/
struct block_device *bi_bdev; /* 目标块设备(如 /dev/sda1)*/
blk_opf_t bi_opf; /* 操作类型:REQ_OP_READ / REQ_OP_WRITE + 标志位 */
unsigned short bi_flags; /* bio 状态标志 */
unsigned short bi_ioprio; /* IO 优先级(0-7,7 最高)*/
struct bvec_iter bi_iter; /* 当前 IO 位置的迭代器(磁盘扇区地址 + 已处理量)*/
bio_end_io_t *bi_end_io; /* IO 完成回调函数(Page Cache 用此函数解锁页)*/
void *bi_private; /* 回调函数的私有数据 */
unsigned short bi_vcnt; /* bi_io_vec 中有效条目数(内存页数量)*/
unsigned short bi_max_vecs; /* bi_io_vec 的容量 */
atomic_t __bi_cnt; /* 引用计数 */
struct bio_vec *bi_io_vec; /* 内存页向量数组(描述 IO 数据在内存中的位置)*/
struct bio_set *bi_pool; /* bio 来自哪个内存池(用于 bio 的分配与释放)*/
struct bio_vec bi_inline_vecs[]; /* 内联 bio_vec(避免小 IO 的额外内存分配)*/
};
/* bio_vec:描述一个内存页片段(page + offset + length)*/
struct bio_vec {
struct page *bv_page; /* 内存页(Page Cache 中的物理页)*/
unsigned int bv_len; /* 该页参与 IO 的字节数 */
unsigned int bv_offset; /* 在该页内的起始偏移 */
};bio 的核心概念——scatter-gather(分散-聚集)IO:
一个文件的数据在内存中可能分散在多个不连续的物理页(Page Cache 页),但在磁盘上是连续的(或由 Extent 描述的连续段)。bio 的 bi_io_vec 数组允许将多个不连续的内存页描述为一个单一的 IO 请求——磁盘控制器通过 DMA 的 scatter-gather 模式,将这些不连续的内存区域一次性传输到磁盘,不需要中间的内存拷贝。
bio 的内存布局示意:
bi_bdev = /dev/sda
bi_iter.bi_sector = 8192 ← 目标磁盘扇区(LBA)
bi_opf = REQ_OP_WRITE
bi_vcnt = 3 ← 3 个内存页
bi_io_vec[0]: page=0xffff..1234, offset=0, len=4096 ← Page Cache 第 N 页
bi_io_vec[1]: page=0xffff..5678, offset=0, len=4096 ← Page Cache 第 N+1 页
bi_io_vec[2]: page=0xffff..9abc, offset=0, len=2048 ← Page Cache 第 N+2 页(部分)
总 IO 大小:4096 + 4096 + 2048 = 10240 字节(20 个 512 字节扇区)
磁盘目标范围:扇区 8192 ~ 8211
2.2 bio 的创建与提交
文件系统(ext4)在需要从磁盘读取数据时,创建并提交 bio:
/* ext4 提交读 IO 的简化流程 */
static int ext4_mpage_readpages(struct address_space *mapping,
struct list_head *pages,
struct page *page, ...) {
struct bio *bio = NULL;
/* 遍历需要读取的页 */
for (/* 每一页 */) {
/* 查询 Extent 树,得到物理块号 */
ext4_map_blocks(NULL, inode, &map, 0);
if (!bio) {
/* 分配一个新的 bio */
bio = bio_alloc(GFP_KERNEL, BIO_MAX_VECS);
bio->bi_bdev = bdev;
bio->bi_iter.bi_sector = map.m_pblk << (blkbits - 9);
bio->bi_opf = REQ_OP_READ;
bio->bi_end_io = mpage_end_io; /* IO 完成时解锁 Page Cache 页 */
}
/* 将当前页加入 bio_vec */
if (bio_add_page(bio, page, blocksize, 0) < blocksize) {
/* 当前 bio 已满(或磁盘扇区不连续):先提交当前 bio,再创建新的 */
submit_bio(bio);
bio = NULL;
continue;
}
}
/* 提交最后一个 bio */
if (bio)
submit_bio(bio);
}submit_bio() 是文件系统提交 IO 请求的统一入口,之后的处理完全由通用块层接管。
第 3 章 传统单队列块层的瓶颈
3.1 单队列架构
Linux 3.13 之前的传统块层使用**单请求队列(single request queue)**架构:
传统单队列架构:
CPU 0 CPU 1 CPU 2 CPU 3
| | | |
└──────┴──────┴──────┘
|
submit_bio()
|
┌────────────────────────────────────┐
│ request_queue(单一队列) │
│ spin_lock(一把大锁!) │
│ elevator(IO 调度器) │
└────────────────────────────────────┘
|
设备驱动(dispatch)
|
硬件(单个 DMA 引擎)
关键问题:一把大锁(q->queue_lock)
所有 CPU 提交 IO 请求时,都必须争抢同一把 spin_lock(自旋锁)。在传统 HDD 时代,这不是问题——HDD 的 IOPS 只有 ~150,锁竞争极少发生。
但 NVMe SSD 的 IOPS 可以达到 100万+,如果所有 CPU 都在争抢同一把锁,锁竞争本身会消耗大量 CPU 时间,成为严重的性能瓶颈。测试数据表明,在高并发场景下,传统单队列架构只能达到 NVMe 理论 IOPS 的 10-20%。
3.2 单队列的 CPU 浪费分析
# 用 perf 观察传统块层的锁竞争(单队列 SATA SSD 上的高并发 fio)
perf top -a
# 高占比函数:
# _raw_spin_lock_irqsave ← 争抢 queue_lock 的时间
# blk_queue_bio ← 单队列的 bio 入队函数
# cfq_dispatch_requests ← CFQ 调度器的分发(单线程)
#
# 在 NVMe 高并发场景,这些函数加起来可能占 40-60% 的 CPU 时间
# 这意味着一半的 CPU 算力在等锁,而不是在做真正的 IO第 4 章 blk-mq:多队列块层的革命
4.1 blk-mq 的设计思想
blk-mq(Multi-Queue Block Layer) 于 Linux 3.13(2014 年)引入,核心设计思想是彻底消除全局锁争用,通过两级队列结构实现 CPU 本地化处理:
graph TD classDef cpu fill:#ffb86c,stroke:#ff79c6,color:#282a36 classDef sq fill:#6272a4,stroke:#bd93f9,color:#f8f8f2 classDef hq fill:#50fa7b,stroke:#69ff47,color:#282a36 classDef hw fill:#ff5555,stroke:#ff5555,color:#f8f8f2 C0["CPU 0"]:::cpu C1["CPU 1"]:::cpu C2["CPU 2"]:::cpu C3["CPU 3"]:::cpu S0["软件提交队列 0<br/>(per-CPU,无锁)"]:::sq S1["软件提交队列 1<br/>(per-CPU,无锁)"]:::sq S2["软件提交队列 2<br/>(per-CPU,无锁)"]:::sq S3["软件提交队列 3<br/>(per-CPU,无锁)"]:::sq H0["硬件调度队列 0"]:::hq H1["硬件调度队列 1"]:::hq H2["硬件调度队列 2"]:::hq H3["硬件调度队列 3"]:::hq HW["NVMe 控制器<br/>(多个 SQ/CQ 对)"]:::hw C0 --> S0 C1 --> S1 C2 --> S2 C3 --> S3 S0 --> H0 S1 --> H1 S2 --> H2 S3 --> H3 H0 --> HW H1 --> HW H2 --> HW H3 --> HW
两级队列:
第一级:软件提交队列(Software Staging Queue,每 CPU 一个)
- 每个 CPU core 有独立的提交队列,
submit_bio()只操作本 CPU 的队列 - 无需跨 CPU 加锁——CPU 0 的 IO 请求只进入 CPU 0 的队列
- 在这一层可以做请求合并(将相邻扇区的 IO 合并为一个大请求)和IO 调度(mq-deadline 等调度器运行在此层)
第二级:硬件调度队列(Hardware Dispatch Queue)
- 对应设备的物理 IO 队列(NVMe 的 Submission Queue)
- 数量由硬件决定(NVMe 可以有 64 个甚至更多)
- 多个软件队列的 IO 请求被分发到多个硬件队列,真正并行提交给设备
4.2 blk-mq 的核心数据结构
/* blk_mq_hw_ctx:硬件调度队列的描述符 */
struct blk_mq_hw_ctx {
struct {
/* 与硬件队列关联的 CPU 集合 */
cpumask_var_t cpumask; /* 哪些 CPU 的 IO 分发到这个硬件队列 */
unsigned int nr_ctx; /* 关联的软件队列数量 */
struct blk_mq_ctx **ctxs; /* 软件队列数组 */
} ____cacheline_aligned_in_smp;
unsigned long state; /* 硬件队列状态(active、stopped、detached)*/
struct request *dispatch_rq; /* 当前正在分发的请求 */
struct blk_mq_tags *tags; /* 此硬件队列的 tag 集合(每个 inflight request 一个 tag)*/
void *driver_data; /* 驱动私有数据(NVMe 驱动用此存 nvme_queue)*/
atomic_t nr_active; /* 当前 inflight(已提交给硬件但未完成)的请求数 */
/* ... */
};
/* blk_mq_ctx:软件提交队列(per-CPU)*/
struct blk_mq_ctx {
struct {
spinlock_t lock; /* 保护本 CPU 的请求列表(竞争极少)*/
struct list_head rq_lists[HCTX_MAX_TYPES]; /* 请求链表 */
} ____cacheline_aligned_in_smp;
unsigned int cpu; /* 所属 CPU 编号 */
unsigned int index_hw[HCTX_MAX_TYPES]; /* 本 CPU 提交到哪个硬件队列 */
struct blk_mq_hw_ctx *hctxs[HCTX_MAX_TYPES]; /* 关联的硬件队列 */
/* ... */
};4.3 IO 请求在 blk-mq 中的流转
submit_bio(bio) ← 文件系统调用入口
↓
blk_mq_submit_bio(bio)
↓
__blk_mq_alloc_request() ← 在当前 CPU 的软件队列分配 request 对象
↓
blk_mq_bio_to_request() ← 将 bio 转换为 request
↓
【尝试合并(Plug 机制)】
blk_plug_add_rq() ← 加入当前进程的 plug 列表(延迟分发)
└─ 等到 blk_finish_plug() 或队列超限时 ← 批量合并多个相邻 IO
统一 flush 到硬件队列
【直接分发路径(plug 满或不使用 plug)】
blk_mq_run_hw_queue()
↓
IO 调度器 dispatch(如 mq-deadline) ← 调度器决定哪些请求先发送
↓
q->mq_ops->queue_rqs() 或 queue_rq() ← 调用驱动的提交函数
↓
【NVMe 驱动】nvme_queue_rq()
└─ 将请求写入 NVMe Submission Queue(SQ) ← 通过 MMIO 写 doorbell 寄存器通知控制器
└─ 控制器处理 IO,完成后写 Completion Queue(CQ)
└─ MSI-X 中断触发,nvme_irq() 处理完成事件
└─ bio->bi_end_io() 回调(解锁 Page Cache 页)
4.4 Plug 机制:IO 批量化的关键优化
Plug(堵塞) 是 blk-mq 中减少 IO 碎片化的关键机制,其思想来源于水管堵塞:先把水(IO 请求)积蓄起来,再一次性放开(批量提交)。
/* 使用 Plug 批量提交 IO(文件系统/写回路径中常见)*/
struct blk_plug plug;
blk_start_plug(&plug); /* 开始 plug:后续的 submit_bio 先缓冲在 plug 列表中 */
for (/* 每个需要写回的脏页 */) {
submit_bio(bio); /* 不立即提交,加入 plug 缓冲 */
}
blk_finish_plug(&plug); /* 结束 plug:将缓冲的 bio 批量合并并提交 */Plug 的合并效果:
假设写回 16 个相邻的 4KB 脏页(对应 16 个连续磁盘块):
- 无 Plug:16 个独立的 4KB
bio→ 16 次设备驱动调用 → 16 个独立的 IO 请求 - 有 Plug:16 个
bio在 plug 列表中合并 → 1 个 64KBbio→ 1 次设备驱动调用
合并后的大 IO 对 HDD 效率提升显著(减少寻道),对 SSD 也有好处(减少命令处理开销)。
第 5 章 request 与 tag:inflight IO 的追踪
5.1 从 bio 到 request
bio 描述的是一段 IO 操作的意图(读/写哪些内存页到磁盘哪个位置)。当 bio 进入通用块层后,会被转换为 request(请求对象),request 在等待、合并、调度、执行的过程中一直存在:
struct request {
struct request_queue *q; /* 所属的请求队列 */
struct blk_mq_ctx *mq_ctx; /* 所属的软件队列(CPU 本地)*/
struct blk_mq_hw_ctx *mq_hctx; /* 目标硬件队列 */
blk_opf_t cmd_flags; /* 操作类型(READ/WRITE)+ 标志 */
req_flags_t rq_flags; /* 请求状态标志 */
/* IO 位置信息 */
sector_t __sector; /* 起始磁盘扇区(LBA)*/
unsigned int __data_len; /* 总数据量(字节)*/
/* bio 链表(一个 request 可能合并了多个 bio)*/
struct bio *bio; /* 第一个 bio */
struct bio *biotail; /* 最后一个 bio(用于追加合并)*/
/* tag:标识这个 inflight 请求的唯一 ID */
int tag; /* 硬件队列中的 tag(0 ~ queue_depth-1)*/
int internal_tag; /* 内部 tag(调度器使用)*/
/* 时间统计(用于 IO 延迟统计和 deadline 调度器的超时判断)*/
u64 start_time_ns;
u64 io_start_time_ns;
/* ... */
};5.2 tag 的作用
NVMe 等现代存储协议支持命令队列深度(Queue Depth)——可以同时向设备提交多个未完成的 IO 请求,设备乱序处理并返回完成通知。tag 是标识每个 inflight 请求的整数 ID(0 到 queue_depth - 1):
提交 3 个 IO 请求:
request A → tag=0 → NVMe SQ[0]: (cmd_id=0, opcode=READ, slba=1000, nlb=8)
request B → tag=1 → NVMe SQ[1]: (cmd_id=1, opcode=WRITE, slba=2000, nlb=16)
request C → tag=2 → NVMe SQ[2]: (cmd_id=2, opcode=READ, slba=3000, nlb=4)
NVMe 控制器乱序完成(B 最先完成):
CQ[0]: (cmd_id=1, status=SUCCESS) → 通过 cmd_id=1=tag=1,找到 request B
→ request B 的 bio->bi_end_io() 回调
NVMe 控制器稍后完成 A 和 C:
CQ[1]: (cmd_id=0) → request A 完成
CQ[2]: (cmd_id=2) → request C 完成
tag 机制使得乱序完成成为可能——这是 NVMe 在深队列(queue depth=32 甚至更高)场景下实现超高 IOPS 的关键。
第 6 章 IO 路径的完整追踪:以 NVMe read 为例
6.1 从 Page Cache miss 到 DMA 完成
将前面所有知识串联,追踪一次完整的 NVMe 读 IO:
① 用户程序:read(fd, buf, 4096)
↓
② VFS:vfs_read → generic_file_read_iter → filemap_read
↓
③ Page Cache miss:find_get_page() 返回 NULL
→ 分配新页,加入 address_space->i_pages(XArray)
→ 调用 a_ops->readahead(或 readpage)
↓
④ ext4 文件系统:ext4_mpage_readpages
→ ext4_map_blocks():在 Extent 树中查找,得到物理块号 P
→ 创建 bio:sector = P * 8(4KB 块 = 8 个 512B 扇区),vcnt=1,op=READ
→ submit_bio(bio)
↓
⑤ blk-mq:blk_mq_submit_bio
→ 当前 CPU 的软件队列:检查是否可以合并(merge with existing request)
→ 通过 IO 调度器(mq-deadline)排序
→ blk_mq_run_hw_queue:将 request 分发到硬件队列
↓
⑥ NVMe 驱动:nvme_queue_rq
→ 构造 NVMe Read 命令(NVM Command Set,64 字节)
→ 写入 Submission Queue(内存映射 ring buffer)
→ 写 doorbell 寄存器(MMIO write)通知 NVMe 控制器
↓
⑦ NVMe 控制器:
→ 从 SQ 读取命令
→ 根据 LBA 定位 NAND Flash 的物理地址
→ 通过 DMA(PCIe DMA)将数据写入 bio 的目标物理内存(Page Cache 页)
→ 写 Completion Queue(CQ),设置 status=SUCCESS
↓
⑧ 中断处理:MSI-X 中断触发(NVMe 控制器 → CPU 中断控制器)
→ nvme_irq():读取 CQ 条目,找到 tag 对应的 request
→ blk_mq_end_request(rq, BLK_STS_OK)
→ bio->bi_end_io() = mpage_end_io:
- 设置 PageUptodate(页内容有效)
- 清除 PageLocked(页解锁)
- wake_up_page():唤醒等待此页的进程
↓
⑨ 回到 filemap_read:
→ wait_on_page_locked() 返回(页已解锁)
→ copy_page_to_iter():将 Page Cache 页拷贝到用户缓冲区 buf
↓
⑩ 用户程序:read() 返回 4096
6.2 NVMe 的多队列优势
# 查看 NVMe 设备支持的队列数量
cat /sys/block/nvme0n1/queue/nr_hw_queues
# 32 ← 32 个硬件队列,每个 CPU 对应一个(或按 CPU 数量分配)
# 查看队列深度
cat /sys/block/nvme0n1/queue/nr_requests
# 1023 ← 每个硬件队列最多 1023 个 inflight 请求(NVMe 支持到 65535)
# 与 HDD/SATA 的对比:
cat /sys/block/sda/queue/nr_hw_queues
# 1 ← SATA 只有 1 个硬件队列!
cat /sys/block/sda/queue/nr_requests
# 128 ← 队列深度 128NVMe 的并发 IO 能力:
- HDD:1 个硬件队列 × 深度 32 = 32 个并发 IO(通常 IOPS = 150-200)
- SATA SSD:1 个硬件队列 × 深度 32(NCQ)= 32 个并发 IO(IOPS = ~50k)
- NVMe SSD:32 个硬件队列 × 深度 1024 = 32768 个并发 IO(IOPS = 100万+)
blk-mq 的多队列设计使 CPU 可以并行向所有 32 个硬件队列同时提交 IO,充分利用 NVMe 的并发能力。
第 7 章 IO 上下文与进程级 IO 统计
7.1 io_context:进程的 IO 统计中心
io_context(进程 IO 上下文)是内核为每个进程维护的 IO 统计和调度信息结构:
struct io_context {
atomic_long_t refcount; /* 引用计数 */
atomic_t active_ref; /* 活跃引用 */
unsigned short ioprio; /* 进程的 IO 优先级(ioprio_set 设置的值)*/
struct radix_tree_root icq_tree; /* 与各块设备队列的关联(cfq_io_context 等)*/
/* ... */
};# 设置进程的 IO 优先级(ionice)
ionice -c 2 -n 0 -p <pid> # -c 2: best-effort class(最常用),-n 0: 最高优先级(0-7)
ionice -c 3 -p <pid> # -c 3: idle class(只在磁盘空闲时运行,适合备份任务)
ionice -c 1 -n 0 -p <pid> # -c 1: realtime(危险!会饿死其他 IO)
# 查看进程当前的 IO 优先级
ionice -p <pid>
# best-effort: prio 4 ← 默认 best-effort class,优先级 4
# 查看系统 IO 使用情况(按进程)
iotop -aod 1
# Total DISK READ: 125.00 M/s | Total DISK WRITE: 50.00 M/s
# PID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
# 12345 be/4 mysql 80.00 M/s 0.00 B/s 0.00 % 70.12 % mysqld
# 6789 be/7 backup 45.00 M/s 0.00 B/s 0.00 % 35.88 % rsync7.2 块设备 IO 统计
# /proc/diskstats:块设备 IO 统计(sar、iostat 的数据来源)
cat /proc/diskstats | grep "sda " | awk '{
print "reads: " $4
print "reads_merged: " $5
print "sectors_read: " $6
print "read_ms: " $7
print "writes: " $8
print "writes_merged: " $9
print "sectors_written: " $10
print "write_ms: " $11
print "io_in_flight: " $12 ← 当前 inflight 的 IO 数量
print "io_ms: " $13 ← IO 耗时累计(毫秒)
print "io_weighted_ms: " $14 ← 加权 IO 等待时间(用于计算 %util)
}'
# 实时 IO 统计
iostat -x 1 sda
# Device r/s w/s rkB/s wkB/s await svctm %util
# sda 123.4 45.6 1234.5 456.7 8.45 3.21 54.3
#
# await:IO 请求的平均等待时间(ms)= 队列等待 + 设备服务时间
# svctm:设备实际服务时间(已废弃,不可靠)
# %util:设备利用率(IO 时间占总时间的比例)≈ 是否成为瓶颈
# 诊断:如果 %util ≈ 100% 且 await 远大于 svctm,说明 IO 队列积压
# 解决:更换更快设备,或减少 IO 并发(降低队列深度)第 8 章 虚拟化环境下的块设备
8.1 virtio-blk:准虚拟化存储
在 KVM/QEMU 虚拟机中,传统的 SCSI 或 IDE 模拟性能极差(每次 IO 都需要 VM exit + 模拟设备寄存器)。virtio-blk 是一种半虚拟化(para-virtualized)存储接口——Guest 和 Host 共享一个 virtqueue(基于共享内存的环形缓冲区),通过 virtqueue 直接传递 IO 描述符,避免了设备寄存器模拟的开销:
# 查看虚拟机中的 virtio-blk 设备
lsblk -d -o NAME,MODEL
# NAME MODEL
# vda Virtual disk ← virtio-blk 设备
# virtio-blk 的队列数量(多队列 virtio-blk)
cat /sys/block/vda/queue/nr_hw_queues
# 4 ← 4 个虚拟硬件队列(对应 QEMU 的 iothread 线程数)8.2 云存储的 IO 路径
在云环境(AWS、阿里云、腾讯云)中,块设备(如 AWS EBS、阿里云云盘)是通过网络连接的远程存储:
云主机上的 IO 路径(以 AWS i3 实例 + EBS 为例):
用户程序 write()
↓
ext4 → bio
↓
blk-mq → nvme 驱动(EBS 通过 NVMe-oF 协议)
↓
NVMe-oF:通过网络(25Gbps VPC 网络)发送到 EBS 存储服务器
↓
EBS 存储服务器:SSD 阵列
↓
完成通知:通过网络返回,触发云主机的 IRQ
延迟:~200µs - 1ms(受网络往返延迟影响)
吞吐:最高 1 GB/s(EBS gp3 最大)
小结
块设备栈是 Linux 存储系统的”高速公路”——所有实际的磁盘 IO 都经过这里:
核心数据结构:
bio:IO 请求的最小单元,描述内存页 → 磁盘扇区的映射(scatter-gather)request:bio在通用块层的载体,携带 tag、合并多个 bio、追踪生命周期blk_mq_hw_ctx:硬件调度队列,每个 NVMe 队列对应一个
单队列 → blk-mq 的演进:
- 单队列:一把
queue_lock,NVMe 高并发下锁竞争成瓶颈 - blk-mq:per-CPU 软件队列(无跨 CPU 锁) + 多个硬件队列,充分利用 NVMe 的并发能力
- Plug 机制:进程级 IO 缓冲,批量合并相邻 IO
NVMe 的多队列优势:
- 硬件支持 32+ 队列 × 深度 1024,理论并发 IO = 32768
- blk-mq 为每个 CPU 分配独立的软件队列,并行提交,无锁竞争
- 实测:blk-mq + NVMe 可以达到 100 万+ IOPS(单核单队列架构下只能达到 ~20 万)
下一篇 06 IO 调度器——CFQ、Deadline 与 mq-deadline 的演进 将聚焦于 IO 调度器的内部机制:为什么 HDD 需要 CFQ 而 SSD 不需要?mq-deadline 如何在保证 IO 延迟上限的同时维持吞吐量?以及不同负载场景下调度器的选型建议。
思考题
- blk-mq 为每个 CPU 核心创建软件队列,映射到硬件队列。NVMe SSD 支持 64K 硬件队列——blk-mq 可以充分利用。在 HDD(单队列)上 blk-mq 仍然有价值——减少了软件队列的锁竞争。但 HDD 的 IOPS 只有数百——blk-mq 的多队列优势是否被 HDD 的物理限制淹没?
- IO Scheduler 的
none在 NVMe 上通常最佳。但多应用共享 NVMe 时,mq-deadline的 FIFO 过期保证了公平性。blk-cgroup 的io.weight(比例权重)和io.max(绝对限制)如何与 IO Scheduler 配合?在 cgroup v2 中,IO 限制是在调度器层面还是在提交层面实施的?/sys/block/sda/queue/nr_requests控制每个队列的最大排队请求数。增大可提高吞吐但增加延迟。在延迟敏感的数据库场景中,nr_requests与应用层iodepth的关系是什么?如果nr_requests=128但应用只提交iodepth=4,增大nr_requests是否有意义?