浅谈Linux页面缓存


Linux 页面缓存(Page Cache)对于每个SRE来说都是必不可少和至关重要的。对页面缓存理解可以帮助完成日常的 DevOps 类任务以及紧急调试和救火。

什么是Linux 页面缓存?
从本质上讲,页面缓存是虚拟文件系统 ( VFS )的一部分,您可以猜到,其主要目的是改善读写操作的 IO 延迟。回写式缓存算法是页面缓存的核心构建块。

页面缓存中的“页面”意味着 Linux 内核使用称为页面的内存单元。跟踪和管理信息片段将是一件麻烦且困难的事情。
因此,Linux 的方法(顺便说一下,不仅仅是 Linux 的方法)是在几乎所有结构和操作中使用页面(通常4K是长度)。
因此,页面缓存中的最小存储单位是页面,无论您要读取或写入多少数据。所有文件 IO 请求都与一定数量的页面对齐。

由此可以得出一个重要的事实:如果写入的内容小于页面大小,内核会在完成写入之前读取整个页面。

读取:内核按以下方式处理读取:

  • 当用户空间应用程序想要从磁盘读取数据时,它会使用特殊的系统调用(例如 、 、 、 、read()等pread())vread()向mmap()内核sendfile()请求数据。
  • Linux 内核依次检查页面缓存中是否存在页面,如果存在则立即将其返回给调用者。正如你所看到的,在这种情况下内核进行了 0 次磁盘操作。
  • 如果Page Cache中没有这样的页面,内核必须从磁盘加载它们。为此,它必须在页面缓存中为请求的页面找到一个位置。如果没有可用内存(在调用者的 cgroup 或系统中),则必须执行内存回收过程。随后,内核调度读磁盘IO操作,将目标页面存储到内存中,最后将请求的数据从Page Cache返回到目标进程。从这一刻开始,任何未来读取这部分文件的请求(无论来自哪个进程或cgroup)都将由Page Cache处理,没有任何磁盘IOP,直到这些页面没有被驱逐。

写入的逐步过程:

  • 当用户空间程序想要将一些数据写入磁盘时,它也会使用一堆系统调用,例如: 、 、 、write()等pwrite()。writev()与mmap()读取的一个很大的区别是,写入通常更快,因为真正的磁盘IO操作不会立即执行。然而,只有当系统或 cgroup 没有内存压力问题并且有足够的空闲页面(我们将在稍后讨论逐出过程)时,这才是正确的。所以通常情况下,内核只是更新页面缓存中的页面。它使写入管道本质上是异步的。调用者不知道实际的页面刷新何时发生,但它确实知道后续读取将返回最新数据。页面缓存可保护所有进程和 cgroup 之间的数据一致性。包含未刷新数据的此类页面有一个特殊的名称:脏页面。
  • 如果进程的数据不重要,它可以依靠内核及其刷新进程,最终将数据持久保存到物理磁盘上。但是,如果您开发数据库管理系统(例如,用于货币交易),则需要写入保证以保护您的记录免受突然中断的影响。对于这种情况,Linux 提供了fsync()、fdatasync()和msync()系统调用,它们会阻塞,直到文件的所有脏页都提交到磁盘。还有open()标志:O_SYNC和O_DSYNC,您也可以使用它们来使所有文件写入操作在默认情况下持久。

更好地理解页面缓存会有以下好处:

  • 更精确的容量规划和集装箱限制计算;
  • 更好的内存和磁盘密集型应用程序(例如数据库管理系统和文件共享存储)的调试和调查技能;
  • 为内存和/或 IO 密集型临时任务构建安全且可预测的运行时rsync(例如:备份和恢复脚本、单行程序等)。

这些实用程序的内部结构将重点展示页面缓存统计信息、事件、系统调用和内核接口:、

  • procfs文件:/proc/PID/smaps、/proc/pid/pagemap、/proc/kpageflags和文件:/proc/kpagecgroup;sysfs/sys/kernel/mm/page_idle
  • 系统调用:mincore()、mmap()、fsync()、msync()、posix_fadvise()、madvise()等;
  • 不同的打开和建议标志O_SYNC、FADV_DONTNEED、POSIX_FADV_RANDOM、MADV_DONTNEED等。

演示代码:github.com/brk0v/sre-page-cache-article

逐出策略
任何缓存系统最关键的部分是它的逐出策略,与任何其他缓存一样,Linux 页面缓存持续监视最后使用的页面,并决定哪些页面应删除以及哪些页面应保留在缓存中。

控制和调整页面缓存的主要方法是 cgroup 子系统。您可以将服务器的内存划分为多个较小的缓存(cgroup),从而控制和保护应用程序和服务。此外,cgroup 内存和 IO 控制器提供了大量统计信息,对于调整软件和了解缓存的内部结构非常有用。

Linux 页面缓存与 Linux 内存管理、cgroup 和虚拟文件系统 (VFS) 紧密结合。因此,为了了解逐出的工作原理,我们需要从内存回收策略的一些基本内部原理开始。它的核心构建块是每个 cgroup 的一对活动列表和非活动列表:

  1. 第一对用于匿名内存(例如,分配malloc()或不分配文件后端mmap());
  2. 第二对用于 Page Cache 文件内存(所有文件操作包括read()、write文件mmap()访问等)。

前者正是我们感兴趣的。这对是linux用于页面缓存清除过程的。

算法LRU是每个列表的核心。这两个列表形成双时钟数据结构。一般来说,Linux应该选择最近没有使用过的页面(非活动的),因为最近没有使用过的页面在短时间内不会被频繁使用。这就是LRU算法的基本思想。活动列表和非活动列表的条目均采用 FIFO(先进先出)的形式。新的元素被添加到链表的头部,中间的元素逐渐向尾部移动。当需要回收内存时,内核总是选择非活动列表末尾的页面进行释放。

