《Linux内核设计艺术》——第7章 缓冲区和多进程操作文件

1. 缓冲区的作用

缓冲区是在物理内存中开辟的一块空间,这块内存空间的物理性质与进程所占的内存空间没有什么本质的不同。块设备与缓冲区交互数据同硬盘与进程内存空间交互数据从物理层面上看完全相同,既不会影响交互数据的正确性,也不会影响交互数据的传输速度。从这个角度看,没有缓冲区没有什么不可以,完全可以实现进程与硬盘的数据交互。

由此可见缓冲区不是必须的,设计缓冲区一定是为了操作系统更好的运行。
设计缓存区带来的好处

1)形成所有块设备数据的统一集散地,操作系统的设计更方便、更灵活。

2)对块设备的文件操作运行效率更高。

第1方面比较容易理解,第2方面是理解操作系统的难点之一。所以,这一章我们将通过两个多进程操作文件实例,详细讲解缓冲区在提高块设备操作文件运行效率方面的设计。

仔细观察图7-1,可以发现这里面似乎有一个问题,进程内存空间与缓冲区内存空间是同样的内存。进程内存空间与硬盘交换数据的时候,中间加上一个缓冲区,
只会增加一次数据在内存中倒手的时间,而这次数据倒手没有任何数据处理,只是简单的倒手,应该只会增加对CPU资源的消耗,怎么会比进程直接与硬盘交换数
据还快呢?

快的原因是缓冲区的共享。在计算机中,内存与内存的数据交互的速度是内存与硬盘数据交互速度的2个量级。如果A进程从硬盘读到缓冲区的数据,恰好也是B进
程需要读取的,B进程就不用从硬盘读取,可以直接从缓冲区中读取,B进程所花费的时间大约只有A进程读取这个数据的百分之一。效率一下子提高了2个量级。
如果还有C进程、D进程、E进程……恰好都需要读取这个数据,计算机的整体效率就会大大提高。这是缓冲区共享的一种模式,就是不同进程之间共享缓冲区中的
数据。如果A进程读取、使用完这个数据,过了一段时间需要再一次读取这个数据,此时这个数据还停留在缓冲区,A进程就可以直接从缓冲区中读取,不需要花费
100倍的时间从硬盘上读取。这是共享的另一种模式,就是同一个进程在不同时间多次共享缓冲区中的同一个数据。再有就是这两种模式的组合模式。以上分析的
是读操作的共享。写操作的共享与此类似。

仔细思考上面的分析,可以发现,要想通过缓冲区的设计提高操作系统读写文件的整体效率,就应该尽可能多地共享缓冲区中的数据。而尽可能多地共享缓冲区中的数据,最有效、最直接的方法就是让缓冲区中的数据在缓冲区中停留的时间尽可能长!

可以说,缓冲区的所有的代码都是围绕着如何保证数据交互的正确性、如何让数据在缓冲区中停留的时间尽可能长来设计的。本章后续的内容将通过两个实例,详细讲解操作系统代码是如何实现“让数据在缓冲区中停留的时间尽可能长”这个目标的。

2. 缓冲区的总体结构

在Linux操作系统中,为缓冲区配套地设计了两个非常重要的管理信息:buffer_head、request。其中buffer_head主要负责进
程与缓冲区中的缓冲块的数据交互,在确保数据交互正确的前提下,让数据在缓冲区中停留的时间尽可能长;request主要负责缓冲区中的数据与块设备之间
的数据交互,在确保数据交互正确的前提下,尽可能及时地将进程修改过的缓冲块中的数据同步到块设备上。

3. b_dev b_blocknr request的作用

b_dev和b_blocknr这两个字段是buffer_head结构中非常重要的两个字段,是缓冲区支持多进程共享文件的基础。它们既是正确性的基础,又是“让数据在缓冲区中停留的时间尽可能长”的基础。下面先来介绍这两个字段是如何确保正确性的。

3.1 保证进程与缓冲块数据交互的正确性

进程与缓冲区不是以文件为单位、而是以缓冲块为单位进行数据交互的,一次交互若干个块,数据不足一个缓冲块的仍占用一个缓冲块。缓冲区与硬盘的交互也是以
缓冲块为单位的,而且缓冲块与硬盘块的单位大小一致。进程操作文件时,由进程提出的文件操作请求,最终由操作系统落实到与硬盘上具体某个数据块进行交互。
有了缓冲区,进程和硬盘上的数据块不是直接交互数据,而是通过缓冲块再进行交互。保证数据交互的正确性,首先要保证硬盘上的数据块与缓冲块必须严格对应。

因为硬盘的设备号、块号能唯一确定硬盘块,第2章讲解过每一个缓冲块都有唯一的一个buffer_head管理,所以,操作系统采取的策略是,内核通过
buffer_head结构中的b_dev和b_blocknr两个字段,把缓冲块和硬盘数据块的关系绑定。这样,就保证了硬盘块与缓冲块关系的唯一性,
进程、缓冲块的数据交互与进程、硬盘数据块的数据交互等价,确保数据交互不会出现混乱。代码如下:

从以上代码中可以看出,新申请缓冲块的时候就将缓冲块与数据块的关系锁死,使得内核在进程方向上,只要将文件的操作位置确定,并将其转换成b_dev、b_blocknr就可以了,硬盘数据块与缓冲块的关系不用考虑,最终与硬盘的交互肯定是正确的。

读文件时,内核通过文件操作指针计算出文件数据内容所在的b_dev、b_blocknr,进程方面的延伸到缓冲块为止,执行bread()函数以后就不再与硬盘数据块直接打交道了。代码如下:


与读文件类似,写文件时,内核通过文件操作指针计算出文件数据内容所在的b_dev和b_blocknr,进程方面的延伸到此为止。代码如下:


进程方向上交互文件内容时如此,交互文件管理信息时也是如此。

内核读取i节点时,通过i节点号以及超级块中的信息,计算出i节点所在的b_dev和b_blocknr,而不会跨过缓冲块直接操作硬盘数据块。代码如下:


与内核读i节点类似,内核写入i节点时,通过i节点号以及超级块中的信息,计算出i节点所在的b_dev和b_blocknr,动作到此为止。代码如下:


同样,内核加载超级块时,通过传递下来的设备号和指定的块号,计算出超级块、i节点位图、逻辑块位图所在的b_dev和b_blocknr,动作延伸至此。代码如下:


从硬盘方向看,内核通过另一个数据结构(request)来支持缓冲块与硬盘的交互,请求项中设备号dev和块的首扇区号sector(块是操作系统的概
念,硬盘只有扇区的概念)决定了数据交互的位置。而这两个字段的数值,也是通过buffer_head中b_dev和b_blocknr两个字段的值来设
置的。这说明,只要缓冲块的设备号和块号确定了,内核通过请求项与硬盘交互时,最多考虑到缓冲区就足够了,不需要再往进程方面延伸,去考虑进程的文件操作
问题。代码如下:

3.2 让数据在缓冲区中停留的时间尽可能长

b_dev和 b_blocknr 不仅保证了正确性,而且是数据在缓冲区中多停留一段时间的处理
数据释放停留在缓冲区的标志是,缓冲块释放还与硬盘的数据存在绑定关系

可见若 设备号 块号 对应的缓冲块与硬盘数据的绑定关系还存在,则说明数据还在缓冲块中,可以直接使用,避免了低效的读硬盘。

若不存在绑定的缓冲块,则分配缓冲块并绑定 设备号和块号

这两行代码的意思是,新申请的缓冲块与数据块建立绑定关系。新申请一个缓冲块会有两种情景。一种是操作系统刚启动的时候,该缓冲块没有和任何数据块建立绑
定关系。另一种是操作系统已经运行了一段时间,执行过充分多的文件读写操作,所有缓冲块都与硬盘数据块建立了绑定关系,而且所有的缓冲块与新申请的缓冲块
的b_dev、b_blocknr不符,不能共享。所以只能在暂时没有进程使用(b_count为0)的缓冲块中,强行占用一个缓冲块。在这种情景下,这
两行代码就有两层意思:

1)建立一个新的缓冲块与硬盘数据块之间的绑定关系产生。

2)废除之前的缓冲块与硬盘数据块的绑定关系。

值得注意的是,为了让数据块中的数据尽可能长时间地停留在缓冲块中,内核中没有任何机制、也没有任何代码能够将已经建立了绑定关系的缓冲块、硬盘数据块,
刻意地、主动地解除绑定关系。只有在迫不得已的情况下,才被新建立的绑定关系强行替换。所有这些的目的只有一个,就是尽可能使数据在缓冲区中停留更长的时
间。现在,我们可以看出,b_dev和b_blocknr是硬盘数据块的时间在缓冲区中停留更长时间的非常重要的管理信息。

反观请求项request的设计思路,和缓冲区正好相反,它的目的是,尽可能快地让缓冲块和硬盘交互数据。前面我们介绍到,request中也有与
b_dev和b_blocknr类似的字段,它们就是设备号dev和块的首扇区号sector。它们除了保证缓冲块和硬盘数据块交互的正确性外,更多的是
尽可能快地让缓冲块和数据块进行交互。我们来看如下代码:


若设备的请求队列未空,则立即执行新的请求,否则,将新的请求加到 请求队列

处理请求项的代码如下


从以上代码中可以看出,无论是读盘中断服务程序还是写盘中断服务程序,在执行完一对缓冲块和数据块的交互后,都要调用end_request()函数和
do_hd_request()函数,这样就形成了处理请求项队列中的请求项的循环操作。do_hd_request()函数中INIT_REQUEST
这个宏,用以判断循环是否结束。如果当前请求项不为空,即队列中还有请求项对应的缓冲块需要交互,就继续下达交互命令,直到把请求项中所有的任务都执行
完,即CURRENT为空,然后返回。此循环处理的情景如图7-6所示。

请求项这样设计的目的只有一个,就是要尽快让缓冲块和硬盘数据块进行交互。

4. uptodate 和 dirt 的作用

前面一节介绍到,b_dev和b_blocknr两个字段是进程能共享缓冲块的基础,是缓冲块中数据是否仍然停留的标志。停留,就是要被共享,使用会延伸
至两个方向:一个是进程方向,即进程能共享哪些缓冲块,不能共享哪些;一个是硬盘方向,即哪些需要同步到硬盘上,哪些不需要同步。而这两个使用方向的核心
任务,就是确保缓冲块和数据块上数据的正确性。

buffer_head中b_uptodate和b_dirt这两个字段,都是为了解决缓冲块和数据块的数据正确性问题而存在的。

b_uptodate针对进程方向,它的作用是,告诉内核,只要缓冲块的b_uptodate字段被设置为1,缓冲块的数据已经是数据块中最新的,就可以
放心地支持进程共享缓冲块的数据。反之,如果b_uptodate为0,就提醒内核缓冲块并没有用绑定的数据块中的数据更新,不支持进程共享该缓冲块。

b_dirt是针对硬盘方向的。只要缓冲块的b_dirt字段被设置为1,就是告诉内核,这个缓冲块中的内容已经被进程方向的数据改写了,最终需要同步到硬盘上。反之,如果为0,就不需要同步。

4.1 b_uptodate 的作用

我们先来看进程方向上,如果没有b_uptodate字段的控制,会出现什么情况。

没有b_uptodate字段的控制,缓冲块与硬盘块绑定后,进程直接操作缓冲块中的数据,可能会出现错误。以读文件为例,如图7-7所示。


所以没有 b_uptodate ,进程就 无法判断 缓冲区是否为垃圾数据,若直接读磁盘到缓冲块,则违背了缓冲块的初衷。



值得注意的是,b_uptodate被设置为1,是告诉内核,缓冲块中的数据已经用数据块中的数据更新过了,但并不等于两者的数据就完全一致。比如,为文
件创建新数据块,就需要新建一个缓冲块与这个新建数据块确立绑定关系。关系确立后,先将缓冲块清零,然后将该缓冲块的b_uptodate字段设置为1。
当然,此时并没有实质地同步数据,缓冲块和硬盘块的数据内容并不一致,但这并不影响数据的正确同步。
通过第5章的介绍我们得知,新建的数据块只可能有两个用途,要么用来存储文件的内容,要么用来存储文件的i_zone的间接块管理信息。

如果是存储文件内容,由于新建数据块和新建硬盘数据块,此时都是垃圾数据,都不是进程需要的,无所谓数据是否更新,结果“等效于”更新问题已经解决,所以将该缓冲块的b_uptodate字段设置为1(仔细想想,这时缓冲块不清零,也没有问题)。

