In-depth understanding of the Linux kernel--page cache

page cache

页高速缓存(page cache)是Linux内核所使用的主要磁盘高速缓存。在绝大多数情况下,内核在读写磁盘时都引用页高速缓存。
新页被追加到页高速缓存以满足用户态进程的读请求。如果页不在高速缓存中,新页就被加到高速缓存中,然后用从磁盘读出的数据填充它。
如果内存有足够的空闲空间,就让该页在高速缓存(内存)中长期保留,使其他进程再使用该页时不再访问磁盘。

同样,在把一页数据写到块设备之前,内核首先检查对应的页是否已经在高速缓存中;
如果不在,就要先在其中增加一个新项,并用要写到磁盘中的数据填充该项。
I/O数据的传送并不是马上开始,而是要延迟几秒之后才对磁盘进行更新,
从而使进程有机会对要写入磁盘的数据做进一步的修改(换句话说,就是内核执行延迟的写操作)。以便对磁盘IO进行寻道优化等。
内核的代码和内核数据结构不必从磁盘读,也不必写入磁盘,因此,页高速缓存中的页(磁盘页)可能是下面的类型:
1.含有普通文件数据的页。
2.含有目录的页。
3.含有直接从块设备文件(跳过文件系统层)读出的数据的页。
4.含有用户态进程数据的页,但页中的数据已经被交换到磁盘。
内核可能会强行在页高速缓存中保留一些页面,而这些页面中的数据已经被写到交换区(可能是普通文件或磁盘分区)。
5.属于特殊文件系统文件的页,如共享内存的进程间通信(Interprocess Communication,IPC)所使用的特殊文件系统shm。

正如你所看到的,页高速缓存中的每个页所包含的数据肯定属于某个文件。这个文件(或者更准确地说是文件的索引节点)就称为页的所有者(owner)。
几乎所有的文件读和写操作都依赖于页高速缓存。页高速缓存是内存和磁盘的中间层。
只有在O_DIRECT标志被置位而进程打开文件的情况下才会出现例外:此时,I/0数据的传送绕过了页高速缓而使用了进程用户态地址空间的缓冲区;
少数数据库应用软件为了能采用自己的磁盘高速缓存算法而使用了O_DIRECT标志。

内核设计者实现页高速缓存主要为了满足下面两种需要:
1.快速定位含有给定所有者相关数据的特定页。为了尽可能充分发挥页高速缓存的优势,对它应该采用高速的搜索操作。
2.记录在读或写页中的数据时应当如何处理高速缓存中的每个页。
例如,从普通文件、块设备文件或交换区(这些都存储在磁盘上)读一个数据页必须用不同的实现方式,因此内核必须根据页的所有者选择适当的操作。
页高速缓存中的信息单位显然是一个完整的数据页。
一个页中包含的磁盘块在物理上不一定是相邻的,所以不能用设备号和块号来识别它,取而代之的是,
通过页的所有者和所有者数据中的索引(通常是一个索引节点和在相应文件中的偏移量)来识别页高速缓存中的页。

address_space object

页高速缓存的核心数据结构是address_space对象,它是一个嵌入在页所有者的索引节点对象中的数据结构。
高速缓存中的许多页可能属于同一个所有者,从而可能被链接到同一个address_space对象。该对象还在所有者的页和对这些页的操作之间建立起链接关系。
每个页描述符都包括把页链接到页高速缓存的两个字段mapping和index。
mapping字段指向拥有页的索引节点的address_space对象,
index 字段表示在所有者的地址空间中以页大小为单位的偏移量,也就是在所有者的磁盘映像中页中数据的位置。
在页高速缓存中查找页时使用这两个字段。
值得庆幸的是,页高速缓存可以包含同一磁盘数据的多个副本。
例如,可以用下述方式访问普通文件的同一4KB的数据块:
1.读文件;因此,数据就包含在普通文件的索引节点所拥有的页中。
2.从文件所在的设备文件(磁盘分区)读取块;因此,数据就包含在块设备文件的主索引节点所拥有的页中。
因此,两个不同address_space对象所引用的两个不同的页中出现了相同的磁盘数据。address_space对象包含如表15-1所示的字段。

Insert image description here
Insert image description here

如果页高速缓存中页的所有者是一个文件,address_space对象就嵌入在VFS索引节点对象的i_data字段中。
索引节点的i_mapping字段总是指向索引节点的数据页所有者的address_space对象。address_space对象的host字段指向其所有者的索引节点对象。
因此,如果页属于一个文件(存放在Ext3文件系统中),那么页的所有者就是文件的索引节点,
而且相应的address_space对象存放在VFS索引节点对象的i_data字段中。
索引节点的i_mapping字段指向同一个索引节点的i_data字段,而address_space对象的host字段也指向这个索引节点。

不过,有些时候情况会更复杂。如果页中包含的数据来自块设备文件,即页含有存放着块设备的“原始”数据,
那么就把address_space对象嵌入到与该块设备相关的特殊文件系统bdev中文件的“主”索引节点中
(块设备描述符的bd_inode字段引用这个索引节点)。
因此,块设备文件对应索引节点的i_mapping 字段指向主索引节点中的address_space对象。
相应地,address_space对象的host 字段指向主索引节点。
这样,从块设备读取数据的所有页具有相同的address_space对象,即使这些数据位于不同的块设备文件。
i_mmap、i_mmap_writable、i_mmap_nonlinear和i_mmap_lock字段涉及内存映射和反映射。

backing_dev_info字段指向backing_dev_info描述符,后者是对所有者的数据所在块设备进行有关描述的数据结构。
backing_dev_info结构通常嵌入在块设备的请求队列描述符中。
private_list字段是普通链表的首部,文件系统在实现其特定功能时可以随意使用。
例如,Ext2文件系统利用这个链表收集与索引节点相关的“间接”块的脏缓冲区。
当刷新操作把索引节点强行写入磁盘时,内核也同时刷新该链表中的所有缓冲区。
此外,Ext2文件系统在assoc_mapping字段中存放指向间接块所在块设备的address_space对象,
并使用assoc_mapping->private_lock自旋锁保护多处理器系统中的间接块链表。
address_space对象的关键字段是a_ops,它指向一个类型为address_space_operations 的表,
表中定义了对所有者的页进行处理的各种方法。这些方法如表15-2所示。

Insert image description here
Insert image description here

最重要的方法是readpage、writepage、prepare_write和commit_write。
在绝大多数情况下,这些方法把所有者的索引节点对象和访问物理设备的低级驱动程序联系起来。
例如,为普通文件的索引节点实现readpage方法的函数知道如何确定文件页的对应块在物理磁盘设备上的位置。

base tree

Linux支持大到几个TB的文件。访问大文件时,页高速缓存中可能充满太多的文件页,以至于顺序扫描这些页要消耗大量的时间。
为了实现页高速缓存的高效查找,Linux 2.6 采用了大量的搜索树,其中每个address_space对象对应一棵搜索树。
address_space对象的page_tree字段是基树(radix tree)的根,它包含指向所有者的页描述符的指针。
给定的页索引表示页在所有者磁盘映像中的位置,内核能够通过快速搜索操作来确定所需要的页是否在页高速缓存中。
当查找所需要的页时,内核把页索引转换为基树中的路径,并快速找到页描述符所(或应当)在的位置。
如果找到,内核可以从基树获得页描述符,而且还可以很快确定所找到的页是否是脏页(也就是应当被刷新到磁盘的页),以及其数据的I/O传送是否正在进行。

