Linux内核块设备层介绍之bio层

简介: 块设备非常重要,尤其随着持久化存储的不断发展以及未来持久化内存的持续增长,块设备抽象的应用场景将越来越广泛。所以今天就让我们来剖析和解读一下块设备接口。

本文主要翻译并改编自lwn文章 https://lwn.net/Articles/736534/

有一位读者郭健在阅读本文后,对我们的翻译提供了勘误,所以重发此文,并对这位读者表示感谢,勘误部分做了标红。如果大家还有其他问题,欢迎发邮件到boyu点mt at alibaba-inc.com。

像linux这样的操作系统, 一个非常有价值的东西就是提供了一套具体设备的抽象接口,比如我们常提到的字符设备, 块设备,网络设备,位图显示器等等。其中块设备非常重要,尤其随着持久化存储的不断发展以及未来持久化内存的持续增长,块设备抽象的应用场景将越来越广泛。所以今天就让我们来剖析和解读一下块设备接口。

首先定义一下什么是“块层”(block layer)。一般当我们提到“块层”时,是指Linux内核中应用程序和文件系统用来访问多种不同的存储设备的模块接口。那么究竟哪些代码构成了块层呢?一个不动脑子的答案就是在linux kernel源代码中block子目录下的所有代码都是块层。 这一堆代码可以看做提供了两个抽象层,他们合作紧密,但是又有所不同。这两层目前在社区并没有统一的叫法,我们姑且叫他们bio层和request层。本文主要介绍bio层,而request层则会在另外一篇文章中介绍。

块层之上

在深入了解bio层之前,还是有必要了解一下块设备之上的层。这里提到的”上“,是指离用户态更近一些,而离硬件更远一些。下图表述了块层在内核中的位置。

image


访问块设备通常是通过/dev目录下文件来实现,例如/dev/sda这样的就是块设备,它们在内核中被映射成S_IFBLK属性的inodes。这些文件并不代表真正的块设备,而更像是软链接,我们可以通过它们代表的’major:minor’这样的数字来找到真正的块设备。在内核的inode结构体中,i_bdev这个成员被用来指向一个代表真实设备的结构体struct block_device。而这个struct block_device中的bd_inode则指向了另外一个inode,这个inode才和这个块设备的I/O真正相关。

image

当设备没有使用O_DIRECT打开的时候, 这个bd_inode(实现在fs/block_dev.c, fs/buffer.c等)的主要角色是提供page cache。像一个正常被打开的文件一样,这个inode节点的page同意被用于对这个设备进行缓冲读,预读,缓冲写,延迟写等等。当这个设备被以O_DIRECT的方式打开的时候,读写则会直接到块设备。一般来说,当一个文件系统挂载一个块设备时,文件系统的读写操作通常都是直接访问块设备。但是对于另外一些文件系统(比如我们经常用到的ext *系列),它们则会用bd_inode的page cache来管理一些文件系统的元数据。
这里需要重点提到一个与块设备特别相关的open标志是O_EXCL。块设备通过这个flag来确定每个块设备最多可以有一个“持有人”。当我们试图持有一个块设备(例如,在内核中使用blkdev_get()或类似的调用)的时候,如果在我们之前已经有另外一个不同的持有者已经拥有了该设备,那么我们的持有请求将会失败。一般的文件系统试图挂载设备的时候会使用这个open标志,从而确保自己是独占设备的。所以如果文件系统以O_EXCL的方式成功打开了设备,那么从此以后它就会成为这个设备的持有者。如果这以后再有文件系统尝试去mount的话, 就会失败。这里有一点比较有趣的是,使用O_EXCL并不会阻止在没有O_EXCL的情况下打开块设备,因此它其实并不会阻止并发写入,而仅仅是阻止了其他文件系统以独占方式打开设备。

无论以哪种方式访问块设备,有一点是一致的,都是bio层在向上提供的主要接口, 包括发送读取或写入请求,或者其他一些请求比如”discard”,并最终将答复返回给上层。

bio层

linux上是用结构体gendisk来代表块设备, 这个结构体并不包含很多实用的信息,而主要是作为上面的文件系统和下面的设备层之间的接口。在gendisk之上是一个或多个struct block_device,也就是前文提到的/dev中的inode链接。 当一个gendisk有多个分区时,它会和多个block_device结构关联。所以我们会有一个block_device代表整个gendisk(比如/dev/sda),也有可能还有一些其他的block_device代表gendisk中的分区(比如/dev/sda1, /dev/sda2, …)。


