深入理解 Linux 内核—页高速缓存

释放双眼,带上耳机,听听看~!

磁盘高速缓存是一种软件机制,它允许系统把通常存放在磁盘上的一些数据保留在 RAM 中,以便对那些数据的进一步访问不用再访问磁盘而能尽快得到满足。

页高速缓存

几乎所有的文件读写操作都依赖于高速缓存,只有在 O_DIRECT 标志被置位,而进程打开文件的情况下才会出现例外。

页高速缓存中的信息单位是一个完整的页。
一个页包含的磁盘块在物理上不一定相邻,所以不能用设备号和块号标识,而是通过页的所有者和所有者数据中的索引来识别。

address_space 对象

页高速缓存的核心数据结构是 address_space 对象,它是一个嵌入在页所有者的索引节点对象中的数据结构。
高速缓存中的许多页可能属于同一个所有者,从而可能被链接到同一个 address_space 对象
该对象还在所有者的页和对这些页的操作之间建立起链接关系。

每个页描述符都包含把页链接到页高速缓存的两个字段 mapping 和 index。
mapping 指向拥有页的索引节点的 address_space 对象;
index 表示所有者的地址空间中以页大小为单位的偏移量,即页中数据在所有者的磁盘映像中的位置。
在页高速缓存中查找页时使用这两个字段。

页高速缓存可包含同一磁盘数据的多个副本。
因此,两个不同 address_space 对象所引用的两个不同的页中出现了相同的磁盘数据。

address_space 的一些字段:

  • host:如果页高速缓存中页的所有者是一个文件,address_space 对象就嵌入在 VFS 索引节点对象的 i_data 字段中。

索引节点的 i_mapping 字段总是指向索引节点的数据页所拥有的 address_space 对象。
address_space 对象的 host 字段指向其所有者的索引节点对象。

  • backing_dev_info:指向 backing_dev_info 描述符,是对所有者的数据所在块设备进行描述的数据结构。

backing_dev_info 结构通常嵌入在块设备的请求队列描述符中。

  • private_list:普通链表的首部,文件系统在实现其特定功能时可随意使用。

如,Ext2 文件系统利用该链表收集与索引节点相关的“间接”块的脏缓冲区。
当刷新操作把索引节点强行写入磁盘时,内核页同时刷新该链表中的所有缓冲区。

  • a_ops:指向一个类型为 address_space_operations 的表,表中定义了对所有者的页进行处理的各种方法。

最重要的方法是 readpage、writepage、prepare_write 和 commit_write。

基树

为实现页高速缓存的高效查找,每个 address_space 对象对应一棵搜索树。

address_space 的 page_tree 字段是基树的根,包含指向所有者的页描述符的指针。
给定的页索引表表示页在所有者磁盘映像中的位置,内核能通过快速搜索操作确定所需要的页是否在页高速缓存中。
当查找所需要的页时,内核把页索引转换为基树中的路径,并快速找到页描述符所在的位置。
如果找到,内核可从基树获得页描述符,并很快确定所找的页是否为脏页,以及其数据的 I/O 传送是否正值进行。

基数的每个节点可有多达 64 个指针指向其他节点或页描述符。
底层节点存放指向页描述符的指针(叶子节点),而上层的节点存放指向其他节点(孩子节点)的指针。
每个节点由 radix_tree_node 数据结构表示,包含三个字段:

  • slots:包含 64 个指针的数组
  • count:记录节点中非空指针数量的计数器
  • tags:二维的标志数组

树根由 radix_tree_root 数据结构表示,有三个字段:

  • height:树的当前深度(不包括叶子节点的层数)
  • gfp_mask:为新节点请求内存时所用的标志
  • rnode:指向与树中第一层节点相应的数据结构 radix_tree_node

深入理解 Linux 内核---页高速缓存

基树中,页索引相当于线性地址,但页索引中要考虑的字段的数量依赖于基树的深度。
如果基树的深度为 1,就只能表示从 0 ~ 63 范围的索引,因此页索引的低 6 位被解释为 slots 数组的下标,每个下标对应第一层的一个节点。
如果基树深度为2,就可以表示从 0 ~ 4085 范围的索引,页索引的低 12 位分成两个 6 位的字段,高位的字段表示第一层节点数组的下标,而低位的字段用于表示第二层节点数组的下标。依次类推。

