Linux 存储与文件系统深度剖析(三):块设备层详解

前言

在 Linux I/O 栈中,块设备层(Block Layer)是连接文件系统与底层硬件驱动的关键枢纽。无论是 ext4 的 writepage、XFS 的 journal 写入,还是数据库的 Direct I/O,最终都会落到这一层,转化为标准的 I/O 请求发送给设备驱动。

本文基于 Linux 6.4-rc1(ac9a78681b92)内核源码,从数据结构到代码执行路径,深度解析块设备层的工作原理。理解这一层,是做存储性能分析、I/O 调度调优乃至驱动开发的必要基础。


一、块设备层架构概述

1.1 传统单队列架构(legacy single-queue)

在 Linux 3.13 以前,块设备层采用单一请求队列(request_queue)设计。所有 CPU 的 I/O 请求都需要竞争同一把 queue_lock 自旋锁,然后将请求插入电梯调度器(如 CFQ、Deadline)。这一设计在 HDD 时代游刃有余——机械盘的 seek time 比锁竞争开销高出几个数量级,调度器对 I/O 的合并与排序能显著提升吞吐量。

然而,随着 NVMe SSD 的普及,设备延迟已经降至微秒级,单队列的软件开销反而成了瓶颈:

  • 全局锁竞争:在 NUMA 多核系统上,锁争用带来严重的 cache-line bouncing
  • 单队列深度不足:高端 NVMe 设备支持 64K+ 的队列深度,单队列无法利用
  • 调度器开销:为慢速设备设计的调度算法在快速 NVMe 上反而增加了延迟

1.2 blk-mq 多队列架构

为此,Jens Axboe 在 2013-2014 年引入了 blk-mq(block multi-queue)架构(block/blk-mq.c,版权归 Jens Axboe 和 Christoph Hellwig 所有),从根本上重构了块设备层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Application (read/write syscall)


VFS / Page Cache


File System (ext4/xfs/btrfs)
│ submit_bio()

┌─────────────────────────────────────────┐
│ Block Layer (blk-mq) │
│ │
│ Per-CPU Software Queue (blk_mq_ctx) │
│ CPU0 │ CPU1 │ CPU2 │ ... │ CPUn │
│ ↘ ↓ ↙ │
│ Hardware Queue (blk_mq_hw_ctx) │
│ HQ0 HQ1 HQ2 ... HQm │
└─────────────────────────────────────────┘
│ queue_rq()

Block Driver (nvme/scsi/virtio-blk)


Physical Device

