[toc]

在这里插入图片描述

block/blk-core.c 块层核心(Block Layer Core) I/O请求的派发与管理中心

历史与背景

这项技术是为了解决什么特定问题而诞生的?

block/blk-core.c 是Linux块层(Block Layer)的绝对核心。它的诞生是为了解决一个根本性的架构问题:如何在种类繁多的上层I/O请求者(文件系统、裸设备访问、交换等)和五花八门的下层块设备驱动(SATA, SCSI, NVMe等)之间建立一个高效、通用、可扩展的中间层

这个中间层的核心任务包括:

  • 请求抽象与标准化:将来自不同源头的I/O请求(在现代内核中,这些请求被封装在 struct bio 中)转换成一个标准的、可供调度和处理的单元(struct request)。
  • 请求排队与派发 (Queuing & Dispatch):为每个块设备维护一个请求队列(struct request_queue),管理等待处理的I/O请求。
  • I/O调度 (I/O Scheduling):在将请求发送给硬件之前,通过可插拔的I/O调度器对它们进行排序和合并,以优化磁盘寻道时间(对HDD)或内部并行性(对SSD)。
  • 请求合并与拆分 (Merging & Splitting):为了提高效率,将物理上相邻的小I/O请求合并成一个大的请求;反之,将过大的或跨越边界的请求拆分成多个小请求。

如果没有blk-core.c这个核心,每个文件系统都将需要编写与每种块设备驱动直接对话的“胶水代码”,这将导致代码冗余、效率低下且无法维护。

它的发展经历了哪些重要的里程碑或版本迭代?

blk-core.c 的演进史就是Linux I/O栈的演进史:

  1. 经典的 make_request 时代:早期的块层相对简单,所有I/O都通过一个make_request函数进入块层。I/O调度器(如elevator)在这个路径上工作。
  2. bio 结构的引入struct bio 的引入是一个重要的进步,它成为了描述I/O请求的通用“语言”,解耦了上层逻辑与块层的内部实现。
  3. 多队列块层 (Multi-Queue Block Layer, blk-mq) (革命性里程碑):随着NVMe等多队列高速SSD的出现,传统的单请求队列模型成为了严重的性能瓶颈。Jens Axboe领导开发了blk-mq,这是一个全新的、从头设计的块层核心。blk-core.c 中的代码被大规模地重构,以支持:
    • 多硬件队列:直接映射到硬件提供的多个提交/完成队列。
    • 软件暂存队列 (Staging Queues):在软件层面为每个CPU核心创建一个队列,极大地减少了锁竞争,提升了扩展性。
    • blk-core.c 现在的核心职责之一就是管理blk-mq的上下文,并将bio分配到正确的CPU软件队列中。
  4. I/O调度器的演进:伴随着blk-mq,I/O调度器也演变为mq-deadline, kyber, bfqmq-aware的版本。

目前该技术的社区活跃度和主流应用情况如何?

blk-core.cblk-mq.c 是Linux内核I/O栈中最活跃、最受关注的部分之一。随着存储硬件的飞速发展(更快的NVMe, ZNS, SMR),块层核心需要不断演进以充分发挥硬件性能。它被所有Linux系统使用,其性能直接决定了整个系统的存储性能。

核心原理与设计

它的核心工作原理是什么?

在现代blk-mq架构下,blk-core.c 的核心工作流程如下:

  1. I/O提交入口 (submit_bio):所有上层(如block/fops.c)的I/O请求都以struct bio的形式通过submit_bio进入块层。
  2. 获取队列和CPU暂存队列submit_bio会找到目标块设备的主请求队列(request_queue),然后根据当前CPU ID,获取一个该CPU专属的软件暂存队列(struct blk_mq_ctx)。
  3. bio到request的转换bio会被尝试插入到暂存队列中。在这个过程中,块层会尝试将新的bio与队列中已有的request进行合并(如果它们物理上相邻)。如果无法合并,就会从一个request池中分配一个新的struct request,并将bio的信息填充进去。
  4. 运行暂存队列 (Running the Queue):当暂存队列中有请求时,或者有其他触发条件时,块层会“运行”这个队列。这意味着暂存队列中的request会被**派发(dispatch)**到一个或多个硬件派发队列(struct blk_mq_hw_ctx)中。
  5. I/O调度器介入:在派发过程中,附加在硬件派发队列上的I/O调度器会被调用。调度器会对request进行排序,以优化性能。
  6. 驱动程序处理:一旦请求被放入硬件派发队列,设备驱动程序的queue_rq函数就会被调用。驱动程序负责将request中的信息翻译成硬件命令(如SATA的NCQ命令,或NVMe的提交队列条目),并将其发送给硬件。
  7. 中断处理与完成:当硬件完成I/O操作后,会产生一个中断。中断处理程序最终会调用blk_mq_complete_request()等函数,来标记request已完成,并通知上层调用者。