如果基树的最大索引小于应该增加的页的索引,则内核相应地增加树的深度;基数的中间节点依赖于页索引的值。

页高速缓存的处理函数

查找页

find_get_page() 参数为指向 address_space 对象的指针和偏移量。
它获取地址空间的自旋锁,并调用 radix_tree_lookup() 搜索拥有指定偏移量的基树的叶子节点。
该函数根据偏移量中的位依次从树根开始向下搜索。
如果遇到空指针,返回 NULL;否则,返回叶子节点的地址,即所需要的页描述符指针。
如果找到所需要的页,增加该页的使用计数器,释放自旋锁,并返回该页的地址;否则,释放自旋锁并返回 NULL。

find_get_pages() 与 find_get_page() 类似,但它实现在高速缓存中查找一组具有相邻索引的页。
参数为指向 address_space 对象的指针、地址空间中相对于搜索起始位置的偏移量、所检索到页的最大数量、指向由该函数赋值的页描述符数组的指针。

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_or_create_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 结构不成功,则终止并返回错误码 -ENOMEM。

  1. 获取 mapping->tree_lock 自旋锁。
  2. 调用 radix_tree_insert() 在树中插入新节点,该函数执行如下操作:

a. 调用 radix_tree_maxindex() 获得最大索引,该索引可能被插入具有当前深度的基树;
如果新页的索引不能用当前深度表示,就调用 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。

  1. 增加页描述符的使用计数器 page->count。
  2. 由于页是新的,所以其内容无效:设置页框的 PG_locked 标志,以阻止其他的内核路径并发访问该页。
  3. 用 mapping 和 offset 参数初始化 page->mapping 和 page->index。
  4. 递增在地址空间所缓存页的计数器(mapping->nrpages)。
  5. 释放地址空间的自旋锁。
  6. 调用 radix_tree_preload_end() 重新启用内核抢占。
  7. 返回 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 分配器高速缓存。

  1. page->mapping = NULL。
  2. page->mapping->nrpages–。
  3. 释放自旋锁 page->mapping->tree_lock,打开中断,函数终止。

更新页

read_cache_page() 确保高速缓存中包括最新版本的指定页。
参数为指向 address_space 对象的指针 mapping、表示所请求页的偏移量的值 index、
指向从磁盘读取页数据的函数的指针 filter、传递给 filter 函数的指针 data(通常为 NULL)。

  1. 调用 find_get_page() 检查页是否已经在页高速缓存中。
  2. 如果页不在高速缓存中,则执行下列子步骤:

a. 调用 alloc_pages() 分配一个新页框。
b. 调用 add_to_page_cache() 在页高速缓存中插入相应的页描述符。
c. 调用 lru_cache_add() 把页插入该管理区的非活动 LRU 链表中。

  1. 此时,所请求的页已经在页高速缓存中了。调用 mark_page_accessed() 记录页已经被访问过的事实。
  2. 如果页不是最新的(PG_uptodate 标志为 0),就调用 filler 函数从磁盘读该页。
  3. 返回页描述符的地址。

基树的标记

页高速缓存不仅允许内核能快速获得含有块设备中指定数据的页,还允许内核从高速缓存中快速获得给定状态的页。

如,假设内核必须从高速缓存获得属于指定所有者的所有页和脏页。
存放在页描述符中的 PG_dirty 标志表示页是否是脏的。
但如果大多数页不是脏页,遍历整个基树的操作就太慢了。

为了能快速搜索脏页,基树中的每个中间节点都包含一个针对每个孩子的节点的脏标记,当且至少一个孩子节点的脏标记被置位时该标记被设置。
最底层节点的脏标记通常是页描述符的 PG_dirty 标志的副本。
通过这种方式,当内核遍历基树搜索脏页时,就可以跳过脏标记为 0 的中间节点的所有子树。

PG_writeback 标志同理,该标志表示页正在被写回磁盘。

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

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

radix_tree_delete() 从基树删除页描述符,并更新从根节点到叶子节点的路径中的相应标记。。

radix_tree_insert() 不更新标记,因为插入基树的所有页描述符的 PG_dirty 和 PG_writeback 标志都被认为是清 0 的。
如果需要,内核可随后调用 radix_tree_tag_set()。