基树的每个节点可以有多到64个指针指向其他节点或页描述符。
底层节点存放指向页描述符的指针(叶子节点),而上层的节点存放指向其他节点(孩子节点)的指针。
每个节点由radix_tree_node数据结构表示,它包括三个字段:
slots是包括64个指针的数组,count是记录节点中非空指针数量的计数器,tags是二维的标志数组,

树根由radix_tree_root数据结构表示,它有三个字段:
height表示树的当前深度(不包括叶子节点的层数),
gfp_mask指定为新节点请求内存时所用的标志,
rnode指向与树中第一层节点相应的数据结构radix_tree_node(如果有的话)。

我们来看一个简单的例子。
如果树中没有索引大于63,那么树的深度就等于1,因为可能存在的64个叶子可以都存放在第一层的节点中[如图15-1(a)所示]。
不过,如果与索引131 相应的新页的描述符肯定存放在页高速缓存中,那么树的深度就增加为2,这样基树就可以查找多达4095个索引[如图15-1(b)所示]。

Insert image description here

表15-3显示了页索引的最大值和基于32位体系结构的基树中与每个给定深度相应的文件的最大长度。
在这里,基树的最大深度是6,当然系统中的页高速缓存不大可能使用那么大的基树。
因为页索引存放在32位变量中,当树的深度为6时,最高层的节点最多可以有4个孩子节点。

Insert image description here
Insert image description here

回顾一下分页系统是如何利用页表实现线性地址到物理地址转换的,从而理解如何实现页查找。
线性地址最高20位分成两个10位的字段:第一个字段是页目录中的偏移量,而第二个字段是某个页目录项所指向的页表中的偏移量。
基树中使用类似的方法。页索引相当于线性地址,不过页索引中要考虑的字段的数量依赖于基树的深度。
如果基树的深度为1,就只能表示从0~63范围的索引,因此页索引的低6位被解释为slots数组的下标,每个下标对应第一层的一个节点。
如果基树的深度为2,就可以表示从0~4095范围的索引,
页索引的低12位分成两个6位的字段,高位的字段用于表示第一层节点数组的下标,而低位的字段用于表示第二层节点数组的下标。
依此类推,如果深度等于6,
页索引的最高两位表示第一层节点数组的下标,接下来的6位表示第二层节点数组的下标,这样一直到最低6位,它们表示第六层节点数组的下标。
如果基树的最大索引小于应该增加的页的索引,那么内核相应地增加树的深度;基树的中间节点依赖于页索引的值。

Page cache handling functions

对页高速缓存操作的基本高级函数有查找、增加和删除页。在以上函数的基础上还有另一个函数确保高速缓存包含指定页的最新版本。

Find page

函数find_get_page()接收的参数为指向address_space对象的指针和偏移量。
它获取地址空间的自旋锁,并调用radix_tree_lookup()函数搜索拥有指定偏移量的基树的叶子节点。
该函数根据偏移量值中的位依次从树根开始并向下搜索,如上节所述。
如果遇到空指针,函数返回NULL;否则,返回叶子节点的地址,也就是所需要的页描述符指针。
如果找到了所需要的页,find_get_page()函数就增加该页(页框)的使用计数器,释放自旋锁,并返回该页的地址;否则,函数就释放自旋锁并返回NULL。
函数find_get_pages()与find_get_page()类似,但它实现在高速缓存中查找一组具有相邻索引的页。
它接收的参数是:
指向address_space对象的指针、
地址空间中相对于搜索起始位置的偏移量、
所检索到页的最大数量、
指向由该函数赋值的页描述符数组的指针。
find_get_pages()依赖radix_tree_gang_lookup()函数实现查找操作,
radix_tree_gang_lookup()函数为指针数组赋值并返回找到的页数。
尽管由于一些页可能不在页高速缓存中而会出现空缺的页索引,但所返回的页还是递增的索引值。

还有另外几个函数实现页高速缓存上的查找操作。
例如,find_lock_page()函数与find_get_page()类似,但它增加返回页的使用记数器,并调用lock_page()设置PG_locked标志,
从而当函数返回时调用者能够以互斥的方式访问返回的页。
随后,如果页已经被加锁,lock_page()函数就阻塞当前进程。
最后,它在PG_locked位置位时调用_wait_on_bit_lock()函数。
后面的函数把当前进程置为TASK_UNINTERRUPTIBLE 状态,把进程描述符存入等待队列,

执行address_space对象的sync_page方法以取消文件所在块设备的请求队列,最后调用schedule()函数来挂起进程,直到把PG_locked 标志清0。
内核使用unlock_page()函数对页进行解锁,并唤醒在等待队列上睡眠的进程。

函数find_trylock_page()与find_lock_page()类似,仅有一点不同,就是find_trylock_page()从不阻塞:
如果被请求的页已经上锁,函数就返回错误码。
最后要说明的是,函数find_or_create_page()执行find_lock_page();
不过,如果找不到所请求的页,就分配一个新页(页框)并把它插入页高速缓存(和所有者建立关联)。

Add page

函数add_to_page_cache()把一个新页的描述符插入到页高速缓存。它接收的参数有:
页描述符的地址page、
address_space对象的地址mapping、
表示在地址空间内的页索引的值offset
和为基树分配新节点时所使用的内存分配标志gfp_mask。函数执行以下操作:
1.  调用radix_tree_preload()函数,它禁用内核抢占,并把一些空的radix_tree_node结构赋给每CPU变量radix_tree_preloads。
radix_tree_node结构的分配由slab分配器高速缓存radix_tree_node_cachep来完成。
如果radix_tree_preload()预分配radix_tree_node结构不成功,函数add_to_page_cache()就终止并返回错误码-ENOMEM。
否则,如果radix_tree_preload()成功地完成预分配,
add_to_page_cache()函数肯定不会因为缺乏空闲内存或因为文件的大小达到了64GB而无法完成新页描述符的插入。
2.  获取mapping->tree_lock自旋锁——注意,radix_tree_preload()函数已经禁用了内核抢占。
3.  调用radix_tree_insert()在树中插入新节点,该函数执行下述操作:
a.调用radix_tree_maxindex()获得最大索引,该索引可能被插入具有当前深度的基树;
如果新页的索引不能用当前深度表示,
就调用radix_tree_extend()通过增加适当数量的节点来增加树的深度(例如,对图15-1(a)所示的基树,radix_tree_extend()在它的顶端增加一个节点)。
分配新节点是通过执行radix_tree_node_alloc()函数实现的,
该函数试图从slab分配器高速缓存获得radix_tree_node结构,
如果分配失败,就从存放在radix_tree_preloads中的预分配的结构池中获得radix_tree_node结构。
b.根据页索引的偏移量,从根节点(mapping->page_tree)开始遍历树,直到叶子节点,如上一节所述。
如果需要,就调用radix_tree_node_alloc()分配新的中间节点。
c.把页描述符地址存放在对基树所遍历的最后节点的适当位置,并返回0。
4.  增加页描述符的使用计数器page->_count。
5.  由于页是新的,所以其内容无效:函数设置页框的PG_locked标志,以阻止其他的内核路径并发访问该页。
6.  用mapping和offset参数初始化page->mapping和page->index。
7.  递增在地址空间所缓存页的计数器(mapping->nrpages)。
8.  释放地址空间的自旋锁。
9.  调用radix_tree_preload_end()重新启用内核抢占。
10.返回0(成功)。