它的主要优势体现在哪些方面?

  • 高性能与高扩展性blk-mq架构通过per-CPU队列和直接映射硬件队列,极大地减少了锁竞争,使得Linux能够充分利用现代多核CPU和高速多队列SSD的性能。
  • 模块化与灵活性:可插拔的I/O调度器设计,使得系统可以根据硬件类型(HDD vs SSD)和工作负载选择最合适的调度策略。
  • 通用性:为所有类型的块设备提供了统一的管理框架。

它存在哪些已知的劣势、局-限性或在特定场景下的不适用性?

  • 复杂性blk-mq的内部逻辑非常复杂,涉及多层队列、CPU亲和性、中断处理等,使得调试和性能分析变得困难。
  • 对慢速设备可能的开销:对于传统的、低速的单队列设备(如一些USB设备),blk-mq的复杂框架可能会带来比传统单队列模型略高的开销。尽管内核有优化,但这仍是一个理论上的考量。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

blk-core.c所有块设备I/O的必经之路,因此它没有“可选”的场景。以下场景的性能表现高度依赖blk-core.c及其blk-mq架构的效率:

  • 高性能数据库:OLTP数据库产生大量随机、小块I/O,blk-mq的低延迟路径对于提升TPS(每秒事务数)至关重要。
  • 大数据与分析:这些工作负载通常涉及大量顺序读写,blk-core.c的请求合并能力和I/O调度器可以有效地将这些流式I/O聚合,提升吞吐量。
  • 虚拟化与云计算:在单个物理主机上运行大量虚拟机或容器时,存储I/O会从多个源头汇集而来。blk-mq的高扩展性确保了I/O性能不会因为高并发而崩溃。

是否有不推荐使用该技术的场景?为什么?

不能“不使用”该技术。但是,对于某些非块设备的存储,会绕过这个路径:

  • 网络文件系统 (NFS, CIFS):这些文件系统的I/O请求会直接进入网络栈,而不是块层。
  • 某些用户空间存储驱动 (SPDK, DPDK):为了追求极致的低延迟,这些框架会完全绕过内核(包括块层),在用户空间直接轮询硬件(polling)来处理I/O。这是一种特殊的高性能计算场景,牺牲了通用性换取性能。

对比分析

请将其 与 其他相似技术 进行详细对比。

blk-core.c (块层核心) vs. block/fops.c (块设备文件操作)

特性 blk-core.c (块层核心) block/fops.c (块设备文件操作)
角色定位 I/O处理引擎。是块层内部的、对上层透明的请求处理和调度中心。 用户空间接口。是连接用户空间和块层的“门面”或“适配器”。
工作在哪个层次 块层内部。处理的是内核数据结构struct biostruct request VFS与块层之间。负责将来自用户空间的read/write系统调用翻译成struct bio
主要职责 请求排队、调度、合并、拆分、派发给驱动。 实现file_operations接口,处理open, read, write, ioctl等。
关系 下游block/fops.cblk-core.c的众多“客户”之一。fops.c通过调用submit_bio()将工作交给了blk-core.c 上游。为blk-core.c提供了来自裸设备访问的I/O源。

blk-core.c (块层) vs. SCSI/NVMe子系统

特性 blk-core.c (块层核心) SCSI/NVMe子系统
抽象层次 通用。提供的是与具体硬件协议无关的、统一的块I/O模型(request)。 特定。实现了具体的存储协议(如SCSI命令集,或NVMe的提交/完成队列规范)。
主要职责 管理I/O流,优化性能。 将块层的通用request结构翻译成特定协议的命令,并与硬件交互。
关系 上层blk-core.c将经过调度和处理的request派发给下层的协议驱动。 下层。作为blk-core.c的“执行者”,负责实现与具体硬件的通信。

