Linux 缓存与页交换

引言

本文整理了 Linux 内核中缓存与页交换的相关知识,其他 Linux 相关文章均收录于 <Linux系列文章>

缓存与页交换

从外部存储设备读取数据,比从物理设备读取数据要慢得多,因此 Linux 使用了缓存机制将已经读取的数据保存在内存中,供后续访问使用。而外部存储设备相较于内存来说容量一般大很多,所以当物理内存不够时,会将一部分物理内存中的内容暂存在外部存储中,在有需要的时候再取回来。这两个主题就是缓存与页交换,它们之间十分相似,又有很多联系,可以说缓存是页交换的逆操作,接下来我们就将详细地介绍一下它们。

页缓存和块缓存

缓存利用部分系统物理内存,确保最重要、最常使用的块设备数据在操作时可直接从主内存获取,而无须从低速设备读取。物理内存还用于存储从块设备读取的数据,使得随后对该数据的访问可直接在物理内存进行,而无须从外部设备内取用。

当然,这项工作是透明的,应用程序不会也不能注意到数据来源方面的差别。数据并非在每次修改后都立即写回,而是在一定的时间间隔之后才进行回写,时间间隔的长度取决于多种因素,如空闲物理内存的容量、物理内存中数据的利用率,等等。在进行回写时,单个的写请求会被收集起来,并打包进行,这在总体上花费的时间较少,因而,延迟写操作在总体上改进了系统性能,这就是我们前面介绍块设备时提到的 IO 调度。

但是,缓存也有负面效应,我们必须审慎地使用它:

  • 通常,物理内存的容量比块设备小得多,因而只能缓存仔细挑选的部分数据。
  • 用于缓存的内存区不能分配给“普通”的应用程序使用,这就减少了实际上可用的物理内存容量。
  • 如果系统崩溃(例如,由于停电),缓存包含的数据可能没有写回到底层的块设备。这造成了不可恢复的数据丢失。

内核为块设备提供了两种通用的缓存,它们分别是:

  • 页缓存(page cache)针对以页为单位的所有操作,并考虑了特定体系结构上的页长度。因为其他类型的文件访问也是基于内核中的页缓存实现的,所以页缓存实际上负责了块设备的大部分缓存工作。
  • 块缓存(buffer cache)以块为操作单位。在进行 I/O 操作时,存取的单位是设备的各个块,而不是整个内存页,尽管页长度对所有文件系统都是相同的,但块长度取决于特定的文件系统或其设置。因而,块缓存必须能够处理不同长度的块。

在许多场合下,页缓存和块缓存是联合使用的,一个缓存的页可以在写操作期间划分为不同的缓冲区(写操作时也被称为缓冲区),这样可以更加细粒度地进行修改,而在数据回写时,可以只回写被修改的缓冲区,而无需将整个页都传输回底层设备。

页缓存

我们知道页缓存的任务是通过一些物理内存页,加速在块设备上按页为单位执行的操作。页缓存的运作方式对用户应用是透明的,应用无法感知数据来自页缓存还是块设备,read 和 write 系统调用的结构相同。在访问外部存储数据时,会先检查缓存中是否存在需要的数据,如果不存在的话,才会从块设备中读取到内存中,读入内存后,该页就会被插入到缓存中,因而后续访问可以快速完成。

这里,我们需要尽可能地减小搜索页缓存的时间,这样我们的缓存才有价值,Linux 通过基数树来管理页缓存中的页。下图就展示了基数树中如何组织各个页缓存,值得注意的是,该树并不是自平衡的,树的不同分支之间可能有任意高度差。
page-cache-tree
树的根记录了树的高度和第一个树干节点的指针,叶子节点内存页,树干节点本质上是一个数组,数组的长度一般是 64,上图为了化简所以只画出了 5 个的数组元素,除此之外每个树干节点上还会有一个 Count 记录数组中的元素数目,同时每个树节点具有两个搜索标记,一个用来标记给定页当前是否是脏的,另一个用来标识该页是否正在进行回写,标记不仅对直接树干节点设置,还会一直向上设置到根节点,如果某个层次 n+1 设置了某个标记,那么其在 n 层的父节点也会设置该标记,通过它可以快速确认某个区域是否有脏页或者正在回写的页。

因为树干节点的数组长度是固定的,所以当树的高度确定时,我们就能计算出该树所能容纳的节点数目。当树的高度为 N 时,能容纳的叶子节点数就有 64 的 N 次方。我们可以通过一个整数(实际上就是页偏移,该整数的每 6 位代表了基数树中一层的槽下标)作为 key 来标识树中的所有节点,就以上图中最右边的内存页为例,它的 key 为 43,因为我们可以通过第一层的 4 号槽 + 第二层的 3 号槽定位到它,在树中查找元素就通过这个 key 进行。因为不同树高所能容纳的最大节点数是固定的,所以如果我们使用的 key 为 32 位时,树的最大高度就是 6,如果使用的 key 为 64 位时,树的最大高度为 11,当向树中插入节点时发现树高不够时,就会增加树的高度,增加树高的方式也很简单,就是在临近树根的地方增加一层,并将原来与树根相连的节点放到新节点的 0 号槽上,这样树的容量就扩大了 64 倍。

由于页缓存的存在,所以写操作并不是直接对块设备进行,而是在内存中进行,修改的数据先收集起来,然后被传输到更底层的块设备 I/O 调度中,在那里对写操作进行进一步优化。不过块 I/O 层的内容我们前面已经介绍过了(I/O 操作排队执行,并且调度算法可能会进行操作的重排与合并来加快 I/O 执行效率),所以这里我们主要关心页缓存的数据在什么时机进行回写。对于这个问题,在运行不同的应用中可能追求的效果并不相同,为了迎合各种场景,内核提供了如下几种同步方案:

  • 内核有几个守护进程(pdflush),它们周期性地激活,负责扫描缓存中的页,并将一定时间没有与底层设备同步的页回写。
  • 如果缓存中修改的数据项在短时间内激增,内核会主动激活 pdflush(增加激活频率)。
  • 提供相关的系统调用,可由用户进程决定何时回写未同步的数据,例如 sync 调用。

内核通过 address_space 管理缓存页与块设备之间的关系,大家还记得么,在页缓存的 page 数据结构中保存了从属的 address_space 对象指针以及在从属文件中的页偏移,而 address_space 对象中保存了对某一文件的内存映射关系,address_space 就是通过上述的基数树来管理文件的内存映射(页缓存),而基数树节点对应的 key 正是该页数据在文件中的偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct page{
unsigned long flags;
atomic_t _count;
atomic_t _mapcount;
unsigned long private;
struct address_space *mapping; // 地址空间
pgoff_t index; // 页偏移
struct list_head lru;
void *virtual;
};
struct address_space{
struct inode *host; //所有者:inode或块设备,在 inode 的结构中也保存了其对应的 address_space 对象
struct radix_tree_root page_tree; //所有页的基数树
unsigned int i_mmap_wrutable; //VM_SHAREAD映射的计数
struct prio_tree_root i_mmap; //私有和共享映射的树
struct list_head i_mmap_nonlinear; //VM_NONLINEAR映射的链表元素
unsigned long nrpages; //页的总数
pgoff_t writeback_index; //回写由此开始
struct address_space_operations *a_ops; //方法,即地址空间操作
unsigned long flags; //错误标志位/gfp掩码
struct backing_dev_info *backing_dev_info;//设备预读
struct list_head; private_list;
struct address_space private_list;
} __attribute__((aligned(sizeof(long))));

读文件时,首先通过要读取的文件内容的字节偏移量 offset 计算出要读取的页偏移 index =(offset / page_size),然后通过该文件的 inode 找到这个文件对应的地址空间 address_space,然后在 address_space 中的基数树中通过页偏移 index 找到对应的页缓存,如果页缓存命中,那么直接返回文件内容,如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,然后从磁盘中读取相应文件的页填充该缓存页,随后从页缺失异常中恢复,继续往下读。