如果是存储文件的间接块管理信息,必须清零,表示没有索引间接数据块,否则垃圾数据会导致索引错误,破坏文件操作的正确性。这时虽然缓冲块与硬盘数据块的数据不一致。依据相同的原理,b_uptodate字段设置为1仍然没问题。

设计者综合考虑,采取的策略是,只要为新建的数据块新申请了缓冲块,不管这个缓冲块将来用作什么,反正进程现在不需要里面的数据,干脆全都清零。这样不管
与之绑定的数据块用来存储什么信息,都无所谓,将该缓冲块的b_uptodate字段设置为1,更新问题“等效于”已解决。

b_uptodate被设置为1后,针对该缓冲块无非会发生读写两方面情况,下面我们看看会怎么样。

先看读的情况,缓冲块是新建的,虽然里面是垃圾数据,考虑到是新建文件,这时候不存在读没有内容的文件数据块的逻辑需求,内核代码不会做出这种愚蠢的动作。

再看写的情况,由于是新建缓冲块被清零、新建的硬盘数据块都是垃圾数据,此时缓冲块和数据块里面的数据都不是进程需要的,无所谓是否更新、是否覆盖。无所谓更新,可以“等效地”看成已经更新。所以,执行写操作不会违背进程的本意。

以上就是内核中b_uptodate被设置为1的情景。图7-9表现了硬盘数据块用来存储文件内容的情景。白色部分代表清零了,不论存储的是文件的数据块数据还是间接块信息,都没有问题。

反之,如果缓冲区中数据没有更新,b_uptodate为0,即更新问题没有解决,内核会阻拦进程,不让其共享缓冲块中的数据,无论读写,都不可以。其目的是为了避免前面讲述的没有更新带来的数据混乱。比如在读取块设备数据的时候,判断了两次,代码如下:

此代码中,getblk函数很有可能在缓冲区中找到一个已经建立了绑定关系(b_dev和b_blocknr都匹配),而且正好可以被当前进程使用的缓冲块,但就是由于b_uptodate为0,这个缓冲块也不能使用,只好再释放掉。

再比如,为与文件中已有的数据块交互而新申请的一个缓冲块,就把b_uptodate标志设置为0,表示此缓冲块数据现在还没有被更新,不能被进程共享,代码如下:

7. 1 节中介绍过这部分代码,一个新的缓冲块就是在这里诞生的,刚诞生的一刻,数据肯定和硬盘数据块是不一致的,所以要把b_uptodate设置为0,确保不被进程误用。

4.2 b_dirt 的作用

b_uptodate标志设置为1后,内核就可以支持进程共享该缓冲块的数据了,读写都可以。读操作不会改变缓冲块中数据的内容,所以不影响数据;而执行
了写操作后,就改变了缓冲块数据内容,就要将b_dirt标志设置为1。比如,往块设备文件写入数据,往普通文件写入数据,等等。具体代码如下:


buffer_head中在进程方向和硬盘方向分别设置了两个字段(b_uptodate和b_dirt),请求项结构中也有这两个方向上的考虑。相比写操作而言,读操作对用户进程更紧迫,所以请求项为这两种操作设定了大小不同的空间,代码如下:

从以上代码不难发现,request[32]中只有2/3的空间可以用来写操作,而全部的空间都可以用来读操作,在同等的条件下,读操作执行的机会更多。

另外,仔细考察b_uptodate、b_dirt可以发现,只要b_uptodate被设置为1,进程就可以共享里面的数据。而且在缓冲区中没有进程可
以直接共享的缓冲块的情况下,只要b_count为0,就可以挪作他用,让该缓冲块与其他数据块绑定,另行使用,不用担心数据会发生错误。但如果
b_dirt被设置为1,情况就不一样了,这个缓冲块的数据已经和数据块上的不一致了,需要同步,虽然用不着立即就同步,但在同步之前不能挪作他用,否则
就会覆盖掉这些数据,硬盘数据块中的数据没有体现进程改写的内容,出现数据混乱。这时如果出现缓冲块不够进程用的情况,那就只好让进程等待,等同步完成
后,内核就会立刻将b_dirt设置为0,腾出更多缓冲块供进程使用。代码如下:

4.3 i_uptodate i_dirt s_dirt 的作用

以上介绍了控制文件内容正确性的方面,内容通过b_uptodate和b_dirt这两个字段,保证缓冲区数据与硬盘数据块数据的正确性。文件管理信息也
有类似的字段,比如inode_table[32]中存储的i节点,在多进程操作同一文件时,就要共享文件i节点信息。为此它的数据结构中也设计了两个字
段:i_uptodate(Linux 0.11中没有实际使用)和i_dirt。代码如下:

设计i_dirt字段不难理解,比如改变文件大小后,文件i节点中就要改变对大小的记录,这样inode_table[32]中的i节点和硬盘上的内容就
不一样了,需要同步。i节点中i_uptodate标志并没有在系统中用到过。这是因为,这些文件管理信息在硬盘上都是以数据块的形式存储的,它们也都是
以块的形式载入缓冲区的,载入缓冲区后与硬盘数据块内容一样,等价于已经更新,直接可以用来共享,不需要在管理结构中再搞一套i_uptodate标志
了。

super_block[8]中存储的超级块,也存在被进程共享的问题。超级块中保存着整个文件系统的管理信息,多进程操作文件时,免不了都会用到。它的数据结构中也有一个字段:s_dirt,代码如下:

结构中没有类似uptodate这样的字段,理由和i节点结构中i_uptodate没有被用到是一样的。它们也都是以块的形式载入缓冲区的,载入缓冲区
后与硬盘数据块内容一样,等价于已经更新,不需要在管理结构中再搞一套uptodate标志了。而s_dirt字段,除了在读超级块时被设置为0后,再没
有被使用过。这是因为,在Linux
0.11中,进程共享超级块信息,全部从super_block[8]中读取信息,并没有往表项中写入数据,所以没有s_dirt字段。

5. count, lock,wait, request 的作用

数据停留在缓冲块后,在进程方向的使用问题继续延伸,就是本节要介绍的b_count、b_lock和*b_wait。

5.1 b_count 的作用