I/O 繁忙时间统计更新: update_io_ticks

本代码片段定义了内核块设备层中一个核心的统计更新函数 update_io_ticks。其主要功能是以一种高效、无锁(lock-free)的方式,精确地计算并累加一个块设备处于“繁忙”状态(即至少有一个 I/O 请求在处理中)的总时长。这个函数是 /proc/diskstats 中第13个字段(加权 I/O 毫秒数)的数据来源,对于上层 I/O 性能监控工具(如 iostat)至关重要。

实现原理分析

此函数的实现是 Linux 内核中一个精妙的、用于高并发环境的无锁编程范例。它不使用传统的自旋锁来保护统计数据,而是采用了一种基于原子操作的乐观更新(optimistic update)策略。

  1. 时间戳与原子更新 (bd_stamp & try_cmpxchg):

    • 每个块设备结构体中都有一个时间戳成员 bd_stamp。这个时间戳记录了上一次本函数成功更新 io_ticks 的时刻(以 jiffies 计)。
    • 函数的核心是 try_cmpxchg(&part->bd_stamp, &stamp, now)。这是一个原子的“比较并交换”(Compare-And-Swap)操作。
    • 算法流程:
      a. 首先,通过 READ_ONCE 无锁地读取当前的 bd_stamp 值到局部变量 stamp
      b. 然后,try_cmpxchg 尝试将 part->bd_stamp 的值更新为当前时间 now,但前提条件part->bd_stamp 的值仍然等于之前读取的 stamp
      c. 如果 try_cmpxchg 成功,意味着从 stampnow 这段时间内,没有其他任何任务(或其他 CPU 上的任务)成功更新过这个时间戳。当前任务便“赢得”了更新的权利,于是它将时间差 now - stamp 累加到 io_ticks 统计中。
      d. 如果 try_cmpxchg 失败,说明就在 READ_ONCE 之后,另一个任务已经抢先更新了 bd_stamp。此时,当前任务就简单地放弃本次更新。这样做是正确的,因为那个抢先的任务已经把到它那个时间点的繁忙时长计算过了,避免了重复计算。
  2. 更新条件 (end || bdev_count_inflight(part)):

    • try_cmpxchg 成功后,还需要满足一个条件才能真正累加 io_ticks
    • bdev_count_inflight(part) 检查设备当前是否仍有在途 I/O。如果 > 0,说明设备仍然是繁忙的,因此从 stampnow 这段时间理应被计入繁忙时长。
    • end 标志位是一个特殊情况。当最后一个在途 I/O 完成时,bdev_count_inflight 会变为 0。如果不做处理,now - stamp 这最后一段繁忙时间将不会被计入。因此,处理最后一个 I/O 完成的调用者会设置 end = true 来强制执行这最后一次的累加,确保统计的完整性。
  3. 分层统计 (goto again):

    • update_io_ticks 的设计考虑了磁盘和分区的层级关系。
    • 当对一个分区(bdev_is_partition 为真)调用此函数后,它会通过 bdev_whole(part) 找到该分区所属的整个磁盘设备,然后使用 goto again 重新对整个磁盘设备执行一遍完全相同的逻辑
    • 这确保了对分区的 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
/**
* @brief update_io_ticks - 更新块设备的 I/O 繁忙时间统计。
* @param part: 目标块设备或分区。
* @param now: 当前时间 (通常是 jiffies)。
* @param end: 布尔标志。如果为 true,表示这是最后一个在途 I/O 的完成,需要强制更新。
*/
void update_io_ticks(struct block_device *part, unsigned long now, bool end)
{
unsigned long stamp; /// < 用于存储从 part->bd_stamp 读出的旧时间戳。
again:
// 使用 READ_ONCE 安全地读取可能被并发修改的时间戳。
stamp = READ_ONCE(part->bd_stamp);
// 如果当前时间晚于记录的时间戳 (正常情况),并且...
if (unlikely(time_after(now, stamp)) &&
// ...成功地以原子方式将 bd_stamp 从旧的 stamp 值更新为 now (乐观锁),并且...
likely(try_cmpxchg(&part->bd_stamp, &stamp, now)) &&
// ...满足以下任一条件:这是最后一个I/O的完成,或者设备上仍有在途I/O。
(end || bdev_count_inflight(part)))
// 则将从 stamp 到 now 的时间差累加到 io_ticks 统计中。
__part_stat_add(part, io_ticks, now - stamp);

// 如果当前处理的 part 是一个分区...
if (bdev_is_partition(part)) {
// ...则找到它所属的整个磁盘设备...
part = bdev_whole(part);
// ...并跳转回 again,对整个磁盘设备重复上述逻辑,以实现统计的层级累加。
goto again;
}
}