image


在bio层里面还有一个结构体叫做struct bio。

image

它代表来自block_device的读取和写入请求, 以及其他一些控制请求。这些请求从block_device发出, 经过gendisk再到设备驱动。 一个bio结构体里面主要包括具体的块设备信息,块设备中的偏移量,请求大小,请求类型(读或写)以及放置数据的内存位置。在Linux 4.14之前,bio中通过指向struct block_device的指针来标识目标设备。4.14以后,struct block_device被替换成一个指向struct gendisk的指针以及一个可以由bio_set_dev()设置的分区号。考虑到gendisk结构的核心作用,这样的改动更自然一些。
一旦bio构造完成,我们就可以通过调用generic_make_request或者submit_bio来发起bio请求。但是一般情况下我们并不会等待请求完成,而只是将其插入队列以便后续处理,所以整个过程是异步的。不过这里有一点需要注意,在一些场景下generic_make_request()仍然可能由于等待内存可用(比如它可能会等待先前的请求在完成以后从队列中摘除,从而腾出队列中的空间)而在短时间内阻塞。在这种场景下,如果在bi_opf字段中设置了REQ_NOWAIT标志,那么generic_make_request()就不会等待,而是把bio设置为BLK_STS_AGAIN或者BLK_STS_NOTSUPP,然后直接返回。不过在撰写本文时,这个功能的实现还有一些问题。

bio层和request层之间的契约以及交互协议很简单,主要的动作就是让设备通过调用blk_queue_make_request()并传入自己的make_request_fn函数。如果设备传入了自己的make_request_fn,genric_make_request()会调用它从而完成bio的发送工作。当一个bio代表的I/O请求完成以后,这个请求的bi_status字段设置为成功或失败,同时bio_endio()会被调用来结束这个bio。


image


除了上面提到的处理bio的读写请求之外,bio层最值得展开的两件事情是避免递归的技巧以及队列的插入和拔出,接下来让我们分别阐述一下。

避免递归

在使用虚拟块设备,比如md(软RAID),dm(lvm2)的时候,我们会将一个块设备叠在另一个块设备上面,在这种场景下一个块设备的bio会被修改并发到下一层块设备的bio里面去,实现起来很简单,但是运行起来会给内核栈的使用造成极大的负担。在2.6.22之前,由于文件系统已经使用了很大一部分内核栈,内核栈溢出可能会有很严重的问题。为了解决这个问题,generic_make_request()会检测是否被递归调用并作相应的处理。在发生递归的时候它不将bio传递到下一层,而只是内部(通过使用current->bio_list )对bio进行排队。只有当父bio完成的时候,它才会提交这个请求。由于前面提到generic_make_request()一般不会等待bio完成才返回,所以不立即处理bio也没啥问题。

这个避免递归的方案在大部分场景下是可以工作的,但却在一些特殊场景可能会导致死锁。让我们再次描述一下这个场景,在generic_make_request调用make_request_fn的时候,看到之前有bio提交,就等待这个先前提交的bio完成。那么如果等待的那个bio还在current->bio_list队列上怎么办?很明显这两个bio都存在问题,就导致了死锁。

在实际情况中,一个bio等待另一个bio的场景是非常微妙的,所以一般这种死锁都是通过测试发现的, 而不是代码检查,我们在这里举一个可能导致死锁的例子。当一个bio提交的时候, 如果遇到了大小限制或者对齐要求,make_request_fn就会把这个bio分成2个(bio层通过bio_split,bio_chain来实现),但是这个操作需要为第二个bio分配内存空间。怎么分配内存呢?我们知道当系统没内存的时候,申请内存总是危险的,linux通常的做法是写脏页来释放内存, 但是如果写脏页也需要内存的话,那么很可能就死锁了。所以这里标准的做法是使用mempool预先分配一些内存,然后我们直接从mempool中分配从而避免内存分配的死锁问题。看上去不错,是么?在bio这个场景下这个解法的问题来了。由于bio从mempool分配可能会需要等待以前的用户返回他们使用的mempool内存,而这个等待的依赖关系又会是某些之前的bio,所以可能会再次导致generic_make_request()死锁。我的天哪,内核编程简直就是在和各种死锁,各种内存不足做斗争中。。。