delete page

函数remove_from_page_cache()通过下述步骤从页高速缓存中删除页描述符:
1.  获取自旋锁page->mapping->tree_lock并关中断。
2.  调用radix_tree_delete()函数从树中删除节点。该函数接收树根的地址(page->mapping->page_tree)和要删除的页索引作为参数,并执行下述步骤:
a.如上节所述,根据页索引从根节点开始遍历树,直到到达叶子节点。
遍历时,建立radix_tree_path结构的数组,描述从根到与要删除的页相应的叶子节点的路径构成。
b.从最后一个节点(包含指向页描述符的指针)开始,对路径数组中的节点开始循环操作。
对每个节点,把指向下一个节点(或页描述符)位置数组的元素置为NULL,并递减count字段。
如果count变为0,就从树中删除节点并把radix_tree_node结构释放给slab分配器高速缓存。
然后继续循环处理路径数组中的节点。否则,如果count不等于0,继续执行下一步。
c.   返回已经从树中删除的页描述符指针。
3.  把page->mapping字段置为NULL。
4.  把所缓存页的page->mapping->nrpages计数器的值减1。
5.  释放自旋锁page->mapping->tree_lock,打开中断,函数终止。

update page

函数read_cache_page()确保高速缓存中包括最新版本的指定页。
它的参数是
指向address_space对象的指针mapping、
表示所请求页的偏移量的值index、
指向从磁盘读页数据的函数的指针filler(通常是实现地址空间readpage方法的函数)
以及传递给filler函数的指针data(通常为NULL),
下面是对这个函数的简单说明:
1.  调用函数find_get_page()检查页是否已经在页高速缓存中。
2.  如果页不在页高速缓存中,则执行下述子步骤:
a.  调用alloc_pages()分配一个新页框。
b.调用add_to_page_cache()在页高速缓存中插入相应的页描述符。
c.调用1ru_cache_add()把页插入该管理区的非活动LRU链表中。
3.  此时,所请求的页已经在页高速缓存中了。调用mark_page_accessed()函数记录页已经被访问过的事实。
4.  如果页不是最新的(PG_uptodate标志为0),就调用filler函数从磁盘读该页。
5.  返回页描述符的地址。

Base tree tag

前面我们曾强调,页高速缓存不仅允许内核快速获得含有块设备中指定数据的页,还允许内核从高速缓存中快速获得给定状态的页。
例如,我们假设内核必须从高速缓存获得属于指定所有者的所有页和脏页(即其内容还没有写回磁盘)。
存放在页描述符中的PG_dirty标志表示页是否是脏的,
但是,如果绝大多数页都不是脏页,遍历整个基树以顺序访问所有叶子节点(页描述符)的操作就太慢了。
相反,为了能快速搜索脏页,基树中的每个中间节点都包含一个针对每个孩子节点(或叶子节点)的脏标记,
当有且只有至少有一个孩子节点的脏标记被置位时这个标记被设置。
最底层节点的脏标记通常是页描述符的PG_dirty标志的副本。
通过这种方式,当内核遍历基树搜索脏页时,就可以跳过脏标记为0的中间结点的所有子树:中间结点的脏标记为0说明其子树中的所有页描述符都不是脏的。

同样的想法应用到了PG_writeback标志,该标志表示页正在被写回磁盘。
这样,为基树的每个结点引入两个页描述符的标志:PG_dirty和PG_writeback。
每个结点的tags字段中有两个64位的数组来存放这两个标志。
tags[0](PAGECACHE_TAG_DIRTY)数组是脏标记,而tags[1](PAGECACHE_TAG_WRITEBACK)数组是写回标记。

设置页高速缓存中页的PG_dirty或PG_writeback标志时调用函数radix_tree_tag_s et(),它作用于三个参数:
基树的根、页的索引以及要设置的标记的类型(PAGECACHE_TAG_DIRTY或PAGECACHE_TAG_WRITEBACK)。
函数从树根开始并向下搜索到与指定索引对应的叶子结点;
对于从根通往叶子路径上的每一个节点,函数利用指向路径中下一个结点的指针设置标记。
然后,函数返回页描述符的地址。结果是,从根结点到叶子结点的路径中的所有结点都以适当的方式被加上了标记。

清除页高速缓存中页的PG_dirty或PG_writeback标志时调用函数radix_tree_tag_clear(),
它的参数与函数radix_tree_tag_set()的参数相同。
函数从树根开始并向下到叶子结点,建立描述路径的radix_tree_path结构的数组。
然后,函数从叶子结点到根结点向后进行操作:清除底层结点的标记,
然后检查是否结点数组中所有标记都被清0,
如果是,函数把上层父结点的相应标记清0,并如此继续上述操作。
最后,函数返回页描述符的地址。

从基树删除页描述符时,必须更新从根结点到叶子结点的路径中结点的相应标记。
函数radix_tree_delete()可以正确地完成这个工作(尽管我们在上一节没有提到这一点)。
而函数radix_tree_insert()不更新标记,因为插入基树的所有页描述符的PG_dirty 和PG_writeback标志都被认为是清零的。
如果需要,内核可以随后调用函数radix_tree_tag_set()。
函数radix_tree_tagged()利用树的所有结点的标志数组来测试基树是否至少包括一个指定状态的页。
函数通过执行下面的代码轻松地完成这一任务(root是指向基树的radix_tree_root结构的指针,tag是要测试的标记):
因为可能假设基树所有结点的标记都正确地更新过,所以radix_tree_tagged()函数只需要检查第一层的标记。
使用该函数的一个例子是:确定一个包含脏页的索引节点是否要写回磁盘。
注意,函数在每次循环时要测试在无符号长整型的32个标志中,是否有被设置的标志。
函数find_get_pages_tag()和find_get_pages()类似,只有一点不同,就是前者返回的只是那些用tag参数标记的页。
正如我们将在“把脏页写入磁盘”一节所见的,该函数对快速找到一个索引节点的所有脏页是非常关键的。

Store blocks in page cache

VFS(映射层)和各种文件系统以叫做“块”的逻辑单位组织磁盘数据。
在Linux内核的旧版本中,主要有两种不同的磁盘高速缓存:
页高速缓存和缓冲区高速缓存,
前者用来存放访问磁盘文件内容时生成的磁盘数据页,后者把通过VFS(管理磁盘文件系统)访问的块的内容保留在内存中。
从2.4.10的稳定版本开始,缓冲区高速缓存其实就不存在了。事实上,由于效率的原因,不再单独分配块缓冲区;
相反,把它们存放在叫做“缓冲区页”的专门页中,而缓冲区页保存在页高速缓存中。
缓冲区页在形式上就是与称做“缓冲区首部”的附加描述符相关的数据页,
其主要目的是快速确定页中的一个块在磁盘中的地址。实际上,页高速缓存内的页中的一大块数据在磁盘上的地址不一定是相邻的。

Block buffers and buffer headers

