Linux Kernel - The Page Cache and Page Writeback

Table of Contents

页高速缓存(page cache)是 Linux 内核实现的磁盘缓存。它主要用来减少对磁盘的 I/O 操作。具体地讲,是通过把磁盘中的数据缓存到物理内存中,把对磁盘的访问变为对物理内存的访问。本文将讨论页高速缓存与页回写(将页高速缓存中的变更数据刷新回磁盘的操作)。

磁盘高速缓存之所以在任何现代操作系统中尤为重要源自两个因素:第一,访问磁盘的速度要远远低于(差好几个数量级)访问内存的速度——ms 和 ns 的差距,因此,从内存访问数据比从磁盘访问速度更快,若从处理器的 L1 和 L2 高速缓存访问则更快。第二,数据一旦被访问,就很有可能在短期内再次被访问到。这种在短时期内集中访问同一片数据的原理称作临时局部原理(temporal locality)临时局部原理能保证:如果在第一次访问数据时缓存它,那就极有可能在短期内再次被高速缓存命中(访问到高速缓存中的数据)。正是由于内存访问要比磁盘访问快得多,再加上数据一次被访问后更可能再次被访问的特点,所以磁盘的内存缓存将给系统存储性能带来质的飞跃。

1. 缓存手段

页高速缓存是由内存中的物理页组成的,物理页的内容对应磁盘上的物理块。页高速缓存大小能动态调整它可以通过占用空闲内存以扩张大小,也可以自我收缩以缓解内存使用压力。我们称正被缓存的存储设备为后备存储,因为缓存背后的磁盘无疑才是所有缓存数据的归属。当内核开始一个读操作(比如,进程发起一个 read 系统调用),它首先会检查需要的数据是否在页高速缓存中。如果在,则放弃访问磁盘,而直接从内存中读取。这个行为称作缓存命中。如果数据没有在缓存中,称为缓存未命中,那么内核必须调度块 I/O 操作从磁盘去读取数据。然后内核将读来的数据放入页缓存中,于是任何后续相同的数据读取都可命中缓存了。注意,系统并不一定要将整个文件都缓存。缓存可以持有某个文件的全部内容,也可以存储另一些文件的一页或者几页。到底该缓存谁取决于谁被访问到。

1.1. 写缓存

上面解释了在读操作过程中页高速缓存的作用,那么在进程写磁盘时,比如执行 write 系统调用,缓存如何被使用呢?通常来讲,缓存一般被实现成下面三种策略之一。

第一种策略,称为不缓存(nowrite),也就是说高速缓存不去缓存任何写操作。 当对一个缓存中的数据片进行写时,将直接跳过缓存,写到磁盘,同时也使缓存中的数据失效。 那么如果后续读操作进行时,需要再重新从磁盘中读取数据。不过这种策略很少使用,因为该策略不但不去缓存写操作,而且需要额外费力去使缓存数据失效。

第二种策略,写操作将自动更新内存缓存,同时也更新磁盘文件。 这种方式,通常称为写透缓存(write-through cache),因为写操作会立刻穿透缓存到磁盘中。这种策略对保持缓存一致性很有好处——缓存数据时刻和后备存储保持同步,所以不需要让缓存失效,同时它的实现也最简单。

第三种策略,也是 Linux 所采用的,称为“回写(writeback)”。 在这种策略下,程序执行写操作直接写到缓存中,后端存储不会立刻直接更新,而是将页高速缓存中被写入的页面标记成“脏(dirty)”,并且被加入到脏页链表中。然后由一个进程(回写进程)周期行将脏页链表中的页写回到磁盘,从而让磁盘中的数据和内存中最终一致。最后清理“脏”页标识。注意, 这里“脏页”这个词可能引起混淆,因为实际上脏的并非页高速缓存中的数据(它们是干干净净的),而是磁盘中的数据(它们已过时了)。也许更好的描述应该是“未同步”吧。 尽管如此,我们说缓存内容是脏的,而不是说磁盘内容。回写策略通常认为要好于写透策略,因为通过延迟写磁盘,方便在以后的时间内合并更多的数据和再一次刷新。当然,其代价是实现复杂度高了许多。

1.2. 缓存回收

缓存算法最后涉及的重要内容是缓存中的数据如何清除;或者是为更重要的缓存项腾出位置;或者是收缩缓存大小,腾出内存给其他地方使用。这个工作,也就是决定缓存中什么内容将被清除的策略,称为缓存回收策略。 Linux 的缓存回收是通过选择干净页(不脏)进行简单替换。如果缓存中没有足够的干净页面,内核将强制地进行回写操作,以腾出更多的干净可用页。 最难的事情在于决定什么页应该回收。理想的回收策略应该是回收那些以后最不可能使用的页面。当然要知道以后的事情你必须是先知。也正是这个原因,理想的回收策略称为预测算法。但这种策略太理想了,无法真正实现。