写文件时,首先通过所写内容在文件中的字节偏移量 offset 计算出相应的页偏移 index =(offset / page_size),然后还是通过 inode 找到 address_space,通过 address_space 的基数树根据页偏移 index 找到对应的页缓存,如果页缓存命中,直接把文件内容修改更新在页缓存的页中。写文件就结束了。这时候文件修改位于页缓存,并没有写回到磁盘文件中去。

一个页缓存中的页如果被修改,那么会被标记成脏页。脏页需要写回到磁盘中的文件块。一般是通过 pdflush 守护进程或者主动 sync 的方式回写到块设备中。

现在,当我们再回到介绍 VFS 时用到的各组件关系图时,就能很容易的将各个部分串联起来了,进程通过文件描述符管理打开的文件,文件又会指向对应的 inode 实例,inode 中又指向了 address_space 对象,其中的 page_tree 记录了文件的缓存,在进行文件读写时实际上是在读写缓存的内容。
vfs-all-kinds-of-component
在进行回写时,又会通过 inode 找到文件从属的块设备,根据块设备使用的文件系统的不同会以不同的方式组织硬盘的数据块,在文件系统的下层是块设备的 I/O 层,这里会涉及到 I/O 的调度过程,最后通过块设备驱动将数据传到硬盘中,这里又涉及到总线的传输协议。
block-io
通常,修改文件或其他按页缓存的对象时,只会修改页的部分,而非全部。这就在数据同步时引起了一个问题。将整页写回到块设备是没有意义的,因为内存中该页的大部分数据仍然与块设备是同步的。为节省时间,内核在写操作期间,将缓行中的每页划分为较小的单位,称为缓冲区。在同步数据时,内核可以将回写操作限制在那些实际发生了修改的较小的单位上。因而,页缓存的思想没有受到危害。

最后让我们简单地介绍一下预读的过程,我们知道在我们读取一个文件的数据时,一般并不会只读取一页数据,而是顺序读取多页,就下例而言,假如我们想要读取第一页,这时候内核会一次读取 8 页,这 8 页被称为一个预读窗口,然后我们读完第一页之后继续向后读,这时候内核的预读过程就带来了实质性的性能提升,当我们访问到第 6 页时(注意该页也在预读窗口中),因为预读过程会在预读窗口的其中一页中设置 PG_Readahead 标志,而第 6 页就设置了该标志,读取第六页时会触发一个异步读取过程,因为在第六页之后还有几页在内存中,所以这里没必要使用一个同步读取过程,而后台进行的 I/O 操作将确保进程的进一步读取时,相关页已经进入缓存,之后预读的内容也会在其中一页设置 PG_Readahead 标志,如此不断重复以完成整个文件的预读过程。

块缓存

内核的早期版本只包含了块缓存,来加速文件操作和提高系统性能。这是来自于其他具有相同结构的类 UNIX 操作系统的遗产。与内存页相比,块不仅比较小(大多数情况下),而且长度是可变的,依赖于使用的块设备(或文件系统)。

但是,现在更加倾向于使用基于页操作实现的通用文件存储方法,块缓存作为中枢系统缓存的重要性已经逐渐失去,现在主要的缓存任务由页缓存承担。现在使用块缓冲的地方很少,一般在处理小数据时才会使用,例如处理文件系统的元数据时,通常会使用此类方法。

块缓存在结构上由两部分构成:

  1. 缓冲头(buffer head) 包含了与缓冲区状态相关的所有管理数据,包括块号、块长度、访问计数器等。
  2. 实际缓存数据保存在专门分配的页中,这些页也可能同时存在于页缓存中,这进一步细分了页缓存。在下例中,页被划分为 4 个长度相同的部分,每一部分由其自身的缓冲头描述。缓冲头存储的内存区域与实际数据存储的区域是无关的。

page-block-cache
实际上在 Linux 2.6 中,才将块缓的数据区存合并到页缓存中,之前的版本中这两部分缓存数据是分别管理的,所以需要显式的同步,而现在这种块缓存管理方式,会导致当某一块数据既存在页缓存又存在块缓存时,块缓存在页缓存的内部,这样当修改块缓存时,页缓存也跟着得到了修改,不需要显式地同步。

但这并不意味着,块缓存完全在页缓存的管理范围内,这里只是说它们的数据部分是整合在一起的,而管理机制是独立的两套机制。在读取文件系统的超级块时,使用的就是块缓存,而不是页缓存。块缓存的运作是独立于页缓存的,而不是在其基础上建立的。它的管理方式比较简单,所有缓存头都管理在一个长度恒定的数组中,各个数组项按照 LRU(最近最少使用)方式管理,当一个数组项用过之后,将其置于索引 0 位置,其他项下移,这样最常用的缓存就会处于数组的开头,而不常用的就会处于数组的末尾,如果长时间不使用,就会掉出数组,继而这部分内存就会被回收。

地址空间

接下来我们介绍一下前面提到的地址空间 address_space,在之前的例子中 address_space 只与文件 inode 关联,描述文件内容与缓存的映射关系,但是实际上地址空间是一个更通用的接口,这使得缓存也能容纳其他源的数据并快速访问,比如它也可以和块设备关联(某一硬盘分区),对缓存的回写会直接回写到块设备上而不是块设备的某一文件中。

前面我们提到过,内存中的页会被分配到一个地址空间中,这些页内的数据就是缓存的内容。而后备存储器标识了一个地址空间中的页的数据来源。我们知道,物理内存页实际上也会映射到处理器的虚拟地址空间中,当访问虚拟内存的某一区域时,发现该位置没有关联到物理内存页,内核可根据地址空间结构来找到读取数据的来源。
address-space-relationship

数据同步

物理内存和硬盘空间在很大程度上是可以互换的。如果有大量的物理内存是空闲的,则内核使用一部分内存来缓冲块设备的数据,反过来,如果物理内存太少,可以将数据换出内存,转移到磁盘空间。二者有个共同点,数据总是在物理内存中操作,随后在随机的时间点写回(或刷出)到磁盘,以持久保存修改。在这里,块存储设备通常称为物理内存的后备存储器。

前面我们讨论了 Linux 提供的各种缓存方法,但是并没有详细介绍缓存是如何回写的,这里我们开始详细地介绍它们。

  1. 后台线程重复检查系统内存的状态,周期性地回写数据。
  2. 在系统缓存中脏页过多,而内核需要干净页的情况下,将进行显式刷出。

在页的刷出(flushing)、交换(swapping)、释放(releasing)操作之间,有着明确的关系。不仅需要定期检查内存页的状态,还需要检查空闲内存的大小。在完成检查后,未使用或很少使用的页将自动换出,但在换出前,其中包含的数据将与后备存储器同步,以防数据丢失。对动态生成的页,系统交换区充当后备存储器。对映射自文件的页来说,其交换区就是底层文件系统中与页对应的部分。如果内存发生严重的不足,必须强制刷出脏数据,以获得干净的页。

同步和交换不能彼此混淆。同步只保证物理内存中保存的数据与后备存储器一致,而交换将导致从物理内存刷出数据,以释放空间,用于优先级更高的事项。在数据从物理内存清除之前,将与相关的后备存储器进行同步。

内核可能因不同原因、在不同的时机触发不同的刷出数据的机制。

  • 周期性的内核线程,将扫描脏页的链表,并根据页变脏的时间,来选择一些页写回。如果系统不是太忙于写操作,那么内核会在脏页的数目,以及刷出页所需的硬盘访问操作对系统造成的负荷之间,维持一个平衡。
  • 如果系统中的脏页过多(一个大型的写操作可能造成这种情况),内核将触发进一步的机制对脏页与后备存储器进行同步,直至脏页的数目降低到个可接受的程度。
  • 内核的各个组件可能要求数据必须在特定事件发生时同步,例如在重新装载文件系统时。

