前言
在 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
|
核心设计思想是两级队列:
- 软件队列(Software Queue,
blk_mq_ctx):每个 CPU 绑定一个,用于接收该 CPU 提交的 I/O 请求,无需全局加锁。
- 硬件队列(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
| struct bio { struct bio *bi_next; struct block_device *bi_bdev; blk_opf_t bi_opf; unsigned short bi_flags; 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 unsigned short bi_vcnt; unsigned short bi_max_vecs; atomic_t __bi_cnt; struct bio_vec *bi_io_vec; struct bio_set *bi_pool; struct bio_vec bi_inline_vecs[]; };
|
关键字段逐一解析:
| 字段 |
类型 |
说明 |
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_SYNC、REQ_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
| enum req_op { REQ_OP_READ = 0, REQ_OP_WRITE = 1, REQ_OP_FLUSH = 2, REQ_OP_DISCARD = 3, REQ_OP_SECURE_ERASE = 5, REQ_OP_WRITE_ZEROES = 9, REQ_OP_ZONE_APPEND = 13, };
|
注意操作号的奇偶性有意义:奇数为写方向(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
| struct request { struct request_queue *q; struct blk_mq_ctx *mq_ctx; struct blk_mq_hw_ctx *mq_hctx;
blk_opf_t cmd_flags; req_flags_t rq_flags;
int tag; int internal_tag;
unsigned int timeout;
unsigned int __data_len; sector_t __sector;
struct bio *bio; struct bio *biotail;
union { struct list_head queuelist; struct request *rq_next; };
struct block_device *part; u64 start_time_ns; u64 io_start_time_ns;
unsigned short nr_phys_segments; unsigned short ioprio;
enum mq_rq_state state; atomic_t ref;
union { struct hlist_node hash; struct llist_node ipi_list; };
union { struct rb_node rb_node; struct bio_vec special_vec; 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; };
rq_end_io_fn *end_io; void *end_io_data; };
|
几个关键设计细节:
tag 与 internal_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_ns 和 io_start_time_ns 的差值就是在软件层(调度器)排队等待的时间,这正是 iostat -x 中 await 减去 svctm 的部分。
req_flags_t(RQF_*) 描述请求的内部生命周期状态:
1 2 3 4 5
| #define RQF_STARTED (1 << 1) #define RQF_FLUSH_SEQ (1 << 4) #define RQF_MQ_INFLIGHT (1 << 6) #define RQF_IO_STAT (1 << 13) #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
| struct request_queue { struct request *last_merge; struct elevator_queue *elevator;
struct percpu_ref q_usage_counter;
struct blk_queue_stats *stats; struct rq_qos *rq_qos;
const struct blk_mq_ops *mq_ops;
struct blk_mq_ctx __percpu *queue_ctx;
unsigned int queue_depth;
struct xarray hctx_table; unsigned int nr_hw_queues;
void *queuedata; unsigned long queue_flags;
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;
struct blk_flush_queue *fq;
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
| struct blk_mq_hw_ctx { struct { spinlock_t lock; struct list_head dispatch; unsigned long state; } ____cacheline_aligned_in_smp;
struct delayed_work run_work; cpumask_var_t cpumask; int next_cpu; int next_cpu_batch;
unsigned long flags;
void *sched_data; 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;
unsigned short type; unsigned short nr_ctx; struct blk_mq_ctx **ctxs;
struct blk_mq_tags *tags; struct blk_mq_tags *sched_tags;
unsigned long queued; unsigned long run;
unsigned int numa_node; unsigned int queue_num; atomic_t nr_active; };
|
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
| struct blk_mq_ctx { struct { spinlock_t lock; struct list_head rq_lists[HCTX_MAX_TYPES]; } ____cacheline_aligned_in_smp;
unsigned int cpu; unsigned short index_hw[HCTX_MAX_TYPES]; 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
| 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;
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; } }
if (current->bio_list && (!bio_list_empty(¤t->bio_list[0]) || !bio_list_empty(¤t->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); gfp_mask = saved_gfp; p = mempool_alloc(&bs->bio_pool, gfp_mask); } if (unlikely(!p)) return NULL;
bio = p + bs->front_pad;
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
| 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
| 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;
bio = blk_queue_bounce(bio, q);
if (bio_may_exceed_limits(bio, &q->limits)) { bio = __bio_split_to_limits(bio, &q->limits, &nr_segs); if (!bio) return; }
if (!bio_integrity_prep(bio)) return;
bio_set_ioprio(bio);
rq = blk_mq_get_cached_request(q, plug, &bio, nr_segs); if (!rq) { if (!bio) return; 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);
blk_mq_bio_to_request(rq, bio, nr_segs);
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; }
if (op_is_flush(bio->bi_opf)) { blk_insert_flush(rq); return; }
if (plug) { blk_add_rq_to_plug(plug, rq); return; }
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
| void bio_endio(struct bio *bio) { again: if (!bio_remaining_done(bio)) return;
if (!bio_integrity_endio(bio)) return;
rq_qos_done_bio(bio);
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); }
if (bio->bi_end_io == bio_chain_endio) { bio = __bio_chain_endio(bio); goto again; }
blk_throtl_bio_endio(bio); bio_uninit(bio); if (bio->bi_end_io) bio->bi_end_io(bio); } EXPORT_SYMBOL(bio_endio);
|
注意 bio_chain_endio 分支的尾递归优化:当多个 bio 通过 bio_chain() 串联时,如果用普通递归处理,深度链可能导致栈溢出。goto again 将递归转为循环,同时 __bio_chain_endio 返回父 bio 让循环继续处理,这是内核中防止栈溢出的经典技巧。
四、blk-mq 多队列架构详解
4.1 队列映射:CPU → 软件队列 → 硬件队列
blk-mq 使用两级映射:
- CPU → 软件队列(
blk_mq_ctx):通过 per_cpu 机制,每个 CPU 直接访问自己的 queue_ctx。
- 软件队列 → 硬件队列(
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; 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
| 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); 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--;
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: blk_mq_handle_dev_resource(rq, list); goto out; case BLK_STS_ZONE_RESOURCE: blk_mq_handle_zone_resource(rq, &zone_list); needs_resource = true; break; default: blk_mq_end_request(rq, ret); } } while (!list_empty(list)); out: if (!list_empty(&zone_list)) list_splice_tail_init(&zone_list, list); }
|
bd.last 字段很有趣——它允许驱动实现”批量提交”优化。NVMe 驱动利用此标志决定何时真正 ring doorbell(更新提交队列尾指针),当 last=false 时只填写 SQ Entry 但暂不通知设备,last=true 时才一次性通知,大幅减少 MMIO 写操作次数(每次 MMIO 写耗时数百纳秒)。
4.3 请求完成:跨 CPU 的 IPI 机制
在 NUMA 系统或中断亲和性配置不当时,I/O 完成中断可能在与提交请求不同的 CPU 上触发。blk-mq 有两种完成路径:
- 本地完成:
blk_mq_complete_request() → 直接调用 rq->q->mq_ops->complete(rq)
- 跨 CPU 完成(IPI):通过
llist 和 RAISE_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
| 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; else if (blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector) return ELEVATOR_FRONT_MERGE; 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
| int ll_back_merge_fn(struct request *req, struct bio *bio, unsigned int nr_segs) { if (req_gap_back_merge(req, bio)) return 0;
if (blk_integrity_rq(req) && integrity_req_gap_back_merge(req, bio)) return 0;
if (!bio_crypt_ctx_back_mergeable(req, bio)) return 0;
if (blk_rq_sectors(req) + bio_sectors(bio) > blk_rq_get_max_sectors(req, blk_rq_pos(req))) { req_set_nomerge(req->q, req); return 0; }
return ll_new_hw_segment(req, bio, nr_segs); }
|
5.4 调度器的哈希加速
为了快速找到可合并的 request,调度器维护一个以扇区号为键的哈希表(RQF_HASHED 标志表示请求在哈希表中)。每次 bio 提交时,通过 elv_merge() 在哈希表中 O(1) 查找末尾扇区匹配的 request,而不是遍历全部请求。
request_queue 的 last_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 完成时,更新 ios、sectors、nsecs,递减 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
| blktrace -d /dev/nvme0n1 -w 10 -o /tmp/nvme_trace
blkparse /tmp/nvme_trace.blktrace.* | head -50
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
| biolatency -d nvme0n1 10
biosnoop -d nvme0n1
blkqueue
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
| cat /sys/block/nvme0n1/queue/scheduler echo mq-deadline > /sys/block/nvme0n1/queue/scheduler
cat /sys/block/nvme0n1/queue/nr_requests echo 256 > /sys/block/nvme0n1/queue/nr_requests
cat /sys/block/nvme0n1/queue/max_sectors_kb
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
| cat /sys/kernel/debug/block/nvme0n1/state
ls /sys/kernel/debug/block/nvme0n1/
cat /sys/kernel/debug/block/nvme0n1/hctx0/dispatch
cat /sys/kernel/debug/block/nvme0n1/hctx0/ctx0/rq_list
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 数据库服务器,通常推荐 none 或 mq-deadline(设置合理的 write_expire 以控制写入延迟)。
8.2 NUMA 感知调优
在多 NUMA 节点系统上,确保设备中断亲和性和进程调度在同一 NUMA 节点:
1 2 3 4 5
| cat /proc/irq/$(ls /sys/block/nvme0n1/device/msi_irqs/ | head -1)/smp_affinity_list
numactl --cpunodebind=0 --membind=0 fio --name=test ...
|
九、小结
本文从源码层面系统梳理了 Linux 块设备层的核心机制:
- 架构演进:从单队列到 blk-mq 多队列,解决了 NVMe 时代的扩展性问题
- 核心数据结构:
bio 描述单次 I/O,request 是调度器视角的单元,request_queue 是设备的控制中枢,blk_mq_hw_ctx/blk_mq_ctx 实现了两级队列的无锁化设计
- bio 生命周期:从
bio_alloc_bioset() 的内存池设计,到 blk_mq_submit_bio() 的 plug/unplug 批处理优化,再到 bio_endio() 的链式 bio 尾递归处理
- 请求合并:通过哈希加速和
last_merge 缓存,在满足 DMA 约束的前提下最大化合并效果
- 统计与调试:
/proc/diskstats 的数据来源,以及 blktrace、BPF 工具链的使用
下一篇将聚焦 I/O 调度器(mq-deadline、bfq、kyber)的实现原理,深入分析请求排序、带宽公平分配和延迟控制算法。
参考资料