radix_tree_tagged() 利用树的所有节点的标志数组测试基树是否至少包括一个指定状态的页:


1
2
3
4
5
6
7
8
1// 因为可能假设基树所有节点的标记都正确地更新过,所以只需要检查第一层的标记
2for(idx = 0; idx < 2; idx++)
3{
4   if(root->rnode->tags[tag][idx])
5       return 1;
6}
7
8

find_get_pages_tag() 和 find_get_pages() 类似,但前者返回的只是那些用 tag 参数标记的页。

把块存放在页高速缓存中

VFS(映射层)和各种文件系统以“块”的逻辑单位组织磁盘数据。

Linux 内核旧版本中,主要有两种不同的磁盘高速缓存:

  • 页高速缓存,存放访问磁盘文件内容时生成的磁盘数据页。
  • 缓冲区高速缓存,把通过 VFS 访问的块的内容保留在内存中。

后来,缓冲区高速缓存就不存在了,出现于效率,不再单独分配块缓冲区,而是把它们放在“缓冲区页”中,缓冲区页保存在页高速缓存中。

缓冲区页在形式上是与“缓冲区首部”的附加描述符相关的数据页,主要目的是快速确定页中的一个块在磁盘中的地址。
实际上,页高速缓存内的页的多个块的数据在磁盘上的地址不一定相邻。

块缓冲区和缓冲区首部

每个块缓冲区都有 buffer_head 类型的缓冲区首部描述符,包含内核必须了解的、有关如何处理块的所有信息。

buffer_head 的一些字段:

  • b_dev:包含块的块设备,通常是磁盘或分区。
  • b_blocknr:逻辑块号,即块在磁盘或分区中的编号。
  • b_data:块缓冲区在缓冲区页中的位置。

如果页在高端内存,则 b_data 存放的是块缓冲区相对于页的起始位置的偏移量,否则,b_data 存放块缓冲区的线性地址。

  • b_state:几个标志。

管理缓冲区首部

缓冲区首部有自己的 slab 分配器高速缓存,其描述符 kmem_cache_s 存在变量 bh_cachep 中。
alloc_buffer_head() 和 free_buffer_head() 分别获取和释放缓冲区首部。

buffer_head 的 b_count 字段是相应的块缓冲区的引用计数器。
每次对块缓冲区操作前递增计数器,操作后递减。
除了周期性地检查保持在页高速缓存中的块缓冲区外,当空闲内存变得很少时也检查它,当引用计数器为 0 时回收块缓冲区。

缓冲区页

只要内核必须单独地访问一个块,就要涉及存放块缓冲区的缓冲区页,并检查相应的缓冲区首部。

内核创建缓冲区页的两种普通情况:

  • 当读或写的文件页在磁盘块中不相邻时。因为文件系统为文件分配了非连续的块,或文件有“洞”。
  • 当访问一个单独的磁盘块时。如,当读超级块或索引节点块时。

第一种情况下,把缓冲区的描述符插入普通文件的基树;
保存好缓冲区首部,因为其中存有重要的信息,即数据在磁盘中位置的块设备和逻辑块号。

第二种情况下,把缓冲区页的描述符插入基树,树根是与块设备相关的特殊 bdev 文件系统中索引节点的 address_space 对象。
这种缓冲区页必须满足很强的约束条件,即所有的块缓冲区涉及的块必须是在块设备上相邻存放的。
接下来重点讨论该种情况,即块设备缓冲区页。

一个缓冲区页内的所有块缓冲区大小必须相同,因此,在 80×86 体系结构上,根据块的大小,一个缓冲页可包括 1 ~ 8 个缓冲区。

如果一个页作为缓冲区页使用,那么与它的块缓冲区相关的所有缓冲区首部都被收集在一个单向循环链表中。
缓冲区页描述符的 private 字段指向页中第一个块的缓冲区首部;每个缓冲区首部存放在 b_this_page 字段,该字段是指向链表中下一个缓冲区首部的指针。
每个缓冲区首部把缓冲区页描述符的地址存放在 b_page 字段。

深入理解 Linux 内核---页高速缓存

分配块设备缓冲区页