前两种机制是通过 pdflush 实现的,该线程执行同步代码,而第三种机制散落在内核中的多处代码中。

pdflush 机制

前面我们说过系统中会有几个 pdflush 守护线程来负责数据的同步,实际上 pdflush 线程的数量是随着系统负载动态变化的。一般来说最大存在 8 个,最小存在 2 个。如果 1 秒内所有 pdflush 线程都处于繁忙状态,那么就会增加一个新线程,反之如果一个线程空闲了超过 1 秒,就会被销毁,但是线程的总数受最大存在数和最小存在数的限制。

这里为什么需要多个 pdflush 线程呢?现在的系统中通常都有多个块设备,如果只有一个 pdflush 线程并且它忙着给其中一个块设备进行回写时,另一个块设备的写入请求就会一直排队等待,所以我们才需要多个 pdflush 线程,内核期望这些块设备都忙于回写而不是空闲等待。因为不同的块设备有不同的队列,因此数据可以并行写入。这时候,数据传输速率主要受限于 I/O 带宽,而不是硬件 CPU 上的计算能力。下图概述了 pdflush 线程和回写队列之间的关系。pdflush 线程数目是动态变化的,而这些线程有的负责多条 I/O 队列,而有些 I/O 队列是由多个 pdflush 线程一起处理的。
pdflush-io-queue
在一个 pdflush 线程启动后会被置入一个全局链表 pdflush_list 中,然后 pdflush 线程进入睡眠并记录进入睡眠的时间点,当需要为 pdflush 派发实际的工作任务时,会从 pdflush_list 中挑出一个线程(从列表中移除),为其指定工作(目标函数)并调度它。如果派发任务时发现当前列表 pdflush_list 中的剩余线程数为 0,指派任务的过程会失败并返回 -1。同时,当 pdflush_list 剩余线程数为 0 时也会记录当前时间,这样在任意一个 pdflush 线程执行完目标任务后,可以根据 pdflush_list 剩余线程数为 0 的时间,来决定是否创建新的线程(数目为 0 超过 1 秒)此外,如果 pdflush_list 存在线程,就会检查 pdflush_list 中的线程睡眠时间超过 1 秒,如果有任意一个线程睡眠超过 1 秒,则销毁该线程,这很像线程池的工作方式。

既然 pdflush 线程实际上相当于一个线程池,那么在这个线程池中,实际的工作内容是什么样的呢?前面我们也说过了,这里有两个方案,一个是周期性回写,一个是强制回写。接下来我们先介绍一下周期性回写。

周期性回写

周期性回写的机制通过一个定期激活组件控制,之后我们会介绍内核如何进行周期性的任务触发,这里我们只需要知道内核有机制能保证周期性的函数调用。内核默认会每隔 5 秒钟执行一次周期性回写函数,这个周期性回写函数需要在一个空闲的 pdflush 线程上运行,如果当前没有空闲的 pdflush 线程,那么内核会 1 秒后重新尝试,还记得吗?pdflush 线程默认 1 秒增加一个,它们之间是有联系的。

周期性回写函数在执行的过程中,对哪些页进行回写呢,实际上并非所有的脏页都会进行回写,因为在回写期间 inode 是需要锁定的,所以脏页会分为小组进行处理,以防止对单个 inode 的过度阻塞,进而影响系统的性能。每次周期性回写过程默认只会回写那些超过 30 秒未回写的脏页,而且一次回写过程默认最多回写 1024 页,然后就会结束,等 5 秒后才会再次被激活。

除了周期性的触发上述回写过程之外,内核还会计算内存中脏页的比例,当在进行 write 系统调用时发现当前脏页的比例达到了 10%(默认),也会触发上述的过程,该过程也是异步的通过 pdflush 线程进行,之后 write 系统调用会返回。之所以这么做是因为周期性回写默认 5 秒触发一个,也就是说超过 30 秒的脏页最多可能延迟 5 秒后才会被处理,所以在 write 系统调用时根据当前脏页比例,主动触发后台的回写过程,有助于控制脏页的比例。

强制回写

在系统负荷不高时,上述的后台回写过程工作的还很不错,内核能尽量确保脏页的数目不会失去控制,并且物理内存与底层块设备之间,数据充分的得到交换。但是,当进行频繁的写操作时,情况就不一样了。

在内核接收到对内存的紧急请求,而同时因为有大量脏页而不能满足该请求时,内核必须设法尽快将脏页的内容传输到块设备,以尽快释放物理内存用于其他目的。在这种情况下,使用的方法与后台刷出数据所用的方法是相同的,但同步操作不是由周期性过程发起,而是由内核显式触发,换句话说,这种回写是“强制”的,此外在这个时候,回写的页也不再要求已经脏了一段时间了(30 秒),而是任何脏页都可以被回写。在申请内存时,如果内存不足,会进行强制回写,它会每次回写 1024 页直到内存够用为止。除此之外 write 系统调用也会在脏页比例超过 40%(默认)时进行同步的强制回写,直到脏页比例下降到 40% 以下为止,在这个过程中 write 不会返回而是会阻塞。

系统调用同步

除了上述内核控制的回写过程外,我们还可以从用户空间通过各种系统调用来启用内核同步机制,以确保内存和块设备之间(完全或部分)的数据完整性。有如下 3 个基本选项可用。

  1. 使用 sync 系统调用刷出整个缓存内容。在某些情况下,这可能非常耗时。
  2. 各个文件的内容(以及相关 inode 的元数据)可以被传输到底层的块设备。内核为此提供了 fsync(回写元数据) 和 fdatasync(不回写元数据) 系统调用。
  3. msync 用于同步内存映射。

页面回收和页交换

要满足用户的需求,或一直满足内存密集型应用程序的需求,无论计算机上可用的物理内存有多少,都是不够的。因而,内核将很少使用的部分内存换出到块设备,这相当于提供了更多的主内存。这种机制称为页交换(swapping)或换页(paging),由内核实现,它对应用程序是透明的。但页交换不是从内存逐出页的唯一机制。如果一个很少使用的页的后备存储器是一个块设备(例如,文件的内存映射),那么就无须换出被修改的页,而是可以直接与块设备同步。腾出的页帧可以用在其他地方,如果再次需要该数据,可以从来源重新建立该页。如果页的后备存储器是一个文件,但不能在内存中修改(例如,二进制可执行文件的数据),那么在当前不需要的情况下,可以直接丢弃该页。这三种技术,连同选择很少使用页的策略,统称为页面回收(page reclaim)。请注意,分配给核心内核(即并非用于缓存)的页是不能回收的,因为这种做法带来的复杂性的增加,将超出其好处。

除此之外,缓存的长度从来都不是固定的,可以根据需要增长(没有使用的物理内存,与其浪费,还不如用来缓存一些数据)。但如果一些重要的任务需要被缓存占用的内存,内核将回收这部分缓存使用到的内存以支持这些需求。接下来我们将介绍页交换和页面回收的实现。

前面我们描述了数据与底层块设备的同步,这能够缓解内核在可用物理内存达到极限时所面临的态势。将缓存的数据回写,可以释放一些内存,以便将物理内存用于更重要的功能。所涉及的数据可以在需要时从块设备再次读取,虽然会花费时间,但不会丢失信息。但是该过程也有其局限性。在某些时候,缓存和缓冲区都不能再收缩。而且数据同步对动态产生的内存页是不适用的,因为这种页没有后备存储器。对于这类内存,我们就需要使用交换区来保存内存中的数据。

这里我们只有少数的几类内存页需要换出到交换区,而其他有后备存储器的页,直接换出到对应的后备存储器即可。

  1. 进程的栈或者匿名映射的内存区
  2. 私有映射,映射修改后不向底层块设备回写的文件,通常需要换出到交换区
  3. 进程堆以及使用 malloc 分配的页
  4. 用于实现某些进程间通讯的页,例如共享内存

