页高速缓存
在绝大多数情况下,内核在读写磁盘时都引用页高速缓存。新页被追加到页高速缓存以满足用户态进程的读请求。
如果页不在高速缓存中,新页就被加到高速缓存中,然后用从磁盘读出的数据填充它。
如果内存有足够的空闲空间,就让该页在高速缓存(内存)中长期保留,使其他进程再使用该页时不再访问磁盘。
同样,在把一页数据写到块设备之前,内核首先检查对应的页是否已经在高速缓存中;
如果不在,就要先在其中增加一个新项,并用要写到磁盘中的数据填充该项。
I/O数据的传送并不是马上开始,而是要延迟几秒之后才对磁盘进行更新,从而使进程有机会对要写入磁盘的数据做进一步的修改(换句话说,就是内核执行延迟的写操作)。以便对磁盘IO进行寻道优化等。
内核的代码和内核数据结构不必从磁盘读,也不必写入磁盘,因此,页高速缓存中的页(磁盘页)可能是下面的类型:
(1). 含有普通文件数据的页。
(2). 含有目录的页。
(3). 含有直接从块设备文件(跳过文件系统层)读出的数据的页。
(4). 含有用户态进程数据的页,但页中的数据已经被交换到磁盘。
内核可能会强行在页高速缓存中保留一些页面,而这些页面中的数据已经被写到交换区(可能是普通文件或磁盘分区)。
(5). 属于特殊文件系统文件的页,如共享内存的进程间通信所使用的特殊文件系统shm
。
页高速缓存中的页所包含的数据属于某个文件时。
这个文件(或者更准确地说是文件的索引节点)就称为页的所有者(owner
)。
几乎所有的文件读和写操作都依赖于页高速缓存。页高速缓存是内存和磁盘的中间层。
只有在O_DIRECT
标志被置位而进程打开文件的情况下才会出现例外:此时,I/0
数据的传送绕过了页高速缓而使用了进程用户态地址空间的缓冲区;少数数据库应用软件为了能采用自己的磁盘高速缓存算法而使用了O_DIRECT
标志。
内核设计者实现页高速缓存主要为了满足下面两种需要:
(1). 快速定位含有给定所有者
相关数据的特定页。为了尽可能充分发挥页高速缓存的优势,对它应该采用高速的搜索操作。
(2). 记录在读或写页中的数据时应当如何处理高速缓存中的每个页。
例如,从普通文件、块设备文件或交换区(这些都存储在磁盘上)读一个数据页必须用不同的实现方式,因此内核必须根据页的所有者
选择适当的操作。一个页中包含的磁盘块在物理上不一定是相邻的,通过页的所有者
和所有者数据中的索引
(通常是一个索引节点和在相应文件中的偏移量)来识别页高速缓存中的页。
address_space对象
页高速缓存的核心数据结构是address_space
对象,它是一个嵌入
在页所有者的索引节点对象
中的数据结构。
高速缓存中的许多页可能属于同一个所有者,从而可能被链接到同一个address_space
对象。
每个页描述符的mapping
字段指向拥有页的索引节点的address_space对象
,
每个页描述符的index
字段表示在所有者的地址空间中以页大小为单位的偏移量
,也就是在所有者的磁盘映像中页中数据的位置。
可以用下述方式访问普通文件的同一4KB
的数据块:
(1). 读文件,数据就包含在普通文件的索引节点
所拥有的页中。
(2). 从文件所在的设备文件(磁盘分区)读取块;数据就包含在块设备文件的主索引节点
所拥有的页中。
两个不同address_space对象
所引用的两个不同的页中出现了相同的磁盘数据
。
address_space对象包含如表15-1所示的字段。
(1). 如果页高速缓存中页的所有者是一个文件
,
address_space对象
就嵌入在VFS索引节点对象的i_data字段
中。
VFS索引节点的i_mapping字段
总是指向索引节点的数据页所有者的address_space对象
。
address_space对象
的host字段
指向其所有者的索引节点对象
。
故,如果页属于一个文件(存放在Ext3
文件系统中),那么页的所有者
就是文件的索引节点
,而且相应的address_space对象
存放在VFS索引节点对象
的i_data字段
中。索引节点的i_mapping字段
指向同一个索引节点的i_data字段
,而address_space对象
的host字段
也指向这个索引节点
。
(2). 如果页中包含的数据来自块设备文件,即页含有存放着块设备的“原始”数据,
那么就把address_space对象
嵌入到与该块设备相关的特殊文件系统bdev中文件的“主”索引节点
中(块设备描述符
的bd_inode字段
引用这个索引节点)。
块设备文件对应索引节点的i_mapping 字段
指向主索引节点中的address_space对象
。
address_space对象
的host 字段
指向主索引节点
。
backing_dev_info字段
指向backing_dev_info描述符
,后者是对所有者的数据所在块设备进行有关描述的数据结构
。
address_space对象
的关键字段是a_ops
,它指向一个类型为address_space_operations 的表
,表中定义了对所有者的页进行处理的各种方法。这些方法如表15-2所示。
在绝大多数情况下,这些方法把所有者的索引节点对象
和访问物理设备的低级驱动程序
联系起来。
例如,为普通文件的索引节点实现readpage方法的函数知道如何确定文件页的对应块在物理磁盘设备上的位置。
基树
访问大文件时,页高速缓存中可能充满太多的文件页,以至于顺序扫描这些页要消耗大量的时间。
为了实现页高速缓存的高效查找,Linux 2.6
采用了大量的搜索树,其中每个address_space对象
对应一棵搜索树
。
address_space对象
的page_tree字段
是基树(radix tree)的根
。
给定的页索引
表示页在所有者磁盘映像中的位置
,内核能够通过快速搜索操作来确定所需要的页是否在页高速缓存中。
当查找所需要的页时,内核把页索引
转换为基树中的路径
,并快速找到页描述符
所(或应当)在的位置。
如果找到,内核可以从基树获得页描述符
,而且还可以很快确定所找到的页是否是脏页
(也就是应当被刷新到磁盘的页),以及其数据的I/O传送
是否正在进行。
基树的每个节点
可以有多到64个指针
指向其他节点或页描述符。
底层节点
存放指向页描述符
的指针(叶子节点),而上层的节点
存放指向其他节点
(孩子节点)的指针。
每个节点由radix_tree_node
数据结构表示,它包括三个字段:
slots
是包括64个指针
的数组,
count
是记录节点中非空指针数量的计数器,
tags
是二维的标志数组,
树根由radix_tree_root
数据结构表示,它有三个字段:
(1). height
表示树的当前深度(不包括叶子节点的层数),
(2). gfp_mask
指定为新节点请求内存时所用的标志,
(3). rnode
指向与树中第一层节点相应的数据结构radix_tree_node
(如果有的话)。
我们来看一个简单的例子。
如果树中没有索引大于63
,那么树的深度就等于1
,因为可能存在的64个
页描述符可以都存放在第一层的节点中[如图15-1(a)所示]。
不过,如果与索引131
相应的新页的描述符肯定存放在页高速缓存中,那么树的深度就增加为2
[如图15-1(b)所示]。
页高速缓存的处理函数
查找页–find_get_page()
参数:
指向address_space对象的指针,
偏移量。
它获取地址空间的自旋锁
调用radix_tree_lookup()函数搜索拥有指定偏移量的基树的叶子节点。
该函数根据偏移量值中的位依次从树根开始并向下搜索,如上节所述。
如果遇到空指针,函数返回NULL;
否则,返回叶子节点的地址,也就是所需要的页描述符指针。
如果找到了所需要的页,
find_get_page()
函数就增加该页(页框)的使用计数器,
释放自旋锁,
返回该页的地址;
否则,函数就释放自旋锁并返回NULL。
find_get_pages():
与find_get_page()类似,但它实现在高速缓存中查找一组具有相邻索引的页。
参数:
(1). 指向address_space对象的指针、
(2). 地址空间中相对于搜索起始位置的偏移量、
(3). 所检索到页的最大数量、
(4). 指向由该函数赋值的页描述符数组的指针。
依赖radix_tree_gang_lookup()
函数实现查找操作。
radix_tree_gang_lookup()
函数为指针数组赋值并返回找到的页数。
find_lock_page()函数:
与find_get_page()
类似,但它增加返回页的使用记数器,
并调用lock_page()
设置PG_locked
标志,从而当函数返回时调用者能够以互斥的方式访问返回的页。
如果页已经被加锁,lock_page()函数就阻塞当前进程。调用_wait_on_bit_lock()
函数。当前进程置为TASK_UNINTERRUPTIBLE
状态,把进程描述符存入等待队列,执行address_space
对象的sync_page
方法以取消文件所在块设备的请求队列,最后调用schedule()
函数来挂起进程,直到把PG_locked
标志清0
。内核使用unlock_page()
函数对页进行解锁,并唤醒在等待队列上睡眠的进程。
增加页–add_to_page_cache()
把一个新页的描述符插入到页高速缓存。
参数:
(1). 页描述符的地址page
、
(2). address_space
对象的地址mapping
、
(3). 表示在地址空间内的页索引的值offset
(4). 为基树分配新节点时所使用的内存分配标志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
(成功)。
删除页
函数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
,打开中断,函数终止。
更新页
函数read_cache_page()
确保高速缓存中包括最新版本的指定页。它的参数是
(1). 指向address_space
对象的指针mapping
、
(2). 表示所请求页的偏移量的值index
、
(3). 指向从磁盘读页数据的函数的指针filler
(通常是实现地址空间readpage方法的函数)
(4). 传递给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). 返回页描述符的地址。
基树的标记
前面我们曾强调,页高速缓存不仅允许内核快速获得含有块设备中指定数据的页,还允许内核从高速缓存中快速获得给定状态的页。
例如,我们假设内核必须从高速缓存获得属于指定所有者的所有页和脏页(即其内容还没有写回磁盘)。
存放在页描述符中的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_set()
,它作用于三个参数:
(1). 基树的根、
(2). 页的索引
(3). 要设置的标记的类型(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
参数标记的页。正如我们将在“把脏页写入磁盘”一节所见的,该函数对快速找到一个索引节点的所有脏页是非常关键的。
把块存放在页高速缓存中
VFS
(映射层)和各种文件系统以叫做“块”的逻辑单位组织磁盘数据。在Linux
内核的旧版本中,主要有两种不同的磁盘高速缓存:
页高速缓存和缓冲区高速缓存,前者用来存放访问磁盘文件内容时生成的磁盘数据页,后者把通过VFS
(管理磁盘文件系统)访问的块的内容保留在内存中。
从2.4.10
的稳定版本开始,缓冲区高速缓存其实就不存在了。事实上,由于效率的原因,不再单独分配块缓冲区;
相反,把它们存放在叫做“缓冲区页”的专门页中,而缓冲区页保存在页高速缓存中。缓冲区页在形式上就是与称做“缓冲区首部”的附加描述符相关的数据页,其主要目的是快速确定页中的一个块在磁盘中的地址。实际上,页高速缓存内的页中的一大块数据在磁盘上的地址不一定是相邻的。
块缓冲区和缓冲区首部
每个块缓冲区都有buffer_head
类型的缓冲区首部描述符。该描述符包含内核必须了解的、有关如何处理块的所有信息。因此,在对所有块操作之前,内核检查缓冲区首部。缓冲区首部的字段在表15-4中列出。
缓冲区首部的两个字段编码表示块的磁盘地址:
(1). b_bdev
字段表示包含块的块设备,通常是磁盘或分区;
(2). b_blocknr
字段存放逻辑块号,即块在磁盘或分区中的编号。
(3). b_data
字段表示块缓冲区在缓冲区页中的位置。实际上,这个位置的编号依赖于页是否在高端内存。
如果页在高端内存,则b_data
字段存放的是块缓冲区相对于页的起始位置的偏移量,
否则,b_data
存放的是块缓冲区的线性地址。
4. b_state
字段可以存放几个标志。其中一些标志是通用的,把它们列在表15-5
中。每个文件系统还可以定义自己的私有缓冲区首部标志。
管理缓冲区首部
缓冲区首部有它们自己的slab
分配器高速缓存,其描述符kmem_cache_s
存在变量bh_cachep
中。
alloc_buffer_head()
和free_buffer_head()
函数分别用于获取和释放缓冲区首部。
缓冲区首部的b_count
字段是相应的块缓冲区的引用计数器。在每次对块缓冲区进行操作之前递增计数器并在操作之后递减它。
除了周期性地检查保存在页高速缓存中的块缓冲区之外,当空闲内存变得很少时也要对它进行检查,只有引用计数器等于0
的块缓冲区才可以被回收。当内核控制路径希望访问块缓冲区时,应该先递增引用计数器。确定块在页高速缓存中的位置的函数(__getblk()
,自动完成这项工作,因此,高层函数通常不增加块缓冲区的引用计数器。
当内核控制路径停止访问块缓冲区时,应该调用__brelse()
或__bforget()
递减相应的引用计数器。这两个函数之间的不同是__bforget()
还从间接块链表(缓冲区首部的b_assoc_buffers
字段)中删除块,并把该缓冲区标记为干净的,因此强制内核忽略对缓冲区所做的任何修改,但实际上缓冲区依然必须被写回磁盘。
缓冲区页
只要内核必须单独地访问一个块,就要涉及存放块缓冲区的缓冲区页,并检查相应的缓冲区首部。
下面是内核创建缓冲区页的两种普通情况:
(1). 当读或写的文件页在磁盘块中不相邻时。发生这种情况是因为文件系统为文件分配了非连续的块,或因为文件有“洞”。
(2). 当访问一个单独的磁盘块时(例如,当读超级块或索引节点块时)。
在第一种情况下,把缓冲区页的描述符插入普通文件的基树;
保存好缓冲区首部,因为其中存有重要的信息,即存有数据在磁盘中位置的块设备和逻辑块号。
在第二种情况下,把缓冲区页的描述符插入基树,
树根是与块设备相关的特殊bdev文件系统中索引节点的address_space对象
。
这种缓冲区页必须满足很强的约束条件,就是所有的块缓冲区涉及的块必须是在块设备上相邻存放的。
这种情况的一个应用实例是:
如果虚拟文件系统要读大小为1024
个字节的索引节点块(包含给定文件的索引节点)。
内核并不是只分配一个单独的缓冲区,而是必须分配一个整页,从而存放四个缓冲区;
这些缓冲区将存放块设备上相邻的4
块数据,其中包括所请求的索引节点块。
本章我们将重点讨论第二种类型的缓冲区页,即所谓的块设备缓冲区页(有时简称为块设备页)。
在一个缓冲区页内的所有块缓冲区大小必须相同,因此,在80x86
体系结构上,根据块的大小,一个缓冲区页可以包括1~8
个缓冲区。
如果一个页作为缓冲区页使用,那么与它的块缓冲区相关的所有缓冲区首部都被收集在一个单向循环链表中。
缓冲区页描述符的private
字段指向页中第一个块的缓冲区首部;
每个缓冲区首部在b_this_page
字段中,该字段是指向链表中下一个缓冲区首部的指针。
此外,每个缓冲区首部还把缓冲区页描述符的地址存放在b_page
字段中。
图15-2
显示了一个缓冲区页,其中包含四个块缓冲区和对应的缓冲区首部。
分配块设备缓冲区页
当内核发现指定块的缓冲区所在的页不在页高速缓存中时,就分配一个新的块设备缓冲区页。
特别是,对块的查找操作会由于下述原因而失败:
(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. 返回页描述符地址。
(3). 为页解锁(函数find_or_create_page()
曾为页加了锁)。
(4). 递减页的使用计数器(函数find_or_create_page()
曾递增了计数器)。
(5). 返回1
(成功)。
释放块设备缓冲区页
当内核试图获得更多的空闲内存时,就释放块设备缓冲区页。
显然,不可能释放有脏缓冲区或上锁的缓冲区的页。内核调用函数try_to_release_page()
释放缓冲区页,该函数接收页描述符的地址page
。
执行下述步骤:
(1). 如果设置了页的PG_writeback
标志,则返回0
(因为正在把页写回磁盘,所以不可能释放该页)。
(2). 如果已经定义了块设备address_space
对象的releasepage
方法,就调用它(通常没有为块设备定义的releasepage
方法)。
(3). 调用函数try_to_free_buffers()
并返回它的错误代码。
函数try_to_free_buffers()
依次扫描链接到缓冲区页的缓冲区首部,它本质上执行下列操作:
a. 检查页中所有缓冲区的缓冲区首部的标志。如果有些缓冲区首部的BH_Dirty
或BH_Locked
标志被置位,说明函数不可能释放这些缓冲区,所以函数终止并返回0(
失败)。
b. 如果缓冲区首部在间接缓冲区的链表中,该函数就从链表中删除它。
(4). 清除页描述符的PG_private
标记,把private
字段设置为NULL
,并递减页的使用计数器。清除页的PG_dirty
标记。反复调用free_buffer_head()
,以释放页的所有缓冲区首部。
(5). 返回1
(成功)。
在页高速缓存中搜索块
当内核需要读或写一个单独的物理设备块时(例如一个超级块),必须检查所请求的块缓冲区是否已经在页高速缓存中。
在页高速缓存中搜索指定的块缓冲区(由块设备描述符的地址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()函数
函数__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()函数
函数__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()函数
函数__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). 返回缓冲区首部的地址。
向通用块层提交缓冲区首部
一对submit_bh()
和ll_rw_block()
函数,允许内核对缓冲区首部描述的一个或多个缓冲区进行I/O
数据传送。
submit_bh()函数
内核利用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()函数
有些时候内核必须立刻触发几个数据块的数据传输,这些数据块不一定物理上相邻。
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
字段置位,为缓冲区解锁,并递减它的引用计数器。
把脏页写入磁盘
正如我们所了解的,内核不断用包含块设备数据的页填充页高速缓存。
只要进程修改了数据,相应的页就被标记为脏页,即把它的PG_dirty
标志置位。
Unix
系统允许把脏缓冲区写入块设备的操作延迟执行,因为这种策略可以显著地提高系统的性能。
对高速缓存中的页的几次写操作可能只需对相应的磁盘块进行一次缓慢的物理更新就可以满足。
此外,写操作没有读操作那么紧迫,因为进程通常是不会由于延迟写而挂起,而大部分情况都因为延迟读而挂起。
正是由于延迟写,使得任一物理块设备平均为读请求提供的服务将多于写请求。
一个脏页可能直到最后一刻(即直到系统关闭时)都一直逗留在主存中。然而,从延迟写策略的局限性来看,它有两个主要的缺点:
(1). 如果发生了硬件错误或电源掉电的情况,那么就无法再获得RAM
的内容,因此,从系统启动以来对文件进行的很多修改就丢失了。
(2). 页高速缓存的大小(由此存放它所需的RAM
的大小)就可能要很大——至少要与所访问块设备的大小相同。
因此,在下列条件下把脏页刷新(写入)到磁盘:
a. 页高速缓存变得太满,但还需要更多的页,或者脏页的数量已经太多。
b. 自从页变成脏页以来已过去太长时间。
c. 进程请求对块设备或者特定文件任何待定的变化都进行刷新。
通过调用sync()
、fsync()
或fdatasync()
系统调用来实现。
缓冲区页的引入使问题更加复杂。与每个缓冲区页相关的缓冲区首部使内核能够了解每个独立块缓冲区的状态。如果至少有一个缓冲区首部的BH_Dirty
标志被置位,就应该设置相应缓冲区页的PG_dirty
标志。当内核选择要刷新的缓冲区页时,它扫描相应的缓冲区首部,并只把脏块的内容有效地写到磁盘。一旦内核把页缓冲区的所有脏块刷新到磁盘,就把页的PG_dirty
标记清0
。
pdflush内核线程
早期版本的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
表示)。
所有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
通常执行下面的回调函数之一:
a. background_writeout()
:系统地扫描页高速缓存以搜索要刷新的脏页。
b. wb_kupdate()
:检查页高速缓存中是否有“脏”了很长时间的页。
搜索要刷新的脏页
所有的基树都可能有要刷新的脏页。为了得到所有这些页,就要彻底搜索与在磁盘上有映像的索引节点相应的所有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()
函数被调用的条件为:
(1). 指针wbc->bdi
和wbc ->older_than_this
被置为NULL
,
(2). WB_SYNC_NONE
同步模式和wbc->nonblocking
标志置位(这些值都由background_writeout()
函数设置)。
函数writeback_inodes()
扫描在super_blocks
变量中建立的超级块链表。当遍历完整个链表或刷新的页数达到预期数量时,就停止扫描。对每个超级块sb
。
函数执行下述步骤:
(1). 检查sb->s_dirty
或sb->s_io
链表是否为空:第一个链表集中了超级块的脏索引节点,而第二个链表集中了等待被传输到磁盘的索引节点(见下面)。如果两个链表都为空,说明相应文件系统的索引节点没有脏页,因此函数处理链表中的下一个超级块。
(2). 此时,超级块有脏索引节点。对超级块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()
回写与所选择的索引节点相关的脏缓冲区
f.1. 如果索引节点被锁定,就把它移到脏索引节点链表中(inode->i_sb->s_dirty
)并返回0
。(因为我们假定wbc->sync_mode
字段不等于WB_SYNC_ALL
,所以函数不会因为等待索引结点解锁而阻塞。)
f.2. 使用索引节点地址空间的writepages
方法,或者在没有这个方法的情况下使用mpage_writepages()
函数来写wbc->nr_to_write
个脏页。该函数调用find_get_pages_tag()
函数快速获得索引节点地址空间的所有脏页,细节将在下一章描述。
f.3. 如果索引节点是脏的,就用超级块的write_inode
方法把索引节点写到磁盘。实现该方法的函数通常依靠submit_bh()
来传输一个数据块。
f.4. 检查索引节点的状态。如果索引节点还有脏页,就把索引节点移回sb->s_dirty
链表;如果索引节点引用计数器为0
,就把索引节点移到inode_unused
链表中;否则就把索引节点移到inode_in_use
链表中。
f.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()
函数终止。
(3). 回到writeback_inodes()
函数中。如果wbc->nr_to_write
大于0
,就跳转到第1
步,并继续处理全局链表中的下一个超级块。否则,就返回。
回写陈旧的脏页
如前所述,内核试图避免当一些页很久没有被刷新时发生饥饿危险。因此,脏页在保留一定时间后,内核就显式地开始进行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()和fdatasync()系统调用
在本节我们简要介绍用户应用程序把脏缓冲区刷新到磁盘会用到的三个系统调用:
sync()
,允许进程把所有的脏缓冲区刷新到磁盘
fsync()
,允许进程把属于特定打开文件的所有块刷新到磁盘。
fdatasync()
,与fsync()
非常相似,但不刷新文件的索引节点块。
sync()系统调用
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()和fdatasync()系统调用
系统调用fsync()
强制内核把文件描述符参数fd
所指定文件的所有脏缓冲区写到磁盘中(如果需要,还包括存有索引节点的缓冲区)。相应的服务例程获得文件对象的地址,并随后调用fsync
方法。通常这个方法以调用函数__writeback_single_inode
结束,该函数把与被选中的索引节点相关的脏页和索引节点本身都写回磁盘。
系统调用fdatasync()
与fsync()
非常相似,但是它只把包含文件数据而不是那些包含索引节点信息的缓冲区写到磁盘。由于Linux 2.6
没有提供专门的fdatasync()
文件方法,该系统调用使用fsync
方法,因此与fsync()
是相同的。