当内核发现指定块的缓冲区所在的页不在页高速缓存中时,就分配一个新的块设备缓冲区页。
特别是,对块的查找操作因以下原因而失败时:

  1. 包含数据块的页不在块设备的基树中:必须把新页的描述符加到基树中。
  2. 包含数据块的页在块设备的基树中,但该页不是缓冲区页:必须分配新的缓冲区首部,并将它链接到所属的页,从而把它变成块设备缓冲区页。
  3. 包含数据块的缓冲区页在块设备的基树中,但页中块的大小与所请求的块大小不同:必须释放旧的缓冲区首部,分配经过重新复制的缓冲区首部并将它链接到所属的页。

内核调用 grow_buffers() 把块设备缓冲区页添加到页高速缓存中,参数:

  • block_device 描述符的地址 bdev
  • 逻辑块号 block(块在块设备中的位置)
  • 块大小 size

执行下列操作:

  1. 计算数据页在所请求块的块设备中的偏移量 index。
  2. 如果需要,调用 grow_dev_page() 创建新的块设备缓冲区页。

a. 调用 find_or_create_page(),参数为块设备的 address_space 对象(bdev->bd_inode->i_mapping)、页偏移 index 及 GFP_NOFS 标志。
在页高速缓存中搜索需要的页,如果需要,就把新页插入高速缓存。
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. 返回页描述符地址。

  1. 为页解锁(find_or_create_page() 曾为页加了锁)。
  2. 递减页的使用计数器(find_or_create_page() 曾递增了计数器)。
  3. 返回 1(成功)。

释放块设备缓冲区页

try_to_release_page() 释放缓冲区页,参数为页描述符的地址 page,执行下述步骤:

  1. 如果设置了页的 PG_writeback 标志,则返回 0(正在把页写回磁盘,不能释放该页)。
  2. 如果已经定义了块设备 address_space 对象的 releasepage 方法,就调用它。
  3. 调用 try_to_free_buffers() 并返回它的错误码。

try_to_free_buffers() 依次扫描链接到缓冲区页的缓冲区首部,本质上执行下列操作:

  1. 检查页中所有缓冲区首部的标志。

如果有些缓冲区首部的 BH_Dirty 或 BH_Locked 标志置位,则不能释放这些缓冲区,函数终止并返回 0(失败)。

  1. 如果缓冲区首部在间接缓冲区的链表中,则从链表中删除它。
  2. 请求页描述符的 PG_private 标记,把 private 字段设置为 NULL,并递减页的使用计数器。
  3. 清除页的 PG_dirty 标记。
  4. 反复调用 free_buffer_head(),释放页的所有缓冲区首部。
  5. 返回 1(成功)。

在页高速缓存中搜索块

在页高速缓冲中搜索指定的块缓冲区(由块设备描述符的地址 bdev 和逻辑块号 nr 表示):

  1. 获取一个指针,让它指向包含指定块的的块设备的 address_space 对象(bdev->bd_inode->i_mapping)。
  2. 获得设备的块大小(bdev->bd_block_size),并计算包含指定块的页索引。

如,如果块的大小为 1024 字节,每个缓冲区包含四个块缓冲区,则页的索引为 nr/4。

  1. 在块设备的基树中搜索缓冲区页。获得页描述符后,内核访问缓冲区首部,它描述了页中块缓冲区的状态。

在实现中,为提高系统性能,内核维持一个小磁盘高速缓存数组 bh_lrus(每个 CPU 对应一个数组元素),即最近最少使用(LRU)块高速缓存。

_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 块高速缓存中,根据块号和块大小得到与块设备相关的页的索引:


1
2
3
1index = block >> (PAGE_SHIFT - bdev->bd_inode->i_blkbits);
2
3
  1. 调用 find_get_page() 确定包含所请求的块缓冲区的缓冲区页的描述符在页高速缓存中的位置。

参数:指向块设备的 address_space 对象的指针(bdev->bd_inode->i_mapping)和页索引。
页索引用于确定存有所请求的块缓冲区的缓冲区页的描述符在页高速缓存中的位置,没有时返回 NULL。

  1. 此时,已得到缓冲区页描述符的地址,扫描链接到缓冲区页的缓冲区首部链表,查找逻辑块号等于 block 的块。
  2. 递减页描述符的 count 字段(find_get_page() 曾递增过)。
  3. 把 LRU 块高速缓存中的所有元素向下移动一个位置,并把指向所请求块的缓冲区首部的指针插入到第一个位置。