内核使用的页绝对不会换出,我们之前也讨论过相关的问题,这是内核设计时定的一个规则,如果内核内存可以换出,会增加内核代码的复杂度,而且内核也不需要非常多的内存,和换出内核内存所带来的额外工作量相比,将内核内存换出所带来的好处实在是太少了。

此外,将外设映射到内存空间的页也不会换出,因为这些页是用作应用程序与设备间通讯的手段,而并非用于持久存储数据。

因为在通常的系统中硬盘容量比物理内存空间大很多,内核连同处理器(处理器管理的虚拟地址空间比实际存在的物理内存要大很多)可以征用部分磁盘,用作内存的扩展。由于硬盘比物理内存慢很多,页交换只是在紧急情况下的备用方案,它使得系统可以运行,但速度会降低很多。

在实现页交换和页面回收时,我们主要考虑如下问题:

  1. 换出的页在硬盘中如何组织,内核如何将页写入交换区
  2. 何时将多少页换出,内核必须维持空闲页帧与换出操作所需时间的均衡
  3. 哪些页需要被回收,怎么让性能损失最小化,如果交换区和物理内存之间频繁的交换(也称为页颠簸),则效率就会很差
  4. 如果后续要使用该数据,如何将页从交换区换回到物理内存中
  5. 哪些缓存可以被删除,当系统内存不足时需要缩减缓存的数据

接下来我会先介绍一下交换区的管理方案,然后再介绍页面回收的完整过程,这里我不会再介绍页缓存回写之后回收内存页的情况,因为我们前面花了很大的篇幅来解释相关的知识,但是我们心里要清楚地明白:整个这套内存回收的机制不仅适用于有后备存储的页(回写文件)和还适用于无后备存储的页(交换区)。它们唯一的区别是内核决定将页从内存移除时,页数据所写入的位置。如果一个页有后备存储,那么在回收该页时,会写出到后备存储器中,而其他页会写入到交换区中。

管理交换区

换出的页可以保存在一个没有文件系统的专用分区中,也可以存储在某个现存文件系统中的一个定长文件中。而且系统中还可以同时存在多个交换区。还可以根据各个交换区的速度不同,为其指定优先级。内核使用交换区时可以根据优先级进行选择。如果使用了几个优先级相同的交换区,内核将使用循环的方式来确保尽可能均匀地利用各个交换区。如果交换区的优先级不同,内核首先使用高优先级的交换区,当高优先级的交换区不够时,才会逐渐转移到优先级较低的交换区。

我们可以通过 proc 文件系统来查看交换分区的状态,下例中我们使用了一个专用分区和 2 个文件来容纳交换区,交换分区的优先级最高,因而内核会优先使用它,而两个文件的优先级较低,都是 0,当交换分区用尽后,内核会均匀地使用这两个交换文件。

1
2
3
4
5
cat /proc/swaps
Filename Type Size Used Priority
/dev/hda5 partition 136512 96164 1
/mnt/swap1 file 65556 6432 0
/tmp/swap2 file 65556 6432 0

每个交换区都细分为若干连续的槽(slot),每个槽的长度刚好与系统的一个页帧相同。在大多数处理器上,一个页的长度是 4KB。本质上,系统中的任何一页都可以容纳到交换区的任一槽中。但内核还使用了一种称为聚集(clustering)的方案,使得内存数据能够尽快交换到交换区。进程内存区中连续的页(或者一次换出过程中涉及到的页)将按照特定的聚集大小(通常是256页)逐一写到硬盘上。如果交换区中没有那么大的空间来容纳此长度的聚集,内核才会将聚集打散,然后使用不连续的空闲槽位。

为跟踪交换分区的使用情况,内核必须维护一些数据结构,将该信息保存在内存中,其中最重要的数据成员是一个位图,用于跟踪交换区中各槽位的使用空闲状态。同时,为了更加高效进行槽位查询,该结构中还包括了其他数据内容,我们接下来就来介绍一下它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct swap_info_struct {
unsigned int flags; /* 交换区的状态,是否可用等 */
int prio; /* 交换区的优先级 */
struct file *swap_file; /* 如果是交换分区,则指向设备文件的 file 对象,如果是交换文件,则指向对应文件的 file 对象 */
struct block_device *bdev; /* 底层块设备 */
struct list_head extent_list; /* 管理槽位到交换文件块号之间的映射关系,后面介绍 */
struct swap_extent *curr_swap_extent; /* 管理槽位到交换文件块号之间的映射关系,后面介绍 */
unsigned short * swap_map; /* short 数组,保存了交换映射,数组的长度和槽总数相同,每一项对应了一个槽,数组中的每项保存了共享该槽的换出页的进程数,为 0 时表示该槽未使用 */
unsigned int lowest_bit; /* 减少扫描时间,记录了最低可用槽位的下标 */
unsigned int highest_bit; /* 减少扫描时间,记录了最高可用槽位的下标 */
unsigned int cluster_next; /* 现存聚集中接下来可以使用的槽位的下标 */
unsigned int cluster_nr; /* 聚集中仍然可以使用的槽位数,当消耗了空闲槽位后,必须新建一个聚集,如果交换区不够容纳一个聚集,则会采用更细粒度分配方式 */
unsigned int pages; /* 可用槽位数 */
unsigned int max; /* 最大槽位数 */
unsigned int inuse_pages; /* 使用中的槽位数 */
int next; /* 交换区列表中的下一项 */
};

在上述结构中,extent_list 和 curr_swap_extent 用来管理交换区槽位与交换文件磁盘块之间的映射关系,当我们使用交换区时,不需要使用它,因为一个分区的磁盘块是线性排列的,因而槽位与交换区磁盘块的映射关系很简单,只需要知道起始块号就够了。而交换文件则复杂得多,因为一个文件的所有块在磁盘上并不一定是连续的,因此,我们需要一个链表将交换文件的各个磁盘块串联起来,就以下图为例,交换文件由 3 个部分组成,每个部分包括一串连续的磁盘块(分别包含 3,10,7 页),但是这三个部分并不是首尾相连的(它们的起始块号分别是 A,B,C)。
exchange-file-extent-list
因为当交换文件很大时,上述的链表会很长,为了减少检索时间,内核会将上次检索用到的 swap_extent 对象保存在 curr_swap_extent 中,下次检索时会从该 swap_extent 开始检索,这样通常能很快的找到目标。如果没有找到目标,内核就要挨个检索所有 swap_extent 才能找到目标磁盘块的位置。

有两个用户空间工具可用于创建和启用交换区,分别是 mkswap(用于“格式化”一个交换分区/文件) 和 swapon(用于启用个交换区),这里就不展开介绍了。

交换缓存

在交换子系统中,内核使用了另一个缓存,称为交换缓存,该缓存在选择换出页与实际执行页交换之间充当一个协调者的角色。乍看之下,这一层好像很奇怪,不知道它的意义是什么,我将以下图来介绍内存页换入换出的过程,介绍完大家就明白交换缓存的意义了。
swap-cache
先说说物理内存换出的过程,首先内核会在一定的条件下才会触发页面回收过程,就像内核进行页缓存的回写一样,分为周期性触发和主动触发。这个过程我们下一小节就介绍,这里明白内核会在一定是时机才触发页面回收的逻辑就够了。