1.2.1. 最近最少使用

缓存回收策略通过所访问的数据特性,尽量追求预测效率。最成功的算法(特别是对于通用目的的页高速缓存)称作“最近最少使用”算法,简称 LRU。LRU 回收策略需要跟踪每个页面的访问踪迹(或者至少按照访问时间为序的页链表),以便能回收最老时间戳的页面(或者回收排序链表头所指的页面)。该策略的良好效果源自于缓存的数据越久未被访问,则越不大可能近期再被访问,而最近被访问的最有可能被再次访问。但是, LRU 策略并非是放之四海而皆准的法则,对于许多文件被访问一次,再不被访问的情景,LRU 尤其失败。 将这些页面放在 LRU 链的顶端显然不是最优,当然,内核并没办法知道一个文件只会被访问一次,但是它却知道过去访问了多少次。

1.2.2. 双链表策略

Linux 实现的是一个修改过的 LRU,也称为“双链表策略”。 和以前不同,Linux 维护的不再是一个 LRU 链表,而是维护两个链表:活跃链表(active list)和非活跃链表(inactive list)。处于活跃链表上的页面被认为是“热”的且不会被换出,而在非活跃链表上的页面则是可以被换出的。 当一个 page 被访问并且已经在 inactive list 里了才会移到 active list 中。 两个链表都被伪 LRU 规则维护:页面从尾部加入,从头部移除,如同队列。两个链表需要维持平衡——如果活跃链表变得过多而超过了非活跃链表,那么活跃链表的头页面将被重新移回到非活跃链表中,以便能再被回收。 双链表策略解决了传统 LRU 算法中对仅一次访问的窘境。 而且也更加简单的实现了伪 LRU 语义。这种双链表方式也称作 LUR/2。更普遍的是 n 个链表,故称 LRU/n。

我们现在知道页缓存如何构建(通过读和写),如何在写时被同步(通过回写)以及旧数据如何被回收来容纳新数据(通过双链表)。现在让我们看看真实世界应用场景中,页高速缓存如何帮助系统。假定你在开发一个很大的软件工程(比如 Linux 内核)那么你将有大量的源文件被打开,只要你打开读取源文件,这些文件就将被存储在页高速缓存中。只要数据被缓存,那么从一个文件跳到另一个文件将瞬间完成。当你编辑文件时,存储文件也会瞬间完成,因为写操作只需要写到内存,而不是磁盘。当你编译项目时,缓存的文件将使得编译过程更少访问磁盘,所以编译速度也就更快了。如果整个源码树太大了,无法一次性放入内存,那么其中一部分必须被回收由于双链表策略,任何回收的文件都将处于非活跃链表,而且不大可能是你正在编译的文件。幸运的是,在你没在编译的时候,内核会执行页回写,刷新你所修改文件的磁盘副本由此可见,缓存将极大地提高系统性能。为了看到差别,对比一下缓存冷(cache cold)时(也就是说重启后,编译你的大软件工程的时间)和缓存热(cache warm)时的差别吧。

2. Linux 页高速缓存

从名字可以看出,页高速缓存缓存的是内存页面。缓存中的页来自对正规文件、块设备文件和内存映射文件的读写。如此一来,页高速缓存就包含了最近被访问过的文件的数据块。在执行一个操作前(比如操作),内核会检查数据是否已经在页高速缓存中了,如果所需要的数据确实在高速缓存中,那么内核可以从内存中迅速地返回需要的页,而不再需要从相对较慢的磁盘上读取数据。在接下来的章节里我们将剖析具体的数据结构以及内核如何使用它们管理缓存。

2.1. address_space 对象

在页高速缓存中的页可能包含了多个不连续的物理磁盘块。也正是由于页面中映射的磁盘块不一定连续,所以在页高速缓存中检查特定数据是否已经被缓存是件颇为困难的工作。因为不能用设备名称和块号来做页高速缓存中的数据的索引,要不然这将是最简单的定位办法。

另外,Linux 页高速缓存对被缓存的页面范围定义非常宽泛。实际上,在最初 System V Release 4 引入页高速缓存时,仅仅只用作缓存文件系统数据,所以 SVR4 的页高速缓存使用它的等价文件对象(称为 vnode 结构体)管理页高速缓存。 Linux 页高速缓存的目标是缓存“任何基于页的对象”,这包含各种类型的文件和各种类型的内存映射。