请求队列的创建与初始化 (blk_alloc_queue)

核心功能

blk_alloc_queue 是一个核心工厂函数,其唯一职责是分配并全面初始化一个 struct request_queue 对象。这个对象是块设备驱动与内核块设备层之间进行 I/O 交互的核心数据结构。它不仅是一个请求的容器,更是一个集成了锁、定时器、引用计数、I/O 调度器接口、统计信息和硬件限制等众多子系统的复杂实体。任何一个想要接收 I/O 请求的块设备,都必须拥有一个由该函数创建的请求队列。

实现原理分析

  1. 并发管理中心: request_queue 是一个高度并发的数据结构,它可能同时被多个上下文访问:

    • 用户进程通过系统调用提交 I/O 请求(进程上下文)。
    • 内核线程(如 kblockd)处理工作(进程上下文)。
    • 硬件中断处理程序报告 I/O 完成(中断上下文)。
    • 定时器中断处理请求超时(中断上下文)。
      因此,blk_alloc_queue 的一个主要任务是初始化各种锁来保护队列的内部状态。它使用了多种锁,体现了精细化锁的设计思想:sysfs_lock 用于保护对 sysfs 属性的访问,elevator_lock 用于保护 I/O 调度器(elevator)的数据结构,而 queue_lock (自旋锁) 则用于保护队列本身的核心数据路径。
  2. 高级引用计数 (percpu_ref): 队列的生命周期管理使用了一种高级的、为高性能而设计的 percpu_ref 引用计数器。与简单的原子引用计数(atomic_t)不同,percpu_ref 允许每个 CPU 在本地、无锁地增加引用计数,这极大地降低了在 I/O 密集型场景下的锁竞争。当需要销毁队列时,内核会“杀死”这个 percpu_ref,此时它会等待所有 CPU 上的本地引用计数都归零后,再调用注册的回调函数 blk_queue_usage_counter_release。这是一种高效且健壮的,用于管理高并发对象生命周期的模式。

  3. 两阶段超时处理: 请求超时处理被设计为两阶段过程以降低中断上下文的负担。
    a. timer_setup(&q->timeout, blk_rq_timed_out_timer, 0): 注册一个标准的内核定时器。当定时器到期时,硬件中断会触发 blk_rq_timed_out_timer 函数。
    b. kblockd_schedule_work(&q->timeout_work): 在定时器中断处理函数中,唯一做的事情就是调度一个工作(work_struct)。它不会执行任何复杂的逻辑。
    c. blk_timeout_work: 实际的、可能很复杂的超时处理逻辑在这个工作队列函数中执行。
    这种模式是内核的标准实践:在(硬)中断上下文中只做最少的工作,将耗时或可能休眠的操作转移到(软中断或)进程上下文(工作队列)中去执行。

  4. 硬件能力描述 (queue_limits): 函数接收一个 struct queue_limits 参数。这个结构体是驱动程序向块设备层描述其底层硬件能力的“说明书”。驱动在其中填写诸如最大单次传输字节数、逻辑块大小、对齐要求、是否支持 DISCARD 等信息。blk_alloc_queue 将这份说明书拷贝到队列的 limits 成员中,块设备层后续会依据这些限制来对上层发来的 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
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
/**
* @brief 当请求队列的 percpu_ref 引用计数降至零时被调用的回调函数。
* @param[in] ref 指向 percpu_ref 结构体的指针。
*/
static void blk_queue_usage_counter_release(struct percpu_ref *ref)
{
// 通过 container_of 宏从 ref 成员的地址获取其宿主结构 request_queue 的地址。
struct request_queue *q =
container_of(ref, struct request_queue, q_usage_counter);

// 唤醒所有可能正在等待队列冻结或停止的线程。
wake_up_all(&q->mq_freeze_wq);
}