当页面回收进行时,内核通过一定的策略选择了一些页要进行回收,这时候交换缓存的意义并不大,因为交换缓存是换出页与实际回写过程之间的协调者,所以在交换缓存负责了空闲交换槽的分配,这设计到有聚集优先使用聚集,无聚集则按页进行交换,这个过程又涉及到查找页对应的磁盘块位置,最后内核通过逆向映射(还记得吗,内存页指向了所处的地址空间对象,地址空间对象中的优先树记录了共享某一页的进程集)会找到所有使用该内存页的进程,然后修改它们页表,标记该页已被换出,并且记录该内存页所处交换区的位置,内核通过一个交换区标识符和一个交换区内偏移来记录该页该页在交换区中的位置。
swap-zone-position
而当交换区的数据换入物理内存时,交换缓存的意义就显示出来了,如果之前换出的页由多个进程同时使用,它们的页表中都会记录物理内存在交换区中的位置。当其中某一个进程 A 要访问该页的数据时,该页被换入,该进程对应的页表项将改为该页当前物理内存地址。而其他进程 B,C 的页表中仍然会指向交换区中的位置,即使该交换区中的内容已经换入到物理内存中,想一想这也是为了性能考虑,因为其他进程 B C 目前不一定需要该内存数据,所以没必要一同修改所有共享该内存的进程页表。回到刚才的话题,既然其他进程 B C 的页表并没有修改,那么当它们也需要该内存数据时,岂不是又要从交换区读了吗。实际上,它们会先去交换缓存中查找一下,因为之前的进程 A 已经将交换区的数据换入到物理内存中了,那么从该内存页也会保存在交换缓存中,这样在 B C 需要该内存页时,可以从交换缓存中直接获取,而不必重新从交换区中读取,而交换缓存以基数树的结构保存了从交换区地址到内存页的映射关系。下图就概述了这一过程。
swap-cache-example

何时发起内存回收

在换出内存页之前,内核会检查内存的使用情况,确定可用内存容量是否较低。与同步页的方法相似,内核通过如下两种机制来检查内存容量是否过低。

  1. 一个周期性的守护进程(kswapd)在后台运行,每一个 NUMA 节点都由一个专门对应的 kswapd 进程负责,该进程不断检查当前的内存使用情况,以便在达到特定的阈值时发起页的换出操作。通过该方法,可以确保不会出现突然需要换出大量页的情况。这种情况将导致系统出现很长的等待时间,必须不惜一切代价防止它的发生。
  2. 但内核在某些情况下,必须能够处理突发的严重内存不足,例如在通过伙伴系统分配大块内存时,或创建缓冲区时。如果没有足够的物理内存可用来满足对内存的请求,内核必须尽快换出页,以期释放一些内存空间。在紧急情况下的换出操作,属于直接回收(direct reclaim)的一部分。

在 NUMA 机器上,所有处理器对内存的共享并不是一致的,对每个 NUMA 节点来说,都有一个单独的 kswapd 守护进程。每个守护进程负责一个 NUMA 结点上所有的内存域。在非 NUMA 系统上,只有一个 kswapd 实例,负责系统中所有的内存域。回想一下,IA-32 可以有最多3个内存域: DMA内存域、普通内存域、高端内存域。
如果内核无法满足对内存的请求,甚至在换出页之后也无法满足,那么虚拟内存子系统只有一个选择,即通过 OOM (out of memory,内存不足)killer 来结束一个进程。虽然 OOM killer 有时候可能导致严重的损失,但总比系统完全崩溃要好。如果在内存不足的情况下不采取措施,很可能导致系统崩溃。

现在,我们有两个问题,kswapd 守护进程开始工作的阈值是什么?在什么条件下内核会进行直接内存回收?要解释这两个问题,我们必须先了解内存水位线的概念,我们知道在每个 NUMA 节点上的内存是分区的,每个区的大小都各不一样,内核对每个区针对该区的大小,都设定了 3 个水位线水位线,high,low,min。这些标记的含义分别为:剩余内存在 high 以上表示内存剩余较多,目前内存使用压力不大;high-low 的范围表示目前剩余内存存在一定压力;low-min 表示内存开始有较大使用压力,剩余内存不多了;min 是最小的水位标记,当剩余内存达到这个状态时,就说明内存面临很大压力。小于 min 这部分内存,内核是保留给特定情况下(内核自己使用,防止系统崩溃)使用的,一般不会分配给用户进程。内存回收行为就是基于剩余内存的水位标记进行决策的,当任意内存区进行内存分配时发现剩余内存低于 watermark[low] 的时候,会唤醒内核的 kswapd 进程,它会以 HZ/10 为周期触发内存回收工作。直到所有内存区剩余内存达到 watermark[high] 的时候停止。如果内存消耗导致剩余内存达到了或超过了 watermark[min] 时,就会触发直接回收(direct reclaim)。无论是周期性回收还是直接回收,都是殊途同归下层都会走到同一块逻辑,对每个内存区进行页回收,这里为了简化逻辑我们将在后文中着重介绍内存区的回收,而淡化其他部分的内容。

我们可以通过 cat /proc/zoneinfo 查看每个分区的水分线信息,水位线的单位是页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat /proc/zoneinfo
Node 0,zone DMA
...
pages free 3963
min 8
low 11
high 14
Node 0,zone DMA32
pages free 32754
min 414
low 615
high 816
Node 0,zone Normal
pages free 411033
min 16473
low 24467
high 32461

各个内存区的水位线是如何计算的呢?首先内核有一个最小空闲内存的配置项(/proc/sys/vm/min_free_kbytes),它决定了所有内存区的 min 水位线总和,各个内存区按照内存区的大小均匀的分配。

1
2
cat /proc/sys/vm/min_free_kbytes
67584 // 16896 页,结合上面各个区的 min 水位线,你会发现各个区的 min 水位线之后基本等于 min_free_kbytes

在我的例子中,DMA32 和 Normal 的 low 和 high 水位线大约分别是 min 水位线的 1.5 倍和 2 倍,而 DMA 区的比例更低一点。low 和 high 水位线的计算方式可能不同版本下计算方式有些不同,但是 min 水位线的计算方式一般没怎么变过。

选择要回收的页

前面讨论了内核什么条件下会考虑进行内存回收,接下来我们讨论一下内核如何决策回收哪些页。为了保证用最低成本给系统带来最大收益的前提下,我们应该换出哪些页呢?内核实现了一种粗粒度的 LRU 方法,而且使用硬件特性来加速这个过程,即在访问页之后设置一个访问位,虽然该特性并不是在所以体系结构上都可用,但是我们可以毫不费力地进行仿真。

内核对 LRU 的实现基于两个链表,分别称为活动链表和惰性链表(系统中的每个内存域都有这样的两个链表)。顾名思义,所有使用中的页在一个链表上,而所有惰性页则保存在另一个链表上。为在两个链表之间管理内存页,内核需要定期对它们进行均衡,通过上述访问位来确定一页是活动的还是惰性的,换言之,我们通过该页是否经常被系统中的应用程序访问来判定该页是不是活跃的,如果该页没有被置位,则说明是惰性的,它需要被移到惰性链表,而如果页被置位,说明它近期被访问过,则应该移到活动链表。随着时间的推移,最不活跃的页会处于惰性链表的尾端,在出现内存不足时,内核会换出这些页,因为这些页从出生到被换出时,一直都很少被使用,所以根据 LRU 的原理,换出这些页对系统的破坏是最小的。