虽然 Linux 页高速缓存可以通过扩展 inode 结构体支持页 I/O 操作,但这种做法会将页高速缓存局限于文件。 为了维持页高速缓存的普遍性(不应该将其绑定到物理文件或者 inode 结构体), Linux 页高速缓存使用了一个新对象管理缓存项和页 I/O 操作。这个对象是 address_space 结构体。该结构体是虚拟地址 vm_area_struct 的物理地址对等体。当一个文件可以被 10 个 vm_area_struct 结构体标识(比如有 5 个进程,每个调用 mmap() 映射它两次),那么这个文件只能有一个 address_space 数据结构——也就是文件可以有多个虚拟地址,但是只能在物理内存有一份。 与 Linux 内核中其他结构一样, address_space 也是文不对题,也许更应该叫它 page_cache_entity 或者 physical_pages_of_a_file

该结构定义在文件<linux/fs.h>中,下面给出具体形式:

struct address_space {
    struct inode            *host;             /* owning inode */
    struct radix_tree_root  page_tree;         /* radix tree of all pages */
    spinlock_t              tree_lock;         /* page_tree lock */
    unsigned int            i_mmap_writable;   /* VM_SHARED ma count */
    struct prio_tree_root   i_mmap;            /* list of all mappings */
    struct list_head        i_mmap_nonlinear;  /* VM_NONLINEAR ma list */
    spinlock_t              i_mmap_lock;       /* i_mmap lock */
    atomic_t                truncate_count;    /* truncate re count */
    unsigned long           nrpages;           /* total number of pages */
    pgoff_t                 writeback_index;   /* writeback start offset */
    struct address_space_operations *a_ops;    /* operations table */
    unsigned long           flags;             /* gfp_mask and error flags */
    struct backing_dev_info *backing_dev_info; /* read-ahead information */
    spinlock_t              private_lock;      /* private lock */
    struct list_head        private_list;      /* private list */
    struct address_space    *assoc_mapping;    /* associated buffers */
};

其中 i_mmap 字段是一个“优先搜索树”,它的搜索范围包含了在 address_space 中所有共享的与私有的映射页面。优先搜索树是一种巧妙地将堆与基树(radix)结合的快速检索树。 回忆早些提到的:一个被缓存的文件只和一个 address_space 结构体相关联,但它可以有多个 vm_area_struct 结构体——一物理页到虚拟页是个一对多的映射。 i_mmap 字段可帮助内核高效地找到关联的被缓存文件。

address_space 页总数由 nrpages 字段描述。

address_space 结构往往会和某些内核对象关联。通常情况下,它会与一个索引节点(inode)关联,这时 host 域就会指向该索引节点;如果关联对象不是一个索引节点的话,比如 address_spaceswapper 关联时, host 域会被置为 NULL。

3. The Buffer Cache

独立的磁盘块通过块 I/O 缓冲也要被存入页高速缓存。 一个缓冲(buffer)是一个物理磁盘块在内存里的表示。缓冲的作用就是映射内存中的页面到磁盘块,这样一来页高速缓存在块 I/O 操作时也减少了磁盘访问,因为它缓存磁盘块和减少块 I/O 操作。 这个缓存通常称为缓冲区缓存(buffer cache),虽然实现上它没有作为独立缓存,而是作为页高速缓存的一部分。

块 I/O 操作一次操作一个单独的磁盘块。普遍的块 I/O 操作是读写 inode。内核提供了 bread() 函数实现从磁盘读一个块的底层操作。通过缓存,磁盘块映射到它们相关的内存页,并缓存到页高速缓存中。

缓冲(buffer)和页(page)高速缓存并非天生就是统一的,2.4 内核的主要工作之一就是统一它们。 在更早的内核中,有两个独立的磁盘缓存:页高速缓存和缓冲区高速缓存。前者缓存页面,后者缓存缓冲区,这两个缓存并没有统一。一个磁盘块可以同时存于两个缓存中,这导致必须同步操作两个缓冲中的数据,而且浪费了内存,去存储重复的缓存项。今天我们只有一个磁盘缓存,即页高速缓存。虽然如此,内核仍然需要在内存中使用缓冲来表示磁盘块,幸好,缓冲是用页映射块的,所以它正好在页高速缓存中。

4. flusher 线程

