图 1:第 15 章章首插图
这一篇在干嘛?
重复访问同一份磁盘数据太常见了——你用 cp 复制文件再用编辑器改它,两个进程在不同时刻访问同一个文件。页缓存就是内核的对策:把磁盘数据整页整页地留在 RAM 里,读过的下次直接命中,写的先攒着延迟落盘。本章讲页缓存的数据结构(address_space、基数树)、块缓冲如何也塞进页缓存,以及脏页如何被 pdflush 内核线程写回磁盘。
页缓存总览 | address_space 对象 | 基数树 | 页缓存处理函数 | 在页缓存中存块 | 搜索块与提交 I/O | 把脏页写回磁盘 | sync 系列系统调用
页缓存总览:Linux 的主磁盘缓存
**磁盘缓存(disk cache)**是一种软件机制,让系统把通常存放在磁盘上的数据保留在 RAM 中,后续访问就能快速命中而无需碰磁盘。它对系统性能至关重要:用户态进程有权反复读写同一份磁盘数据,不同进程也可能在不同时刻需要同一份数据——比如你先用 cp 复制一个文本文件,再打开编辑器修改它,shell 会创建两个进程在不同时刻访问同一个文件。
第 12 章已经遇到过两个别的磁盘缓存:dentry 缓存(存放表示路径名的 dentry 对象)和 inode 缓存(存放表示磁盘 inode 的 inode 对象)。不过 dentry 和 inode 对象并不是单纯存放某些磁盘块内容的缓冲,所以这两个缓存算是相当特殊的磁盘缓存。
页缓存才是 Linux 内核的主力磁盘缓存,它以整页数据为工作单位。大多数情况下,内核读写磁盘都经由页缓存:
- 读:新页被加进页缓存来满足用户态进程的读请求。若页不在缓存中,就在缓存里加一个新表项、用磁盘读来的数据填充。只要空闲内存够用,这一页就会无限期留在缓存里,可以被其他进程复用而不再访问磁盘。
- 写:把一页数据写到块设备之前,内核先看对应页是否已在缓存中;不在就加一个新表项、填入要写的数据。但 I/O 传输并不立即启动:磁盘更新被推迟几秒,给进程留下继续修改这份数据的机会——换句话说,内核实现的是延迟写(deferred write)。
内核代码和内核数据结构不需要读写磁盘,所以页缓存里的页可以是这些类型:
- 包含常规文件数据的页(第 16 章讲内核如何处理对它们的读、写、内存映射);
- 包含目录的页(第 18 章:Linux 把目录当常规文件一样处理);
- 包含直接从块设备文件读来的数据的页(跳过文件系统层;第 16 章讲,内核用与常规文件数据页同一套函数处理它们);
- 包含被换出到磁盘的用户态进程数据的页(第 17 章:内核可能被迫把内容已写入交换区的页保留在页缓存里,交换区可以是常规文件或磁盘分区);
- 属于特殊文件系统文件的页,比如用于进程间通信共享内存的 shm 特殊文件系统(第 19 章)。
可见页缓存里的每一页都包含属于某个文件的数据,这个文件——更准确地说是文件的 inode——叫做该页的拥有者(owner)。(第 17 章会看到,包含换出数据的页即使指向不同交换区,也拥有同一个拥有者。)
几乎所有的 read() 和 write() 文件操作都走页缓存。唯一的例外是进程以 O_DIRECT 标志打开文件:此时页缓存被绕过,I/O 传输直接使用进程用户态地址空间里的缓冲(第 16 章”直接 I/O 传输”);不少数据库应用用 O_DIRECT 以便实现自己的磁盘缓存算法。
内核设计者实现页缓存时要满足两个主要需求:
- 快速定位包含给定拥有者数据的特定页——搜索必须是极快的操作,否则缓存毫无意义;
- 记录每页该怎么读写——从常规文件、块设备文件还是交换区读一页,方式各不相同,内核必须根据页的拥有者选择正确的操作。
页缓存保存的信息单位当然是一整页数据。第 18 章会看到,一页不一定包含物理上相邻的磁盘块,所以页不能靠设备号+块号来标识。页缓存中的页由拥有者 + 拥有者数据中的索引标识——通常就是一个 inode 加上该文件内的偏移。
address_space 对象
页缓存的核心数据结构是 address_space 对象——一个嵌入在拥有该页的 inode 对象里的数据结构。缓存中的很多页可以指向同一个拥有者,因此可以链接到同一个 address_space 对象。这个对象还在拥有者的页与操作这些页的一组方法之间建立了联系。
每个页描述符有两个字段把页链入页缓存(第 8 章”页描述符”):
- mapping:指向拥有该页的 inode 的 address_space 对象;
- index:以页为单位指定该页在拥有者”地址空间”内的偏移——也就是页的数据在拥有者磁盘映像中的位置。
查找页缓存中的页时用的就是这两个字段。
有点出人意料的是:页缓存可以心安理得地包含同一份磁盘数据的多份拷贝。比如某个常规文件的一个 4 KB 数据块可以通过两种方式访问:读文件(数据被装进由该文件 inode 拥有的页);读承载该文件的设备文件(磁盘分区)上的块(数据被装进由块设备文件主 inode 拥有的页)。于是同一份磁盘数据出现在两个不同的页里,被两个不同的 address_space 对象引用。
address_space 对象的字段:
| 类型 | 字段 | 说明 |
|---|---|---|
struct inode * | host | 承载本对象的 inode 指针 |
struct radix_tree_root | page_tree | 标识拥有者各页的基数树的根 |
spinlock_t | tree_lock | 保护基数树的自旋锁 |
unsigned int | i_mmap_writable | 地址空间中共享内存映射的数目 |
struct prio_tree_root | i_mmap | 基数优先搜索树的根(第 17 章) |
struct list_head | i_mmap_nonlinear | 地址空间中非线性内存区的链表 |
spinlock_t | i_mmap_lock | 保护基数优先搜索树的自旋锁 |
unsigned int | truncate_count | 截断文件时使用的顺序计数器 |
unsigned long | nrpages | 拥有者的页总数 |
unsigned long | writeback_index | 拥有者各页最后一次回写操作的页索引 |
struct address_space_operations * | a_ops | 操作拥有者各页的方法表 |
unsigned long | flags | 错误位和内存分配器标志 |
struct backing_dev_info * | backing_dev_info | 存放拥有者数据的块设备的 backing_dev_info |
spinlock_t | private_lock | 通常用于管理 private_list 链表 |
struct list_head | private_list | 通常是 inode 关联的间接块的脏缓冲链表 |
struct address_space * | assoc_mapping | 通常指向包含间接块的块设备的 address_space |
页缓存的页拥有者是文件时,address_space 对象嵌入在 VFS inode 对象的 i_data 字段中。inode 的 i_mapping 字段总是指向”包含该 inode 数据的那些页的拥有者”的 address_space 对象;address_space 的 host 字段则指向嵌入它的 inode 对象。
因此,属于 Ext3 文件系统中某个文件的页:拥有者是文件的 inode,对应的 address_space 存在该 VFS inode 对象的 i_data 字段里,inode 的 i_mapping 指向同一 inode 的 i_data,address_space 的 host 也指向同一 inode——三者闭环。
但有时情况更复杂:页包含从块设备文件读来的数据(即块设备的”原始”数据)时,address_space 对象嵌在与该块设备关联的 bdev 特殊文件系统那个文件的”主 inode”里(第 14 章讲过,block_device 描述符的 bd_inode 字段引用这个 inode)。于是块设备文件的 inode 的 i_mapping 指向嵌在主 inode 里的 address_space,host 也指向主 inode。这样一来,所有包含从块设备读来数据的页共享同一个 address_space 对象,哪怕它们是经由不同的块设备文件被访问的。
i_mmap、i_mmap_writable、i_mmap_nonlinear、i_mmap_lock 字段涉及内存映射与逆向映射,第 16、17 章讨论。backing_dev_info 指向存放拥有者数据的块设备的 backing_dev_info 描述符——第 14 章讲过,它通常嵌在块设备的请求队列描述符里。private_list 是文件系统可自由使用的通用链表头:比如 Ext2 用它收集 inode 关联的”间接块”的脏缓冲(第 18 章”数据块寻址”),刷新操作强制把 inode 写盘时,内核也连带刷新这个链表里的所有缓冲;Ext2 还在 assoc_mapping 里存包含间接块的块设备的 address_space 指针,并用 assoc_mapping->private_lock 自旋锁在多处理器系统上保护间接块链表。
关键的字段是 a_ops,指向一张 address_space_operations 类型的方法表:
| 方法 | 说明 |
|---|---|
writepage | 写操作(从页写到拥有者的磁盘映像) |
readpage | 读操作(从拥有者的磁盘映像读到页) |
sync_page | 启动拥有者各页上已排定的 I/O 数据传输 |
writepages | 把给定数量的脏拥有者页回写磁盘 |
set_page_dirty | 把拥有者的页标记为脏 |
readpages | 从磁盘读一串拥有者的页 |
prepare_write | 准备一次写操作(磁盘文件系统用) |
commit_write | 完成一次写操作(磁盘文件系统用) |
bmap | 由文件块索引得到逻辑块号 |
invalidatepage | 使拥有者的页失效(截断文件时用) |
releasepage | 日志文件系统用来准备释放页 |
direct_IO | 拥有者各页的直接 I/O 传输(绕过页缓存) |
最重要的方法是 readpage、writepage、prepare_write、commit_write,第 16 章讨论。多数情况下这些方法把拥有者 inode 对象与访问物理设备的底层驱动链接起来——比如常规文件 inode 的 readpage 方法实现知道如何定位文件每一页对应的数据块在物理磁盘设备上的位置。本章不必再深究它们。
基数树
Linux 里文件可以很大,甚至几个 TB。访问大文件时,页缓存里可能塞满该文件的页,顺序扫描所有页太耗时。为了高效查找,Linux 2.6 用一组搜索树——每个 address_space 对象一棵基数树(radix tree)。
address_space 的 page_tree 字段就是基数树的根,树里存放拥有者各页描述符的指针。给定一个页索引(页在拥有者磁盘映像中的位置),内核能极快地判断所需页是否已在页缓存中:把索引解释为树内的一条路径,迅速到达页描述符所在(或应在)的位置。找到了就能取回页描述符;还能顺带快速判断页是否为脏(待刷盘)、是否有 I/O 传输正在进行。
基数树每个节点最多有 64 个指向其他节点或页描述符的指针。最底层的节点存放指向页描述符的指针(叶子),高层节点存放指向子节点的指针。每个节点用 radix_tree_node 结构表示,含三个字段:slots 是 64 个指针的数组;count 是节点中非 NULL 指针的计数;tags 是一个两元素标志数组(“基数树的标签”一节细讲)。树的根用 radix_tree_root 结构表示,三个字段:height(当前树高,不含叶子的层数)、gfp_mask(为新节点申请内存时的标志)、rnode(指向第 1 层节点的 radix_tree_node,若有)。
图 2:两个基数树示例——(a) 树高为 1,最多容纳索引 063;(b) 插入索引 131 后树高增至 2,可容纳索引 04095
简单例子:树中所有索引都不超过 63 时树高为 1,因为 64 个潜在叶子全放在第 1 层节点里;一旦要存索引 131 对应的页描述符,树高就增至 2,可精确定位到索引 4095。
| 基数树高度 | 最高索引 | 最大文件大小 |
|---|---|---|
| 0 | 无 | 0 字节 |
| 1 | 2⁶−1 = 63 | 256 KB |
| 2 | 2¹²−1 = 4095 | 16 MB |
| 3 | 2¹⁸−1 = 262143 | 1 GB |
| 4 | 2²⁴−1 = 16777215 | 64 GB |
| 5 | 2³⁰−1 = 1073741823 | 4 TB |
| 6 | 2³²−1 = 4294967295 | 16 TB |
32 位体系结构上基数树最大高度为 6(页索引存在 32 位变量里,树高为 6 时最高层节点最多 4 个孩子)——不过系统页缓存用到这么高的树相当罕见。
理解页查找的最好办法是类比分页系统用页表把线性地址翻译成物理地址(第 2 章”常规分页”):线性地址最高的 20 位被拆成两个 10 位字段,第一个是页目录内的偏移,第二个是页表内的偏移。
基数树用的是同样的思路,“线性地址”换成了页索引。考虑几个字段取决于树高:
- 树高 1:只能表示索引 0–63,页索引的低 6 位就是第 1 层节点 slots 数组的下标;
- 树高 2:可表示索引 0–4095,低 12 位拆成两个 6 位字段——高位字段作为第 1 层节点的数组下标,低位字段作为第 2 层节点的数组下标;
- 树高 6:最高 2 位是第 1 层节点下标,接着 6 位是第 2 层,以此类推,最低 6 位是第 6 层节点下标。
当基数树的最高索引小于待加入页的索引时,内核相应地增加树高,中间节点取决于页索引的值(见图 1)。
基数树的标签
页缓存不仅让内核快速取回包含指定数据的页,还能快速取回处于给定状态的页。
比如内核要取回缓存中属于某拥有者、且是脏的(内容尚未写盘)的所有页。页描述符的 PG_dirty 标志能说明页是否脏,但遍历整棵树顺序访问所有叶子——在大多数页不脏时——太慢了。
办法是:基数树每个中间节点为每个子节点(或叶子)保留一个脏标签(dirty tag),当且仅当子节点的脏标签至少有一个置位时,该标签置位。最底层节点的脏标签通常是页描述符 PG_dirty 标志的拷贝。这样内核沿树找脏页时,可以整棵跳过脏标签为 0 的中间节点所 rooted 的子树——那里面肯定全是干净页。
PG_writeback 标志(表示页正在写回磁盘)同理。于是树的每个节点传播页描述符的两个标志:PG_dirty 和 PG_writeback(第 8 章)。为此每个节点的 tags 字段包含两个 64 位数组:tags[0](PAGECACHE_TAG_DIRTY)是脏标签,tags[1](PAGECACHE_TAG_WRITEBACK)是写回标签。
相关函数:
- radix_tree_tag_set():设置缓存页的 PG_dirty 或 PG_writeback 标志时调用。参数为树的根、页索引、标签类型。它从根下行到对应叶子,沿途为路径上每个节点的”指向下一节点的指针”关联的标签置位,最后返回页描述符地址——从根到叶的路径上每个节点都被恰当地打上了标签。
- radix_tree_tag_clear():清除标志时调用,参数相同。它先从根下行到叶子、用 radix_tree_path 结构数组记录路径,然后从叶往根回溯:清除最底层节点的标签,检查该节点标签数组是否已全部为 0;是则清除上一层父节点的相应标签,再检查,依此递推。
- 页描述符从树中删除时,路径上各节点的标签必须更新——
radix_tree_delete()会正确处理(前一节有意略过未提)。radix_tree_insert()则不更新标签,因为插入的页描述符假定 PG_dirty、PG_writeback 都是 0;必要时内核稍后调radix_tree_tag_set()。 - radix_tree_tagged():利用全树的标签数组测试树里是否至少有一页处于给定状态:
for (idx = 0; idx < 2; idx++) {
if (root->rnode->tags[tag][idx])
return 1;
}
return 0;由于可以假定所有节点的标签都被正确维护,它只需检查第 1 层节点的标签。典型用途:判断一个 inode 是否含有待写盘的脏页。注意每次迭代实际测试的是 unsigned long 里 32 个标志是否有任一置位。
- find_get_pages_tag():类似
find_get_pages(),但只返回带指定标签的页——“把脏页写回磁盘”一节会看到,它是快速识别 inode 所有脏页的关键。
页缓存处理函数
页缓存的高层基本操作:查找、添加、删除页,以及一个保证缓存包含某页最新版本的操作。
查找页
find_get_page() 接收 address_space 对象指针和偏移值。它获取地址空间的自旋锁,调 radix_tree_lookup() 在基数树中搜索具有所需偏移的叶子节点——从根节点出发,按偏移值的各位逐层下行(上节的原理)。遇到 NULL 指针返回 NULL;否则返回叶子节点地址即所需页描述符指针。找到后 find_get_page() 递增该页使用计数、释放自旋锁、返回其地址;否则释放锁返回 NULL。
find_get_pages() 类似,但一次查找一组索引连续的页。参数为 address_space 指针、起始偏移、最大页数、待填充的页描述符指针数组。它靠 radix_tree_gang_lookup() 填数组并返回找到的页数。返回页的索引递增,但可能有空洞(有的页不在缓存里)。
其他搜索函数:
- find_lock_page():类似 find_get_page(),但额外调
lock_page()置 PG_locked 标志——函数返回时调用者可独占访问该页。lock_page()在页已被锁定时阻塞当前进程:调__wait_on_bit_lock()作用于 PG_locked 位——把当前进程置为 TASK_UNINTERRUPTIBLE 状态、进程描述符存入等待队列、执行 address_space 的sync_page方法拔下包含该文件的块设备的请求队列,最后schedule()挂起进程直到页的 PG_locked 清除。解锁并唤醒等待进程用unlock_page()。 - find_trylock_page():类似 find_lock_page() 但永不阻塞:页已锁定则返回错误码。
- find_or_create_page():执行 find_lock_page();找不到就分配新页并插入页缓存。
添加页
add_to_page_cache() 把新页描述符插入页缓存。参数:页描述符地址 page、address_space 对象地址 mapping、页在地址空间内的索引 offset、分配基数树新节点用的内存分配标志 gfp_mask。执行:
- 调
radix_tree_preload()——禁用内核抢占,用若干空闲 radix_tree_node 结构填充每 CPU 变量radix_tree_preloads(结构分配走 radix_tree_node_cachep slab 缓存)。预分配失败则返回 -ENOMEM;成功则可以确信插入新页描述符不会因缺内存而失败——至少对不超过 64 GB 的文件如此。 - 获取
mapping->tree_lock自旋锁(抢占已被 radix_tree_preload() 禁掉了)。 - 调
radix_tree_insert()把新节点插入树:- 调
radix_tree_maxindex()得到当前树高能表示的最大索引;新页索引超出时调radix_tree_extend()增加树高、加足节点(比如对图 1(a) 的树,会加一个顶层节点)。新节点由radix_tree_node_alloc()分配——先试 slab 缓存,失败再从预分配池取; - 从根(
mapping->page_tree)出发按页索引遍历到叶子,需要时用radix_tree_node_alloc()分配新的中间节点; - 把页描述符地址存进最后到达节点的相应槽位,返回 0。
- 调
- 递增页描述符的使用计数
page->_count。 - 新页内容无效:置 PG_locked 标志,防止其他内核控制路径并发访问。
- 用参数 mapping、offset 初始化
page->mapping和page->index。 - 递增地址空间的缓存页计数
mapping->nrpages。 - 释放地址空间自旋锁。
- 调
radix_tree_preload_end()重新启用内核抢占。 - 返回 0(成功)。
删除页
remove_from_page_cache() 把页描述符从页缓存移除:
- 获取
page->mapping->tree_lock自旋锁并禁中断; - 调
radix_tree_delete()(参数为树根地址和待删页索引):- 从根按页索引遍历到叶子,途中构建 radix_tree_path 结构数组描述从根到叶的路径;
- 从路径数组最后一个节点(存有页描述符指针)开始循环:把 slots 数组中指向下一节点(或页描述符)的元素设为 NULL、递减
count;count 归零则把该节点从树中移除、把 radix_tree_node 结构归还 slab 缓存,继续处理路径上上一个节点;count 不为零则结束循环; - 返回被删除页描述符的指针。
- 把
page->mapping置 NULL; - 递减
page->mapping->nrpages; - 释放自旋锁、开中断、结束。
更新页
read_cache_page() 确保缓存包含某页的最新版本。参数:address_space 指针 mapping、指定所求页的偏移 index、从磁盘读页数据的函数指针 filler(通常就是实现 address_space readpage 方法的函数)、传给 filler 的指针 data(通常 NULL)。简化流程:
- 调
find_get_page()检查页是否已在缓存中; - 不在则:调
alloc_pages()分配新页框;调add_to_page_cache()把页描述符插入页缓存;调lru_cache_add()把页插入所在区的非活动 LRU 链表(第 17 章); - 页已在缓存:调
mark_page_accessed()记录页被访问过(第 17 章); - 页不是最新的(PG_uptodate 清除)则调 filler 从磁盘读页;
- 返回页描述符地址。
在页缓存中存块
第 14 章讲过,VFS、映射层和各文件系统按”块”组织磁盘数据。
老版本内核有两个主要磁盘缓存:页缓存(存整页磁盘数据,源于对磁盘文件内容的访问)和缓冲区缓存(buffer cache)(存 VFS 管理磁盘文件系统时访问的块内容)。从稳定版 2.4.10 起,缓冲区缓存实际上不复存在了:出于效率,块缓冲不再单独分配,而是存进专门的页——缓冲页(buffer page)——保存在页缓存里。
形式上,缓冲页就是一页数据加上称为”缓冲区首部”的附加描述符,其主要用途是快速定位页中每个单独块的磁盘地址——因为属于页缓存的页里存放的数据块在磁盘上未必相邻。
块缓冲与缓冲区首部
每个块缓冲有 buffer_head 类型的缓冲区首部描述符,包含内核处理该块所需的全部信息;内核操作块之前先查它的缓冲区首部。字段如下:
| 类型 | 字段 | 说明 |
|---|---|---|
unsigned long | b_state | 缓冲状态标志 |
struct buffer_head * | b_this_page | 缓冲页链表中的下一元素 |
struct page * | b_page | 承载该块的缓冲页的页描述符 |
atomic_t | b_count | 块使用计数 |
u32 | b_size | 块大小 |
sector_t | b_blocknr | 相对块设备的块号(逻辑块号) |
char * | b_data | 块在缓冲页内的位置 |
struct block_device * | b_bdev | 块设备描述符指针 |
bh_end_io_t * | b_end_io | I/O 完成方法 |
void * | b_private | I/O 完成方法使用的数据指针 |
struct list_head | b_assoc_buffers | 与 inode 关联的间接块链表指针 |
编码块磁盘地址的是两个字段:b_bdev 标识包含块的块设备(通常是磁盘或分区,第 14 章),b_blocknr 存逻辑块号(块在其磁盘或分区内的索引)。
b_data 指定块缓冲在缓冲页内的位置,编码方式取决于页是否在高内存:高内存页存块缓冲相对页首的偏移,否则存块缓冲的线性地址。
b_state 可存多个标志,通用标志如下;各文件系统还可以定义自己的私有标志:
| 标志 | 说明 |
|---|---|
BH_Uptodate | 缓冲包含有效数据时置位 |
BH_Dirty | 缓冲是脏的——含必须写到块设备的数据 |
BH_Lock | 缓冲被锁定,通常发生在缓冲参与磁盘传输时 |
BH_Req | 初始化缓冲的数据传输已被请求过 |
BH_Mapped | 缓冲已映射到磁盘——b_bdev 和 b_blocknr 字段有效 |
BH_New | 对应块刚分配、从未被访问 |
BH_Async_Read | 缓冲正被异步读 |
BH_Async_Write | 缓冲正被异步写 |
BH_Delay | 缓冲尚未在磁盘上分配 |
BH_Boundary | 本块之后提交的块与本块不相邻 |
BH_Write_EIO | 写本块时发生 I/O 错误 |
BH_Ordered | 本块必须严格写在其之前提交的块之后(日志文件系统用) |
BH_Eopnotsupp | 块设备驱动不支持所请求的操作 |
管理缓冲区首部
缓冲区首部有自己的 slab 分配器缓存(kmem_cache_s 描述符存在 bh_cachep 变量里),alloc_buffer_head() 和 free_buffer_head() 分别获取和释放缓冲区首部。
b_count 是块缓冲的使用计数:每次操作块缓冲前立刻递增、操作后立刻递减。页缓存里的块缓冲会被定期检查,也在空闲内存稀缺时被检查,只有使用计数为零的块缓冲才可能被回收(第 17 章)。
内核控制路径想访问块缓冲时,应先递增使用计数。在页缓存中定位块的函数 __getblk()(见下节)会自动做这件事,所以上层函数通常不必自己递增。停止访问时应调 __brelse() 或 __bforget() 递减计数。两者区别:__bforget() 还会把块从任何间接块链表(缓冲区首部的 b_assoc_buffers 字段)里移除,并把缓冲标记为干净——强迫内核忘掉缓冲中尚未写盘的任何修改。
缓冲页
内核需要单独寻址某个块时,就引用承载该块缓冲的缓冲页并查对应缓冲区首部。内核创建缓冲页的两种常见情形:
- 读写的文件页没有存放在连续的磁盘块里——要么文件系统给文件分配了不连续的块,要么文件含”洞”(第 18 章”文件洞”)。这种缓冲页的描述符插在常规文件的基数树里;缓冲区首部必须保留,因为它们存着宝贵信息——指定数据在磁盘位置的块设备和逻辑块号。内核如何使用这种缓冲页见第 16 章。
- 访问单个磁盘块——比如读超级块或 inode 块。这种缓冲页的描述符插在 bdev 特殊文件系统中块设备 inode 的 address_space 对象为根的基数树里。这类缓冲页必须满足强约束:所有块缓冲必须对应底层块设备的相邻块。
举个第二类的例子:VFS 想读 1024 字节的 inode 块(包含某文件的 inode)。内核不是只分配一个缓冲,而是必须分配一整页装 4 个缓冲——这些缓冲装着块设备上 4 个相邻块(含所求 inode 块)的数据。本章聚焦第二类,即所谓块设备缓冲页(block device buffer pages,简称 blockdev’s pages)。
单个缓冲页内所有块缓冲大小必须相同,所以在 80×86 上一个缓冲页按块大小可含 1–8 个缓冲。页充当缓冲页时,关联的所有缓冲区首部收集在一条单向循环链表里:缓冲页描述符的 private 字段指向页中第一块的缓冲区首部;每个缓冲区首部的 b_this_page 字段指向链表中下一个;每个缓冲区首部的 b_page 字段存缓冲页描述符地址。
图 2:一个包含 4 个块缓冲及其缓冲区首部的缓冲页
分配块设备缓冲页
内核发现页缓存里没有包含某块缓冲的页时,就分配新的块设备缓冲页。查找可能失败有三种原因:
- 块设备的基数树里没有包含该块数据的页:需向基数树添加新页描述符;
- 基数树里有包含该块数据的页,但这页不是缓冲页:需分配新缓冲区首部并链接到页上,把它变成块设备缓冲页;
- 基数树里有包含该块数据的缓冲页,但页已被拆分成不同大小的块:需释放旧缓冲区首部、分配新的一组并链接到页上。
内核调 grow_buffers() 向页缓存添加块设备缓冲页。参数为标识块的三元组:block_device 描述符地址 bdev、逻辑块号 block、块大小 size。执行:
- 计算包含所求块的那个数据页在块设备内的偏移 index;
- 必要时调
grow_dev_page()创建新块设备缓冲页,它执行:- 调
find_or_create_page()(参数为块设备的 address_space 对象bdev->bd_inode->i_mapping、页偏移 index、GFP_NOFS 标志)在页缓存中找页、必要时插新页; - 现在所需页已在缓存、拿到描述符地址。检查 PG_private 标志:为空说明还不是缓冲页(没有关联的缓冲区首部),跳到步骤 2e;
- 页已是缓冲页:从描述符
private字段取第一个缓冲区首部地址 bh,检查块大小bh->size是否等于所求块大小;相等则页缓存中找到的页是有效的缓冲页,跳到 2g; - 页的块大小不对:调
try_to_free_buffers()(见下文)释放缓冲页原有的缓冲区首部; - 调
alloc_page_buffers()为页内所求大小的各块分配缓冲区首部,并把它们插进b_this_page字段实现的单向循环链表;同时初始化缓冲区首部的b_page字段(页描述符地址)和b_data字段(块缓冲在页内的偏移或线性地址); - 把第一个缓冲区首部的地址存进
private字段,置 PG_private 标志,递增页的使用计数(页内的块缓冲算一个页的使用者); - 调
init_page_buffers()初始化链接到页的缓冲区首部的b_bdev、b_blocknr、b_state字段。所有块在磁盘上相邻,逻辑块号连续、可从 block 直接推导; - 返回页描述符地址。
- 调
- 解锁页(页被 find_or_create_page() 锁定了);
- 递减页使用计数(同样是被 find_or_create_page() 递增的);
- 返回 1(成功)。
释放块设备缓冲页
第 17 章会看到,内核设法腾出更多空闲内存时会释放块设备缓冲页。显然含脏缓冲或锁定缓冲的页不能释放。释放缓冲页用 try_to_release_page()(参数为页描述符地址):
- 页的 PG_writeback 置位则返回 0(页正在写回磁盘,无法释放);
- 如有定义,调块设备 address_space 对象的
releasepage方法(块设备通常不定义它); - 调
try_to_free_buffers()并返回其错误码。
try_to_free_buffers() 扫描链接到缓冲页的缓冲区首部:
- 检查页内所有缓冲的缓冲区首部标志。任一置了 BH_Dirty 或 BH_Locked 则返回 0(失败):无法释放;
- 某缓冲区首部插在间接缓冲链表里则从链表移除;
- 清页描述符的 PG_private 标志、
private字段置 NULL、递减页使用计数; - 清页的 PG_dirty 标志;
- 反复对页的缓冲区首部调
free_buffer_head(),全部释放; - 返回 1(成功)。
搜索块与提交 I/O
内核需要读写物理设备的单个块(比如超级块)时,必须先检查所需块缓冲是否已在页缓存中。以块设备描述符地址 bdev 和逻辑块号 nr 指定的块缓冲,其搜索分三步:
- 取包含该块的块设备的 address_space 对象指针(
bdev->bd_inode->i_mapping); - 取设备块大小(
bdev->bd_block_size),算出包含该块的页的索引——这总是对逻辑块号的一次位移。比如块大小 1024 字节时每个缓冲页含 4 个块缓冲,页索引就是 nr/4; - 在块设备的基数树里搜索缓冲页。拿到页描述符后,内核就能访问描述页内块缓冲状态的缓冲区首部了。
细节还要更多一些。为了提升性能,内核管理着一个 bh_lrus 数组——每 CPU 一个的小磁盘缓存,叫 LRU 块缓存:每个缓存含 8 个指针,指向最近被某 CPU 访问过的缓冲区首部。每 CPU 数组的元素已排序,最近使用的缓冲区首部指针下标为 0。同一缓冲区首部可能出现在多个 CPU 数组里(但同一数组内绝不重复);缓冲区首部在 LRU 块缓存中的每一次出现,其 b_count 使用计数都加 1。
__find_get_block() 函数
参数为 block_device 描述符地址 bdev、块号 block、块大小 size;返回页缓存中该块缓冲关联的缓冲区首部地址,不存在则 NULL。执行:
- 检查执行 CPU 的 LRU 块缓存数组里是否有
b_bdev、b_blocknr、b_size分别等于 bdev、block、size 的缓冲区首部; - 在则把该缓冲区首部指针挪到数组首位(下标 0)、递增其
b_count、跳第 8 步; - 不在则由块号和块大小推导相对块设备的页索引:
index = block >> (PAGE_SHIFT - bdev->bd_inode->i_blkbits);- 调
find_get_page()(传块设备的 address_space 指针和页索引)在页缓存中定位包含所需块缓冲的缓冲页描述符;缓存里没有则返回 NULL(失败); - 拿到缓冲页描述符地址后,扫描链接到缓冲页的缓冲区首部链表,找逻辑块号等于 block 的块;
- 递减页描述符的 count 字段(find_get_page() 递增过);
- 把 LRU 块缓存的所有元素下移一位,把所求块的缓冲区首部指针插到首位。被挤出缓存的缓冲区首部递减其
b_count使用计数; - 调
mark_page_accessed()必要时把缓冲页移进合适的 LRU 链表(第 17 章); - 返回缓冲区首部指针。
__getblk() 函数
参数同 __find_get_block(),返回与缓冲关联的缓冲区首部地址。区别在于永不失败:即使块根本不存在,__getblk() 也会殷勤地分配一个块设备缓冲页、返回本应描述该块的缓冲区首部指针。注意返回的块缓冲不一定含有效数据——缓冲区首部的 BH_Uptodate 标志可能没置位。步骤:
- 调
__find_get_block()检查块是否已在页缓存中;在则返回其缓冲区首部地址; - 否则调
grow_buffers()为所求块分配新缓冲页; grow_buffers()分配失败则调free_more_memory()(第 17 章)回收一些内存;- 跳回第 1 步。
__bread() 函数
参数同上,返回与缓冲关联的缓冲区首部地址。与 __getblk() 不同,必要时它会先从磁盘把块读出来再返回。步骤:
- 调
__getblk()在页缓存中找所求块关联的缓冲页、拿缓冲区首部指针; - 块已在页缓存且缓冲含有效数据(BH_Uptodate 置位)则返回缓冲区首部地址;
- 否则递增缓冲区首部使用计数;
- 把
b_end_io字段设为end_buffer_read_sync()的地址(见下节); - 调
submit_bh()把缓冲区首部交给通用块层(见下节); - 调
wait_on_buffer()把当前进程放进等待队列,直到读 I/O 操作完成——即缓冲区首部的 BH_Lock 标志被清除; - 返回缓冲区首部地址。
把缓冲区首部提交给通用块层
submit_bh() 和 ll_rw_block() 两个函数让内核能对缓冲区首部描述的一个或多个缓冲启动 I/O 数据传输。
submit_bh() 向通用块层提交单个缓冲区首部、请求传输单个数据块。参数为传输方向(本质是 READ 或 WRITE)和描述块缓冲的缓冲区首部指针 bh。它假定缓冲区首部已完全初始化——尤其 b_bdev、b_blocknr、b_size 必须已正确设置以标识磁盘上含所求数据的块。块缓冲属于块设备缓冲页时,初始化由 __find_get_block() 完成(上节);不过下一章会看到,submit_bh() 也可以作用于属于常规文件缓冲页的块。
submit_bh() 基本上是个”胶水函数”:根据缓冲区首部的内容造出一个 bio 请求,然后调 generic_make_request()(第 14 章)。主要步骤:
- 置缓冲区首部的 BH_Req 标志(记录该块至少被提交过一次);方向为 WRITE 时清 BH_Write_EIO 标志;
- 调
bio_alloc()分配新 bio 描述符(第 14 章); - 按缓冲区首部内容初始化 bio 字段:
bi_sector设为块的第一个扇区号(bh->b_blocknr * bh->b_size / 512);bi_bdev设为块设备描述符地址(bh->b_bdev);bi_size设为块大小(bh->b_size);- 初始化
bi_io_vec数组第一个元素使该段对应块缓冲:bi_io_vec[0].bv_page= bh->b_page,bi_io_vec[0].bv_len= bh->b_size,bi_io_vec[0].bv_offset= bh->b_data 指定的块缓冲在页内偏移; bi_vcnt设为 1(bio 上只有一个段),bi_idx设为 0(当前待传输段);bi_end_io设为end_bio_bh_io_sync()的地址,bi_private设为缓冲区首部地址——传输结束时将调用该函数(见下)。
- 递增 bio 的引用计数(变为 2);
- 调
submit_bio():设置bi_rw标志为传输方向,更新 page_state 每 CPU 变量以统计读写的扇区数,然后对 bio 描述符调generic_make_request(); - 递减 bio 使用计数——bio 描述符不释放,因为它已被插进 I/O 调度器的某个队列;
- 返回 0(成功)。
bio 上的 I/O 传输结束时,内核执行 bi_end_io 方法——此处即 end_bio_bh_io_sync():从 bio 的 bi_private 字段取缓冲区首部地址,调用缓冲区首部的 b_end_io 方法(调 submit_bh() 前已正确设置),最后调 bio_put() 销毁 bio 结构。
ll_rw_block() 用于一次触发多个(未必物理相邻的)数据块的传输。参数:传输方向(READ 或 WRITE)、待传输块数、描述对应块缓冲的缓冲区首部指针数组。函数遍历所有缓冲区首部,对每一个:
- 测试并置 BH_Lock 标志;缓冲已被锁定说明传输已被其他内核控制路径激活,跳到第 9 步;
- 递增缓冲区首部使用计数
b_count; - 方向为 WRITE 时把
b_end_io设为end_buffer_write_sync()地址,否则设为end_buffer_read_sync()地址; - 方向为 WRITE 时测试并清 BH_Dirty 标志;未置位说明无需写盘,跳第 7 步;
- 方向为 READ 或 READA(预读)时检查 BH_Uptodate 是否置位;置位说明无需从磁盘读,跳第 7 步;
- 该块确实要读或写:调
submit_bh()把缓冲区首部交给通用块层,跳第 9 步; - 清 BH_Lock 解锁缓冲区首部,唤醒所有等待该块解锁的进程;
- 递减缓冲区首部的
b_count; - 数组里还有别的缓冲区首部则选下一个、跳回第 1 步;否则终止。
注意:ll_rw_block() 把缓冲区首部交给通用块层后,会让缓冲保持锁定、引用计数递增——传输完成前缓冲不能被访问、也不能被释放。块的传输结束时内核执行缓冲区首部的 b_end_io 完成方法:没有 I/O 错误的话,end_buffer_write_sync() 和 end_buffer_read_sync() 只是置缓冲区首部的 BH_Uptodate 字段、解锁缓冲、递减使用计数。
把脏页写回磁盘
内核不断往页缓存里填块设备数据的页。进程一修改数据,对应页就被标记为脏——PG_dirty 标志置位。
Unix 系统允许延迟写脏页,因为这样能显著提升性能:对缓存页的多次写操作,可以合并成对相应磁盘扇区的一次缓慢物理更新。而且写操作没有读操作关键——进程通常不会因为延迟写而挂起,却经常因为延迟读而挂起。多亏延迟写,每个物理块设备平均服务的读请求数远多于写请求数。
脏页理论上可以在内存里待到最后一刻——直到系统关机。但把延迟写推到极致有两大坏处:
- 硬件或电源故障发生时,RAM 内容无法找回,系统启动以来许多文件更新就丢了;
- 页缓存(进而是 RAM)的规模必须巨大——至少与被访问块设备的总容量一样大。
因此脏页在以下条件下被**刷新(写回)**磁盘:
- 页缓存太满需要腾出更多页,或脏页数量太多;
- 页保持脏状态的时间太长;
- 进程请求刷新某块设备或某文件的全部待决修改——通过调用
sync()、fsync()或fdatasync()系统调用(见下节)。
缓冲页引入了进一步的复杂性:缓冲页关联的缓冲区首部让内核能跟踪每个单独块缓冲的状态。只要至少一个缓冲区首部的 BH_Dirty 置位,缓冲页的 PG_dirty 就应置位。内核选中一个脏缓冲页刷新时,会扫描关联的缓冲区首部,只把脏块的内容真正写盘;页中所有脏块都刷完后,内核才清页的 PG_dirty 标志。
pdflush 内核线程
老版本 Linux 用两个内核线程:bdflush 系统性地扫描页缓存找脏页刷新,kupdate 保证没有页脏得太久。Linux 2.6 把两者都换成了通用内核线程组 pdflush。
pdflush 结构灵活:它们作用于两个参数——要执行的函数指针和函数参数。系统中 pdflush 线程的数量动态调整:太少就创建、太多就杀死。因为这些线程执行的函数可能阻塞,多个 pdflush 比单个性能更好。生灭规则:
- 至少 2 个、至多 8 个 pdflush 内核线程;
- 上一秒内没有空闲 pdflush,就应新建一个;
- 最后一个 pdflush 空闲超过 1 秒,就应移除一个。
每个 pdflush 线程有一个 pdflush_work 描述符:
| 类型 | 字段 | 说明 |
|---|---|---|
struct task_struct * | who | 内核线程描述符指针 |
void(*)(unsigned long) | fn | 内核线程执行的回调函数 |
unsigned long | arg0 | 回调函数的参数 |
struct list_head | list | pdflush_list 链表链接指针 |
unsigned long | when_i_went_to_sleep | 线程变为可用的时间(jiffies) |
空闲 pdflush 的描述符收集在 pdflush_list 链表里,多处理器系统上由 pdflush_lock 自旋锁保护。nr_pdflush_threads 变量存线程总数(空闲加忙)。last_empty_jifs 变量存 pdflush_list 链表最后一次变空的时间(jiffies)。
每个 pdflush 线程执行 __pdflush() 函数,无尽循环直到线程死亡。空闲时线程睡在 TASK_INTERRUPTIBLE 状态。被唤醒后,__pdflush() 访问自己的 pdflush_work 描述符,执行 fn 字段的回调函数、传入 arg0 参数。回调结束后 __pdflush() 检查 last_empty_jifs:若超过 1 秒没有空闲 pdflush 且线程数少于 8,就再启动一个内核线程;若 pdflush_list 最后一个表项空闲超过 1 秒且线程数多于 2,本线程就终止(第 3 章讲过,内核线程执行 _exit() 系统调用后被销毁);否则把自己的 pdflush_work 描述符重新插回 pdflush_list 并睡眠。
pdflush_operation() 激活一个空闲 pdflush 线程。参数为要执行的函数指针 fn 和参数 arg0:
- 从
pdflush_list取出一个空闲 pdflush_work 描述符指针 pdf。链表空则返回 -1;链表原来自动只有一个元素的话,把last_empty_jifs设为 jiffies; - 把 fn 和 arg0 存进 pdf->fn 和 pdf->arg0;
- 调
wake_up_process()唤醒空闲 pdflush 线程(pdf->who)。
pdflush 干什么活?几类活全与刷新脏数据有关,通常执行以下回调之一:
- background_writeout():系统性地遍历页缓存找脏页刷新(见下);
- wb_kupdate():检查页缓存里没有页脏得太久(见下)。
查找待刷新的脏页
每棵基数树都可能有待刷新的脏页,找全它们意味着对所有有磁盘映像的 inode 关联的 address_space 对象做穷举搜索。页缓存可能很大,一次跑完整个缓存扫描会让 CPU 和磁盘忙很久。所以 Linux 采用精致机制,把页缓存扫描拆成多轮执行。
wakeup_bdflush() 接收要刷新的脏页数作参数;零值表示缓存中所有脏页都应写回。它调 pdflush_operation() 唤醒一个 pdflush 线程,委托其执行 background_writeout() 回调——后者真正从页缓存取回指定数量的脏页并写回磁盘。
wakeup_bdflush() 在内存稀缺或用户显式请求刷新时执行,具体在以下时刻被调用:
- 用户态进程发
sync()系统调用; grow_buffers()分配新缓冲页失败;- 页框回收算法调
free_more_memory()或try_to_free_pages()(第 17 章); mempool_alloc()分配新内存池元素失败(第 8 章”内存池”)。
此外,任何修改页缓存页内容、导致脏页比例升过脏背景阈值的进程,都会唤醒一个执行 background_writeout() 的 pdflush 线程。背景阈值通常设为系统全部页数的 10%,可通过写 /proc/sys/vm/dirty_background_ratio 调整。
background_writeout() 依赖 writeback_control 结构,它是双向通信设备:一方面告诉辅助函数 writeback_inodes() 该做什么,另一方面存放写盘页数的统计。最重要的字段:
| 字段 | 说明 |
|---|---|
sync_mode | 同步模式:WB_SYNC_ALL 表示遇到锁定 inode 必须等待而不能跳过;WB_SYNC_HOLD 表示锁定 inode 放入列表稍后处理;WB_SYNC_NONE 表示直接跳过锁定 inode |
bdi | 非 NULL 时指向 backing_dev_info 结构:只刷新属于该底层块设备的脏页 |
older_than_this | 非 NULL 时跳过比指定值新的 inode |
nr_to_write | 本轮尚待写的脏页数 |
nonblocking | 置位时进程不能被阻塞 |
background_writeout() 只有一个参数 nr_pages——应刷新到磁盘的最少页数。执行:
- 从 page_state 每 CPU 变量读页缓存当前的页数和脏页数。脏页比例低于给定阈值且已至少刷新 nr_pages 页时终止。阈值通常约为系统页数的 40%,可通过写
/proc/sys/vm/dirty_ratio调整; - 调
writeback_inodes()尝试写 1024 个脏页(见下); - 检查实际写出的页数、递减待写页数;
- 若写的页数不足 1024 或有页被跳过,多半是块设备的请求队列拥塞了:把当前进程放进特殊等待队列睡 100 毫秒,或睡到队列解除拥塞;
- 跳回第 1 步。
writeback_inodes() 只有一个参数——指向 writeback_control 描述符的指针 wbc;其 nr_to_write 字段含要刷盘的页数,函数返回时同字段存剩余待刷页数(一切顺利的话为 0)。
假设以 background_writeout() 设置的值调用它:wbc->bdi 和 wbc->older_than_this 为 NULL、同步模式 WB_SYNC_NONE、wbc->nonblocking 置位。函数扫描 super_blocks 变量为根的超块链表(第 12 章”超块对象”),扫完整条链表或达到刷页目标时结束。对每个超块 sb:
- 检查
sb->s_dirty或sb->s_io链表是否为空——前者收集超块的脏 inode,后者收集等待传输到磁盘的 inode。都空则该文件系统上的 inode 没有脏页,考虑链表中下一个超块; - 超块有脏 inode:对 sb 调
sync_sb_inodes(),它:- 把
sb->s_dirty的所有 inode 移入sb->s_io指向的链表,清空脏 inode 链表; - 从
sb->s_io取下一个 inode 指针;链表空则返回; - 若该 inode 是在
sync_sb_inodes()开始之后才变脏的,跳过它的脏页并返回(此时sb->s_io链表里可能还剩一些脏 inode); - 若当前进程是 pdflush 内核线程,检查另一个 CPU 上运行的 pdflush 是否已在为属于该块设备的文件刷新脏页——通过对 inode 的 backing_dev_info 的 BDI_pdflush 标志做原子测试并置位。同一请求队列上搞多个 pdflush 毫无意义;
- 递增 inode 使用计数;
- 调
__writeback_single_inode()写回选中 inode 关联的脏缓冲:- inode 被锁定则把它移回脏 inode 链表(inode->i_sb->s_dirty)并返回 0(假定 sync_mode 不是 WB_SYNC_ALL,函数不阻塞等解锁);
- 用 inode 地址空间的
writepages方法(没有就用mpage_writepages()函数)写至多 wbc->nr_to_write 个脏页。该函数用find_get_pages_tag()快速取回 inode 地址空间中的所有脏页(“基数树的标签”一节)。细节见下一章; - inode 本身是脏的话,用超块的
write_inode方法把 inode 写盘。实现该方法的函数通常靠submit_bh()传输单个数据块; - 检查 inode 状态,相应地:inode 还有脏页则移回
sb->s_dirty;引用计数为零则移入 inode_unused;否则移入 inode_in_use(第 12 章); - 返回步骤 2f2 所调函数的错误码。
- 回到
sync_sb_inodes():当前进程是 pdflush 线程则清步骤 2d 设的 BDI_pdflush 标志; - 刚处理的 inode 有被跳过的页,说明它含锁定缓冲:把
sb->s_io链表里剩余的所有 inode 移回sb->s_dirty,稍后再考虑; - 递减 inode 使用计数;
wbc->nr_to_write仍大于 0 则跳回 2b 找同一超块的其他脏 inode;否则sync_sb_inodes()终止。
- 把
- 回到
writeback_inodes():wbc->nr_to_write大于零则跳第 1 步继续全局链表的下一个超块;否则返回。
取回老旧脏页
内核还要防范另一种风险:某些页长期不刷新导致的”饿死”。因此页脏了预定义的一段时间后,内核显式启动 I/O 传输把它的内容写盘。
取回老旧脏页的活委托给一个被周期性唤醒的 pdflush 线程。内核初始化时,page_writeback_init() 函数设置 wb_timer 动态定时器,在 dirty_writeback_centisecs 个百分之一秒后到期(通常 500,即 5 秒;可写 /proc/sys/vm/dirty_writeback_centisecs 调整)。定时器函数 wb_timer_fn() 本质上就是调 pdflush_operation()、传入 wb_kupdate() 回调函数的地址。
wb_kupdate() 遍历页缓存找”老旧”的脏 inode:
- 调
sync_supers()把脏超块写盘(见下节)。它与刷新页缓存页没有严格关系,只是确保没有超块脏得太久; - 在 writeback_control 描述符的
older_than_this字段存入一个 jiffies 值:当前时间减 30 秒。30 秒是一页允许保持脏状态的最长时间; - 从每 CPU 的 page_state 变量得出页缓存当前脏页的粗略数目;
- 反复调
writeback_inodes(),直到写盘页数达到上一步算出的值,或所有超过 30 秒的页都已写完。循环中某些请求队列拥塞时函数可能睡眠; - 用
mod_timer()重启 wb_timer 定时器:自本次调用起再过 dirty_writeback_centisecs 个百分之一秒到期(若本次执行耗时过长,则为自现在起 1 秒)。
常见坑:脏页与数据丢失
延迟写意味着你的程序 write() 成功返回,不代表数据已经落盘——它可能还在页缓存里当脏页。进程崩溃不要紧(内核还在),但断电或系统崩溃会丢掉最多 30 秒内的所有修改。所以数据库、消息队列这类在意持久性的软件要么定期调 fsync(),要么干脆用 O_DIRECT 绕过页缓存自己管缓存。调
/proc/sys/vm/下的参数(dirty_ratio、dirty_background_ratio、dirty_writeback_centisecs)可以平衡性能与丢失风险。
sync 系列系统调用
用户应用有三个把脏缓冲刷到磁盘的系统调用:
- sync():让进程把所有脏缓冲刷到磁盘;
- fsync():让进程把属于特定打开文件的所有块刷到磁盘;
- fdatasync():与 fsync() 非常相似,但不刷新文件的 inode 块。
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() 扫描超块链表找待刷新的脏 inode;参数 wait 指定是否必须等到刷新完成。函数扫描所有已挂载文件系统的超块;对每个含脏 inode 的超块,先调 sync_sb_inodes() 刷新相应脏页(前节已述),再调 sync_blockdev() 显式刷新包含超块的块设备所拥有的脏缓冲页。后一步是必要的:许多磁盘文件系统的 write_inode 超块方法只是把对应磁盘 inode 的块缓冲标记为脏,sync_blockdev() 确保这些更新真正落盘。
sync_supers() 必要时用相应的 write_super 超块操作把脏超块写盘。sync_filesystems() 对所有可写文件系统执行 sync_fs 超块方法——这是提供给文件系统的钩子,供它在每次 sync 时执行特殊操作;只有 Ext3 这类日志文件系统才使用(第 18 章)。
注意 sync_inodes() 和 sync_filesystems() 被调用了两次:先 wait=0 再 wait=1。这是有意为之:第一遍快速刷完未锁定的 inode;第二遍等每个锁定的 inode 解锁、逐个写完。
fsync() 和 fdatasync() 系统调用
fsync() 强制内核把属于 fd 文件描述符指定文件的所有脏缓冲写盘(必要时包括含其 inode 的缓冲)。服务例程先推出 file 对象地址,然后调 file 对象的 fsync 方法。该方法通常最终调 __writeback_single_inode(),把选中 inode 关联的脏页和 inode 本身一并写回(前节已述)。
fdatasync() 与 fsync() 非常相似,但只写包含文件数据的缓冲,不写包含 inode信息的缓冲。不过 Linux 2.6 没有为 fdatasync() 提供专门的文件方法,它复用 fsync 方法——因此效果与 fsync() 完全相同。
通关标准
能回答:页缓存中的页靠什么标识(owner + index,而非设备号+块号)?address_space 和基数树如何配合实现 O(1) 级查找与脏页快速定位?块缓冲为什么进了页缓存(buffer cache 消亡)?pdflush 的两个回调各自防什么问题(缓存太满 vs 页脏太久)?sync/fsync/fdatasync 的差别?
为什么同一份磁盘数据可能在页缓存中存在两份拷贝?这算 bug 吗?
不算 bug,是设计使然。页缓存以”拥有者+索引”标识页:读常规文件时数据进文件 inode 拥有的页;对块设备文件做原始访问读同一块时,数据进块设备主 inode 拥有的另一个页。两个页分属两个 address_space 对象、服务不同的访问路径(文件视图 vs 设备原始视图),内核选择不为消除重复引入复杂的一致性逻辑。
基数树的标签机制如何让"找出所有脏页"变快?
每个中间节点为每个孩子保留一个脏标签,孩子中有任一脏标签置位它就置位;最底层节点的脏标签是页描述符 PG_dirty 的拷贝。于是查找脏页时,凡是脏标签为 0 的中间节点,其整棵子树可被直接跳过——脏页越稀疏,剪枝越有效。PG_writeback(正在写回)标签同理,tags[0] 存 DIRTY、tags[1] 存 WRITEBACK。
缓冲区缓存去哪了?缓冲区首部还存在吗?
从 2.4.10 起独立的 buffer cache 不复存在:块缓冲不再单独分配,而是装进缓冲页、随页一起放在页缓存里。缓冲区首部(buffer_head)仍然存在——它附加在缓冲页上,用于快速定位页内每个单独块的磁盘地址(b_bdev + b_blocknr),并跟踪每个块缓冲的状态(脏、锁、有效等)。
__getblk()和__bread()的区别是什么?
__getblk()只负责”拿到”:先查 LRU 块缓存和页缓存,找不到就grow_buffers()分配缓冲页,永远返回一个缓冲区首部——但缓冲里可能没有有效数据(BH_Uptodate 未置位)。__bread()在拿到之后检查 BH_Uptodate,无效就构造 bio(经 submit_bh() 提交读请求)并wait_on_buffer()睡到读完,返回的一定是含有效数据的缓冲。
pdflush 线程数量如何动态调整?为什么不止一个?
规则:至少 2 个至多 8 个;过去 1 秒内没有空闲 pdflush 就新建;最后一个 pdflush 空闲超过 1 秒就杀掉一个。因为 pdflush 执行的回调函数(如写回脏页)可能阻塞(比如请求队列拥塞时睡 100ms),单个线程阻塞期间就没有别的线程能刷盘了,多线程才能持续服务。