进程向内核提出申请的时候,内核只能在下面两种情况中做出选择:让进程和其他进程共享某个缓冲块,该缓冲块的所有控制字段的数值也一并先继承下来;为进程申请一个没被任何进程占用的缓冲块,所有的控制字段重新设置。

要做选择,进程就要知道哪些缓冲块已经被其他进程占用了,哪些没有被占用。有些缓冲块可能不止被一个进程共享,这就需要在缓冲块中设置一个字段,使内核能够随时知道“每个缓冲块有多少进程在共享”,这个字段就是b_count。

缓冲区在初始化的时候,没有进程引用缓冲块,所以每个缓冲块的b_count被设置为0。代码如下:

新申请一个缓冲块时,这个缓冲块必须没有被任何进程占用,b_count值设置为0;申请到后,缓冲块被第一个进程共享,b_count被设置为1。代码如下:

缓冲块陆续被更多的进程共享,b_count的数值在原来的基础上逐渐累加。代码如下:

而在进程读写文件完毕后,不再需要共享缓冲块,内核会解除该进程和缓冲块的关系,b_count数值随之减1。如果所有进程和该缓冲块的关系都解除了,则b_count的值就被递减为0,这个缓冲块就又可以被当做新缓冲块来申请了。代码如下:


值得注意的是,在所有共享缓冲块的进程全部脱离共享关系后,虽然b_count肯定为0,但这并不等于缓冲块与数据块解除了绑定关系。如果将来某个进程再
操作这个缓冲块,只要这个缓冲块的b_dev、b_blocknr没有改变,就不需要从硬盘中重新读取,完全可以直接沿用这个缓冲块。

5.2 i_count的作用

进程与缓冲块之间共享的是文件的内容数据,不仅管理文件的内容数据时需要b_count字段,而且文件的管理信息中,凡是需要“搜索空闲项”、“搜索到空
闲项后可以另作他用”的数据结构,都需要与之类似的字段。比如inode_table[32]中,就有类似字段。代码如下:

inode_table[32]是文件管理信息,进程引用了文件数据块所对应的缓冲块,也就必然相应地引用了inode_table[32]中的i节点
项,此两者是同步的。所以inode_table[32]中也需要i_count这一字段来标识该i节点项被多少进程共享了。如果没被共享,则
i_count就是0,就可以被当做空闲项。比如进程要打开一个从未打开的文件,无法与其他进程共享i节点项时,就可以用这个空闲i节点项来装载i节点。

而super_block[8]则与此不同,一个设备就一个超级块项目,整个系统就只能安装8个超级块,这都是有数的。一个超级块项从加载到文件系统卸
载,只代表某个设备的超级块,所以不存在是否空闲、是否打算着另作他用的情况。多个进程可以加载相同的文件系统,需要操作相同的超级块,但就用不着
count这样的字段来记录该超级块被引用的次数。代码如下:

从以上代码中可以看出,没有类似count的字段。

值得注意的是,除了i节点和超级块外,文件管理信息还包括i节点位图和逻辑块位图。这两类文件管理信息并没有专用的数据结构,但它们也要支持共享。它们存储在缓冲块中,而且是常驻。这些缓冲块只为i节点位图、逻辑块位图使用。代码如下:
![](https://img2022.cnblogs.com/blog/1037222/202209/1037222-20220907154610014-61993165.png)


从以上代码中可以看出,i节点位图、超级块位图载入缓冲块后,这些缓冲块的b_count被设置为1,之后并没有将其释放过,这样这些缓冲块的引用计数就无法还原为0了。所以任何进程申请新缓冲块的时候,都无法申请到它们,所以这些缓冲块成为专用。

5.3 b_lock, b_wait 的作用

内核为进程申请到缓冲块,尤其是申请到b_count为0的缓冲块时,因为同步的原因,有可能这个缓冲块正在与硬盘交互数据,为此buffer_head
结构中设置了b_lock字段。如果此字段被设置为1,就说明正在和硬盘交互数据,内核就会拦截进程对该缓冲块的操作,等到与硬盘的交互结束时,再把该字
段设置为0,以此解除对进程方面的拦截。

如果为进程申请到的缓冲块中b_lock字段被设置为1,即便已经申请到了,该进程也需要挂起,直到该缓冲块被解锁后,才能访问。在缓冲块被加锁的过程
中,而且无论有多少进程申请到了这个缓冲块,都不能立即操作该缓冲块,都要挂起,并切换到其他进程去执行。这就需要记录有哪些进程因为等待这个缓冲块的解
锁而被挂起了。由于使用了进程等待队列,所以一个字段就可以解决这个记录问题。这个字段就是*b_wait。

这两个字段往往是联合使用的,我们来看如下代码。

在怠速以前,初始化缓冲块的时候,b_lock全部被设置为0,*b_wait被设置为NULL。

缓冲块被申请后,开始底层块操作前,就要先把该块加锁,即把b_lock设置为1,然后进行底层操作。执行代码如下:

缓冲块与硬盘数据块开始交互数据时,lock_buffer()函数先判断缓冲块是否加锁。如果加锁了(很有可能该缓冲块早就被别的进程申请了,现在正与
硬盘交互数据),就直接调用sleep_on()函数将进程挂起,并切换到其他进程去执行。等到将来切换回当前进程后,再将缓冲块继续加锁。如果没加锁,
就将其加锁,以防其他进程误操作。b_lock和*b_wait的联用不仅体现在这里,其他只要需要判断缓冲块的使用状态的,都需要两者的联用。代码如
下:

给缓冲块加锁并将进程挂起时,两者联用;相反,给缓冲块解锁并将进程唤醒时,两者也是联用。代码如下:

读盘或写盘结束后,中断服务程序执行,会给缓冲块解锁,随后也将原来等待该缓冲块的进程唤醒。代码如下:

5.4 i_lock, i_wait, s_lock, s_wait的作用

在共享文件内容时,b_lock和*b_wait字段存在于缓冲块中,共享文件管理信息和共享文件内容是配套的,所以,文件管理信息的数据结构也有同样类似的字段存在,比如inode_table[32],super_block[8]。代码如下:


inode_table[32]中i_lock和i_wait联用时的代码如下:



super_block中 s_lock s_wait的联用


5.5 补充request 的作用

缓冲块、i节点、超级块等结构中设置的字段,为进程共享缓冲块建立了基础,解决了“能共享还是不能共享”的问题。下面介绍如何更高效地共享缓冲块。

本节到这里介绍了缓冲块在进程方向上延伸的使用问题。下面介绍在硬盘方向上延伸的使用问题。我们来看请求项的数据结构,代码如下:

请求项request要和硬盘进行交互,所以要明确是读交互还是写交互,为此设计了cmd字段;此外,还需要明确是哪个缓冲块要进行交互,比如bh
buffer字段;还需要考虑数据块与扇区的映射规则,比如sector和nr_sectors字段;还需要考虑如果交互出现了问题怎么办,用
errors记录出现问题的次数。这些字段都是为了与硬盘交互设置的。

硬盘方向完全是某个缓冲块和某个数据块一对一的交互,不存在共享问题,所以请求项中也就没有类似b_count的字段,请求项对交互状况的记录,只存在两种状态:忙、空闲。因此dev字段不仅代表设备号,还可以通过它来判断请求项是否正在被占用,代码如下:


另外值得注意的是,请求项request设置为32,尽可能地实现了主机和硬盘数据交互的平衡,但这种平衡并不绝对,比如说,写盘过于频繁,或者由于硬盘
自身出现故障,导致数据交互失败,就有可能在请求项中积压数据,最终导致请求项不够用了。那么内核即便为进程申请到了缓冲块,而由于没有请求项,进程也只
能被挂起,同样也需要字段来记录哪个进程被挂起了。*waiting记录挂起的进程,代码如下:

等到有了空闲请求项,再唤醒进程,代码如下:

同理,多个进程也可能都由于等待某个请求项被挂起,waiting一个字段是无法记录的,同样需要进程等待队列解决这个问题。本小节前面也提到了
b_wait记录等待缓冲块解锁的进程,这里的*waiting与此类似,都需要用到进程等待队列的技巧来完成对多个等待进程的记录。

6. 实例1:缓冲块的进程等待队列

下面我们通过一个多进程操作相同文件的案例,一方面把共享地问题形象地进行体现,另一方面把进程等待队列的原理讲解清楚。

假设硬盘上已有一个文件名为hello.txt的文件,这个文件的大小为700 B,小于一个数据块大小(1 

KB),被载入缓冲区后,一个缓冲块就可以承载其全部内容,这三个进程一旦开始操作这个文件,就相当于在依托系统操作同一个缓冲块,这样就会产生进程等待
队列。本节将详细介绍该队列的产生过程,以及队列中进程的唤醒过程。

进程A是一个读盘进程,目的是将hello.txt文件中的100字节读入buffer[100],代码如下:

进程B也是一个读盘进程,目的是将hello.txt文件中的200字节读入buffer[200],代码如下:

进程C是一个写盘进程,目的是往hello.txt文件中写入str1[]中的字符“ABCDE”,代码如下:

这三个进程执行顺序为:进程A先执行,之后进程B执行,最后进程C执行。这三个进程没有父子关系。

1.进程A读取文件后被挂起

进程A启动后,执行“int 

fd=open(“/mnt/user/user1/user2/hello.txt”,O_RDWR,0644));”,open()函数最终会映射到
sys_open()函数去执行。sys_open()函数的执行情况,已在第5章中介绍。代码如下:

之后执行“read(fd,buffer,sizeof(buffer))”,read()函数最终会映射到sys_read()函数去执行;之后,
sys_read()函数调用file_read()函数来读取文件内容;file_read()函数调用bread()函数从硬盘上读取数据。执行代码
如下:


在ll_rw_block()函数执行时,缓冲块已经被加锁(本书3.3.1.3节中已介绍)。while(bh->b_lock)条件为真,之后调用
sleep_on()函数,传递的实参是&bh->b_wait。其中bh->b_wait表示等待该缓冲块解锁的进程指针。由于系统在初始化时,已经将
所有缓冲块中b_wait的值设置为NULL,此缓冲块又是新申请的,从来没有被其他进程用过,所以此时bh->b_wait的值为NULL。下面进入
sleep_on()函数,执行代码如下:

进程A被挂起后,调用schedule()函数,切换到进程B执行。

与此同时,硬盘也正在向数据寄存器端口中传递数据,此情景如图7-13所示。

值得注意的是,代码中的tmp存储在进程A的内核栈中,存储的是NULL,bh->b_wait此时存储的是进程A的指针,如图7-14所示。

2. 进程B读取文件后被挂起

进程B首先执行“int 

fd=open(“/mnt/user/user1/user2/hello.txt”,O_RDWR,0644));”这行代码。open()函数最终
会映射到sys_open()函数去执行;sys_open()函数会在文件管理表file_table[64]中新申请一个空闲表项,让进程B
task_struct中的*filp[20]与file_table[64]空表项挂接。虽然进程B和进程A打开的是相同的文件,但实例1中,这两个进
程彼此对文件的操作没有关系,所以需要两套账本。

之后调用open_namei()函数,获取hello.txt文件的i节点,并最终将i节点与file_table[64]相挂接,执行代码如下:

值得注意的是,此次获取hello.txt文件的i节点,与进程A获取该i节点有所不同,代码如下:



一个文件只能对应一个i节点。进程A和进程B对文件的操作,需要两本账来记录。但它们操作的hello.txt文件的i节点只能有一个,而进程A已经把i节点载入了inode_table[32],现在进程B就要沿用这个i节点。

文件打开后,进程B执行“read(fd,buffer,sizeof(buffer));”这行代码,读取hello.txt文件的内容。

read()函数最终会映射到sys_read()函数去执行,之后,sys_read()函数调用file_read()函数来读取文件内容,file_read()函数调用bread()函数从硬盘上读取数据,执行代码如下:



接下来执行sheep_on()函数。因为实例1中进程A和进程B操作的是同一文件,对应相同的缓冲块bh,该缓冲块中b_wait的值被设置为进程A的
task_struct指针,所以此次执行sheep_on()函数的情景,与前面进程A执行时的情景完全不同,代码如下:

值得注意的是,代码中的tmp存储在进程B的内核栈中,存储的是进程A的task_struct指针,bh->b_wait此时存储的是进程B的指针,如图7-19所示。

3. 进程C写文件后被挂起