由于页高速缓存的缓存作用,写操作实际上会被延迟。当页高速缓存中的数据比后台存储的数据更新时,该数据就称作脏数据。在内存中累积起来的脏页最终必须被写回磁盘。在以下 3 种情况发生时,脏页被写回磁盘:

  • 当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘以便释放内存,因为只有干净(不脏的)内存才可以被回收。当内存净后,内核就可以从缓存清理数据,然后收缩缓存,最终释放出更多的内存。
  • 当脏页在内存中驻留时间超过一个特定的阈值时,内核必须将超时的脏页写回磁盘,以确保脏页不会无限期地驻留在内存中。
  • 当用户进程调用 sync()fsync() 系统调用时,内核会按要求执行回写动作。

上面三种工作的目的完全不同。实际上,在旧内核中,这是由两个独立的内核线程(请看后文)分别完成的。但是在 2.6 内核中,由一群内核线程(flusher 线程)执行这三种工作。

系统管理员可以在 /proc/sys/vm 中设置回写相关的参数,也可以通过 sysctl 系统调用设置它们。表 1 列出了而使回写的相关设置项。

Table 1: Page Writeback Settings
变量 描述
dirty_background_ratio 占全部内存的百分比。当内存中空闲页达到这个比例时 flusher 开始回写脏页
dirty_expire_interval 该数值以百分之一秒为单位,它描述超时多久的数据将被周期性执行的 flusher 线程写出
dirty_ratio 占全部内存百分比,当一个进程产生的脏页达到这个比例时,就开始被写出
dirty_writeback_interval 该数值以百分之一秒为单位,它描述 flusher 线程的运行频率
laptop_mode 一个布尔值,用于控制膝上型计算机模式,具体请见后续内容

注 1:本文是基于 2.6 内核介绍的,在较新的内核中,已经没有单独的 flusher 线程了,它的工作由线程 [kworker/#.##] 来完成。
注 2:在较新的内核中,dirty_expire_interval/dirty_writeback_interval 被重新命名为了 dirty_expire_centisecs/dirty_writeback_centisecs。

4.1. Laptop Mode

膝上型计算机模式(Laptop Mode)是一种特殊的页回写策略,该策略主要意图是将硬盘转动的机械行为最小化,允许硬盘尽可能长时间地停滞,以此延长电池供电时间。该模式可通过 /proc/sys/vm/laptop_mode 文件进行配置。通常,上述配置文件内容为 0,也就是说膝上型计算机模式关闭,如果需要启用膝上型计算机模式,则向配置文件中写入 1。

膝上型计算机模式的页回写行为与传统方式相比只有一处变化。除了当缓存中的页面太旧时要执行回写脏页以外,flusher 还会找准磁盘运转的时机,把所有其他的物理磁盘 I/O、刷新脏缓冲等通通写回到磁盘,以便保证不会专门为了写磁盘而去主动激活磁盘运行。

上述回写行为变化要求 dirty_expire_interval 和 dirty_writeback_interval 两阈值必须设置得更大,比如 10 分钟。因为磁盘运转并不很频繁,所以用这样长的回写延迟就能保证膝上型计算机模式可以等到磁盘运转机会写入数据。因为关闭磁盘驱动器是节电的重要手段, Laptop Mode 可以延长膝上计算机依靠电池的续航能力。其坏处则是系统崩溃或者其他错误会使得数据丢失。

多数 Linux 发布版会在计算机接上电池或拔掉电池时,自动开启或禁止膝上型计算机模式以及其他需要的回写可调节开关。因此机器可在使用电池电源时自动进入膝上型计算机模式,而在插上交流电源时恢复到常规的页回写模式。

4.2. 历史上的 bdflush、kupdated 和 pdflush

在 2.6 版本前,flusher 线程的工作是分别由 bdflush 和 kupdated 两个线程共同完成。当可用内存过低时,bdflush 内核线程在后台执行脏页回写操作。类似 flusher,它也有一组值参数,当系统中空闲内存消耗到特定阈值以下时,bdflush 线程就被 wakeup_bdflush() 函数唤醒。

在 2.6 内核中, buflush 和 kupdated 已让路给了 pdflush 线程 page dirty flush 的缩写。flusher 线程在 2.6.32 内核系列中取代了 pdflush 线程。

前面提到过,较新内核中没有单独的 flusher 线程了,它的工作由线程 [kworker/#.##] 来完成。

5. 参考

本文摘自《Linux 内核设计与实现,第 3 版》

Author: cig01

Created: <2018-10-28 Sun>

Last updated: <2020-06-07 Sun>

Creator: Emacs 27.1 (Org mode 9.4)