为了避免这个死锁,内核研发人员做了很多尝试。其中一个想法就是大家在调用ps时看到的那些bioset进程。该机制特别关注上述死锁场景,它为每个用于bio分配的mempool分配一个“rescuer”线程。如果bio分配不成功,那么所有当前进程的current->bio_list中的来自同一个bioset的所有bios将会被交给bioset线程进行处理。这种方法相当丑陋,因为我们需要创建了一些几乎从不被使用的线程,而这些线程在内核中的存在仅仅是为了解决这个特定的死锁场景。而其他大多数的死锁情况也涉及将bios分成两个或更多的部分,但是它们并不总是涉及到mempool的分配问题。一股淡淡的忧伤啊!

不过一个好消息是最近的内核已经很少依赖这个特性了,并且已经在尽量避免创建不需要的bioset线程。在Linux 4.11中,研发人员对generic_make_request()做了修改并引入了更通用的替代方案。这个方案系统运行开销较少,只是对驱动有一定的要求。具体来说,当bio被拆分时,其中一半应该直接提交到generic_make_request()并被立刻处理,而另一半则可以以其他适当的方式进行处理。这无疑给了generic_make_request()更多的控制权,它可以根据所提交的块设备堆栈深度对所有bio进行排序,并优先处理底层块设备的bio。这个简单的做法解决了所有令人讨厌的死锁问题。真是换个思路海阔天空啊!

设备队列插入

Device queue plugging,这个词一直没想好怎么翻译,就这样吧!

通常情况下存储设备对单次请求进行操作的开销比较大,因此将一批请求集中在一起并作为一个单元提交它会更有效率。当设备相对较慢时,通常请求队列中会有很多未处理的请求,这样该队列的存在也提供了很多机会来合并请求。反过来当设备速度很快或者当一个慢速设备空闲时,找到合并请求并批量处理的机会就会少很多,那么无脑的尝试合并则会很浪费时间和精力。所以为了解决这个问题,Linux块层创造了一个“插入/拔出”(plug/unplug)的概念。

一开始,队列是空的并且是被插入(plug)的。所以在向空队列提交请求的时候以及今后的一段时间内,不会有任何请求流入底层设备,这样由文件系统提交的bio们就可以有充足的机会进行合并。而当文件系统提交了足够的bio以后,它会显式的进行拔出操作(unlug),或者在一个很短的时间以后被默认拔出,拔出以后IO开始下发。Linux内核的bio层就是通过这样的plug/unplug方式来保证I/O请求能够被批量下发,并且希望找到一个合适的提交I/O请求的数量并达到最终的性能提升。在内核研发早期,每个块设备只有一个plug/unplug队列,这样导致多CPU场景下的队列争抢问题非常严重。在Linux 2.6.39版本,Linux内核合并进了一个新的plug/unlug机制,这个机制允许每个进程在自己的上下文中进行队列的插入工作,从而在CPU多核的扩展性上得到了明显的提升。具体的新机制是这样工作的。

当块设备的文件系统或其他客户端提交请求I/O时,它通常在generic_make_request()前调用blk_start_plug(),结束之后再调用blk_finish_plug()。blk_start_plug主要的作用是初始化current-> plug,该数据结构包含一个blk_plug_cb的队列(还有一个结构请求队列,我们将在下一篇文章中详细介绍)。由于这个队列都是归属于进程的,因此可以在无锁环境下添加相应的条目。 这样make_request_fn就可以对传过来的bio做灵活的处理,比如如果它认为批处理请求更有优势,那么它可以选择将bio添加到队列中。当调用blk_finish_plug()时,或者进程调用schedule()时(例如等待互斥锁或等待内存分配时),current-> plug中存储的每个bio将会被处理。

image

机制看上去很简单,但是这里有两个有意思的设计值得大家思考。

第一个,为什么在发生schedule()的时候需要处理plug队列呢?因为如果进程被阻塞,队列就会被立刻处理,这样可以防止其他进程等待这个进程正在准备提交的bio,防止前面提到的死锁。

第二个,为什么在进程级别维护这样的队列?因为一个进程提交的bio基本都是有关系的,而有关系的bio可以很容易得被检测和合并在一起,另外一点就是相关操作都可以是无锁的。想象一下如果这个不是进程级别的话,在操作bio的时候,肯定需要一个自旋锁或者一个原子变量来保护队列的操作。而通过每个进程的自有队列,我们可以无锁的创建每个进程自己的bio列表,然后只用一次spinlock将它们全部合并到最终的块设备公共队列中。