核心设计思想是两级队列

  1. 软件队列(Software Queue,blk_mq_ctx:每个 CPU 绑定一个,用于接收该 CPU 提交的 I/O 请求,无需全局加锁。
  2. 硬件队列(Hardware Queue,blk_mq_hw_ctx:对应设备的实际硬件队列(如 NVMe 的 Submission Queue),一个或多个软件队列映射到一个硬件队列。

blk-mq 同样支持 I/O 调度器(mq-deadline、bfq、kyber),但调度粒度从全局变为了硬件队列级别,大幅减少了锁竞争。


二、核心数据结构深度分析

2.1 struct bio —— I/O 操作的基本单元

bio(Block I/O)是块设备层最核心的数据结构,定义在 include/linux/blk_types.h 中。它描述了一次 I/O 操作的所有信息:目标设备、起始扇区、数据缓冲区列表、操作类型及完成回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* include/linux/blk_types.h */
struct bio {
struct bio *bi_next; /* request queue link */
struct block_device *bi_bdev;
blk_opf_t bi_opf; /* bottom bits REQ_OP, top bits req_flags */
unsigned short bi_flags; /* BIO_* below */
unsigned short bi_ioprio;
blk_status_t bi_status;
atomic_t __bi_remaining;

struct bvec_iter bi_iter;

blk_qc_t bi_cookie;
bio_end_io_t *bi_end_io;
void *bi_private;
#ifdef CONFIG_BLK_CGROUP
struct blkcg_gq *bi_blkg;
struct bio_issue bi_issue;
#ifdef CONFIG_BLK_CGROUP_IOCOST
u64 bi_iocost_cost;
#endif
#endif
/* ... integrity, encryption ... */
unsigned short bi_vcnt; /* how many bio_vec's */
unsigned short bi_max_vecs; /* max bvl_vecs we can hold */
atomic_t __bi_cnt; /* pin count */
struct bio_vec *bi_io_vec; /* the actual vec list */
struct bio_set *bi_pool;
struct bio_vec bi_inline_vecs[]; /* inline vecs for small I/O */
};

关键字段逐一解析:

字段 类型 说明
bi_next struct bio * 当多个 bio 被合并到一个 request 时,通过此指针形成链表
bi_bdev struct block_device * 目标块设备(含分区信息),通过 bi_bdev->bd_disk 可得 gendisk
bi_opf blk_opf_t(u32) 低 8 位为操作类型(enum req_op),高 24 位为标志位(REQ_SYNCREQ_FUA 等)
bi_flags unsigned short BIO 状态标志,如 BIO_CLONED(克隆 bio)、BIO_CHAIN(链式 bio)等
bi_status blk_status_t I/O 完成状态,BLK_STS_OK(0)表示成功,其余为各类错误码
__bi_remaining atomic_t 引用计数,链式 bio 时最后一个完成才触发 bi_end_io
bi_iter struct bvec_iter I/O 迭代器,记录当前扇区位置(bi_sector)、已处理字节数(bi_done)等
bi_cookie blk_qc_t 提交后返回的 cookie,用于 polling 模式(REQ_POLLED)查询完成状态
bi_end_io 函数指针 I/O 完成回调,驱动完成后调用,用于通知上层(page cache、文件系统等)
bi_private void * 供调用者保存私有上下文,内核不使用
bi_vcnt unsigned short bi_io_vec 数组中有效的 bio_vec 数量
bi_io_vec struct bio_vec * 数据段列表,每个 bio_vec 描述一个物理页面片段(page, offset, len)
bi_inline_vecs[] flexible array 尾部内联的 vec 空间,避免小 I/O 的二次内存分配

操作类型(enum req_op 是理解 bio 语义的关键:

1
2
3
4
5
6
7
8
9
10
11
/* include/linux/blk_types.h */
enum req_op {
REQ_OP_READ = 0, /* 从设备读扇区 */
REQ_OP_WRITE = 1, /* 向设备写扇区 */
REQ_OP_FLUSH = 2, /* 刷新写缓存 */
REQ_OP_DISCARD = 3, /* 丢弃扇区(TRIM/UNMAP) */
REQ_OP_SECURE_ERASE = 5, /* 安全擦除 */
REQ_OP_WRITE_ZEROES = 9, /* 写零 */
REQ_OP_ZONE_APPEND = 13, /* 追加写(ZNS 设备) */
/* ... zone management ops ... */
};

注意操作号的奇偶性有意义:奇数为写方向(TO device),偶数为读方向(FROM device),这由 op_is_write() 内联函数利用 op & 1 快速判断。

2.2 struct request —— 调度器视角的 I/O 请求

bio 是文件系统/VFS 层与块设备层的接口,而 struct request 是块设备层内部的调度单元。一个 request 可能包含多个连续地址的 bio(经过合并后)。它定义在 include/linux/blk-mq.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/* include/linux/blk-mq.h */
struct request {
struct request_queue *q;
struct blk_mq_ctx *mq_ctx; /* 提交 request 的软件队列 */
struct blk_mq_hw_ctx *mq_hctx; /* 分发到的硬件队列 */

blk_opf_t cmd_flags; /* op and common flags */
req_flags_t rq_flags; /* RQF_* 内部状态标志 */

int tag; /* 硬件队列 tag(分配时赋值) */
int internal_tag; /* 调度器内部 tag */

unsigned int timeout;

unsigned int __data_len; /* 总数据长度(字节) */
sector_t __sector; /* 当前扇区游标 */

struct bio *bio; /* bio 链表头 */
struct bio *biotail; /* bio 链表尾 */

union {
struct list_head queuelist; /* 在调度器队列中的链表节点 */
struct request *rq_next; /* 在 plug list 中的下一个 */
};

struct block_device *part; /* 目标分区 */
u64 start_time_ns; /* 请求分配时间(用于延迟统计) */
u64 io_start_time_ns; /* I/O 下发到设备的时间 */

unsigned short nr_phys_segments; /* DMA scatter-gather 段数 */
unsigned short ioprio;

enum mq_rq_state state; /* MQ_RQ_IDLE / IN_FLIGHT / COMPLETE */
atomic_t ref;

union {
struct hlist_node hash; /* 调度器合并哈希表节点 */
struct llist_node ipi_list; /* 跨 CPU 完成通知 */
};

union {
struct rb_node rb_node; /* 调度器红黑树节点 */
struct bio_vec special_vec; /* WRITE SAME 等特殊 payload */
void *completion_data;
};

union {
struct { struct io_cq *icq; void *priv[2]; } elv; /* 调度器私有 */
struct { unsigned int seq; struct list_head list;
rq_end_io_fn *saved_end_io; } flush; /* flush 序列 */
};

rq_end_io_fn *end_io; /* 完成回调 */
void *end_io_data;
};

几个关键设计细节:

  • taginternal_tag 的区别:tag 是真正下发给硬件的编号(从 blk_mq_tags 的 sbitmap 分配),internal_tag 是在使用调度器时由调度器分配的”预分配”编号。只有请求真正下发时才分配硬件 tag。
  • state 的三个取值(MQ_RQ_IDLE → MQ_RQ_IN_FLIGHT → MQ_RQ_COMPLETE)用原子读写保护,驱动通过 blk_mq_start_request() 将状态置为 IN_FLIGHT,完成时置为 COMPLETE
  • start_time_nsio_start_time_ns 的差值就是在软件层(调度器)排队等待的时间,这正是 iostat -xawait 减去 svctm 的部分。

req_flags_t(RQF_*) 描述请求的内部生命周期状态:

1
2
3
4
5
#define RQF_STARTED       (1 << 1)   /* 驱动已开始处理 */
#define RQF_FLUSH_SEQ (1 << 4) /* 属于 flush 序列 */
#define RQF_MQ_INFLIGHT (1 << 6) /* 计入 inflight 统计 */
#define RQF_IO_STAT (1 << 13) /* 计入磁盘 I/O 统计 */
#define RQF_ELV (1 << 22) /* 队列上挂有电梯调度器 */

2.3 struct request_queue —— 块设备的全局控制中心

request_queue 是每个块设备(或分区组)的核心管理结构,定义在 include/linux/blkdev.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/* include/linux/blkdev.h (节选) */
struct request_queue {
struct request *last_merge; /* 最近一次合并的 request,加速后续合并查找 */
struct elevator_queue *elevator; /* 绑定的 I/O 调度器实例 */

struct percpu_ref q_usage_counter; /* 引用计数,freeze 时变为 0 */

struct blk_queue_stats *stats;
struct rq_qos *rq_qos; /* QoS 控制链(throttle、wbt 等) */

const struct blk_mq_ops *mq_ops; /* 驱动操作函数表 */

/* 软件队列:每个 CPU 一个 */
struct blk_mq_ctx __percpu *queue_ctx;

unsigned int queue_depth;

/* 硬件队列:通过 XArray 索引 */
struct xarray hctx_table;
unsigned int nr_hw_queues;

void *queuedata; /* 驱动私有数据 */
unsigned long queue_flags; /* QUEUE_FLAG_* */

int id;
spinlock_t queue_lock;
struct gendisk *disk;

unsigned long nr_requests; /* 最大请求数 */
unsigned int rq_timeout;
struct timer_list timeout;
struct work_struct timeout_work;

struct queue_limits limits; /* 设备限制(max_sectors, seg_size 等) */

/* flush 机制 */
struct blk_flush_queue *fq;

/* requeue 队列:无法立即下发时暂存 */
struct list_head requeue_list;
spinlock_t requeue_lock;
struct delayed_work requeue_work;

int mq_freeze_depth; /* 冻结深度计数 */
wait_queue_head_t mq_freeze_wq;
struct mutex mq_freeze_lock;
/* ... */
};

request_queue 中的 struct queue_limits limits 非常重要,它记录了设备的物理约束,直接影响 I/O 分割和合并的策略:

  • max_sectors:单次 I/O 最大扇区数
  • max_segments:最大 scatter-gather 段数
  • max_segment_size:单个 DMA 段的最大字节数
  • logical_block_size:逻辑块大小(通常 512B 或 4096B)
  • physical_block_size:物理块大小(影响合并对齐)
  • discard_granularity:TRIM/DISCARD 的粒度

nr_hw_queues 决定了多队列的并行度。对于 NVMe SSD,这个值通常等于 CPU 核心数(每个 CPU 一个硬件队列);对于虚拟设备(如 virtio-blk)通常是 1。

2.4 struct blk_mq_hw_ctx —— 硬件队列状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* include/linux/blk-mq.h (节选) */
struct blk_mq_hw_ctx {
struct {
spinlock_t lock;
struct list_head dispatch; /* 待下发但资源不足时暂存的请求 */
unsigned long state; /* BLK_MQ_S_STOPPED / TAG_ACTIVE / SCHED_RESTART */
} ____cacheline_aligned_in_smp;

struct delayed_work run_work; /* 延迟运行硬件队列的 work */
cpumask_var_t cpumask; /* 该 hctx 对应的 CPU 集合 */
int next_cpu;
int next_cpu_batch;

unsigned long flags; /* BLK_MQ_F_* */

void *sched_data; /* I/O 调度器私有数据 */
struct request_queue *queue;
struct blk_flush_queue *fq;
void *driver_data;

struct sbitmap ctx_map; /* 标记哪些软件队列有待处理请求 */

struct blk_mq_ctx *dispatch_from; /* 无调度器时的分发起点(轮转) */
unsigned int dispatch_busy; /* 硬件繁忙度(EWMA 估算) */

unsigned short type; /* HCTX_TYPE_DEFAULT/READ/POLL */
unsigned short nr_ctx; /* 关联的软件队列数量 */
struct blk_mq_ctx **ctxs; /* 软件队列数组 */

struct blk_mq_tags *tags; /* 硬件 tag 集合 */
struct blk_mq_tags *sched_tags; /* 调度器 tag 集合 */

unsigned long queued; /* 累计入队请求数(调试统计) */
unsigned long run; /* 累计下发请求数 */

unsigned int numa_node;
unsigned int queue_num; /* hctx 编号 */
atomic_t nr_active; /* tag set 共享时的活跃请求数 */
/* ... debugfs, cpuhp ... */
};

ctx_map 是一个 sbitmap,每个 bit 对应一个软件队列(blk_mq_ctx)。当某个软件队列有新请求时,对应 bit 被置位(blk_mq_hctx_mark_pending());硬件队列运行时遍历所有置位的软件队列取出请求。这个设计避免了逐个遍历所有 CPU 队列的开销。

2.5 struct blk_mq_ctx —— 软件队列(Per-CPU)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* block/blk-mq.h */
struct blk_mq_ctx {
struct {
spinlock_t lock;
struct list_head rq_lists[HCTX_MAX_TYPES]; /* DEFAULT/READ/POLL 三种类型 */
} ____cacheline_aligned_in_smp;

unsigned int cpu;
unsigned short index_hw[HCTX_MAX_TYPES]; /* 该 ctx 在 hctx->ctx_map 中的 bit 位 */
struct blk_mq_hw_ctx *hctxs[HCTX_MAX_TYPES]; /* 关联的硬件队列 */

struct request_queue *queue;
struct blk_mq_ctxs *ctxs;
struct kobject kobj;
} ____cacheline_aligned_in_smp;

____cacheline_aligned_in_smp 确保每个 CPU 的软件队列数据独占一条 cache line,消除 false sharing。rq_lists 按请求类型分三个链表,允许驱动(通过 enum hctx_type)针对不同操作类型(如 READ vs POLL)映射到不同的硬件队列。


三、bio 的完整生命周期

3.1 bio 分配:bio_alloc_bioset()

bio 通常通过 bio_alloc_bioset() 从内存池(mempool)分配,而不是直接用 kmalloc。这是为了保证在内存紧张时,I/O 路径仍能前进而不死锁。核心逻辑在 block/bio.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* block/bio.c */
struct bio *bio_alloc_bioset(struct block_device *bdev, unsigned short nr_vecs,
blk_opf_t opf, gfp_t gfp_mask,
struct bio_set *bs)
{
gfp_t saved_gfp = gfp_mask;
struct bio *bio;
void *p;

/* Per-CPU 缓存快速路径:REQ_ALLOC_CACHE 标志 + 小 bio */
if (opf & REQ_ALLOC_CACHE) {
if (bs->cache && nr_vecs <= BIO_INLINE_VECS) {
bio = bio_alloc_percpu_cache(bdev, nr_vecs, opf, gfp_mask, bs);
if (bio)
return bio;
} else {
opf &= ~REQ_ALLOC_CACHE;
}
}

/*
* 防死锁:当运行在 submit_bio_noacct() 上下文(即任何块驱动内部)时,
* 已有 bio 在当前任务的 bio_list 中待提交。若此时内存紧张并触发直接回收,
* 可能导致死锁。因此先尝试不阻塞分配,失败时将待提交 bio 转发给救援线程。
*/
if (current->bio_list &&
(!bio_list_empty(&current->bio_list[0]) ||
!bio_list_empty(&current->bio_list[1])) &&
bs->rescue_workqueue)
gfp_mask &= ~__GFP_DIRECT_RECLAIM;

p = mempool_alloc(&bs->bio_pool, gfp_mask);
if (!p && gfp_mask != saved_gfp) {
punt_bios_to_rescuer(bs); /* 将阻塞的 bio 交给 rescuer workqueue */
gfp_mask = saved_gfp;
p = mempool_alloc(&bs->bio_pool, gfp_mask);
}
if (unlikely(!p))
return NULL;

bio = p + bs->front_pad; /* front_pad 供上层(如 md、dm)存放私有数据 */

/* 根据 nr_vecs 决定是否需要从 bvec_pool 额外分配 bvec 数组 */
if (nr_vecs > BIO_INLINE_VECS) {
struct bio_vec *bvl = bvec_alloc(&bs->bvec_pool, &nr_vecs, gfp_mask);
/* ... */
bio_init(bio, bdev, bvl, nr_vecs, opf);
} else if (nr_vecs) {
bio_init(bio, bdev, bio->bi_inline_vecs, BIO_INLINE_VECS, opf);
} else {
bio_init(bio, bdev, NULL, 0, opf);
}

bio->bi_pool = bs;
return bio;
}
EXPORT_SYMBOL(bio_alloc_bioset);

bvec_slabs 的分级设计 值得关注——bio.c 定义了四种规格的 bvec slab:

1
2
3
4
5
6
static struct biovec_slab bvec_slabs[] __read_mostly = {
{ .nr_vecs = 16, .name = "biovec-16" },
{ .nr_vecs = 64, .name = "biovec-64" },
{ .nr_vecs = 128, .name = "biovec-128" },
{ .nr_vecs = BIO_MAX_VECS, .name = "biovec-max" },
};

小于等于 4 个 vec 的 bio 使用 bi_inline_vecs(内联在 bio 结构体尾部),无需额外分配。这对于大量小 I/O(如 4K 随机读写)的场景非常重要。

3.2 bio 提交:submit_bio()submit_bio_noacct()

上层(文件系统、Direct I/O)调用 submit_bio() 将 bio 送入块设备层:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* block/blk-core.c */
void submit_bio(struct bio *bio)
{
if (bio_op(bio) == REQ_OP_READ) {
task_io_account_read(bio->bi_iter.bi_size);
count_vm_events(PGPGIN, bio_sectors(bio));
} else if (bio_op(bio) == REQ_OP_WRITE) {
count_vm_events(PGPGOUT, bio_sectors(bio));
}

submit_bio_noacct(bio);
}
EXPORT_SYMBOL(submit_bio);

submit_bio() 做的事很简单:更新任务 I/O 统计(/proc/<pid>/io)和全局 VM 事件计数器,然后调用 submit_bio_noacct()

submit_bio_noacct() 是真正的入口,它会进行 block cgroup 检查、throttling、wbt(writeback throttling)等 QoS 处理。对于 blk-mq 设备,最终调用 blk_mq_submit_bio()

3.3 blk_mq_submit_bio() —— blk-mq 提交路径

这是 blk-mq 架构的提交核心,位于 block/blk-mq.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/* block/blk-mq.c */
void blk_mq_submit_bio(struct bio *bio)
{
struct request_queue *q = bdev_get_queue(bio->bi_bdev);
struct blk_plug *plug = blk_mq_plug(bio);
const int is_sync = op_is_sync(bio->bi_opf);
struct blk_mq_hw_ctx *hctx;
struct request *rq;
unsigned int nr_segs = 1;
blk_status_t ret;

/* 1. bounce buffer 处理(高端内存设备的 DMA 限制) */
bio = blk_queue_bounce(bio, q);

/* 2. 按设备限制分割 bio(超过 max_sectors 或 max_segments 则拆分) */
if (bio_may_exceed_limits(bio, &q->limits)) {
bio = __bio_split_to_limits(bio, &q->limits, &nr_segs);
if (!bio)
return;
}

/* 3. 数据完整性预处理(T10 PI/DIX) */
if (!bio_integrity_prep(bio))
return;

bio_set_ioprio(bio);

/* 4. 尝试合并到 plug list 中已有的 request(plug 合并,最快路径) */
rq = blk_mq_get_cached_request(q, plug, &bio, nr_segs);
if (!rq) {
if (!bio)
return;
/* 5. 分配新的 request */
rq = blk_mq_get_new_requests(q, plug, bio, nr_segs);
if (unlikely(!rq))
return;
}

trace_block_getrq(bio);
rq_qos_track(q, rq, bio);

/* 6. 将 bio 关联到 request */
blk_mq_bio_to_request(rq, bio, nr_segs);

/* 7. 内联加密 keyslot 获取 */
ret = blk_crypto_rq_get_keyslot(rq);
if (ret != BLK_STS_OK) {
bio->bi_status = ret;
bio_endio(bio);
blk_mq_free_request(rq);
return;
}

/* 8. flush 操作特殊处理 */
if (op_is_flush(bio->bi_opf)) {
blk_insert_flush(rq);
return;
}

/* 9. plug 路径:请求暂存在 plug list,unplug 时批量下发 */
if (plug) {
blk_add_rq_to_plug(plug, rq);
return;
}

/* 10. 直接下发路径 */
hctx = rq->mq_hctx;
if ((rq->rq_flags & RQF_ELV) ||
(hctx->dispatch_busy && (q->nr_hw_queues == 1 || !is_sync))) {
blk_mq_insert_request(rq, 0);
blk_mq_run_hw_queue(hctx, true);
} else {
blk_mq_run_dispatch_ops(q, blk_mq_try_issue_directly(hctx, rq));
}
}

plug 机制是性能优化的关键:调用者在一批 I/O 开始前调用 blk_start_plug(),所有 bio 被暂存在 per-task 的 plug 链表中(不加任何锁),I/O 结束后调用 blk_finish_plug() 触发 unplug,此时进行合并和批量下发。这本质上是将调度从内核侧延迟到应用侧,减少了加锁次数和 context switch。

3.4 bio 完成:bio_endio()

当设备驱动完成 I/O 后,会调用 blk_mq_end_request(),最终触发 bio_endio()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* block/bio.c */
void bio_endio(struct bio *bio)
{
again:
/* 链式 bio:只有所有子 bio 都完成才触发父 bio 的回调 */
if (!bio_remaining_done(bio))
return;

/* 数据完整性验证 */
if (!bio_integrity_endio(bio))
return;

rq_qos_done_bio(bio);

/* blktrace 追踪点 */
if (bio->bi_bdev && bio_flagged(bio, BIO_TRACE_COMPLETION)) {
trace_block_bio_complete(bdev_get_queue(bio->bi_bdev), bio);
bio_clear_flag(bio, BIO_TRACE_COMPLETION);
}

/* 尾递归优化:避免链式 bio 栈溢出 */
if (bio->bi_end_io == bio_chain_endio) {
bio = __bio_chain_endio(bio);
goto again;
}

blk_throtl_bio_endio(bio); /* throttle 记账 */
bio_uninit(bio); /* 释放 cgroup 引用等 */
if (bio->bi_end_io)
bio->bi_end_io(bio); /* 调用上层回调(如 page cache 的 end_bio_read) */
}
EXPORT_SYMBOL(bio_endio);

注意 bio_chain_endio 分支的尾递归优化:当多个 bio 通过 bio_chain() 串联时,如果用普通递归处理,深度链可能导致栈溢出。goto again 将递归转为循环,同时 __bio_chain_endio 返回父 bio 让循环继续处理,这是内核中防止栈溢出的经典技巧。


四、blk-mq 多队列架构详解

4.1 队列映射:CPU → 软件队列 → 硬件队列

blk-mq 使用两级映射:

  1. CPU → 软件队列(blk_mq_ctx:通过 per_cpu 机制,每个 CPU 直接访问自己的 queue_ctx
  2. 软件队列 → 硬件队列(blk_mq_hw_ctx:通过 ctx->hctxs[type],不同类型(DEFAULT/READ/POLL)的请求可以路由到不同硬件队列。

映射关系由 struct blk_mq_queue_map 描述:

1
2
3
4
5
struct blk_mq_queue_map {
unsigned int *mq_map; /* CPU ID → 硬件队列索引 */
unsigned int nr_queues;
unsigned int queue_offset;
};

默认映射算法(blk_mq_map_queues())将 CPU 均匀分散到硬件队列,NUMA 感知版本会优先将 CPU 映射到本 NUMA 节点的硬件队列,减少跨 NUMA 内存访问。

4.2 请求分发:blk_mq_dispatch_rq_list()

当硬件队列需要处理请求时,blk_mq_dispatch_rq_list() 负责将请求下发给驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/* block/blk-mq.c */
bool blk_mq_dispatch_rq_list(struct blk_mq_hw_ctx *hctx, struct list_head *list,
unsigned int nr_budgets)
{
struct request_queue *q = hctx->queue;
struct request *rq;
int queued = 0;
blk_status_t ret = BLK_STS_OK;
LIST_HEAD(zone_list);
bool needs_resource = false;

if (list_empty(list))
return false;

queued = 0;
do {
struct blk_mq_queue_data bd;

rq = list_first_entry(list, struct request, queuelist);
/* 准备分发(预算检查、tag 分配等) */
prep = blk_mq_prep_dispatch_rq(rq, !nr_budgets);
if (prep != PREP_DISPATCH_OK)
break;

list_del_init(&rq->queuelist);
bd.rq = rq;
bd.last = list_empty(list); /* 告知驱动是否是最后一个请求 */

if (nr_budgets)
nr_budgets--;

/* 调用驱动的 queue_rq 回调,真正下发给硬件 */
ret = q->mq_ops->queue_rq(hctx, &bd);
switch (ret) {
case BLK_STS_OK:
queued++;
break;
case BLK_STS_RESOURCE:
needs_resource = true;
fallthrough;
case BLK_STS_DEV_RESOURCE:
/* 设备资源不足,暂停并将未下发请求回放到 dispatch 链表 */
blk_mq_handle_dev_resource(rq, list);
goto out;
case BLK_STS_ZONE_RESOURCE:
/* ZNS 设备 zone 资源不足,移到 zone_list 继续处理其他 zone */
blk_mq_handle_zone_resource(rq, &zone_list);
needs_resource = true;
break;
default:
blk_mq_end_request(rq, ret);
}
} while (!list_empty(list));
out:
/* 将 zone_list 中的请求合并回 list */
if (!list_empty(&zone_list))
list_splice_tail_init(&zone_list, list);
/* ...处理未下发的请求,考虑是否需要 restart... */
}

bd.last 字段很有趣——它允许驱动实现”批量提交”优化。NVMe 驱动利用此标志决定何时真正 ring doorbell(更新提交队列尾指针),当 last=false 时只填写 SQ Entry 但暂不通知设备,last=true 时才一次性通知,大幅减少 MMIO 写操作次数(每次 MMIO 写耗时数百纳秒)。

4.3 请求完成:跨 CPU 的 IPI 机制

在 NUMA 系统或中断亲和性配置不当时,I/O 完成中断可能在与提交请求不同的 CPU 上触发。blk-mq 有两种完成路径:

  1. 本地完成blk_mq_complete_request() → 直接调用 rq->q->mq_ops->complete(rq)
  2. 跨 CPU 完成(IPI):通过 llistRAISE_SOFTIRQ(BLOCK_SOFTIRQ) 将完成通知发送到请求所在 CPU,再在 softirq 上下文处理
1
static DEFINE_PER_CPU(struct llist_head, blk_cpu_done);

blk_cpu_done 是 per-CPU 的无锁链表,跨 CPU 完成时将 request 通过 llist_add() 挂到目标 CPU 的链表上,然后发送 IPI 唤醒 softirq 处理。


五、请求合并机制深度分析

5.1 合并的类型

I/O 合并是块设备层的重要优化,将多个 bio/request 合并为一个大请求,减少 I/O 操作次数。blk-merge.c 实现了三种合并:

类型 说明 条件
后向合并(Back Merge) 新 bio 追加到 request 末尾 req_end_sector == bio_start_sector
前向合并(Front Merge) 新 bio 插入到 request 头部 bio_end_sector == req_start_sector
request 间合并(Elevator Merge) 两个 request 合并为一个 调度器负责,检查相邻性

5.2 合并判断:blk_try_merge()

1
2
3
4
5
6
7
8
9
10
11
/* block/blk-merge.c */
enum elv_merge blk_try_merge(struct request *rq, struct bio *bio)
{
if (blk_discard_mergable(rq))
return ELEVATOR_DISCARD_MERGE;
else if (blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector)
return ELEVATOR_BACK_MERGE; /* request 末尾扇区 == bio 起始扇区 */
else if (blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector)
return ELEVATOR_FRONT_MERGE; /* bio 末尾扇区 == request 起始扇区 */
return ELEVATOR_NO_MERGE;
}

5.3 后向合并的完整检查:ll_back_merge_fn()

仅扇区相邻还不够,还需要通过硬件限制检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* block/blk-merge.c */
int ll_back_merge_fn(struct request *req, struct bio *bio, unsigned int nr_segs)
{
/* 检查是否会产生 DMA gap(虚拟边界约束) */
if (req_gap_back_merge(req, bio))
return 0;

/* 数据完整性 gap 检查(PI/DIF 数据) */
if (blk_integrity_rq(req) &&
integrity_req_gap_back_merge(req, bio))
return 0;

/* 内联加密:key context 必须匹配 */
if (!bio_crypt_ctx_back_mergeable(req, bio))
return 0;

/* 合并后不能超过设备最大 sectors 限制 */
if (blk_rq_sectors(req) + bio_sectors(bio) >
blk_rq_get_max_sectors(req, blk_rq_pos(req))) {
req_set_nomerge(req->q, req); /* 标记该 req 不可再合并,避免重复检查 */
return 0;
}

return ll_new_hw_segment(req, bio, nr_segs); /* 检查 segments 数量限制 */
}

5.4 调度器的哈希加速

为了快速找到可合并的 request,调度器维护一个以扇区号为键的哈希表(RQF_HASHED 标志表示请求在哈希表中)。每次 bio 提交时,通过 elv_merge() 在哈希表中 O(1) 查找末尾扇区匹配的 request,而不是遍历全部请求。

request_queuelast_merge 字段更进一步——它缓存了上一次合并的 request 指针,因为 I/O 模式往往具有时间局部性,顺序写入时后续 bio 极可能与同一个 request 合并。


六、块设备 I/O 统计:/proc/diskstats 的数据来源

iostat 的数据全部来自 /proc/diskstats,而 diskstats 的数据由块设备层在请求生命周期的关键节点记录。核心结构是 struct disk_stats(per-CPU)和 part_stat_* 系列宏。

diskstats 的字段与内核计数器映射:

1
2
3
4
5
6
7
8
9
/proc/diskstats 字段:
reads_completed → part_stat_add(part, ios[STAT_READ], 1)
sectors_read → part_stat_add(part, sectors[STAT_READ], ...)
time_reading_ms → part_stat_add(part, nsecs[STAT_READ], nsecs) / NSEC_PER_MSEC
reads_merged → part_stat_inc(part, merges[STAT_READ])
writes_completed → part_stat_add(part, ios[STAT_WRITE], 1)
...
io_in_progress → part_in_flight() ← 实时值,非累计
time_io_ms → part_stat_add(part, io_ticks, ...)

记录时机:

  • blk_account_io_start(rq):request 下发到设备时,递增 in_flight 计数,记录 start_time_ns
  • blk_account_io_done(rq, now):request 完成时,更新 iossectorsnsecs,递减 in_flight

await(平均 I/O 等待时间)= nsecs[READ] / ios[READ],包含排队时间和设备服务时间。svctm(已被 iostat 废弃,不再可靠)原本估算纯设备服务时间。

io_ticks 是设备繁忙时间的累计,当 in_flight > 0 时每个 tick 递增,对应 iostat 的 %util


七、实际调试技巧

7.1 blktrace:内核级 I/O 追踪

blktrace 利用内核 tracefs/relay 机制,可以捕获 bio/request 在块设备层每个阶段的事件(队列、合并、下发、完成等):

1
2
3
4
5
6
7
8
# 追踪 nvme0n1 设备 10 秒
blktrace -d /dev/nvme0n1 -w 10 -o /tmp/nvme_trace

# 分析追踪结果
blkparse /tmp/nvme_trace.blktrace.* | head -50

# 生成 I/O 模式可视化(需要 seekwatcher)
btt -i /tmp/nvme_trace.blktrace.0

blktrace 事件字母含义:

字母 阶段 说明
Q Queued bio 进入块设备层
G Get request 分配 request 结构
M Merge bio 被合并到已有 request
I Insert request 插入 I/O 调度器
D Issue request 下发给驱动
C Complete request 完成
P Plug 设备被 plugged
U Unplug 设备被 unplugged,触发批量下发

从 Q 到 D 的时间是软件栈延迟,D 到 C 是设备服务时间。

7.2 BPF 工具:biolatency 和 biosnoop

BCC(BPF Compiler Collection)提供了更灵活的 I/O 分析工具:

1
2
3
4
5
6
7
8
9
10
11
# 统计 I/O 延迟分布(直方图)
biolatency -d nvme0n1 10

# 实时追踪每个 I/O 操作
biosnoop -d nvme0n1

# 跟踪 blk-mq 请求队列深度
blkqueue # 需要 bpftrace

# 使用 bpftrace 自定义:统计 submit_bio 调用栈
bpftrace -e 'kprobe:submit_bio { @[kstack] = count(); }'

7.3 系统调优参数

调优块设备层的关键 sysfs 参数(以 nvme0n1 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 查看/设置 I/O 调度器
cat /sys/block/nvme0n1/queue/scheduler
echo mq-deadline > /sys/block/nvme0n1/queue/scheduler

# 队列深度(request_queue.nr_requests)
cat /sys/block/nvme0n1/queue/nr_requests
echo 256 > /sys/block/nvme0n1/queue/nr_requests

# 最大 I/O 大小(对应 queue_limits.max_sectors_kb)
cat /sys/block/nvme0n1/queue/max_sectors_kb

# read-ahead 大小
cat /sys/block/nvme0n1/queue/read_ahead_kb
echo 128 > /sys/block/nvme0n1/queue/read_ahead_kb

# 硬件队列数量(只读)
cat /sys/block/nvme0n1/mq/

# 查看各硬件队列的请求统计
cat /sys/kernel/debug/block/nvme0n1/hctx0/run
cat /sys/kernel/debug/block/nvme0n1/hctx0/queued

7.4 使用 debugfs 分析 blk-mq 状态

Linux 提供了详细的 blk-mq debugfs 接口(需要 CONFIG_BLK_DEBUG_FS=y):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 查看 request_queue 整体状态
cat /sys/kernel/debug/block/nvme0n1/state

# 查看硬件队列状态
ls /sys/kernel/debug/block/nvme0n1/
# 输出: hctx0 hctx1 ... hctxN

# 查看特定硬件队列的 dispatch 队列中的请求
cat /sys/kernel/debug/block/nvme0n1/hctx0/dispatch

# 查看软件队列
cat /sys/kernel/debug/block/nvme0n1/hctx0/ctx0/rq_list

# 查看 tag 使用情况
cat /sys/kernel/debug/block/nvme0n1/hctx0/tags

八、blk-mq 性能调优实战

8.1 选择合适的 I/O 调度器

1
2
3
4
none      → 无调度器,适合 NVMe 等极低延迟设备(高并发随机 I/O)
mq-deadline → deadline 的 blk-mq 版,适合混合负载,防止写饥饿
kyber → 基于 token 的轻量级调度器,适合低延迟 SSD(latency target)
bfq → 基于比例带宽分配,适合桌面/混合负载(公平性好但开销大)

对于 NVMe SSD 数据库服务器,通常推荐 nonemq-deadline(设置合理的 write_expire 以控制写入延迟)。

8.2 NUMA 感知调优

在多 NUMA 节点系统上,确保设备中断亲和性和进程调度在同一 NUMA 节点:

1
2
3
4
5
# 查看 nvme0n1 中断的 CPU 亲和性
cat /proc/irq/$(ls /sys/block/nvme0n1/device/msi_irqs/ | head -1)/smp_affinity_list

# 将 I/O 密集进程绑定到 NVMe 控制器所在 NUMA 节点
numactl --cpunodebind=0 --membind=0 fio --name=test ...

九、小结

本文从源码层面系统梳理了 Linux 块设备层的核心机制:

  1. 架构演进:从单队列到 blk-mq 多队列,解决了 NVMe 时代的扩展性问题
  2. 核心数据结构bio 描述单次 I/O,request 是调度器视角的单元,request_queue 是设备的控制中枢,blk_mq_hw_ctx/blk_mq_ctx 实现了两级队列的无锁化设计
  3. bio 生命周期:从 bio_alloc_bioset() 的内存池设计,到 blk_mq_submit_bio() 的 plug/unplug 批处理优化,再到 bio_endio() 的链式 bio 尾递归处理
  4. 请求合并:通过哈希加速和 last_merge 缓存,在满足 DMA 约束的前提下最大化合并效果
  5. 统计与调试/proc/diskstats 的数据来源,以及 blktrace、BPF 工具链的使用

下一篇将聚焦 I/O 调度器(mq-deadline、bfq、kyber)的实现原理,深入分析请求排序、带宽公平分配和延迟控制算法。


参考资料