前面我们说过内核中每个 NUMA 节点都有一个 kswapd 进程管理其页回收的工作,对于每一个 NUMA 节点的每个内存区都有 2 条 LRU 链表(早期的 Linux 是这样),一条对应的是活跃内存,另一条对应的是不活跃内存,它们都采用 FIFO 的形式进行管理,在 CPU 访问一个内存页时会设置该页的一个标志,这时候我们称该页为活跃的页。在介绍这两个 LRU 链表的交互之前,我打算先介绍一下新申请的内存页如何加入到这两个链表中。因为内存的申请是十分频繁的,如果我们在每次拿到一个新的内存页时就将其加入到 LRU 链表中,势必会造成”热点”问题(锁繁忙)。所以内核采用了一种缓存的方式,来处理新加入的内存页,在每个 CPU 上申请到一个新的内存页时,会先将新页放在该 CPU 的 LRU 缓存中,而且新页默认是不活跃的,只有当 CPU 使用到它时才会认为该页是活跃度。所以,对于每个 CPU 来说都有两个 LRU 缓存,当 LRU 缓存区满了的时候(14 个就满了),才将这个 LRU 缓存中的页加入到该内存区对应的 LRU 链表中,这极大地减少了锁的开销。
lru-link-add-new-page
理解了 LRU 链表中的页是如何添加的之后,我们再来看看 LRU 链表的页是如何在活跃状态和不活跃状态间切换的。这里内核不仅追踪了一个页是否近期被使用过,还追踪了一个页近期被访问的频繁程度。因为只有少数的体系结构支持页引用计数器,所以内核在这里对每个页使用了两个页标志 referenced 和 active。它们一个表示当前的活跃程度,一个表示页当前是否被引用过。
lru-status-change

  • active 标志表示了该页是否活跃,活跃的页处于活跃 LRU 链表中,不活跃的页处于惰性 LRU 链表中
  • 每当 CPU 访问到一个页时,会设置其 referenced 标志,负责该工作的是 mark_page_accessed
  • 内核会每隔一段时间会清除 referenced 标志,该工作是由 page_referenced 负责
  • 在每次进行 mark_page_accessed 时,它会先查看一下 referenced 表示是否已经被设置,如果已经置位,说明 page_referenced 还没来得及将其清零,也就是说该页被访问的频率比 referenced 被清零的频率更高,换句话说该页经常被访问。这时候如果该页处于惰性列表中,就会将该页的 active 置位,并移到活跃链表中,此外还会清除 referenced 标志。
  • 反过来,如果一个页在活跃链表,而清零 referenced 的频率高于 CPU 访问该页的频率(置位 referenced 的频率),换句话说,当 page_referenced 执行时,如果一个页的 referenced 已经为 0 了并且处于活跃链表中,那么就会将它移到惰性链表中,active 置位 0

如果内存页的访问是稳定的,那么 mark_page_accessed 和 page_referenced 的执行就是均匀的,因而各个内存页会比较稳定的保持在 LRU 链表上,上述的算法也被称为”2 次机会 LRU”,因为无论从活跃链表移动到惰性链表,还是反过来从惰性链表移动到活跃链表,都需要连续的两次 page_referenced 检查,当连续两次 page_referenced 检查时 referenced 都为 1 则会从惰性链表移动到活跃链表的头部,反之如果两次 page_referenced 检查时,referenced 都为 0 就会从活跃链表移动到惰性链表的头部。显然,Linux 的这两条 LRU 链表并不是按照严格的时间顺序来排列的,所以说这是一种伪 LRU 算法,之所以这么做,是因为系统中页的数量可能会非常多,如果为每个页都单独维护它的活跃度,效率会很低,而内存处理过程需要效率要尽可能的高,所以 Linux 使用了上述方案来维护页的活跃程度。

早期的 Linux 实现中,每个 zone 中有 active 和 inactive 两个链表,每个链表上存放的页面不区分类型。但是因为文件缓存有现成的后备存储器,而匿名内存需要占用交换区空间,所以内核后来确立了优先回收 page cache 的策略,所以后来的 Linux 版本中将 LRU 链表拓展成了 4 条,它们分别是 LRU_INACTIVE_ANON,LRU_ACTIVE_ANON,LRU_INACTIVE_FILE 和 LRU_ACTIVE_FILE,每个内存区都会对应这样的 4 条 LRU 链表。通过 /proc/zoneinfo 就能看到每个内存区的 4 条链表的长度。

1
2
3
4
5
6
7
8
9
10
11
cat /proc/zoneinfo
Node 0,zone Normal
pages free 411033
min 16473
low 24467
high 32461
nr_free_pages 411033
nr_zone_inactive_anon 459589
nr_zone_active_anon 4926141
nr_zone_inactive_file 294609
nr_zone_active_file 1401333

此外为了能够让用户控制页面回收时释放页缓存和释放匿名内存的比例,内核还提供了一个配置项在 /proc/sys/vm/swappiness,当它越大时,使用交换区的积极性就越高,默认值是 60,如果该值为 0,那么除非没有文件页缓存可以回收,否则不会进行页交换,而当该值为 100 时,页缓存和匿名内存会尽可能等比例的回收。

1
2
cat /proc/sys/vm/swappiness
60

就像前面所说的,系统中的页数量可能非常大,所以上述的 LRU 链表也会非常长,每次扫描要回收的页时如果遍历整个链表,显然会花费很多时间,而且在内存比较充足的时候没必要进行大规模的回收,但是如果内存十分紧张,就有必要多扫描一些。为了满足各种内存回收的场景,内核为内存回收过程定义了一个优先级 priority,priority 越趋向于 0 说明优先级越高,扫描的页数量就越多(注意扫描页数不等于回收页数,只有扫描到满足条件的页时,该页才会回收),默认的优先级是 12,如内存压力不是很大时就会以默认优先级进行内存回收。而当内存严重不足,比如当内存低于 min 水位线时,就会以高优先级进行内存回收,这时候会扫描整个 LRU 链表。那么根据 priority 怎么计算出要扫描多少页呢? 实际上,扫描的页数 = (LRU 长度 >> priority) + 1,之所以加 1 是为了防止扫描页数为 0,除此之外在计算每个 LRU 链表的扫描页数时还要考虑 swappiness 参数,它会控制匿名内存 LRU 和页缓存 LRU 扫描页数的比例。

除了 priority 和 swappiness 所决定的扫描页数外,在进行内存回收时我们还要定义一个回收目标,也就是目标回收页数。在进行周期性回收时,目标回收页数就是当前空闲页距离 high 水位线的差值,而在进行伙伴系统分配内存时如果发现内存不足,则会进行直接内存回收,我们知道在伙伴系统申请页时一次会申请 2 ^ order 个连续的页,所以当它发现内存不足时,内核会试图回收 2 ^ (order + 1),如果每次仅回收 2^order 个页框,满足于本次内存分配(内存分配失败时才会导致内存回收),那么下次内存分配时又会导致内存回收,影响效率,所以,每次 zone 的内存回收,都是尽量回收更多页框,制定回收的目标是 2^(order+1) 个页框,比要求的 2^order 多了一倍。但是当非活动 LRU 链表中的数量不满足这个标准时,则取消这种状态的判断。Zone 的内存回收之后,往往还会伴随着 Zone 的内存压缩(内存碎片的整理),这样伙伴系统一般就能申请到连续的 2 ^ order 个页了。

最后,我们总结一下从页面回收选择到实际页面回收的完整工作流程:

  1. 根据 priority 和 swappiness 计算每个 LRU 链表要扫描的页数量。
  2. 以活动匿名页 LRU 链表、非活动匿名页 LRU 链表、活动文件页 LRU 链表、非活动文件页 LRU 链表的顺序作为一轮扫描,每次每个 LRU 链表扫描 32 个页框
    • 活动匿名页 LRU 链表并不是每次都会进行扫描,它只在非活动匿名页 LRU 链表的页数量小于其目标扫描数量时才会进行
    • 活动 LRU 链表在进行扫描前,会先把本 CPU 对应的 LRU 缓存中的数据添加到 LRU 链表中,然后从后往前进行扫描,如果该页近期被访问过(referenced 标志)则将其移动到链表头,否则将其移动到非活跃链表的头部
    • 非活跃 LRU 链表在进行扫描前,也会把本 CPU 对应的 LRU 缓存中的数据添加到 LRU 链表中,然后从后往前扫描,如果该页近期被访问过(referenced 标志)则将其移动到链表头,否则将进行一定的检查,比如该页有没有被锁定(memory lock 不允许回收),如果检查通过就将回收该页
  3. 如果未达到目标回收页并且每个 LRU 链表的目标扫描数未达到,则重复第 2 步,而如果达到了目标回收页数,在有些情况下会直接进入第 4 步,而有的时候会回到第 2 步,直到达到目标扫描页数,这里我不打算深入介绍其中的细节,因为它不影响我们理解内存回收的大致流程。
  4. 如果当前回写的页过多,则睡眠一段时间,防止堆积过多回写请求