/**
* @brief 请求超时的定时器处理函数 (在硬中断上下文中执行)。
* @param[in] t 指向触发此函数的 timer_list 结构的指针。
*/
static void blk_rq_timed_out_timer(struct timer_list *t)
{
// 从 timer_list 指针安全地获取其所属的 request_queue 结构体指针。
struct request_queue *q = timer_container_of(q, t, timeout);

// 不在中断上下文中做实际工作,而是调度一个工作队列项来处理超时。
kblockd_schedule_work(&q->timeout_work);
}

/**
* @brief 处理请求超时的工作队列函数 (在进程上下文中执行)。
* @param[in] work 指向 work_struct 结构的指针。
*/
static void blk_timeout_work(struct work_struct *work)
{
// 此处是实际的超时处理逻辑,此代码片段中为空。
}

/**
* @brief 分配并初始化一个请求队列。
* @param[in] lim 指向描述硬件能力的 queue_limits 结构,若为NULL则使用默认值。
* @param[in] node_id NUMA 节点 ID,用于内存分配。
* @return 成功则返回指向新队列的指针,失败则返回错误指针。
*/
struct request_queue *blk_alloc_queue(struct queue_limits *lim, int node_id)
{
struct request_queue *q;
int error;

// 从专用的 SLAB/SLOB 缓存中分配 request_queue 结构体的内存。
q = kmem_cache_alloc_node(blk_requestq_cachep, GFP_KERNEL | __GFP_ZERO,
node_id);
if (!q)
return ERR_PTR(-ENOMEM);

q->last_merge = NULL; //!< 初始化最后一次合并请求的缓存。

// 使用 IDA (ID Allocator) 为队列分配一个唯一的、小的整数 ID。
q->id = ida_alloc(&blk_queue_ida, GFP_KERNEL);
if (q->id < 0) {
error = q->id;
goto fail_q;
}

// 为队列分配用于存储统计信息的数据结构。
q->stats = blk_alloc_queue_stats();
if (!q->stats) {
error = -ENOMEM;
goto fail_id;
}

// 检查并设置默认的队列限制值。
error = blk_set_default_limits(lim);
if (error)
goto fail_stats;
q->limits = *lim; //!< 将最终的硬件限制拷贝到队列中。

q->node = node_id; //!< 保存 NUMA 节点 ID。

/* ... */

// 设置请求超时的定时器。
timer_setup(&q->timeout, blk_rq_timed_out_timer, 0);
// 初始化用于处理超时的_work_结构体。
INIT_WORK(&q->timeout_work, blk_timeout_work);
INIT_LIST_HEAD(&q->icq_list); //!< 初始化 I/O 上下文列表。

refcount_set(&q->refs, 1); //!< 初始化一个通用的引用计数。
// 初始化各种互斥锁和自旋锁,用于保护队列的不同部分。
mutex_init(&q->debugfs_mutex);
mutex_init(&q->elevator_lock);
mutex_init(&q->sysfs_lock);
mutex_init(&q->limits_lock);
mutex_init(&q->rq_qos_mutex);
spin_lock_init(&q->queue_lock);

init_waitqueue_head(&q->mq_freeze_wq); //!< 初始化用于冻结队列的等待队列。
mutex_init(&q->mq_freeze_lock);

blkg_init_queue(q); //!< 初始化队列的块设备 I/O 控制器。

/*
* 以原子模式初始化 percpu_ref,这有助于在关闭时更快地进行。
* 详情见 blk_register_queue()。
*/
error = percpu_ref_init(&q->q_usage_counter,
blk_queue_usage_counter_release,
PERCPU_REF_INIT_ATOMIC, GFP_KERNEL);
if (error)
goto fail_stats;

/* ... (lockdep 相关初始化) ... */

// 设置队列的默认深度(可以容纳的请求数)。
q->nr_requests = BLKDEV_DEFAULT_RQ;

return q; //!< 成功,返回队列指针。

// --- 错误处理回滚路径 ---
fail_stats:
blk_free_queue_stats(q->stats);
fail_id:
ida_free(&blk_queue_ida, q->id);
fail_q:
kmem_cache_free(blk_requestq_cachep, q);
return ERR_PTR(error);
}