总结

总之,bio层是一个很薄的层,它的主要功能是以bio的形式接受I/O请求,并将它们直接传递给相应的make_request_fn()函数。它提供了各种支持功能,以简化bio的分拆和调度,同时通过plug/unplug来优化性能。它还执行一些其他简单的任务,例如更新/proc/vmstat中的pgpgin和pgpgout统计信息等等。

当然作为承上启下的模块,他另外一个重要工作是让在它下面的模块的工作能够继续下去,有时下一层是一个驱动程序,例如drbd(分布式复制块设备)或brd(基于RAM的块设备);有时下一层是一个中间层,例如由md和dm提供的虚拟设备;还有一些可能是我们最常见的块层的剩余部分,我们称之为“请求层”(request layer)。这一层的一些错综复杂的内容将在另外一篇文章中展开。

原文发布时间为:2018-07-11
本文作者:杨艇艇 马涛
本文来自云栖社区合作伙伴“Linux宝库”,了解相关信息可以关注“Linux宝库”。

相关文章
|
13天前
|
Linux C语言
Linux内核队列queue.h
Linux内核队列queue.h
|
1月前
|
存储 Shell Linux
【Shell 命令集合 系统设置 】Linux 生成并更新内核模块的依赖 depmod命令 使用指南
【Shell 命令集合 系统设置 】Linux 生成并更新内核模块的依赖 depmod命令 使用指南
31 0
|
1月前
|
Shell Linux C语言
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
29 1
|
6天前
|
算法 Linux 调度
深入理解Linux内核的进程调度机制
【4月更文挑战第17天】在多任务操作系统中,进程调度是核心功能之一,它决定了处理机资源的分配。本文旨在剖析Linux操作系统内核的进程调度机制,详细讨论其调度策略、调度算法及实现原理,并探讨了其对系统性能的影响。通过分析CFS(完全公平调度器)和实时调度策略,揭示了Linux如何在保证响应速度与公平性之间取得平衡。文章还将评估最新的调度技术趋势,如容器化和云计算环境下的调度优化。
|
11天前
|
算法 Linux 调度
深度解析:Linux内核的进程调度机制
【4月更文挑战第12天】 在多任务操作系统如Linux中,进程调度机制是系统的核心组成部分之一,它决定了处理器资源如何分配给多个竞争的进程。本文深入探讨了Linux内核中的进程调度策略和相关算法,包括其设计哲学、实现原理及对系统性能的影响。通过分析进程调度器的工作原理,我们能够理解操作系统如何平衡效率、公平性和响应性,进而优化系统表现和用户体验。
20 3
|
18天前
|
负载均衡 算法 Linux
深度解析:Linux内核调度器的演变与优化策略
【4月更文挑战第5天】 在本文中,我们将深入探讨Linux操作系统的核心组成部分——内核调度器。文章将首先回顾Linux内核调度器的发展历程,从早期的简单轮转调度(Round Robin)到现代的完全公平调度器(Completely Fair Scheduler, CFS)。接着,分析当前CFS面临的挑战以及社区提出的各种优化方案,最后提出未来可能的发展趋势和研究方向。通过本文,读者将对Linux调度器的原理、实现及其优化有一个全面的认识。
|
18天前
|
Ubuntu Linux
Linux查看内核版本
在Linux系统中查看内核版本有多种方法:1) 使用`uname -r`命令直接显示版本号;2) 通过`cat /proc/version`查看内核详细信息;3) 利用`dmesg | grep Linux`显示内核版本行;4) 如果支持,使用`lsb_release -a`查看发行版及内核版本。
36 6
|
21天前
|
Linux 内存技术
Linux内核读取spi-nor flash sn
Linux内核读取spi-nor flash sn
17 1
|
28天前
|
存储 网络协议 Linux
【Linux 解惑 】谈谈你对linux内核的理解
【Linux 解惑 】谈谈你对linux内核的理解
23 0
|
1月前
|
存储 Linux Shell
【Shell 命令集合 系统设置 】Linux 显示Linux内核模块的详细信息 modinfo命令 使用指南
【Shell 命令集合 系统设置 】Linux 显示Linux内核模块的详细信息 modinfo命令 使用指南
26 0