但值得一提的是,页面升级和降级的实际过程要复杂得多。

首先,如果一个系统有NUMA硬件节点 ( man 8 numastat),那么它的 LRU 列表就多出两倍。原因是内核尝试将内存信息存储在 NUMA 节点中,以减少锁争用。

此外,Linux Page Cache 还具有特殊的影子和引用标志逻辑,用于提升、降级和重新提升页面。

影子条目有助于缓解内存抖动问题。当程序的工作集大小接近或大于实际内存大小(可能是 cgroup 限制或系统 RAM 限制)时,就会出现此问题。在这种情况下,读取模式可能会在随后的第二个读取请求出现之前从非活动列表中逐出页面。完整的想法在mm/workingset.c中描述,包括计算故障距离。这个距离用于判断是否立即将影子条目中的页面放入活动LRU列表中。

使用/proc/sys/vm/drop_cachesfile 删除所有页面缓存条目。

vmtouch可以清除文件的缓存。它的-e标志命令内核从页面缓存中逐出所请求文件的所有页面。
vmtouch /var/tmp/file1.db -e

如果您想强制内核将文件内存保留在页面缓存中,无论如何该怎么办?这称为使文件内存不可驱逐。内核提供了一堆系统调用来执行此操作:mlock()、mlock2()和mlockall()。

页面缓存vm.swappiness和现代内核
内核不断维护自身和用户空间需求的空闲页面列表。如果此类列表低于阈值,Linux 内核将开始扫描 LRU 列表以查找要回收的页面。它允许内核将内存保持在某种平衡状态。

页面缓存内存通常是可逐出内存(有一些罕见的mlock()例外)。因此,看起来很明显,页面缓存应该是内存驱逐和回收的第一个也是唯一的选择。既然磁盘已经拥有所有这些数据,对吗?但幸运或不幸的是,在实际生产情况下,这并不总是最佳选择。

如果系统有交换空间,那么内核还有一个选择。它可以交换匿名(非文件支持)页面。这似乎有悖常理,但现实是,有时用户空间守护进程可以加载大量初始化代码,但之后却不再使用它们。例如,某些程序(尤其是静态构建的程序)可能在其二进制文件中具有许多功能,而这些功能在某些边缘情况下可能只使用几次。在所有这些情况下,将它们保留在宝贵的内存中没有多大意义。

因此,为了控制优先扫描哪个非活动 LRU 列表,内核有旋钮sysctl vm.swappiness

sudo sysctl -a | grep swap
vm.swappiness = 60

旧版 cgroup v1 内存子系统有自己的每个 cgroupswappiness旋钮。所有这些使得有关当前vm.swappiness含义的信息难以理解和改变。

  • 首先,默认vm.swappiness设置为 60,最小值为 0,最大值为 200:
  • 其次,cgroup v2 内存控制器根本没有旋钮

对某些Java类型的应用程序的个人经验是,需要关闭交换空间才能正常工作,GC 和交换不能很好地混合。

mmap()概述
内存映射是 Linux 系统最有趣的功能之一。其功能之一是软件开发人员能够透明地处理大小超过系统实际物理内存的文件。

下面谈谈了解如何获取mmap()统计信息,因为几乎每个页面缓存用户空间工具都使用它。

让我们再用 编写一个脚本mmap()。它打印进程的 PID、映射测试文件并休眠。睡眠时间应该足以进行该过程。

import mmap
import os
from time import sleep

print("pid:", os.getpid())

with open(
"/var/tmp/file1.db", "rb") as f:
    with mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ) as mm:f
        sleep(10000)

在一个终端窗口中运行它,并在另一个终端窗口中pmap -x PID使用脚本的 PID 运行。
pmap -x 369029 | less

Cgroup v2
现代 Linux 系统中的每个服务都在自己的 cgroup 下运行。

cgroup子系统是公平分配和限制系统资源的方式。它将所有数据组织在层次结构中,其中叶节点依赖于其父节点并继承其设置。此外,cgroup 还提供了许多有用的资源计数器和统计信息。

cgroup 在理解页面缓存使用情况方面发挥着关键作用。它还通过提供详细的统计数据来帮助调试问题和更好地配置软件。例如:LRU 列表使用 cgroup 内存限制来做出驱逐决策并调整 LRU 列表的长度。

cgroup v2中的另一个重要特点(之前的 v1 无法实现)是:跟踪页面缓存 IO 写回的正确方法。v1 无法理解哪个内存 cgroup 生成磁盘 IOPS,因此它会错误地跟踪和限制磁盘操作。幸运的是,新的 v2 版本修复了这些问题。它已经提供了一系列新功能,可以帮助页面缓存写回。

找出所有 cgroup 及其限制的最简单方法是访问/sys/fs/cgroup. 

内存 cgroup 文件

  1. memory.current– 显示 cgroup 及其后代当前使用的内存总量。当然,它包括页面缓存大小。
  2. memory.stat– 显示了很多内存计数器,对我们来说最重要的可以通过file关键字过滤
  3. memory.numa_stat– 显示上述统计数据,但针对每个NUMA 节点
  4. memory.min、memory.low和memory.high– memory.maxcgroup 限制。使用硬性max或min限制并不是您的应用程序和系统的最佳策略。您可以选择的更好方法是仅设置low和/或high限制更接近您认为的应用程序工作集大小。
  5. memory.events– 显示 cgroup 达到上述限制的次数。
  6. memory.pressure– 该文件包含压力失速信息 (PSI)。它通过测量由于内存不足而损失的 CPU 时间来显示一般 cgroup 内存运行状况。该文件是理解 cgroup 以及页面缓存中回收过程的关键。