[toc]
block/bio.c 块I/O核心结构(Block I/O Core Structure) Linux I/O请求的载体
历史与背景
这项技术是为了解决什么特定问题而诞生的?
struct bio
及其管理代码 block/bio.c
是为了解决Linux内核中一个根本性的I/O请求表示和传递问题而诞生的。它旨在取代一个更早、更原始的结构——struct buffer_head
(bh)——作为I/O请求的主要载体。
buffer_head
存在以下严重问题,限制了I/O性能和系统的可扩展性:
- 粒度过小:一个
buffer_head
严格地代表单个磁盘块(例如512字节或4KB)在内存中的缓存。对于一个大的I/O操作(例如写入64KB数据),内核需要创建和管理16个独立的buffer_head
对象,这非常低效。 - 紧密耦合:
buffer_head
将I/O请求(“我要写这个块”)和内存缓存(“这个块在内存中的副本”)这两个概念紧紧地耦合在一起。这种设计使得实现绕过页面缓存的直接I/O(Direct I/O)或处理非页对齐的I/O变得异常困难和笨拙。 - 不适合向量化I/O:现代硬件能够处理“分散-聚集”(Scatter-Gather)I/O,即一次操作可以从内存中多个不连续的缓冲区读取数据,然后写入到磁盘上的一个连续区域(反之亦然)。
buffer_head
这种单块模型无法自然地表达这种向量化的I/O请求。
struct bio
的诞生就是为了解决这些问题。它的核心设计思想是解耦和向量化:
- 解耦:
bio
只负责描述一个I/O操作本身,它不关心数据是否在页面缓存中。它只是一个指向数据页的指针向量。 - 向量化:一个
bio
可以包含一个由多个页面(或页面片段)组成的列表(bio_vec
),从而能够以一个单一、紧凑的结构来描述一个涉及多个内存段的大型I/O操作。
它的发展经历了哪些重要的里程碑或版本迭代?
bio
结构的引入是Linux 2.5/2.6开发周期中对块设备层(Block Layer)进行的一次重大重构。
- 引入
bio
:bio
被引入,成为文件系统/MM层与块设备驱动之间传递I/O请求的新标准。 - 逐渐取代
buffer_head
:内核中的代码被逐步重构,从直接提交buffer_head
进行I/O,改为先将信息从buffer_head
(如果还在使用)或页面缓存中构建成bio
,再提交bio
。 - 支持块设备分区:
bio
结构中包含了对分区的支持,使得块设备驱动无需关心分区表,上层逻辑可以直接向特定分区提交I/O。 - 支持I/O优先级和类别:为了实现更智能的I/O调度,
bio
中加入了字段来表示I/O的优先级(如实时I/O)和类别(读/写/同步/异步)。 - 支持数据完整性(Integrity):为了支持企业级存储,
bio
被扩展以携带数据完整性校验信息(DIF/DIX)。 - 支持请求队列拆分(Request Queue Splitting):
bio
的提交和完成路径被优化,以更好地适应现代多队列NVMe设备,允许多个CPU核心无锁地向硬件提交I/O。
目前该技术的社区活跃度和主流应用情况如何?
block/bio.c
是Linux块设备层最核心、最基础的部分。它的代码非常稳定,但也是内核I/O性能优化的焦点。
- 绝对核心:所有对块设备(HDD, SSD, NVMe, LVM, RAID等)的I/O请求,无论是来自文件系统、交换(swap)还是裸设备访问,最终都必须被封装成一个
struct bio
对象才能被块层处理。 - 社区活跃度:社区持续对其进行优化,以适应新的硬件特性(如Zoned Block Devices, NVMe over Fabrics)和新的内核接口(如
io_uring
)。任何对块层性能的改进都与bio
的创建、合并、拆分和完成路径密切相关。
核心原理与设计
它的核心工作原理是什么?
block/bio.c
主要负责struct bio
对象的分配、管理和生命周期控制。bio
本身是一个描述I/O操作的**“描述符”或“信封”**。
其核心数据结构struct bio
包含以下关键信息:
bi_iter
: 一个迭代器,指向I/O操作在设备上的目标扇区(bi_sector
)和剩余长度(bi_size
)。bi_io_vec
: 一个bio_vec
结构体数组的指针,这是bio
的精髓。每个bio_vec
条目包含:bv_page
: 指向一个物理页面的指针。bv_len
: 这个I/O操作涉及该页面的字节数。bv_offset
: I/O操作从该页面的哪个偏移量开始。
bi_vcnt
:bi_io_vec
数组中的条目数量。bi_end_io
: 一个完成回调函数的指针。这是异步I/O的关键。当硬件完成I/O操作后,这个函数会被调用。bi_private
: 一个私有数据指针,供bi_end_io
回调函数使用。bi_opf
: 操作码,描述了I/O的类型(读/写/discard/flush等)和属性(同步/异步等)。
block/bio.c
中的函数提供了以下核心功能:
- 分配 (
bio_alloc
):从一个内存池(mempool)或slab缓存中高效地分配bio
和bio_vec
结构。 - 添加页面 (
bio_add_page
):将一个数据页添加到bio
的向量列表中。bio.c
的逻辑能够智能地处理页面合并,如果新添加的页面与前一个bio_vec
在物理上是连续的,它会扩展前一个bio_vec
的bv_len
,而不是创建一个新的条目。 - 提交 (
submit_bio
):这是一个宏,最终会调用generic_make_request()
。这个函数是bio
生命周期中的一个重要转折点。它将bio
传递给I/O调度器(I/O Scheduler)和请求队列(Request Queue)。在这里,多个bio
可能被合并或拆分,最终被转换成一个或多个struct request
对象,request
是真正发送给硬件驱动的命令。 - 完成处理:当I/O完成后,块设备驱动会调用
bio_endio()
或类似函数。这个函数会负责调用bio
中注册的bi_end_io
回调函数,并最终释放bio
结构。
它的主要优势体現在哪些方面?
- 高效的向量化I/O:能够以单一结构描述涉及多个非连续内存页的大型I/O,完美匹配现代硬件的DMA能力。
- 解耦:将I/O操作与数据缓存分离,为直接I/O、交换I/O等多种场景提供了统一的基础。
- 灵活性:通过完成回调机制,完美支持异步I/O,这是高性能服务器应用的基础。
- 可堆叠性:
bio
的结构非常适合在块设备层进行“堆叠”。例如,LVM或RAID驱动可以接收上层文件系统发来的bio
,对其进行转换(例如,将一个bio
拆分成多个bio
并发送到不同的物理磁盘),并在其完成回调中重新组合结果。
它存在哪些已知的劣劣势、局限性或在特定场景下的不适用性?
- 非面向字符设备:
bio
是为块设备设计的,其模型基于扇区地址。它不适用于串口、终端等面向字节流的字符设备。 - 复杂性:
bio
的生命周期管理(特别是其完成路径和在堆叠驱动中的克隆与重定向)相当复杂,是内核中容易出错的部分。 bio
与request
的二元性:历史上,块层同时存在bio
(逻辑请求)和request
(物理请求)两种结构,两者之间的关系和转换增加了块层的复杂性。现代的blk-mq
(多队列块层)架构在一定程度上简化了这一点,但理解两者的区别仍然是理解块层的关键。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
它是Linux内核中处理块设备I/O的唯一且标准的解决方案。
- 文件系统:当文件系统需要从磁盘读取或写入数据块时,它会为这些操作创建一个或多个
bio
并提交。 - 交换子系统 (Swap):当内核需要将匿名页换出到交换分区或交换文件时,它会创建一个
bio
来执行这个写操作。 - s 逻辑卷管理器 (LVM) 和 软件RAID (MD):这些“堆叠”驱动接收上层传来的
bio
,然后根据自己的逻辑创建新的bio
发送给底层的物理设备。 - 裸设备访问:当用户空间程序直接打开一个块设备文件(如
/dev/sda
)并进行读写时,这些操作在内核中也会被转换成bio
。
是否有不推荐使用该技术的场景?为什么?
如上所述,它只适用于块设备。在需要与字符设备、网络设备或伪文件系统交互时,需要使用完全不同的内核API(如tty_driver
, net_device
, VFS file operations等)。
对比分析
请将其 与 其他相似技术 进行详细对比。
struct bio
vs. struct buffer_head
(传统)
特性 | struct bio |
struct buffer_head (bh) |
---|---|---|
核心概念 | I/O操作描述符 (Descriptor of an I/O operation)。 | 内存中的磁盘块缓存 (In-memory cache of a disk block)。 |
粒度 | 向量化。一个bio 可以代表涉及多个内存页的大型I/O。 |
单块。一个bh 只代表一个磁盘块。 |
与缓存关系 | 解耦。bio 只携带数据页指针,不关心数据来源。 |
紧密耦合。bh 本身就是页面缓存的一部分。 |
适用性 | 通用。完美支持缓冲I/O、直接I/O、交换等。 | 主要用于缓冲I/O。 |
当前状态 | 现代标准 | 遗留结构(仍在使用,但主要作为缓存管理,而不是I/O提交的载体)。 |
struct bio
vs. struct request
在块层内部,bio
和request
是两个需要区分的关键概念。
特性 | struct bio (逻辑请求) |
struct request (物理/设备请求) |
---|---|---|
抽象层次 | 较高层。由文件系统或MM层创建,描述“要做什么”。 | 较低层。由I/O调度器和请求队列管理,描述“要发给硬件什么命令”。 |
生命周期 | 相对较短。一个bio 在被合并到request 中后,其生命周期就与request 绑定。 |
较长。一个request 可能由多个bio 合并而成,它会排队等待,直到被驱动程序处理。 |
合并与拆分 | bio 是被合并或被拆分的对象。 |
request 是合并bio 的结果。 |
与硬件关系 | 间接。 | 直接。request 结构最终会被转换为硬件能理解的命令(如SATA的NCQ命令或NVMe的提交队列条目)。 |
主要管理者 | 文件系统 / MM层。 | I/O调度器 / 请求队列 / 设备驱动。 |
BIO Slab 缓存管理
此代码片段揭示了Linux内核bio
子系统背后一个精巧的内存管理策略。由于bio
结构体的大小可以根据bio_set
的需求(特别是front_pad
的存在)而变化, 系统中可能会同时存在多种不同大小的bio
对象。为每一种大小都创建一个独立的kmem_cache
(slab缓存)可能会导致管理混乱和资源浪费。
此代码的核心原理是实现一个全局的、共享的、引用计数的kmem_cache
池, 并使用bio
对象的实际大小作为索引。这允许多个bio_set
实例, 只要它们需要的bio
对象大小完全相同, 就能共享同一个底层的kmem_cache
, 从而极大地提高了内存利用效率并简化了管理。
关键数据结构:
struct bio_slab
: 这是一个管理结构, 它将一个真正的kmem_cache
(slab
) 与其元数据打包在一起, 包括引用计数(slab_ref
)和对象大小(slab_size
)。bio_slabs
(XArray): 这是一个全局的、线程安全的数据结构(由bio_slab_lock
保护), 用作一个字典或映射。它将bio
对象的大小(一个整数)映射到对应的struct bio_slab
管理结构。bio_slab_lock
(Mutex): 一个全局互斥锁, 用于保护bio_slabs
XArray在被并发访问(例如, 两个不同驱动同时初始化)时的数据一致性。
bs_bio_slab_size
: 计算bio
对象总大小
这是一个简单的内联辅助函数, 它的作用是根据bio_set
的配置计算出其实际需要的bio
对象的总大小。这个大小是整个共享机制的关键索引(key)。
1 | /* 计算一个 bio_set 所需的 bio 对象的总大小. */ |
create_bio_slab
: bio_slab
的工厂函数
当bio_find_or_create_slab
发现需要一个特定大小的slab缓存但它尚不存在时, 就会调用这个内部函数来创建它。
1 | /* create_bio_slab: 为一个特定大小创建新的 bio_slab 管理结构和底层的 kmem_cache. */ |
bio_find_or_create_slab
: 获取bio
Slab缓存的主入口
这是bioset_init
调用的主函数, 它实现了”查找或创建”的核心逻辑。
1 | /* bio_find_or_create_slab: 为一个 bio_set 查找一个现有的 slab 缓存, 或者创建一个新的. */ |
bio_put_slab
: 释放对bio
Slab缓存的引用
当一个bio_set
被销毁时(bioset_exit
), 它必须调用此函数来”归还”它对slab缓存的使用权。
1 | /* bio_put_slab: 释放一个 bio_set 对其 slab 缓存的引用. */ |
BIO 子系统初始化
此代码片段展示了Linux内核块设备I/O层最核心的数据结构——bio
——的内存管理和初始化机制。bio
(Block I/O) 结构体是内核中用于描述一个块设备读写请求的基本单元。它像一个”集装箱”, 里面包含了目标设备、起始扇区、数据大小以及指向实际数据缓冲区的一组向量(bio_vec
)等所有信息。由于系统在运行时会产生海量的bio
请求, 如何高效地分配和释放bio
结构体本身, 对I/O性能至关重要。
此代码的核心原理是为bio
和bio_vec
结构体建立多层次、可定制的内存池(mempool)和slab缓存(slab cache), 以实现极快的无锁(lock-free)或低锁(low-contention)分配, 并提供一个”救援”机制来应对内存紧张的状况。
关键组件与原理:
kmem_cache
/ Slab分配器 (最底层):init_bio
函数首先会为不同大小的bio_vec
数组创建多个kmem_cache
实例。Slab分配器是内核中用于高效分配和释放小块、固定大小内存对象的标准机制。它通过预先分配”slab”页并将它们切分成多个对象, 避免了频繁调用伙伴系统的开销。SLAB_HWCACHE_ALIGN
标志确保了对象在硬件缓存行上对齐, 提升性能。
mempool
(中间层):- 在Slab缓存之上,
bioset_init
函数会创建一个mempool_t
实例。内存池(mempool)是一种保证分配成功的机制。它会预先从Slab分配器中申请并保留一定数量的对象(pool_size
)。 - 当驱动程序需要分配一个
bio
时,mempool
会首先尝试从其内部的预留池中快速、无锁地提供一个对象。 - 关键: 如果预留池为空,
mempool
会尝试从底层的Slab分配器中申请新的对象。如果Slab分配也失败了(因为系统内存不足),mempool
可以被配置为睡眠等待, 直到有其他代码释放对象回池中。这保证了即使在内存压力下, 只要等待, I/O请求的描述符最终总能被分配出来, 避免了I/O流程因内存分配失败而死锁。
- 在Slab缓存之上,
bio_set
(最高层/定制层):bio_set
是一个封装了bio
和bio_vec
内存池, 以及一个可选的”救援工作队列”的完整管理单元。不同的块设备驱动(如RAID, LVM, or 文件系统)可以创建自己的bio_set
, 以隔离它们的bio
分配, 避免相互干扰, 并可以定制front_pad
(在bio
结构体前预留空间)等特性。- 救援工作队列 (
rescue_workqueue
): 这是一个非常重要的死锁避免机制。当mempool
因为内存紧张而无法立即分配bio
时, 它会将等待分配的请求放入一个”救援列表”(rescue_list
), 然后调度一个工作项到rescue_workqueue
中。这个工作队列被标记为WQ_MEM_RECLAIM
, 保证了即使在内存极度紧张的情况下, 它的工作者线程也能够被创建和运行。工作者线程(bio_alloc_rescue
)会在一个可以安全睡眠的上下文中, 慢慢地等待内存被释放, 然后再重新尝试处理救援列表中的请求。这就将”等待内存”这个阻塞操作从可能持有锁的、紧急的I/O提交路径中, 转移到了安全的后台线程中。
Per-CPU 缓存 (
BIOSET_PERCPU_CACHE
):- 为了达到极致的性能,
bioset_init
还可以选择性地启用per-cpu缓存。这会在每个CPU核心上创建一个小型的、完全无锁的bio
对象缓存。当一个CPU需要分配bio
时, 它会首先尝试从自己的本地缓存中获取, 这几乎没有任何开销。只有当本地缓存为空时, 它才会去访问有锁的、共享的mempool
。这极大地减少了多核系统上的锁竞争。
- 为了达到极致的性能,
初始化流程总览 (init_bio
)
1 | static struct biovec_slab { |
bioset_init
详细流程
1 | /* |