如果一个缓冲区首部已经不在 LRU 块高速缓存中,就递减它的引用计数器 b_count。

  1. 如果需要,调用 mark_page_accessed() 把缓冲区页移到适当的 LRU 链表中。
  2. 返回缓冲区首部指针。

__getblk()

参数:block_device 描述符的地址 bdev、块号 block 和块大小 size。
返回与缓冲区对应的缓冲区首部的地址。
如果块不存在,分配块设备缓冲区页并返回将要描述块的缓冲区首部的指针。
__getblk() 返回的块缓冲区不必包含有效数据—缓冲区首部的 BH_Uptodate 标志可能被清 0。

  1. 调用 __find_get_block() 检查块是否已经在页高速缓存中,如果找到,返回其缓冲区首部的地址。
  2. 否则,调用 grow_buffers() 为所请求的页分配一个新的缓冲区页。
  3. 如果上一步分配失败,调用 free_more_memory() 回收一部分内存。
  4. 跳到第 1 步。

__bread()

参数:block_device 描述符的地址 bdev、块号 block 和块大小 size。
返回与缓冲区对应的缓冲区首部的地址。
如果需要,在返回缓冲区首部前,从磁盘读块。

  1. 调用 __getblk() 在页高速缓存中查找与所请求的块相关的缓冲区页,并获得指向相应的缓冲区首部的指针。
  2. 如果块已经在页高速缓冲中并包含有效数据(BH_Uptodate 标志被置位),就返回缓冲区首部的地址。
  3. 否则,递增缓冲区首部的引用计数器。
  4. b_end_io = end_buffer_read_sync() 的地址。
  5. 调用 submit_bh() 把缓冲区首部传送给通用块层。
  6. 调用 wait_on_buffer() 把当前进程插入等待队列,直到 I/O 操作完成,即直到缓冲区首部的 BH_Lock 标志被清 0。
  7. 返回缓冲区首部的地址。

向通用块层提交缓冲区首部

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

submit_bh()

向通过块层传递一个缓冲区首部,并由此请求传输一个数据块。
参数为数据传输的方向(READ 或 WRITE)和指向描述符块缓冲区的缓冲区首部的指针 bh。

submit_bh() 只是一个起连接作用的函数,它根据缓冲区首部的内容创建一个 bio 请求,随后调用generic_make_request()。

  1. 设置缓冲区首部的 BH_Req 标志表示块至少被访问过一次。如果数据传输方向为 WRITE,将 BH_Write_EIO 标志清 0。
  2. 调用 bio_alloc() 分配一个新的 bio 描述符。
  3. 根据缓冲区首部的内容初始化 bio 描述符的字段:

a. bi_sector = bh->blocknr * bh->b_size / 512,块中的第一个扇区的号。
b. bi_bdev = bh->b_bdev,块设备描述符的地址。
c. bi_size = bh->b_size,块大小。
d. 初始化 bi_io_vec 数组的第一个元素,使该段对应于块缓冲区:
bi_io_vec[0].bv_page = bh->b_page,bi_io_vec[0].bv_len = bh_b_size,
bi_bio_vec[0].bv_offset = bh->b_data,块缓冲区在页中的偏移量。
e. bi_vnt = 1(只涉及一个 bio 的段),bi_idx = 0(将要传输的当前段)。
f. bi_end_io = end_bio_bh_io_sync() 的地址,bi_private = 缓冲区首部的地址,数据传输结束时调用该函数。

  1. 递增 bio 的引用计数器。
  2. 调用 submit_bio(),把 bi_rw 标志设置为数据传输的方向,更新每 CPU 变量 page_states 以表示读和写的扇区数,并对 bio 描述符调用 generic_make_request()。
  3. 递减 bio 的使用计数器;因为 bio 描述符现在已经被插入 I/O 调度程序的队列,所以没有释放 bio 描述符。
  4. 返回 0(成功)。

对 bio 上的 I/O 传输终止时,内核执行 bi_end_io 方法,即 end_bio_bh_io_sync(),本质上从 bio 的 bi_private 字段获取缓冲区首部的地址,然后调用缓冲区首部的方法 b_end_io,最后调用 bio_put() 释放 bio 结构。