每个块缓冲区都有buffer_head类型的缓冲区首部描述符。
该描述符包含内核必须了解的、有关如何处理块的所有信息。因此,在对所有块操作之前,内核检查缓冲区首部。缓冲区首部的字段在表15-4中列出。

Insert image description here
Insert image description here

缓冲区首部的两个字段编码表示块的磁盘地址:
b_bdev字段表示包含块的块设备,通常是磁盘或分区;
b_blocknr字段存放逻辑块号,即块在磁盘或分区中的编号。
b_data字段表示块缓冲区在缓冲区页中的位置。
实际上,这个位置的编号依赖于页是否在高端内存。
如果页在高端内存,则b_data字段存放的是块缓冲区相对于页的起始位置的偏移量,否则,b_data存放的是块缓冲区的线性地址。
b_state字段可以存放几个标志。其中一些标志是通用的,把它们列在表15-5中。每个文件系统还可以定义自己的私有缓冲区首部标志。

Insert image description here
Insert image description here

Management buffer header

缓冲区首部有它们自己的slab分配器高速缓存,其描述符kmem_cache_s存在变量bh_cachep中。
alloc_buffer_head()和free_buffer_head()函数分别用于获取和释放缓冲区首部。
缓冲区首部的b_count字段是相应的块缓冲区的引用计数器。
在每次对块缓冲区进行操作之前递增计数器并在操作之后递减它。
除了周期性地检查保存在页高速缓存中的块缓冲区之外,
当空闲内存变得很少时也要对它进行检查,只有引用计数器等于0的块缓冲区才可以被回收。
当内核控制路径希望访问块缓冲区时,应该先递增引用计数器。
确定块在页高速缓存中的位置的函数(__getblk(),自动完成这项工作,
因此,高层函数通常不增加块缓冲区的引用计数器。

当内核控制路径停止访问块缓冲区时,应该调用__brelse()或__bforget()递减相应的引用计数器。
这两个函数之间的不同是__bforget()还从间接块链表(缓冲区首部的b_assoc_buffers字段)中删除块,
并把该缓冲区标记为干净的,因此强制内核忽略对缓冲区所做的任何修改,但实际上缓冲区依然必须被写回磁盘。

buffer page

只要内核必须单独地访问一个块,就要涉及存放块缓冲区的缓冲区页,并检查相应的缓冲区首部。
下面是内核创建缓冲区页的两种普通情况:
1.当读或写的文件页在磁盘块中不相邻时。发生这种情况是因为文件系统为文件分配了非连续的块,或因为文件有“洞”。
2.当访问一个单独的磁盘块时(例如,当读超级块或索引节点块时)。
在第一种情况下,把缓冲区页的描述符插入普通文件的基树;保存好缓冲区首部,因为其中存有重要的信息,即存有数据在磁盘中位置的块设备和逻辑块号。
在第二种情况下,把缓冲区页的描述符插入基树,树根是与块设备相关的特殊bdev文件系统中索引节点的address_space对象。
这种缓冲区页必须满足很强的约束条件,就是所有的块缓冲区涉及的块必须是在块设备上相邻存放的。
这种情况的一个应用实例是:
如果虚拟文件系统要读大小为1024个字节的索引节点块(包含给定文件的索引节点)。
内核并不是只分配一个单独的缓冲区,而是必须分配一个整页,从而存放四个缓冲区;
这些缓冲区将存放块设备上相邻的4块数据,其中包括所请求的索引节点块。
本章我们将重点讨论第二种类型的缓冲区页,即所谓的块设备缓冲区页(有时简称为块设备页)。
在一个缓冲区页内的所有块缓冲区大小必须相同,因此,在80x86体系结构上,根据块的大小,一个缓冲区页可以包括1~8个缓冲区。
如果一个页作为缓冲区页使用,那么与它的块缓冲区相关的所有缓冲区首部都被收集在一个单向循环链表中。
缓冲区页描述符的private字段指向页中第一个块的缓冲区首部;
每个缓冲区首部在b_this_page字段中,该字段是指向链表中下一个缓冲区首部的指针。
此外,每个缓冲区首部还把缓冲区页描述符的地址存放在b_page字段中。
图15-2显示了一个缓冲区页,其中包含四个块缓冲区和对应的缓冲区首部。

Insert image description here

Allocate block device buffer page

当内核发现指定块的缓冲区所在的页不在页高速缓存中时,就分配一个新的块设备缓冲区页。特别是,对块的查找操作会由于下述原因而失败:
1.  包含数据块的页不在块设备的基树中:这种情况下,必须把新页的描述符加到基树中。
2.  包含数据块的页在块设备的基树中,但这个页不是缓冲区页:
在这种情况下,必须分配新的缓冲区首部,并将它链接到所属的页,从而把它变成块设备缓冲区页。
3.   包含数据块的缓冲区页在块设备的基树中,但页中块的大小与所请求的块大小不相同:
这种情况下,必须释放旧的缓冲区首部,分配经过重新赋值的缓冲区首部并将它链接到所属的页。
内核调用函数grow_buffers()把块设备缓冲区页添加到页高速缓存中,该函数接收三个标识块的参数:
1.block_device描述符的地址bdev。
2.逻辑块号block(块在块设备中的位置)。
3.块大小size。
该函数本质上执行下列操作:
1.  计算数据页在所请求块的块设备中的偏移量index。
2. 如果需要,就调用grow_dev_page()创建新的块设备缓冲区页。该函数依次执行下列子步骤:
a.调用函数find_or_create_page(),传递给它的参数有:
块设备的address_space对象(bdev->bd_inode->i_mapping)、
页偏移index
以及GFP_NOFS标志。
正如在前面“页高速缓存的处理函数”一节所描述的,
find_or_create_page()在页高速缓存中搜索需要的页,如果需要,就把新页插入高速缓存。
b.此时,所请求的页已经在页高速缓存中,而且函数获得了它的描述符地址。
函数检查它的PG_private标志;如果为空,说明页还不是一个缓冲区页(没有相关的缓冲区首部),就跳到第2e步。
c.页已经是缓冲区页。从页描述符的private字段获得第一个缓冲区首部的地址bh,
并检查块大小bh->size是否等于所请求的块大小;如果大小相等,在页高速缓存中找到的页就是有效的缓冲区页,因此跳到第2g步。
d.如果页中块的大小有错误,就调用try_to_free_buffers()释放缓冲区页的上一个缓冲区首部。
e.调用函数alloc_page_buffers()根据页中所请求的块大小分配缓冲区首部,并把它们插入由b_this_page字段实现的单向循环链表。
此外,函数用页描述符的地址初始化缓冲区首部的b_page字段,用块缓冲区在页内的线性地址或偏移量初始化b_data字段。
f.在字段private中存放第一个缓冲区首部的地址,把PG_private字段置位,并递增页的使用计数器(页中的块缓冲区被算作一个页用户)。
g.调用init_page_buffers()函数初始化连接到页的缓冲区首部的字段b_bdev、b_blocknr和b_bstate。
因为所有的块在磁盘上都是相邻的,因此逻辑块号是连续的,而且很容易从块得出。
h.返回页描述符地址。
6.  为页解锁(函数find_or_create_page()曾为页加了锁)。
7.  递减页的使用计数器(函数find_or_create_page()曾递增了计数器)。
8.  返回1(成功)。

Free block device buffer page

当内核试图获得更多的空闲内存时,就释放块设备缓冲区页。
显然,不可能释放有脏缓冲区或上锁的缓冲区的页。内核调用函数try_to_release_page()释放缓冲区页,
该函数接收页描述符的地址page,并执行下述步骤:
1.   如果设置了页的PG_writeback标志,则返回0(因为正在把页写回磁盘,所以不可能释放该页)。
2.  如果已经定义了块设备address_space对象的releasepage方法,就调用它(通常没有为块设备定义的releasepage方法)。
3.   调用函数try_to_free_buffers()并返回它的错误代码
函数try_to_free_buffers()依次扫描链接到缓冲区页的缓冲区首部,它本质上执行下列操作:
4.  检查页中所有缓冲区的缓冲区首部的标志。
如果有些缓冲区首部的BH_Dirty或BH_Locked标志被置位,说明函数不可能释放这些缓冲区,所以函数终止并返回0(失败)。
5.  如果缓冲区首部在间接缓冲区的链表中,该函数就从链表中删除它。
6.   清除页描述符的PG_private标记,把private字段设置为NULL,并递减页的使用计数器。
7.   清除页的PG_dirty标记。
8.  反复调用free_buffer_head(),以释放页的所有缓冲区首部。
9.  返回1(成功)。

Search the page cache for blocks

当内核需要读或写一个单独的物理设备块时(例如一个超级块),必须检查所请求的块缓冲区是否已经在页高速缓存中。
在页高速缓存中搜索指定的块缓冲区(由块设备描述符的地址bdev和逻辑块号nr表示)的过程分成三个步骤: 
1.  获取一个指针,让它指向包含指定块的块设备的address_space对象(bdev->bd_inode->i_mapping)。
2.  获得设备的块大小(bdev->bd_block_size),并计算包含指定块的页索引。
这需要在逻辑块号上进行位移操作。例如,如果块的大小是1024字节,每个缓冲区页包含四个块缓冲区,那么页的索引是nr / 4。
3.  在块设备的基树中搜索缓冲区页。获得页描述符之后,内核访问缓冲区首部,它描述了页中块缓冲区的状态。
不过,实现的细节要更为复杂。
为了提高系统性能,内核维持一个小磁盘高速缓存数组bh_lrus(每个CPU对应一个数组元素),即所谓的最近最少使用(LRU)块高速缓存。
每个磁盘高速缓存有8个指针,指向被指定CPU最近访问过的缓冲区首部。
对每个CPU 数组的元素排序,使指向最后被使用过的那个缓冲区首部的指针索引为0。
相同的缓冲区首部可能出现在几个CPU数组中(但是同一个CPU数组中不会有相同的缓冲区首部)。
在LRU块高速缓存中每出现一次缓冲区首部,该缓冲区首部的使用计数器b_count 就加1。

__find_get_block() function

函数__find_get_block()的参数有:
block_device描述符地址bdev、块号block和块大小size。
函数返回页高速缓存中的块缓冲区对应的缓冲区首部的地址;如果不存在指定的块,就返回NULL。该函数本质上执行下面的操作:
1.  检查执行CPU的LRU块高速缓存数组中是否有一个缓冲区首部,其b_bdev、b_blocknr和b_size字段分别等于bdev、block和size。
2.  如果缓冲区首部在LRU块高速缓存中,就刷新数组中的元素,以便让指针指在第一个位置(索引为0)刚找到的缓冲区首部,递增它的b_count字段,
并跳转到第8步。
3.  如果缓冲区首部不在LRU块高速缓存中,根据块号和块大小得到与块设备相关的页的索引:
index= block >> (PAGE_SHIFT- bdev->bd_inode->i_blkbits)
4.  调用find_get_page()确定存有所请求的块缓冲区的缓冲区页的描述符在页高速缓存中的位置。
该函数传递的参数有:指向块设备的address_space对象的指针(bdev->bd_inode->i_mapping)和页索引。
页索引用于确定存有所请求的块缓冲区的缓冲区页的描述符在页高速缓存中的位置。
如果高速缓存中没有这样的页,就返回NULL(失败)。
5.  此时,函数已经得到了缓冲区页描述符的地址:它扫描链接到缓冲区页的缓冲区首部链表,查找逻辑块号等于block的块。
6.  递减页描述符的count字段(find_get_page()曾递增它的值)。
7.  把LRU块高速缓存中的所有元素向下移动一个位置,并把指向所请求块的缓冲区首部的指针插入到第一个位置。
如果一个缓冲区首部已经不在LRU块高速缓存中,就递减它的引用计数器b_count。
8.  如果需要,就调用mark_page_accessed()把缓冲区页移至适当的LRU链表中。
9.  返回缓冲区首部指针。

__getblk() function

函数__getblk()与__find_get_block()接收相同的参数,
也就是block_device描述符的地址bdev、块号block和块大小size,并返回与缓冲区对应的缓冲区首部的地址。
即使块根本不存在,该函数也不会失败,__getblk()友好地分配块设备缓冲区页并返回将要描述块的缓冲区首部的指针。
注意,__getblk()返回的块缓冲区不必存有有效数据——缓冲区首部的BH_Uptodate标志可能被清0。 
函数__getblk()本质上执行下面的步骤:
1.  调用__find_get_block()检查块是否已经在页高速缓存中。如果找到块,则函数返回其缓冲区首部的地址。
2.  否则,调用grow_buffers()为所请求的页分配一个新的缓冲区页。
3. 如果grow_buffers()分配这样的页失败,__getblk()试图通过调用函数free_more_memory()回收一部分内存。
4. 跳转到第1步。

__bread() function

函数__bread()接收与__getblk()相同的参数,
即block_device描述符的地址bdev、块号block和块大小size,并返回与缓冲区对应的缓冲区首部的地址。
与__getblk()相反的是,如果需要的话,在返回缓冲区首部之前函数__bread()从磁盘读块。函数__bread()执行下述步骤:
1.  调用__getblk()在页高速缓存中查找与所请求的块相关的缓冲区页,并获得指向相应的缓冲区首部的指针。
2.  如果块已经在页高速缓存中并包含有效数据(BH_Uptodate标志被置位),就返回缓冲区首部的地址。
3. 否则,递增缓冲区首部的引用计数器。
4.  把end_buffer_read_sync()的地址赋给b_end_io字段。
5.  调用submit_bh()把缓冲区首部传送到通用块层。
6.  调用wait_on_buffer()把当前进程插入等待队列,直到I/O操作完成,即直到缓冲区首部的BH_Lock标志被清0。
7.  返回缓冲区首部的地址。

Submitting buffer headers to the common block layer

一对submit_bh()和ll_rw_block()函数,允许内核对缓冲区首部描述的一个或多个缓冲区进行I/O数据传送。

submit_bh() function

内核利用submit_bh()函数向通用块层传递一个缓冲区首部,并由此请求传输一个数据块。
它的参数是数据传输的方向(本质上就是READ或WRITE)和指向描述块缓冲区的缓冲区首部的指针bh。
submit_bh()函数假设缓冲区首部已经被彻底初始化;
尤其是,必须正确地为b_bdev、b_blocknr和b_size字段赋值以标识包含所请求数据的磁盘上的块。
如果块缓冲区在块设备缓冲区页中,就由__find_get_block()完成对缓冲区首部的初始化,就像在上一节所描述的。
不过,我们将在下一章看到,还可以对普通文件所有的缓冲区页中的块调用submit_bh()。
submit_bh()函数只是一个起连接作用的函数,它根据缓冲区首部的内容创建一个bio 请求,
并随后调用generic_make_request()。函数执行的主要步骤如下:
1.  设置缓冲区首部的BH_Req标志以表示块至少被访问过一次。此外,如果数据传输的方向是WRITE,就将BH_Write_EIO标志清0。
2.  调用bio_alloc()分配一个新的bio描述符。
3.  根据缓冲区首部的内容初始化bio描述符的字段:
a.把块中的第一个扇区的号(bh->b_blocknr * bh->b_size / 512)赋给bi_sector字段。
b.把块设备描述符的地址(bh->b_bdev)赋给bi_bdev字段。
c.把块大小(bh->b_size)赋给bi_size字段。
d.初始化bi_io_vec数组的第一个元素以使该段对应于块缓冲区:
把bh->b_page赋给bi_io_vec[0].bv_page,把bh->b_size赋给bi_io_vec[0].bv_len,并把块缓冲区在页中的偏移量bh->b_data赋给bi_io_vec[0].bv_offset。
e.把bi_vcnt置为1(只有一个涉及bio的段),并把bi_idx置为0(将要传输的是当前段)。
f.把end_bio_bh_io_sync()的地址赋给bi_end_io字段,并把缓冲区首部的地址赋给bi_private字段;数据传输结束时调用函数。
4.  递增bio的引用计数器(它变为2)。
5.  调用submit_bio(),把bi_rw标志设置为数据传输的方向,更新每CPU变量page_states以表示读和写的扇区数,
并对bio描述符调用generic_make_request()函数。
6.  递减bio的使用计数器;因为bio描述符现在已经被插人I/O调度程序的队列,所以没有释放bio描述符。
7.  返回0(成功)。
当针对bio上的I/O数据传输终止的时候,内核执行bi_end_io方法,具体来说执行end_bio_bh_io_sync()函数。
后者本质上从bio的bi_private字段获取缓冲区首部的地址,然后调用缓冲区首部(在调用submit_bh()之前已为它正确赋值)的方法b_end_io,
最后调用bio_put()释放bio结构。

ll_rw_block() function

有些时候内核必须立刻触发几个数据块的数据传输,这些数据块不一定物理上相邻。
ll_rw_block()函数接收的参数有数据传输的方向(本质上就是READ或WRITE)、要传输的数据块的块号以及指向块缓冲区所对应的缓冲区首部的指针数组。
该函数在所有缓冲区首部上进行循环,每次循环执行下面的操作:
1.  检查并设置缓冲区首部的BH_Lock标志;如果缓冲区已经被锁住,而另外一个内核控制路径已经激活了数据传输,就不处理这个缓冲区,而跳转到第9步。
2.  把缓冲区首部的使用计数器b_count加1。
3.  如果数据传输的方向是WRITE,就让缓冲区首部的方法b_end_io指向函数end_buffer_write_sync()的地址,
否则让b_end_io指向end_buffer_read_sync()函数的地址。
4.  如果数据传输的方向是WRITE,就检查并清除缓冲区首部的BH_Dirty标志。
如果该标志没有置位,就不必把块写入磁盘,因此跳转到第7步。
5.  如果数据传输的方向是READ或READA(向前读),检查缓冲区首部的BH_Uptodate标志是否被置位;如果是,就不必从磁盘读块,因此跳转到第7步。
6.  此时必须读或写数据块:调用submit_bh()函数把缓冲区首部传递到通用块层,然后跳转到第9步。
7.  通过清除BH_Lock标志为缓冲区首部解锁,然后唤醒所有等待块解锁的进程。
8.  递减缓冲区首部的b_count字段。
9.  如果数组中还有其他的缓冲区首部要处理,就选择下一个缓冲区首部并跳转回到第1步,否则,就结束。
注意,如果函数1l_rw_block()把缓冲区首部传递到通用块层,而留下加了锁的缓冲区和增加了的引用计数器,
这样,在完成数据传输之前就不可能访问该缓冲区,也不可能释放这个缓冲区。
当块的数据传送结束时,内核执行缓冲区首部的b_end_io方法。
假设没有I/O错误,end_buffer_write_sync()和end_buffer_read_sync()函数只是简单地把缓冲区首部的BH_Uptodate字段置位,为缓冲区解锁,
并递减它的引用计数器。 

Write dirty pages to disk

正如我们所了解的,内核不断用包含块设备数据的页填充页高速缓存。只要进程修改了数据,相应的页就被标记为脏页,即把它的PG_dirty标志置位。
Unix系统允许把脏缓冲区写入块设备的操作延迟执行,因为这种策略可以显著地提高系统的性能。
对高速缓存中的页的几次写操作可能只需对相应的磁盘块进行一次缓慢的物理更新就可以满足。
此外,写操作没有读操作那么紧迫,因为进程通常是不会由于延迟写而挂起,而大部分情况都因为延迟读而挂起。
正是由于延迟写,使得任一物理块设备平均为读请求提供的服务将多于写请求。
一个脏页可能直到最后一刻(即直到系统关闭时)都一直逗留在主存中。然而,从延迟写策略的局限性来看,它有两个主要的缺点:
1.如果发生了硬件错误或电源掉电的情况,那么就无法再获得RAM的内容,因此,从系统启动以来对文件进行的很多修改就丢失了。
2.页高速缓存的大小(由此存放它所需的RAM的大小)就可能要很大——至少要与所访问块设备的大小相同。
因此,在下列条件下把脏页刷新(写入)到磁盘:
a.页高速缓存变得太满,但还需要更多的页,或者脏页的数量已经太多。
b.自从页变成脏页以来已过去太长时间。
c.进程请求对块设备或者特定文件任何待定的变化都进行刷新。
通过调用sync()、fsync()或fdatasync()系统调用来实现。缓冲区页的引入使问题更加复杂。
与每个缓冲区页相关的缓冲区首部使内核能够了解每个独立块缓冲区的状态。
如果至少有一个缓冲区首部的BH_Dirty标志被置位,就应该设置相应缓冲区页的PG_dirty标志。
当内核选择要刷新的缓冲区页时,它扫描相应的缓冲区首部,并只把脏块的内容有效地写到磁盘。
一旦内核把页缓冲区的所有脏块刷新到磁盘,就把页的PG_dirty标记清0。

pdflush kernel thread

早期版本的Linux使用bdflush内核线程系统地扫描页高速缓存以搜索要刷新的脏页,
并且使用另一个内核线程kupdate来保证所有的页不会“脏”太长的时间。Linux 2.6用一组通用内核线程pdflush代替上述两个线程。
这些内核线程结构灵活,它们作用于两个参数:
一个指向线程要执行的函数的指针和一个函数要用的参数。
系统中pdflush内核线程的数量是要动态调整的:pdflush线程太少时就创建,太多时就杀死。
因为这些内核线程所执行的函数可以阻塞,所以创建多个而不是一个pdflush内核线程可以改善系统性能。
根据下面的原则控制pdflush线程的产生和消亡:
1.必须有至少两个,最多八个pdflush内核线程。
2.如果到最近的1s期间没有空闲pdflush,就应该创建新的pdflush。
3.如果最近一次pdflush变为空闲的时间超过了1s,就应该删除一个pdflush。
所有的pdflush内核线程都有pdflush_work描述符。空闲pdflush内核线程的描述符都集中在pdFlush_list链表中;
在多处理器系统中,pdflush_lock自旋锁保护该链表不会被并发访问。
nr_pdflush_threads变量存放pdflush内核线程(空闲的或忙的)的总数。
最后,last_empty_jifs变量存放pdflush线程的pdflush_list链表变为空的时间(以jiffies表示)。

Insert image description here

所有pdflush内核线程都执行函数__pdflush(),它本质上循环执行一直到内核线程死亡。
我们不妨假设pdflush内核线程是空闲的,而进程正在TASK_INTERRUPTIBLE状态睡眠。
一但内核线程被唤醒,__pdflush()就访问其pdflush_work描述符,并执行字段fn中的回调函数,把arg0字段中的参数传递给该函数。
当回调函数结束时,__pdflush()检查last_empty_jifs变量的值:
如果不存在空闲pdflush内核线程的时间已经超过1s,而且pdflush内核线程的数量不到8个,函数__pdflush()就创建另外一个内核线程。
相反,如果pdflush_list链表中的最后一项对应的pdflush内核线程空闲时间超过了1s,而且系统中有两个以上的pdflush内核线程,函数__pdflush()就终止:
相应的内核线程执行_exit()系统调用,并因此而被撤消。
否则,如果系统中pdflush内核线程不多于两个,__pdflush()就把内核线程的pdflush_work描述符重新插入到pdflush_list链表中,并使内核线程睡眠。
pdflush_operation()函数用来激活空闲的pdflush内核线程。
该函数作用于两个参数:一个指针fn,指向必须执行的函数;以及参数arg0。函数执行下面的步骤:
1.  从pdflush_list链表中获取pdf指针,它指向空闲pdlush内核线程的pdflush_work描述符。如果链表为空,就返回-1。
如果链表中仅剩一个元素,就把jiffies的值赋给变量last_empty_jifs。
2.  把参数fn和arg0分别赋给pdf->fn和pdf->arg0。
3.  调用wake_up_process()唤醒空闲的pdflush内核线程,即pdf->who。
把哪些工作委托给Pdflush内核线程来完成呢?其中一些工作与脏数据的刷新相关。尤其是,pdflush通常执行下面的回调函数之一:
1.background_writeout():系统地扫描页高速缓存以搜索要刷新的脏页。
2.wb_kupdate():检查页高速缓存中是否有“脏”了很长时间的页。

Search for dirty pages to refresh

所有的基树都可能有要刷新的脏页。为了得到所有这些页,就要彻底搜索与在磁盘上有映像的索引节点相应的所有address_space对象。
由于页高速缓存可能有大量的页,如果用一个单独的执行流来扫描整个高速缓存,会令CPU和磁盘长时间繁忙。
因此,Linux 使用一种复杂的机制把对页高速缓存的扫描划分为几个执行流。
wakeup_bdflush()函数接收页高速缓存中应该刷新的脏页数量作为参数;
0值表示高速缓存中的所有脏页都应该写回磁盘。
该函数调用pdflush_operation()唤醒pdflush内核线程,并委托它执行回调函数background_writeout(),
后者有效地从页高速缓存获得指定数量的脏页,并把它们写回磁盘。
当内存不足或用户显式地请求刷新操作时执行wakeup_bdflush()函数。特别是在下述情况下会调用该函数:
1.用户态进程发出sync()系统调用
2.grow_buffers()函数分配一个新缓冲区页时失败
3.页框回收算法调用free_more_memory()或try_to_free_pages()
4.mempool_alloc()函数分配一个新的内存池元素时失败
此外,执行background_writeout()回调函数的pdflush内核线程也可由满足以下两个条件的进程唤醒的:
一是对页高速缓存中的页内容进行了修改,
二是引起脏页部分增加到超过某个脏背景阈值(background threshold)。
背景阈值通常设置为系统中所有页的10%,不过可以通过修改文件/proc/sys/vm/dirty_background_ratio来调整这个值。
background_writeout()函数依赖于作为双向通信设备的writeback_control结构:
一方面,它告诉辅助函数writeback_inodes()要做什么;
另一方面,它保存写回磁盘的页的数量的统计值。下面是这个结构最重要的字段:
sync_mode
	表示同步模式:
	WB_SYNC_ALL表示如果遇到一个上锁的索引节点,必须等待而不能略过它;
	WB_SYNC_HOLD表示把上锁的索引节点放入稍后涉及的链表中;
	WB_SYNC_NONE表示简单地略过上锁的索引节点。
bdi
	如果不为空,就指向backing_dev_info结构。此时,只有属于基本块设备的脏页将会被刷新。
older_than_this
	如果不为空,就表示应该略过比指定值还新的索引节点。
nr_to_write
	当前执行流中仍然要写的脏页的数量。
nonblocking
	如果这个标志被置位,就不能阻塞进程。
background_writeout()函数只作用于一个参数nr_pages,表示应该刷新到磁盘的最少页数。它本质上执行下述步骤:
1.  从每CPU变量page_state中读当前页高速缓存中页和脏页的数量。如果脏页所占的比例低于给定的阀值,而且已经至少有nr_pages页被刷新到磁盘,
该函数就终止。这个阈值通常大约是系统中总页数的40%,可以通过写文件/proc/sys/vmldirty_ratio来调整这个值。
2.  调用writeback_inodes()尝试写1024个脏页(见下面)。
3.  检查有效写过的页的数量,并减少需要写的页的个数。
4.  如果已经写过的页少于1024页,或略过了一些页,则可能块设备的请求队列处于拥塞状态:
此时,background_writeout()函数使当前进程在特定的等待队列上睡眠100ms,或使当前进程睡眠到队列变得不拥塞。
5.  返回到第1步。
writeback_inodes()函数只作用于一个参数,就是指针wbc,它指向writeback_control 描述符。
该描述符的nr_to_write字段存有要刷新到磁盘的页数。函数返回时,该字段存有要刷新到磁盘的剩余页数,如果一切顺利,则该字段的值被赋为0。
我们假设writeback_inodes()函数被调用的条件为:
指针wbc->bdi和wbc ->older_than_this被置为NULL,
WB_SYNC_NONE同步模式和wbc->nonblocking标志置位(这些值都由background_writeout()函数设置)。
函数writeback_inodes()扫描在super_blocks变量中建立的超级块链表。
当遍历完整个链表或刷新的页数达到预期数量时,就停止扫描。对每个超级块sb,函数执行下述步骤:
6.  检查sb->s_dirty或sb->s_io链表是否为空:
第一个链表集中了超级块的脏索引节点,
而第二个链表集中了等待被传输到磁盘的索引节点(见下面)。
如果两个链表都为空,说明相应文件系统的索引节点没有脏页,因此函数处理链表中的下一个超级块。
7.  此时,超级块有脏索引节点。对超级块sb调用sync_sb_inodes(),该函数执行下面的操作:
a.  把sb->s_dirty的所有索引节点插入sb->s_io指向的链表,并清空脏索引节点链表。
b.从sb->s_io获得下一个索引节点的指针。如果该链表为空,就返回。
c.如果sync_sb_inodes()函数开始执行后,索引节点变为脏节点,就略过这个索引节点的脏页并返回。
注意,sb->s_io链表中可能残留一些脏索引节点。
d.如果当前进程是pdflush内核线程,sync_sb_inodes()就检查运行在另一个CPU上的pdflush内核线程是否已经试图刷新这个块设备文件的脏页。
这是通过一个原子测试和对索引节点的backing_dev_info的BDI_pdflush标志的设置操作来完成的。
本质上,它对同一个请求队列上有多个pdflush内核线程是毫无意义的。
e.把索引节点的引用计数器加1。
f.调用__writeback_single_inode()回写与所选择的索引节点相关的脏缓冲区
(1)如果索引节点被锁定,就把它移到脏索引节点链表中(inode->i_sb->s_dirty)并返回0。
(因为我们假定wbc->sync_mode字段不等于WB_SYNC_ALL,所以函数不会因为等待索引结点解锁而阻塞。)
(2)使用索引节点地址空间的writepages方法,或者在没有这个方法的情况下使用mpage_writepages()函数来写wbc->nr_to_write个脏页。
该函数调用find_get_pages_tag()函数快速获得索引节点地址空间的所有脏页,细节将在下一章描述。
(3)如果索引节点是脏的,就用超级块的write_inode方法把索引节点写到磁盘。实现该方法的函数通常依靠submit_bh()来传输一个数据块。
(4)检查索引节点的状态。如果索引节点还有脏页,就把索引节点移回sb->s_dirty链表;
如果索引节点引用计数器为0,就把索引节点移到inode_unused链表中;否则就把索引节点移到inode_in_use链表中。
(5)返回在第2f(2)步所调用的函数的错误代码。
g.回到sync_sb_inodes()函数中。如果当前进程是pdflush内核线程,就把在第2d步设置的BDI_pdflush标志清0。
h.如果略过了刚处理的索引节点中的一些页,那么该索引节点包括锁定的缓冲区:
把sb->S_io链表中的所有剩余索引节点移回到sb->s_dirty链表中,以后将重新处理它们。
i.把索引节点的引用计数器减1。
j.  如果wbc->nr_to_write大于0,则回到第2b步搜索同一个超级块的其他脏索引节点。否则,sync_sb_inodes()函数终止。
8.  回到writeback_inodes()函数中。如果wbc->nr_to_write大于0,就跳转到第1步,并继续处理全局链表中的下一个超级块。否则,就返回。

Write back stale dirty pages

如前所述,内核试图避免当一些页很久没有被刷新时发生饥饿危险。
因此,脏页在保留一定时间后,内核就显式地开始进行I/O数据的传输,把脏页的内容写到磁盘。
回写陈旧脏页的工作委托给了被定期唤醒的pdflush内核线程。
在内核初始化期间,page_writeback_init()函数建立wb_timer动态定时器,
以便定时器的到期时间发生在dirty_writeback_centisecs文件中所规定的几百分之一秒之后(通常是500分之一秒,
不过可以通过修改/proc/sys/vm/dirty_writeback_centisecs文件调整这个值)。
定时器函数wb_timer_fn()本质上调用pdflush_operation()函数,传递给它的参数是回调函数wb_kupdate()的地址。
wb_kupdate()函数遍历页高速缓存搜索陈旧的脏索引节点,它执行下面的步骤:
1.  调用sync_supers()函数把脏的超级块写到磁盘中。
虽然这与页高速缓存中的页刷新没有很密切的关系,但对sync_supers()的调用确保了任何超级块脏的时间通常不会超过5s。
2.  把当前时间减30s所对应的值(用jiffies表示)的指针存放在writeback_control描述符的older_than_this字段中。允许一个页保持脏状态的最长时间是30s。
3.  根据每CPU变量page_state确定当前在页高速缓存中脏页的大概数量。
4.  反复调用writeback_inodes(),直到写入磁盘的页数等于上一步所确定的值,或直到把所有保持脏状态时间超过30s的页都写到磁盘。
如果在循环的过程中一些请求队列变得拥塞,函数就可能去睡眠。
5.  用mod_timer()重新启动wb_timer动态定时器:
一旦从调用该函数开始经历过文件dirty_writeback_centisecs中规定的几百分之一秒时间后,
定时器到期(或者如果本次执行的时间太长,就从现在开始1s后到期)。

sync(), fsync() and fdatasync() system calls

在本节我们简要介绍用户应用程序把脏缓冲区刷新到磁盘会用到的三个系统调用:
sync()
	允许进程把所有的脏缓冲区刷新到磁盘
fsync()
	允许进程把属于特定打开文件的所有块刷新到磁盘。
fdatasync()
	与fsync()非常相似,但不刷新文件的索引节点块。

sync() system call

sync()系统调用的服务例程sys_sync()调用一系列辅助函数:
wakeup_bdflush(0);
sync_inodes(0);
sync_supers();
sync_filesystems(0);
sync_filesystems(1);
sync_inodes(1);
正如上一节所描述的,wakeup_bdflush()启动pdflush内核线程,把页高速缓存中的所有脏页刷新到磁盘。
sync_inodes()函数扫描超级块的链表以搜索要刷新的脏索引节点;
它作用于参数wait,该参数表示在执行完刷新之前函数是否必须等待。
函数扫描当前已安装的所有文件系统的超级块;
对于每个包含脏索引节点的超级块,
sync_inodes()
首先调用sync_sb_inodes()刷新相应的脏页,
然后调用sync_blockdev()显式刷新该超级块所在块设备的脏缓冲区页。
这一步之所以能完成是因为许多磁盘文件系统的write_inode超级块方法仅仅把磁盘索引节点对应的块缓冲区标记为“脏”;
函数sync_blockdev()确保把sync_sb_inodes()所完成的更新有效地写到磁盘。

函数sync_supers()把脏超级块写到磁盘,如果需要,也可以使用适当的write_super 超级块操作。
最后,sync_filesystems()为所有可写的文件系统执行sync_fs超级块方法。
该方法只不过是提供给文件系统的一个“钩子”,在需要对每个同步执行一些特殊操作时使用,只有像Ext3这样的日志文件系统使用这个方法。

注意,sync_inodes()和sync_filesystems()都是被调用两次,
一次是参数wait等于0时,另一次是wait等于1时。
这样做的目的是:首先,它们把未上锁的索引节点快速刷新到磁盘;其次,它们等待所有上锁的索引节点被解锁,然后把它们逐个地写到磁盘。

fsync() and fdatasync() system calls

系统调用fsync()强制内核把文件描述符参数fd所指定文件的所有脏缓冲区写到磁盘中(如果需要,还包括存有索引节点的缓冲区)。
相应的服务例程获得文件对象的地址,并随后调用fsync方法。通常这个方法以调用函数__writeback_single_inode结束,
该函数把与被选中的索引节点相关的脏页和索引节点本身都写回磁盘。
系统调用fdatasync()与fsync()非常相似,但是它只把包含文件数据而不是那些包含索引节点信息的缓冲区写到磁盘。
由于Linux 2.6没有提供专门的fdatasync()文件方法,该系统调用使用fsync方法,因此与fsync()是相同的。

Guess you like

Origin blog.csdn.net/x13262608581/article/details/132381333