简单的讲,选择回收页的过程就是从 LRU 链表中按照从后往前的顺序,不断小批次的扫描内存页,直到满足回收目标(priority、swappiness 和 order 决定)。在这个过程中,如果活跃 LRU 链表中的页不在活跃,就移到非活跃链表,否则移动到活跃 LRU 头部,如果非活跃链表中的页重新变得活跃,就移到活跃链表头部,否则很可能就会被回收。

处理页面

当我们要开始回收一个页面时,具体的工作流程是怎么样的呢?

  1. 首先内核会检查该页是否正在进行回写并且没有回写完,
    • 如果确实没回写完,会将其重新放回非活跃 LRU 链表的头部,之所以放到头部是因为回写过程可能很慢,所以希望近期先别扫描到该页
    • 这里大家可能有疑问那回写完成后,我们怎么才能快速的释放呢? 实际上在回写结束后,负责回写的代码会检查该页是否是因为内存回收而回写的(reclaim 标志),如果是的话,则将其移动到非活跃 LRU 链表的尾部,这样它就会很快得到回收了
  2. 如果该页近期被访问过,页有可能在被加入待回收列表后到真正回收的过程中又被访问了(referenced 标志等),那么很可能不会回收该页了,之后重新放回活跃 LRU 链表头部
  3. 如果该页不是页缓存,则交给交换缓存,那里负责匿名内存的交换工作。当一个非文件页(匿名页)加入 swap cache 时,主要会做如下几件事:
    1. 首先,分配一个 swap 类型的页表项,将所有映射了此页的进程页表项设置为这个 swap 类型的页表项
    2. 其次,标记此页是一个脏页,这样后面就会通过判断这个进行异步回写了
    3. 最后,将此页的 mapping 指向 swap 分区的 address_space,这样在进行异步回写时,就能将此页异步回写到 swap 分区中
  4. 对于页缓存来说,则没有这一步加入到 swap cache 中,因为每个文件都有自己的 address_space,一个新的文件页(页缓存)就已经有对应文件的 address_space 了
  5. 如果有进程的页表映射了此页,则直接 unmap 取消映射关系,如果是非文件页,那么映射了此非文件页的页表项被设置为之前分配的 swap 类型的页表项,如果是文件页,则清空页表项
  6. 如果页为脏页,则对此页进行异步回写,这就是为什么在交换缓存时要将交换的页置为脏页,因为这样才能在这第 5 步中进行回写,然后对脏页设置 reclaim 标志,这样在页释放结束后就会自动回到非活跃 LRU 链表的末尾,并快速的被回收,而干净的页就可以直接释放了
  7. 如果该内存页回写完了,就将其各种管理数据中清除,比如 swap cache,page cache,buffer_heads(块缓存,缓冲区,记录了与内存页对应的磁盘位置)等,然后物理内存归还给伙伴系统

下面我们默认此页能够回收,忽略回收检查,并且默认没有进程在此期间访问页,将页分为干净文件页,脏文件页,非文件页描述一下回收过程(非文件页只要加入到 swap cache 中就会被设置为脏页):

干净文件页回收过程:
clear-file-page
可以看到,对于干净文件页,由于文件页不加入 swap cache,只需要进行一个 unmap 操作,就可以直接进行回收了,这种页回收效率是最高的。

对于脏文件页:
dirty-file-page
可以看到对于脏文件页,待其回写完成后,内核进行一次内存回收时,如果扫描到此页,只需要直接将其释放就可以了。注意:只有 kswapd 内核线程能够对脏文件页进行回写操作,并且回写完成后并不会主动要求内核进行一次内存回收,也有可能回写完成后,zone 的内存足够了,就不进行内存回收了。

再看看非文件页的回收流程:
anno-page
其实很简单,对于脏页,在回写之后的下次内存回收时,就可以将其回收,而对于干净的页,在本次内存回收时,就可以将其回收。而当非文件页加入 swap cache 后,就会被设置为脏页(dirty 标志)。

总结一下,非文件页相对于文件页来说,在内存回收处理过程中有以下区别:

  1. 一般回收的非文件页在非活动匿名页 LRU 链表中,而回收的文件页在非活动文件页 LRU 链表中。
  2. 非文件页回写前必须要加入 swap cache,并会生成一个以页槽号为偏移量的 swap 类型的页表项;而文件页不会加入 swap cache,并且没有 swap 类型的页表项
  3. unmap时,映射了非文件页的进程页表项会被设置为 swap 类型的页表项,而映射了文件页的进程页表项则直接清空
  4. 非文件页在有进程映射了的情况下,一定要进行回写后才能回收;而文件页即使没有进程映射的情况下,只要是脏页,回收时都要回写
  5. 非文件页没有 buffer_heads(块缓存),不需要对 buffer_heads 进行回收,而文件页回写完后进行需要进行 buffer_heads 的回收

在这里,我们介绍一下内存回收隔离区概念,因为后面的介绍中会用到它。我们知道 LRU 链表是内核中的一个热点数据,这个数据的修改势必需要用到锁,这就面临了一个问题,因为内存回收的过程可能会扫描很多页,那么就会持有很长时间的锁,为了解决这个问题,内核无论是扫描活跃 LRU 链表还是扫描非活跃 LRU 链表时,对于要检查的页都会先将其从 LRU 链表中摘除,放在一个隔离区中,然后放弃全局 LRU 链表的锁,然后继续在隔离区上处理这些页,因为这些页在隔离区中而不在 LRU 链表中,所以内核的其他部分都不能访问它们。当处理完时,这些页有的重新回到 LRU 链表,有的会被释放,这一点就和前面我们介绍的一样。

现在再说说在回写过程中,又有进程映射了此页怎么办?

  1. 内核会通过 page->_count 来描述当前有多少进程或者模块在引用该页,当有任何模块或进程使用该页时此页的 page->_count 就会 ++,这里我们假设一个场景,有 10 个进程映射了一个非文件页,没有其他模块引用此非文件页,那么此页的 page->_count 就为 10。
  2. 然后此页在非活动匿名页 LRU 链表中被内存回收扫描到,内核打算对此页进行回收,第一件做的事情,将此页从 LRU 链表隔离出来,内存回收模块持有该页,这里 page->_count++(就等于11)。
  3. 然后内存回收模块会将此页加入到 swap cache 中,swap cache 模块也持有该页,page->_count++(现在等于12了)。
  4. 最后要对此页进行 unmap,由于有 10 个进程映射了此页,所以 unmap 后,此页的 page->_count -= 10,现在 page->_count 就剩 2 了
    • 如果此页是干净页,那么如之前说的,回收时判断 page->_count == 2 的可以进行回收。
    • 如果此页是脏页,那么就回写,然后将此页放回到非活动匿名页 LRU 链表,这时 page->_count 会减1,因为它已经从内存回收的隔离区移出(这时候就为 1 了,这里为 1 是因为 swap cache 仍在引用此页)。
  5. 之后回写完成再被扫描到时,一样会进行隔离,那么 page->_count++(现在为 2 了),最后一样可以通过 page->_count == 2 判断此页能够释放。
  6. 如果在回写过程中,有进程又映射了此页,因为映射此页那么 page->_count 就会增加,在回写完成后的回收时就会发现 page->_count > 2,这时候内核就能感知道有进程又映射了此页,内核随后会将此页移动到活动匿名页 LRU 链表中。
  7. 而对于文件页,即使没有进程映射它,它的 page->_count 就为1,因为它自出身一来,就被对应文件的 page cache(页缓存模块)引用了。并且因为文件页不需要加入到 swap cache,实际上在内存回收过程中,当没有进程映射此文件页时,它的 page->_count 一样为 2(页缓存模块和内存回收隔离区两处引用)。
  8. 综上所述,在回写结束后,只要判断一个页的当前引用数为 2 则说明该页可以释放了,无论是匿名内存(内存回收隔离区和 swap cache 引用到该页)还是页缓存(内存回收隔离区和页缓存模块引用到该页)。