进程C开始执行后,同样操作hello.txt文件,向该文件中写入数据。进程C执行的技术路线与进程B大体一致,先执行“int 

fd=open(“/mnt/user/user1/user2/hello.txt”,O_RDWR,0644));”这行代码,open()函数最终
会映射到sys_open()函数去执行。sys_open()函数最终的执行结果为,在file_table[64]中再次申请一个空闲表项,进程C
task_struct中的*filp[20]与file_table[64]中的空闲表项挂接。

之后sys_open()函数调用open_namei()函数,同样会在i节点表inode_table[32]中找到该文件的i节点,该i节点的引用计数再次加1,执行代码如下:

进程C继续执行“write(fd,str1,strlen(str1));”这行代码,往hello.txt文件写入数据。

write()函数最终会映射到sys_write()函数去执行,sys_write()函数调用file_write()函数来读取文件内容,file_write()函数调用bread()函数从硬盘上读取数据,执行代码如下:




接下来执行sheep_on()函数。因为实例1中进程A、进程B和进程C操作的是同一文件,对应相同的缓冲块bh,我们已经介绍到该缓冲块中
b_wait的值被设置为进程B的task_struct指针,所以此次执行sheep_on()函数的情景,与前面进程B执行时的情景完全不同,代码如
下:

值得注意的是,代码中的tmp存储在进程C的内核栈中,存储的是进程B的task_struct指针,bh->b_wait此时存储的是进程C的指针,如图7-22所示。

此时的情况是,三个进程因等待bh缓冲块解锁而被系统挂起,于是形成了一个等待队列。挂起前,每个进程的内核栈中都保存着前面被挂起进程的
task_struct指针。图7-22表现的就是这个等待队列。这个队列的作用在于,等到缓冲块解锁时,操作系统可以根据每个被唤醒的进程中内核栈的记
录,来唤醒在此进程挂起之前被挂起的进程,这样,所有等待缓冲块解锁的进程将被依次唤醒。具体过程将在下面详细介绍。

4. 三个进程以相反的顺序被唤醒

此时进程A、进程B和进程C都已经被挂起了,系统中所有的进程又都处于非就绪态了。先以默认切换到进程0去执行,直到数据读取完毕,硬盘产生中断,如图7-23所示。

硬盘中断产生后,中断服务程序将开始工作。此时硬盘已经将指定的数据全部载入缓冲块。中断服务程序开始工作后,将bh缓冲块解锁,并调用wake_up()函数,将bh中wait字段所对应的进程(进程C)唤醒,执行代码如下:

调用wake_up()函数时,传递的参数是&bh->b_wait。从进程等待队列图中不难发现,bh->b_wait指向的是进程C的task_struct指针,所以唤醒的是进程C。

中断服务程序结束后,再次返回进程0中,并切换到就绪的进程C。进程C是在sleep_on()函数中,调用了schedule()函数进行的进程切换,因此,进程C最终回到sleep_on()函数中,首先要执行的代码如下:


此时内核中程序在执行,所使用的是进程C的内核栈,这样tmp对应的是进程B的task_struct指针,所以此时是将进程B设置为就绪态。

内核继续执行,将把进程C的源程序中指定的str1这个字符数组中的数据写入hello.txt文件对应的缓冲块中,执行代码如下:

进程C的时间片削减为0时,要切换进程了。前面已经介绍到,进程C被唤醒后,系统的第一件事就是将进程B设置为就绪态。此时系统中只有进程B和进程C两个进程是就绪态,进程C的时间片用完了,就会切换到进程B去执行,如图7-30所示。

进程B也是在sleep_on()函数中,调用了schedule()函数进行的进程切换,因此,进程B最终回到sleep_on()函数中,首先要执行的代码如下:

此时内核中程序在执行,所使用的是进程B的内核栈,这样tmp对应的是进程A的task_struct指针,所以此时是将进程A设置为就绪态。

当前进程为进程B,将缓冲块中指定的数据读出,执行代码如下:

随着时钟中断的不断产生,进程B的时间片削减为0后,由于此时系统中只有进程A处于就绪态,而且它的时间片没有被削减为0,所以就会切换到进程A去执行,如图7-33所示。

进程A也是在sleep_on()函数中,调用了schedule()函数进行的进程切换,因此,进程A最终回到sleep_on()函数中,首先要执行的代码如下:

7. 总体来看缓冲块和请求项

b_dev、b_blocknr是缓冲区中数据停留的标志。在对缓冲块的实际应用中,内核并没有刻意清除这两个字段。这意味着,如果持续新申请缓冲块,那
么很快所有的缓冲块就都会与数据块绑定。这时再申请缓冲块,就只能用新的绑定关系替换旧的绑定关系,这个缓冲块中已有的数据作废。这体现了让缓冲区中的数
据在缓冲区中停留的时间尽可能长的策略。

为了能够让数据多停留一段时间,内核要做到尽可能地不申请新的缓冲块,能沿用已经建立绑定关系的缓冲块就沿用,实在不行了,非得申请不可了,再去申请。这一做法在代码中的体现如下:

从以上代码中不难发现,内核先搜索哈希表,通过比对b_dev、b_blocknr,来分析是否可以沿用,如果可以,直接返回使用就可以,实在不行,再执行do……while循环,新申请缓冲块。

下面我们来看新申请缓冲块时的情景。

现在的情况是,哈希表中已经遍历过了,缓冲区中所有缓冲块都不能被进程沿用了,内核必须申请一个新缓冲块。申请的时候,从free_list表头开始搜
索,这是为了尽可能不破坏已经绑定数据块的缓冲块,让它们多停留在缓冲区一会儿,实在不行了(比如缓冲区中所有缓冲块都和数据块绑定了),那就只好用新关
系替换老关系了。

循环里面的执行,是在这一前提下开始的。

循环里面并没有分析b_uptodate字段。这是因为,既然前面搜索哈希表时已经确认,没有合适的可以沿用的缓冲块了,那么这就意味着,对于当前进程而
言,缓冲区中所有缓冲块的内容已经不可用了,它们是不是更新了,b_uptodate是1还是0,都无所谓,所以此时也就无须分析b_uptodate的
数值了。