ll_rw_block

要传输的几个数据块不一定物理上相邻。

参数由数据传输的方向(READ 或 WRITE)、要传输的数据块的块号、指向块缓冲区所对应的缓冲区首部的指针数组。

该函数在所有缓冲区首部上循环,每次循环执行下列操作:

  1. 检查并设置缓冲区首部的 BH_Lock 标志;如果缓冲区已经被锁住,说明另一个内核控制路径已经激活了数据传输,则不处理该缓冲区。
  2. 把缓冲区首部的使用计数器 b_count 加 1。
  3. 如果数据传输的方向是 WRITE,就让缓冲区首部的方法 b_end_io 指向 end_buffer_write_sync() 的地址,否则,指向 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 字段。

当块的数据传送结束,内核执行缓冲区首部的 b_end_io 方法。
如果没有 I/O 错误,end_buffer_write_sync() 和 end_buffer_read_snyc() 至少简单地把缓冲区首部的 BH_Uptodate 字段置位,为缓冲区解锁,并递减它的引用计数器。

把脏页写入磁盘

只要进程修改了数据,相应的页就被标记为脏页,其 PG_dirty 标志置位。

在下列条件下把脏页写入磁盘:

  • 页高速缓存变得太满,但还需要更多的页,或脏页的数据已经太多。
  • 自从页变成脏页以来已经过去太长时间。
  • 进程请求对块设备或特定文件任何带动的变化都进行刷新。通过调用 sync()、fsync() 或 fdatasync() 实现。

与每个缓冲区页相关的缓冲区首部使内核能了解每个独立块缓冲区的状态。
如果至少有一个缓冲区首部的 BH_Dirty 标志被置位,就设置相应缓冲区页的 PG_dirty 标志。
当内核选择要刷新的缓冲区页时,它扫描相应的缓冲区首部,并只把脏块的内容写到磁盘。
一旦内核把缓冲区的所有脏页刷新到磁盘,就把页的 PG_dirty 标记清 0。

pdflush 内核线程

pdflush 内核线程通常执行下面的回调函数之一:

  • background_writeout():系统地扫描页高速缓存以搜索要刷新的脏页。
  • wb_kupdate():检查页高速缓冲中是否有“脏”了很长时间的页。

参数:一个执行线程要执行的函数的指针和一个函数要用的参数。
系统中 pdflush 内核线程的数量是要动态调整的,太少时创建,太多时杀死。

根据以下原则控制 pdflush 线程的产生和消亡:

  • 必须有至少两个,最多八个 pdflush 内核线程。
  • 如果到最近的 1s 期间没有空闲 pdflush,就应创建新的 pdflush。
  • 如果最近一次 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_INTERRUPTILE 状态睡眠。
一旦内核线程被唤醒,__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 指针,它指向空闲 pdflush 内核线程的 pdflush_work 描述符。

如果链表为空,返回 -1。如果链表中仅剩一个元素,就把 jiffies 的值赋给变量 last_empty_jifs。

  1. pdf->fn = fn;pdf->arg0 = arg0。
  2. 调用 wake_up_process() 唤醒空闲的 pdflush 内核线程,即 pdf->who。

搜索要刷新的脏页

wakeup_bdflush() 参数为页高速缓存中应该刷新的脏页数量;0 表示高速缓存中的所有脏页都应该写回磁盘。
该函数调用 pdflush_operation() 唤醒 pdflush 内核线程,并委托它执行回调函数 background_writeout(),
以有效地从页高速缓存获得指定数量的脏页,并把它们写回磁盘。

内存不足或用户显式地请求刷新操作时执行 wakeup_bdflush(),特别是以下情况:

  • 用户发出 sync() 系统调用。
  • grow_buffers() 分配一个新缓冲区页时失败。
  • 页框回收算法调用 free_more_memory() 或 try_to_free_pages()。
  • mempool_alloc() 分配一个新的内存池元素时失败。

执行 background_writeout() 回调函数的 pdflush 内核线程是被满足以下两个条件的进程唤醒的:

  • 对页高速缓存中页的内容进行了修改。
  • 引起脏页部分增加到超过某个脏阈值。