交换令牌

前面我们提到过页颠簸问题,而 “2 次机会” 的 LRU 能缓解该情况的发生,这里我们介绍能够缓解页颠簸的另一个机制————交换令牌。该方法既简单又好用,内核会向当前换入页的进程颁发一个所谓的交换令牌,且整个系统内只颁发一枚。交换令牌的好处在于,持有该令牌的进程,它的内存不会被回收,或至少可以尽可能避免被回收。这使得该进程换入的页可以保留在那内存中,增加了该进程完成工作的可能性。

交换令牌通过一个全局指针实现,该指针指向当前应用令牌的进程的 mm_struct (内存管理器)实例,实际上,内存区可能在几个进程间共享,而交换令牌是关联到某个特定内存区的,并非某个具体的进程。在这种意义上讲,交换令牌可能同时属于多个进程。但为简化阐述,这里假定只有一个进程关联到交换令牌所属的内存区。

内核会为每个进程维护一个交换令牌优先级变量(注意:这不是进程调度的优先级)和一个上次等待交换令牌的时长的变量,当一个进程换入一个页后,如果发现当前没人持有交换令牌,很自然地它就会获得令牌。如果当前换入页的进程,就是持有交换令牌的进程,那么它的优先级就会增加。而如果当前换入页的进程不是持有交换令牌的进程的话,会检查它的等待令牌时间,如果当前进程的等待令牌时间大于上次等待令牌的时间的话,则优先级 + 1(如果一个进程一直获得不到令牌,那么最后每次检查时,当前等待时间都会大于上次等待时间,这时候该进程的优先级会越来越大),否则优先级 -1(如果一个进程原来是令牌持有者,不过后来被其他进程夺走了令牌,那么在最初的一段时间里,它的优先级可能会先减小,因为令牌被夺走时进程的优先级不变,所以要先进行减权,当达到上次获得令牌的等待时间后才会开始加权,增加优先级),最后比较当前进程的优先级和持有令牌的进程的优先级,谁的优先级大谁将持有令牌。

处理缺页异常

接下来说一下内存重新换入的机制(缺页异常),Linux 运行的所有体系结构都支持缺页异常的概念,当访问虚拟地址间中的一页,但该页不在物理内存中的时候,将触发缺页异常。缺页异常通知内核从交换区和其他的后备存储器读取缺失的数据。当然,也可能会出现要先删除其他页,以便为新数据腾出空间的情况。

缺页处理分为两个部分。首先,必须使用与处理器相关性较强的代码(汇编语言)来截获该缺页异常,并查询相关的数据。其次,使用系统无关代码进一步处理该异常。由于内核在管理进程时所采用的优化,因而只在后备存储器中查找相关页并将其加载到物理内存是不够的,因为缺页异常可能是由其他原因触发的。例如,这可能涉及写时复制页,这些页仅在进程分裂后执行第一次写访问时,才会进行复制。在按需换页时也会发生缺页异常,按需换页法是指映射的页仅在实际需要时才加载。但这里我们将忽略这些特殊的缺页异常处理问题,而是专注于介绍交换区的页重新加载到物理内存的情况。

在进行换入时,内核首先要检查所请求的页是否已经在交换缓存中,如果确实如此的话,则可以直接获取到对应的物理页编号,而不用从硬盘中读取数据。如果该页不在交换缓存中,内核就必须从硬盘中读取数据。这里要先为该数据分配一块新的物理内存,容纳从交换区或者后备存储器中读入的数据。如果没有足够的物理内存可用,内核会试图换出其他页来提供新的内存。如果该过程失败,将导致换入失败,高层代码将通知 OOM Killer 关闭系统中具有相当大量内存,但是优先度较低的进程,来获得空闲内存。如果换入成功,内核会将该页添加到交换缓存中,并将其添加到 LRU 缓存中。接下来数据才开始从硬盘拷贝到物理内存中。

这里内核需要完成的工作不止是从交换区中查找到目标页。因为将磁头移动到一个新位置(磁盘寻道),会进一步拖低效率,所以内核使用了预读机制来预测接下来将需要哪些页,然后将这些页一并进行读取。还记刚刚提过的聚集方案吗,物理内存的换出一般都是成组地写入一段连续的硬盘中,而在读取连续页时,磁头在理论上只需要单向移动,而无须来回跳转,这样效率就会得到进一步的提高。通常它将预读 2 ^ page_cluster 页,在小于 16 MB 内存的系统上默认是 4 页,而更大内存的系统默认是 8 页,不过该配置也是可以修改的,它位于 /proc/sys/vm/page-cluster。值得一提的是,如果预读区域已经是交换区的边界或者预读区域没有数据,则不会进行预读。

1
2
cat /proc/sys/vm/page-cluster
3 // 2 ^ 3 = 8

缩减内核缓存

换出属于用户空间应用程序的页,并不是内核释放内存空间的唯一方法,缩减大量缓存,通常也会有很好的成效。很显然,这里内核也需要判断从缓存移除哪些数据比较合适,从而在不过度损害系统性能的情况下能够将用于这些数据的内存空间缩减,在这个过程中内核必须均衡利弊。因为内核缓存通常不是特别大,所以仅在万不得已时,内核才考虑缩减缓存。

我们前面已经介绍过,内核在很多领域提供了各种缓存(比如,slab 缓存,目录项缓存等)。这使得很难定义一个一般性的方案并据此缩减缓存,因为很难评估各种缓存所包含的数据的重要性。为此,用于缩减各种缓存的方法是针对各个子系统分别实现的,因为各种缓存的结构有很大的不同,很难采用一种通用的缓存收缩算法。不过好在内核提供了一种通用框架,来管理各种缓存收缩方法。用于缩减缓存的函数在内核中称为收缩器(shrinker),可以动态注册,在缺乏内存时,内核将调用所有注册的收缩器来释放缓存。

参考内容

[1]《Linux内核设计与实现》
[2]《Linux系统编程》
[3]《深入理解Linux内核》
[4]《深入Linux内核架构》
[5] Linux 内核进程管理之进程ID
[6] 服务器三大体系SMP、NUMA、MPP介绍
[7] Linux中的物理内存管理 [一]
[8] Linux内核中的page migration和compaction机制简介
[9] 物理地址、虚拟地址(线性地址)、逻辑地址以及MMU的知识
[10] 逻辑地址
[11] linux内核学习笔记-struct vm_area_struct
[12] Linux中匿名页的反向映射
[13] 系统调用过程详解
[14] 再谈Linux内核中的RCU机制
[15] Unix domain socket 和 TCP/IP socket 的区别
[16] Linux通用块设备层
[17] ext2文件系统结构分析
[18] linux ACL权限规划:getfacl,setfacl使用
[18] 查找——图文翔解RadixTree(基数树)
[19] 页缓存page cache和地址空间address_space
[20] rocketmq使用的系统参数(dirty_background_ration dirty_ratio)
[21] Linux内存调节之zone watermark
[22] Linux的内存回收和交换
[23] Linux中的内存回收[一]
[24] linux内存源码分析 - 内存回收(整体流程)
[25] Linux 软中断机制分析
[26] 对 jiffies 溢出、回绕及 time_after 宏的理解
[27] learn-linux-network-namespace
[28] 显式拥塞通知
[29] 聊聊 TCP 长连接和心跳那些事
[30] 关于 TCP/IP,必知必会的十个问题
[31] TCP协议三次握手连接四次握手断开和DOS攻击
[32] TCP 的那些事儿(上)
[33] TCP 的那些事儿(下)

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/linux/cache-and-swap/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。