循环中首先判断b_count是否为0。如果不为0,说明缓冲块正在被其他进程共享,当前进程不能废除正在被其他进程共享的缓冲块,这个缓冲块不能用,内核得为它再新建缓冲块。如果找遍缓冲区也找不到b_count为0的缓冲块,那么当前进程只能挂起了。

如果找到了b_count为0的缓冲块,还有两个字段会左右进一步的选择。一个是b_dirt,另一个是b_lock。如果这两个字段都为0,选择这样的
缓冲块再合适不过了,可以直接使用。如果b_lock和b_dirt中有一个是1,那么选择哪个合适呢?相比来讲,选择b_lock为1的更为有利。理由
是,这两个字段有一个为1,当前进程肯定都不能使用了,肯定要等,相比之下等的时间当然越少越好。b_lock为1,说明该缓冲块正在跟硬盘交互数据,交
互完了,最终轮到当前进程使用。而b_dirt为1,说明在建立新的绑定关系之前,肯定需要把数据同步到硬盘,同步的时候肯定要加锁——b_lock置
1。所以,选择b_lock为1的比选择b_dirt为1的,少等待由b_dirt为1到b_lock为1的时间。这一点从如下代码中也可以看出来。代码
如下:

可见,先不去申请b_dirt为1的缓冲块,更能让进程尽快执行,对它更有利,所以,#define 

BADNESS(bh)(((bh)->b_dirt<<1)+(bh)->b_lock)中,将b_dirt左移一位,使之权重更高。
BADNESS(tmp)<BADNESS(bh)这行逻辑,将在b_dev、b_blocknr、b_count同等条件下,使b_dirt尽可能靠后
被申请到。

8. 实例2:多进程操作文件

下面我们通过一套多进程操作文件的案例,对缓冲块的选择以及请求项的使用等进行介绍。

进程A是一个写盘进程,目的是往hello1.txt文件中写入str1[]中的字符“ABCDE”,代码如下:

进程B是一个写盘进程,目的是往hello2.txt文件中写入str1[]中的字符“ABCDE”,代码如下:

进程C是一个读盘进程,目的是从hello3.txt文件中读20 000字节到buffer中,代码如下:

这三个进程的执行顺序为:进程A先执行,之后进程B执行,最后进程C执行。这三个进程没有父子关系。

1. 系统不断为进程A向缓冲区写入数据

进程A开始执行后,执行write函数。假设hello1.txt文件没有任何内容,所以进程A只需在缓冲区中不断申请缓冲块并将指定的数据写入缓冲块就
可以了。新申请缓冲块的前提是,该缓冲块空闲且不脏。我们假设现在系统已经在缓冲区中的所有空闲且不脏的缓冲块内写满了数据。图7-35显示了系统已经将
所有空闲且不脏的缓冲块写满的状态。接下来,我们就要看一下,在缓冲区处于图7-35所示的状态时,再新申请缓冲块并进行写入操作,会导致什么情况。

2. 继续执行引发缓冲块数据需要同步

当前进程仍然是进程A。它提出的请求,系统还远没有完成,还要继续向缓冲区写数据。这就需要通过getblk函数在缓冲区中找到一个空闲的缓冲块,即b_count为0的缓冲块。

但现在的情况是,缓冲区中已经没有空闲且不脏的缓冲块,只有空闲且脏的缓冲块。这就意味着,接下来要强行将缓冲区中的数据同步到硬盘,以便在缓冲区中空出更多的空间,为后续的写盘工作提供支持,执行代码如下:

此时,sync_dev()函数用来将缓冲区中的数据同步到硬盘上。进入sync_dev()函数后,代码如下:

sync_dev函数将会遍历整个缓冲区,将所有“脏”的缓冲块中的内容全部同步到硬盘上。每个“脏”缓冲块的同步步骤是这样的:

第一,先将缓冲块与申请到的空闲请求项进行绑定,请求项中记录的内容将作为数据同步的唯一依据。

第二,如果硬盘此时没有工作,则下达写盘命令,将数据进行同步;如果硬盘正在工作,则将该请求项挂接在请求项队列中,硬盘同步完数据并产生中断后,中断服务程序会不断地给硬盘发送指令,以使请求项队列中各个请求项对应的数据陆续同步到硬盘中。

sync_dev函数将会不停地执行上述工作,直到无法再申请到空闲请求项为止。

每个缓冲块同步的过程都是在ll_rw_block函数中完成的。在此过程中,缓冲块会被加锁。加锁只是阻止进程与缓冲块的数据交互,阻止系统自身与缓冲
块的数据交互,但并不阻止缓冲块与硬盘之间的数据交互。在发送同步指令之前,需要同步的缓冲块的脏标志b_dirt将会被设置为0,以表示它不再是“脏”
的缓冲块了。

具体的执行路线是,进入ll_rw_block函数后会调用make_request函数将缓冲块与请求项挂接;在make_request函数中会先将
缓冲块加锁,并通过add_request函数加载请求项;在请求项加载完毕后,系统将通过调用do_hd_request函数向硬盘发送写盘命令。
do_hd_request函数是系统与硬盘交互的底层函数,它将根据请求项的数据,最终实现将指定的缓冲块的数据写到指定的硬盘块上。执行代码如下:

同步一个缓冲块的情况如图7-36所示。在make_request函数中把这个缓冲块加锁了,而在add_request函数中已经将该缓冲块的脏标志
置0,此时这个缓冲块已经成为了空闲且不脏的缓冲块。请读者将图7-36与图7-35对比,注意该缓冲块的状态变化。

sync_dev函数不断同步缓冲块,最终结果如图7-37所示。注意所有留给写入操作的请求项均已被占用,同时与写入请求项对应的缓冲块的状态也已被置为空闲且不脏的状态,而硬盘正在不断对请求项进行处理。



请求项结构中虽然还有空闲的请求项,但留给“写入”操作的请求项只占请求项总数的2/3,对应的代码如下:

由于现在这2/3的请求项已经全部被占用了,所以现在已经没有空闲的请求项为“同步”服务了,如图7-35中的写入请求项所示。

4. 进程A由于等待空闲请求项而被系统挂起

空闲的“写盘”请求项没有了,但sync_dev()函数仍然会被继续调用,再次进入make_request()函数后,将会执行如下代码:

这些代码的作用是,如果最后也没有找到合适的空闲请求项,就将当前进程挂起。调用sleep_on()函数后,进程A就成为了等待空闲请求项的进程,被挂
起了。该过程如图7-38所示,此时硬盘仍然在不断地处理请求项,而进程A已经处于挂起状态,尽管它还有时间片。

5. 进程B开始执行

进程B开始执行。它也是一个写盘进程。系统也要给进程B申请缓冲块,以便其写入数据。执行代码如下:

通过图7-38可以看出,现在缓冲区中的各个空闲缓冲块的状态有所不同,系统就是要在当前情况下综合分析所有的空闲缓冲块的状态,从而确定为进程B申请哪个缓冲块。系统是通过BADNESS(tmp)来进行综合分析的。BADNESS(tmp)的定义如下:

通过前面的分析我们得知,它的作用是将缓冲区中所有的缓冲块按照对进程执行更有利的原则,分为4个等级,从有利到不利依次为:

一等:没有“脏”,且没有“加锁”的空闲缓冲块,此等缓冲块的BADNESS值为0;
二等:没有“脏”,且有“加锁”的空闲缓冲块,此等缓冲块的BADNESS值为1;
三等:有“脏”,且没有“加锁”的空闲缓冲块,此等缓冲块的BADNESS值为2;
四等:有“脏”,且有“加锁”的空闲缓冲块,此等缓冲块的BADNESS值为3。

BADNESS值越小,则该缓冲块就越便于使用;越大就越不便于使用。

系统已经将一些缓冲块加锁,并将它们的“脏”标志设置为0了,于是就让它们的BADNESS值为1。在目前的情况下,这已经是最便于使用的缓冲块了。因此
系统就为进程B申请到了一个BADNESS值为1的缓冲块。这个缓冲块是加锁的。这意味着该缓冲块并不能马上被操作,但总比申请一个脏的缓冲块好得多。图
7-39表示了系统给进程B申请到的缓冲块。

6. 进程B也被挂起

系统申请到的缓冲块是“加锁”的缓冲块,这就导致进程或系统不能立即与该缓冲块进行数据交互。于是,系统会直接调用wait_on_buffer()函数,进程B将会被挂起,如图7-40所示。注意,此时系统和硬盘还在不断处理请求项。

7. 进程C开始执行并随后被挂起

进程C开始执行。它是一个读盘进程。系统也要为它申请一个缓冲块。从现在缓冲区的情况来看,系统给进程C和进程B申请的应该是同一个缓冲块。前面提到的缓冲块是加锁的,该缓冲块同样不能被操作,但可以被申请到,于是进程C也会被挂起。

进程C和进程B现在都因为在等待同一个缓冲块的解锁而被挂起,所以这两个进程现在就形成了一个进程等待队列。

到现在为止,实例2中的三个用户进程都被挂起了,于是默认切换到进程0去执行。它们被挂起的情况如图7-41所示。其中进程A处在等待空闲请求项的等待队列,而进程B和进程C处在等待同一个缓冲块解锁的队列。

8. 进程A和进程C均被唤醒

下面我们将介绍这三个用户进程被唤醒的过程。它们被唤醒后,系统将继续根据缓冲块和请求项的各方面状况来决定用户进程将如何执行。

一段时间之后,硬盘完成了请求项交付的同步工作,产生硬盘中断,中断服务程序开始执行,执行代码如下:

进程A是由于等待空闲请求项才被挂起的,于是“wake_up(&wait_for_request);”这行代码将唤醒进程A,如图7-42所示。

进程B和进程C构成了一个进程等待队列。进程C后挂起,要先唤醒,如图7-42所示。

另外,由于指定的缓冲块中的数据已经操作完毕了,所以中断服务程序还会将该缓冲块解锁。中断服务程序会在以上工作做完后继续调用
do_hd_request函数,如果还有请求项需要处理,再次给硬盘发送写盘指令。很显然,硬盘又要开始进行后续的同步工作了。从图7-42中可以看
出,硬盘已经在处理下一个请求项,而此刻已经有一个可用的“写”请求项空闲。

此时进程C的时间片多于进程A的时间片,所以系统将当前进程由进程0切换到进程C,开始执行后的第一件事就是要唤醒进程B,如图7-42所示。执行代码如下:

系统为进程C申请到的缓冲块现在已经被解锁了,可以使用了。系统将先对这个缓冲块进行设置,包括将它的引用计数设置为1,代码如下:

于是该缓冲块就不再是空闲缓冲块了。然后,在请求项结构中申请一个空闲请求项与此缓冲块绑定,并将该缓冲块再次加锁。但是,请求项设置完毕,并不代表马上
就能处理这个请求项。此时硬盘正忙着同步处理其他请求项,现在只能先将“读盘”请求项插入请求项队列中,代码如下:

然后系统会将进程C挂起。

9. 进程B切换到进程A执行

进程C挂起后,进程B的时间片显然比进程A要多,所以切换到进程B。系统早已经为进程B申请了缓冲块,当时由于这个缓冲块是加锁的,所以进程B要挂起。现在,这个缓冲块仍然是加锁的,所以进程B将再次被挂起,并切换到进程A,如图7-44所示。

接下来,以上步骤将会重复执行。一方面,只要硬盘执行完一次同步操作,就会释放一个请求项,并将其对应的缓冲块解锁,这些都将导致等待空闲请求项或等待缓
冲块解锁的进程被唤醒;另一方面,被唤醒的进程又不断地引发缓冲区与硬盘之间进行数据交互,从而使这些进程不断地被挂起,如图7-46所示。

当前进程是进程A。进程A由于同步缓冲块时缺少空闲请求项才被挂起,所以它被唤醒后,系统将继续同步缓冲块。现在系统已经有空闲的请求项用于写盘了,所以
系统将此请求项与即将要同步的缓冲块绑定,并插入请求项队列中,之后又没有空闲的请求项了,进程A将再次被挂起,如图7-45所示。

直到最后三个进程全部完成各自的写盘任务和读盘任务,如图7-47所示。

理解多进程操作文件的关键是深入理解缓冲区。现在可以看得更清楚,缓冲区的设计指导思想就是让缓冲区中的数据在缓冲区中停留的时间尽可能长,进程与硬盘的交互尽可能在缓冲区中实现,尽可能少地读写硬盘数据。

posted on 2022-09-07 17:00  开心种树  阅读(239)  评论(0编辑  收藏  举报