脏阈值通常设置为系统中所有页的 10%,但可通过修改文件 /proc/sys/vm/dirty_background_ratio 来调整该值。

background_writeout() 依赖于作为双向通信设备的 writeback_control 结构:
一方面,它告诉辅助函数 writeback_indoes() 要做什么;
另一方面,它保存磁盘的页的数量的统计值。

writeback_control 的重要字段:

  • sync_mode:表示同步模式:

WB_SYNC_ALL 表示如果遇到一个上锁的索引节点,必须等待而不能忽略它;
WB_SYNC_HOLD 表示把上锁的索引节点放入稍后涉及的链表中;
WB_SYNC_NONE 表示简单地忽略上锁的索引节点。

  • bid:如果不为空,就指向 backing_dev_info 结构。此时,只有属于基本块设备的脏页会被刷新。
  • older_than_this:如果不为空,就表示应该忽略比指定值还新的索引节点。
  • nr_to_write:当前执行流中仍然要写的脏页的数量。
  • nonblocking:如果这个标志被置位,就不能阻塞进程。

background_writeout() 参数为 nr_pages,表示应该刷新到磁盘的最少页数。

  1. 从每 CPU 变量 page_state 中读当前页高速缓存中页和脏页的数。

如果脏页的比例低于给定的阈值,且已经至少有 nr_pages 页被刷新到磁盘,则终止。
该阈值通常为系统中总页数的 40%,可通过文件 /proc/sys/vm/dirty_ratio 调整该值。

  1. 调用 writeback_inodes() 尝试写 1024 个脏页。
  2. 检查有效写过的页的数量,并减少需要写的页的个数。
  3. 如果已经写过的页少于 1024 页,或忽略了一些页,则块设备的请求队列处于拥塞状态:

此时,使当前进程在特定的等待队列上睡眠 100ms 或直到队列不拥塞。

  1. 返回第 1 步。

writeback_inodes() 参数为指针 wbc,指向 writeback_control 描述符。
该描述符的 nr_to_write 字段存有要刷新到磁盘的页数。
函数返回时,该字段存有要刷新到磁盘的剩余页数,如果一切顺利,该字段为 0。

假设 writeback_inodes() 被调用的条件为:
指针 wbc->bdi 和 wbc->older_than_this 被置为 NULL,WB_SYNC_NONE 同步模式和 wbc->nonblocking 标志置位。

writeback_inodes() 扫描在 super_blocks 变量中建立的超级块链表。
当遍历完整个链表或刷新的页的数量达到预期数量时,就停止扫描。
对每个超级块 sb 执行下述步骤:

  1. 检查 sb->s_dirty 或 sb->s_io 链表是否为空:

第一个链表集中了超级块的脏索引节点,第二个链表集中了等待被传送到磁盘的索引节点。
如果两个来链表为空,说明相应文件系统的索引节点没有脏页,因此处理链表中的下一个超级块。

  1. 此时,超级块有脏索引节点。对超级块 sb 调用 sync_sb_inodes(),该函数执行下面的操作:

a. 把 sb->s_dirty 的所有索引节点插入 sb->s_io 指向的链表,并清空脏索引节点链表。
b. 从 sb->s_io 获得下一个索引节点的指针。如果链表为空,就返回。
c. 如果 sync_sb_inodes() 开始执行后,索引节点变为脏节点,就忽略这个索引节点的脏页并返回。
d. 如果当前进程是 pdflush 内核线程,sync_sb_inodes() 就检查运行在另一个 CPU 上的 pdflush 内核线程是否已经试图刷新这个块设备文件的脏页。
这是通过一个原子测试和对索引节点的 backing_dev_info 的 BDI_pdflush 标志的设置操作完成的。
e. 把索引节点的引用计数器加 1。
f. 调用 __writeback_single_inode() 回写与所选择的索引节点相关的脏缓冲区:
(1)如果索引节点被锁定,就把它移到脏索引节点链表中(inode->i_sb->s_dirty)并返回 0。
(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() 终止。

  1. 回到 writeback_inodes() 中。如果 wbc->nr_to_write 大于 0,就跳到第 1 步,并继续处理全局链表中的下一个超级块;否则返回。

回写陈旧的脏页

脏页在保留一定时间后,内核就显式地开始进行 I/O 数据的传输,把脏页的内容写到磁盘。

回写陈旧脏页的工作委托给了被定期唤醒的 pdflush 内核线程。
在内核初始化期间, page_writeback_init() 建立 wb_timer 动态定时器,以便定时器的到期时间发生在 dirty_writeback_ccentisecs 文件中规定的几百分之一秒后。
定时器函数 wb_timer_fn() 本质上调用 pdflush_operation(),传递给它的参数是回调函数 wb_kupdate() 的地址。

wb_kupdate() 遍历页高速缓存搜索陈旧的脏索引节点,它执行下面的步骤:

  1. 调用 sync_supers() 把脏的超级块写到磁盘。sync_supers() 确保了任何超级块脏的时间通常不会超过 5s。
  2. 把当前时间减 30s 所对应的值(用 jiffies 表示)的指针存放在 writeback_control 描述符的 older_than_this 字段中。

允许一个页保持脏状态的最长时间为 30s。

  1. 根据每 CPU 变量 page_state 确定当前在页高速缓存中脏页的大概数量。
  2. 反复调用 writeback_inodes(),直到写入磁盘的页数等于上一步所确定的值,或直到把所有保持脏状态时间超过 30s 的页都写到磁盘。

如果在循环的过程中一些请求队列变得拥塞,函数就可能睡眠。

  1. 用 mod_timer() 重新启动 wb_timer 动态定时器:一旦从调用该函数开始经历过文件 dirty_writeback_centisecs 中规定的几百分之一秒时间后,定时器到期。

sync()、fsync() 和 fdatasync() 系统调用

sync():允许进程把所有脏缓冲区刷新到磁盘。
fsync():允许进程把属于特定打开文件的所有块刷新到磁盘。
fdatasync():与 fsync() 相似,但不刷新文件的索引节点块。

sync()

sync() 的服务例程 sys_sync() 调用一系列辅助函数:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
1// 启动 pdflush 内核线程,把页高速缓存中的所有脏页刷新到磁盘
2wakeup_bdflush(0);  
3
4// 扫描超级块的链表以搜索要刷新的脏索引节点
5// 对于每个包含脏索引节点的超级块,首先调用 sync_sb_inodes() 刷新相应的脏页
6// 然后调用 sync_blockdev() 显式刷新该超级块所在块设备的脏缓冲页
7// 这一步之所以能完成是因为许多磁盘文件系统的 write_inode 超级块方法仅仅把磁盘索引节点对应的块缓冲区标记为“脏”
8// sync_blockdev() 确保把 sync_sb_inodes() 所完成的更新有效地写到磁盘
9sync_inodes(0);  
10
11// 把脏超级块写到磁盘,如果需要,也可以使用适当的 write_super 超级块操作
12sync_supers();
13
14// 为所有可写的文件系统执行 sync_fs 超级块方法
15// 该方法是提供给文件系统的一个“钩子”,需要对每个同步执行一些特殊操作时使用
16sync_filesystems(0);
17
18// sync_inodes() 和 sync_filesystems() 都被调用两次
19// 一次是参数 wait 等于 0 时,另一次是等于 1
20// 目的是,首先,把未上锁的索引节点快速刷新到磁盘
21// 其次,等待所有上锁的索引节点被解锁,然后把它们逐个写到磁盘
22sync_filesystems(1);
23sync_inodes(1);
24
25

fsync() 和 fdatasync()

fsync() 强制内核把文件描述符参数 fd 所指定文件的所有脏缓冲区写到磁盘中(如果需要,还包括存有索引节点的缓冲区)。
相应的服务例程获得文件对象的地址,并随后调用 fsync 方法。
通常,该方法以调用 __write_back_single_inode() 结束,该函数把与被选中的索引节点相关的脏页和索引节点本身都写回磁盘。

fdatasync() 和 fsync() 很像,但它只把包含文件数据而不是那些包含索引节点信息的缓冲区写到磁盘。
Linux 2.6 中,没有提供专门的 fdatasync() 文件方法,而是调用 fsync 方法,因此与 fsync() 相同。

给TA打赏
共{{data.count}}人
人已打赏
安全运维

WordPress网站专用docker容器环境带Waf

2020-7-18 20:04:44

安全运维

运维安全-Gitlab管理员权限安全思考

2021-9-19 9:16:14

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索