长时间运行的Linux服务器,通常 free 的内存越来越少,让人觉得 Linux 特别能“吃”内存,甚至有人专门做了个网站 LinuxAteMyRam.com解释这个现象。实际上 Linux 内核会尽可能的对访问过的文件进行缓存,来弥补磁盘和内存之间巨大的延迟差距。缓存文件内容的内存就是 Page Cache。
Google 的大神 Jeffrey Dean总结过一个Latency numbers every programmer should know,其中提到从磁盘读取 1MB 数据的耗时是内存的80倍,即使换成 SSD 也是内存延迟的 4 倍。
我在本机做了实验,来体会一下 Page Cache 的作用。首先生成一个 1G 大小的文件:
代码语言:javascript复制# dd if=/dev/zero of=/root/dd.out bs=4096 count=262144
清空 Page Cache:
代码语言:javascript复制# sync && echo 3 > /proc/sys/vm/drop_caches
统计第一次读取文件的耗时:
代码语言:javascript复制# time cat /root/dd.out &> /dev/null
real 0m2.097s
user 0m0.010s
sys 0m0.638s
再此读取同一个文件,由于系统已经将读取过的文件内容放入了 Page Cache ,这次耗时大大缩短:
代码语言:javascript复制# time cat /root/dd.out &> /dev/null
real 0m0.186s
user 0m0.004s
sys 0m0.182s
Page Cache 不仅能加速对文件内容的访问,对共享库建立 Page Cache,可以在多个进程间共享,避免每个进程都单独加载,造成宝贵内存资源的浪费。
Page Cache 是什么
Page Cache 是由内核管理的内存,位于 VFS(Virtual File System) 层和具体文件系统层(例如ext4,ext3)之间。应用进程使用 read
/write
等文件操作,通过系统调用进入到 VFS 层,根据 O_DIRECT 标志,可以使用 Page Cache 作为文件内容的缓存,也可以跳过 Page Cache 不使用内核提供的缓存功能。
另外,应用程序可以使用 mmap ,将文件内容映射到进程的虚拟地址空间,可以像读写内存一样直接读写硬盘上的文件。进程的虚拟内存直接和 Page Cache 映射。
为了了解内核是怎么管理 Page Cache 的,我们先看一下 VFS 的几个核心对象:
- file 存放已打开的文件信息,是进程访问文件的接口;
- dentry 使用
dentry
将文件组织成目录树结构; - inode 唯一标识文件系统中的文件。对于同一个文件,内核中只会有一个 inode 结构。
对于每一个进程,打开的文件都有一个文件描述符,内核中进程数据结构 task_struct 中有一个类型为 files_struct 的 files 字段,保存着该进程打开的所有文件。files_struct 结构的fd_array 字段是 file 数组, 数组的下标是文件描述符,内容指向一个 file 结构,表示该进程打开的文件。file 与打开文件的进程相关联,如果多个进程打开同一个文件,那么每个进程都有自己的 file ,但这些 file 指向同一个 inode。
如上图所示,进程通过文件描述符与 VFS
中的 file
产生联系, 每个 file
对象又与一个 dentry
对应,根据 dentry
能找到 inode
,而 inode
则代表文件本身。上图中进程 A 和进程 B 打开了同一个文件,进程 A 和进程 B 都维护着各自的 file
,但它们指向同一个 inode
。
inode 通过 address_space 管理着文件已加载到内存中的内容,也就是 Page Cache。address_space 的字段 i_pages
指向一棵 xarray 树,与这个文件相关的 Page Cache 页都挂在这颗树上。我们在访问文件内容的时候,根据指定文件和相应的页偏移量,就可以通过 xarray 树快速判断该页是否已经在 Page Cache 中。如果该页存在,说明文件内容已经被读取到了内存,也就是存在于 Page Cache 中;如果该页不存在,就说明内容不在 Page Cache 中,需要从磁盘中去读取。
由于文件和 inode 一一对应,我们可以认为 inode 是 Page Cache 的宿主(host
),内核通过 inode->imapping->i_pages
指向的树,管理维护着 Page Cache。
Page Cache 是如何产生和释放,又是如何与进程相关联的呢?我们需要先了解进程虚拟地址空间。
进程虚拟地址空间
Linux 是多任务系统,它支持多个进程的的并发执行。操作系统和 CPU 联手制造了一个假象:每个进程都独享连续的虚拟内存空间,并且各个进程的地址空间是完全隔离的,因此进程并不会意识到彼此的存在。从进程的角度来看,它会认为自己是系统中唯一的进程。
进程看到的是虚拟内存的地址空间,它也不能直接访问物理地址。当进程访问某个虚拟地址的时候,该虚拟地址由内核负责转换成物理内存地址,即完成虚拟地址到物理地址的映射。这样不同进程在运行的时候,即使访问相同的虚拟地址,但内核会将它们映射到不同的物理地址,因此不会发生冲突。
进程在 Linux 内核由 task_struct 所描述。估计 task_struct 是你学习内核时第一个熟悉的数据结构,因为它实在太重要了。 task_struct描述了进程相关的所有信息,包括进程状态,运行时统计信息,进程亲缘关系,进度调度信息,信号处理,进程内存管理,进程打开的文件等等。我们这里关注的进程虚拟内存空间,是由 task_struct 中的 mm 字段指向的 mm_struct 所描述,它是一个进程内存的运行时摘要信息。
进程的虚拟地址是线性的,使用结构体 vm_area_struct 来描述。内核将每一段具有相同属性的内存区域当作一个 vm_area_struct 进行管理,每个 vm_area_struct 是一个连续的虚拟地址范围,这些区域不会互相重叠。 mm_struct 里面有一个单链表 mmap
,用于将 vm_area_struct 串联起来,另外还有一颗红黑树 mm_rb
,vm_area_struct 根据起始地址挂在这颗树上。使用红黑树可以根据地址,快速查找一个内存区域。
vm_area_struct 可以直接映射到物理内存,也可以关联文件。如果 vm_area_struct 是文件映射,由成员 vm_file
指向对应的文件指针。一个没有关联文件的 vm_area_struct 是匿名内存。
开发者使用 malloc 等 glibc 库函数分配内存的时候,不是直接分配物理内存,而是在进程的虚拟内存空间中申请一段虚拟内存,生成相应的数据结构 vm_area_struct ,然后将它插进 mm_struct 的链表 mmap
,同时挂在红黑树 mm_rb
上,就算完成了工作,根本没有涉及到物理内存的分配。只有当第一次对这块虚拟内存进行读写时,发现该内存区域没有映射到物理内存,这时会触发缺页中断,然后由内核填写页表,完成虚拟内存到物理内存的映射。
当开发者使用 mmap 进行文件映射时,内核根据 vm_area_struct 中代表文件映射关系 vm_file
,将文件内容从磁盘加载到物理内存,也就是 Page Cache 中,最后建立这段虚拟地址到物理地址的映射。
另外,在虚拟内存中连续的页面,在物理内存中不必是连续的。只要维护好从虚拟内存页到物理内存页的映射关系,你就能正确地使用内存。由于每个进程都有独立的地址空间,为了完成虚拟地址到物理地址的映射,每个进程都要有独立的进程页表。在一个实际的进程里面,虚拟内存占用的地址空间,通常是两段连续的空间,而不是完全散落的随机的内存地址。基于这个特点,内核使用多级页表保存映射关系,可以大大减少页表本身的空间占用。最顶级的页表保存在 mm_struct 的 pgd
字段中。
好了,我们对进程虚拟地址空间有了基本的了解,下面看看 Page Cache 的产生和释放,以及如何与进程空间发生联系的。
Page Cache 的产生和释放
Page Cache 的产生有两种不同的方式:
- Buffered I/O
- Memory-Mapped file
使用这两种方式访问磁盘上的文件时,内核会根据指定的文件和相应的页偏移量,判断文件内容是否已经在 Page Cache 中,如果内容不存在,需要从磁盘中去读取并创建 Page Cache 页。
这两种方式的不同之处在于,使用 Buffered I/O
,要先将数据从 Page Cache 拷贝到用户缓冲区,应用才能从用户缓冲区读取数据。而对于 Memory-Mapped file
而言,则是直接将 Page Cache 页映射到进程虚拟地址空间,用户可以直接读写 Page Cache 中的内容。由于少了一次 copy,使用 Memory-Mapped file
要比 Buffered I/O
的效率高一些。
随着服务器运行时间的增加,系统中空闲内存会越来越少,其中很大一部分都被 Page Cache 占用。访问过的文件都被 Page Cache 缓存,内存最终会被耗尽,那什么时候回收 Page Cache 呢? 内核认为,Page Cache 是可回收内存,当应用在申请内存时,如果没有足够的空闲内存,就会先回收 Page Cache,再尝试申请。回收的方式主要是两种:直接回收和后台回收。
使用 Buffered I/O
时,Page Cache 并没有和进程的虚拟内存空间产生直接的关联,而是通过用户缓冲区作为中转。效率更好的Memory-Mapped file
方式,看着比较简单,但背后的实现却有些复杂。下面我们看一下内核是如何实现 Memory-Mapped file
的。
内存文件映射
前面我们介绍过, inode 是 Page Cache 的宿主(host
),内核通过 inode->imapping->i_pages
指向的树,管理维护着 Page Cache。那么内核是如何完成内存文件映射,直接把缓存了文件内容的 Page Cache 映射到进程虚拟内存空间的呢?
我们知道,进程结构体 task_struct 中的字段 mm
指向该进程的虚拟地址空间 mm_struct ,而一段虚拟内存由结构体 vm_area_struct 所描述,将 vm_area_struct 串在一起的链表 mmap
就代表了已经申请分配的虚拟内存。
如果是进行内存文件映射,那么映射了文件的虚拟内存区域 vm_area_struct ,它的 vm_file
会指向被映射的文件结构体 file。file 表示进程打开的文件,它的成员 f_mapping
指向 address_space,这样就和管理文件着 Page Cache 的 address_space 关联了起来。
当第一次访问文件映射的虚拟内存区域时,这段虚拟内存并没有映射到物理内存,这时会触发缺页中断。内核在处理缺页中断时,发现代表这段虚拟内存的 vm_area_struct 有关联的文件,即 vm_file
字段指向一个文件结构体 file。内核拿到该文件的 address_space,根据要访问内容的页偏移量,对 address_space->i_pages
指向的 xarray 树进行查找。这颗树上挂的都是页偏移量对应的内存页,如果没找到,就说明文件内容还没加载进内存,就会分配内存页,将文件内容加载到内存中,然后把内存页挂在 xarray 树上。下次再访问同样的页偏移量时,文件内容已经在树上,可直接返回。 address_space->i_pages
指向的树就是内核管理的 Page Cache。
将文件内容加载到 Page Cache 后,内核就可以填写进程相关的页表项,将这块文件映射的虚拟地址区域,直接映射到 Page Cache 页,完成缺页中断的处理。
当内存紧张需要回收 Page Cache 时,内核需要知道这些 Page Cache 页映射到了哪些进程,这样才能修改进程的页表,解除虚拟内存和物理内存的映射。我们知道,同一个文件可以映射到多个进程空间,所以需要保存反向映射关系,即根据 Page Cache 页找到进程。
Page Cache 页的反向映射关系保存在 address_space 维护的另一颗树 i_mmap
。address_space->i_mmap
是一个优先查找树(Priority Search Tree),关联了这个文件 Page Cache 页的 vm_area_struct 就挂在这棵树上,而这些 vm_area_struct都将指向各自的进程空间描述符 mm_struct,从而建立了 Page Cache 页到进程的联系。
当需要解除一个 Page Cache 页的映射时,利用 address_space->i_mmap
指向的树,查找 Page Cache 页映射到哪些进程的哪些 vm_area_struct,从而确定需要修改的进程页表项内容。
简单总结一下,一个文件对应的 address_space 主要管理着两颗树:i_pages
指向的 xarray 树,维护着的所有 Page Cache 页;i_mmap
指向的 PST 树,维护着文件映射所形成的 vm_area_struct 虚拟内存区域,用来在释放 Page Cache 页时,查找映射了该文件的进程。如果文件没有被映射到进程空间,那么 i_mmap
对应的 PST 树为空。
Page Cache 的观测
可以通过查看 /proc/meminfo
文件获知 Page Cache 相关的各种指标。
/proc
是伪文件系统(Pseudo filesystems )。Linux 通过伪文件系统,让系统和内核信息在用户空间可用。使用 free
、vmstat
等命令查看到的内存信息,数据实际上都来自 /proc/meminfo
。
我们看一个示例:
代码语言:javascript复制$ cat /proc/meminfo
MemTotal: 8052564 kB
MemFree: 129804 kB
MemAvailable: 4956164 kB
Buffers: 175932 kB
Cached: 4896824 kB
SwapCached: 40 kB
Active: 2748728 kB <- Active(anon) Active(file)
Inactive: 4676540 kB <- Inactive(anon) Inactive(file)
Active(anon): 3432 kB
Inactive(anon): 2513172 kB
Active(file): 2745296 kB
Inactive(file): 2163368 kB
Unevictable: 65496 kB
Mlocked: 0 kB
SwapTotal: 2097148 kB
SwapFree: 2095868 kB
Dirty: 12 kB
Writeback: 0 kB
AnonPages: 2411440 kB
Mapped: 761076 kB
Shmem: 170868 kB
...
关于 /proc/meminfo
每一项的详细解释,可以查看 [Linux 内核文档 - The /proc Filesystem](The /proc Filesystem — The Linux Kernel documentation)。我们重点看一下 Page Cache 相关的字段。
当前系统 Page Cache 等于 Buffers Cached 之和 :
代码语言:javascript复制Buffers Cached = 5072756 kB
前面讨论过,如果 vm_area_struct 关联到文件,那么这段内存区域就是 File-backed 内存。没有关联文件的 vm_area_struct 内存区域是匿名内存。我们是否可以认为,和磁盘文件相关联的 File-backed 内存总和,应该等于 Page Cache 呢?
代码语言:javascript复制Active(file) Inactive(file) = 4908664 kB
好像有点对不上,还差了一些,差的这部分是共享内存(Shmem)。
Linux 为了实现“共享内存”(shared memory)功能,即多个进程共同使用同一内存中的内容,需要使用虚拟文件系统。虚拟文件并不是真实存在于磁盘上的文件,它只是由内核模拟出来的。但虚拟文件也有自己的 inode 和 address_space结构。内核在创建共享匿名映射区域时,会创建出一个虚拟文件,并将这个文件与 vm_area_struct关联起来,这样多个进程的 vm_area_struct 会关联到同一个虚拟文件,最终映射到同样的物理内存页,从而实现了共享内存功能。这就是共享内存(Shmem)的实现原理。
由于 Shmem 没有关联磁盘上的文件,因此它不属于 File-backed 内存,而是被记录在匿名内存(Active(anon) 或 Inactive(anon))部分。但因为 Shmem 有自己的 inode ,inode->address_sapce
维护的 Page Cache 页挂在 address_space->i_pages
指向的 xarray 树上,因此 Shmem 部分的内存也应该算在 Page Cache 里。
此外 File-backed 内存还有 Active 和 Inactive 的区别。刚被使用过的数据的内存空间被认为是 Active 的,长时间未被使用过的数据的内存空间则被认为是 Inactive 的。当物理内存不足,不得不释放正在使用的内存时,会首先释放 Inactive 的内存。
Page Cache 和 匿名内存以及 File-backed 内存等之间的关系,如图下图所示。虽然难免存在误差,但大体来说下面的关系式是成立的:
值得注意的是,AnonPages != Active(anon) Inactive(anon)。Active(anon) 和 Inactive(anon) 是用来表示不可回收但是可以被交换到 swap 分区的内存,而 AnonPages 则是指没有对应文件的内存,两者的角度不一样。 Shmem 虽然属于Active(anon) 或者 Inactive(anon),但是 Shmem 有对应的内存虚拟文件,所以它不属于 AnonPages。
总之,Page Cache 肯定关联了文件,不管是真实存在的磁盘文件,还是虚拟内存文件。AnonPages 则没有关联任何文件。Shmem 关联了虚拟文件,它属于 Active(anon) 或者 Inactive(anon),同时也算在 Page Cache 中。
如果我们想知道某个文件有多少内容被缓存在 Page Cache ,可以使用 [fincore](fincore(1) - Linux 手册页 (man7.org)) 命令。例如:
代码语言:javascript复制$ fincore /usr/lib/x86_64-linux-gnu/libc.so.6
RES PAGES SIZE FILE
2.1M 542 2.1M /usr/lib/x86_64-linux-gnu/libc.so.6
RES
是文件内容被加载进物理内存占用的内存空间大小。PAGES
是换算成文件内容占用了多少内存页。 在上面的例子中,文件 /usr/lib/x86_64-linux-gnu/libc.so.6
的全部内容,都被加载进了 Page Cache。
结合 lsof
命令,我们可以查看某一进程打开的文件占用了多少 Page Cache:
$ sudo lsof -p 1270 | grep REG | awk '{print $9}' | xargs sudo fincore
RES PAGES SIZE FILE
64.8M 16580 89.9M /usr/bin/dockerd
32K 8 32K /var/lib/docker/buildkit/cache.db
16K 4 16K /var/lib/docker/buildkit/metadata_v2.db
16K 4 16K /var/lib/docker/buildkit/snapshots.db
16K 4 16K /var/lib/docker/buildkit/containerdmeta.db
284K 71 282.4K /usr/lib/x86_64-linux-gnu/libnss_systemd.so.2
244K 61 594.7K /usr/lib/x86_64-linux-gnu/libpcre2-8.so.0.10.2
156K 39 154.2K /usr/lib/x86_64-linux-gnu/libgpg-error.so.0.29.0
24K 6 20.6K /usr/lib/x86_64-linux-gnu/libpthread.so.0
908K 227 906.5K /usr/lib/x86_64-linux-gnu/libm.so.6
...
另外,对于所有缓存类型,缓存命中率都是一个非常重要的指标。我们可以使用 bcc 内置的工具 cachestat 追踪整个系统的 Page Cache 命中率:
代码语言:javascript复制$ sudo cachestat-bpfcc
HITS MISSES DIRTIES HITRATIO BUFFERS_MB CACHED_MB
2059 0 32 100.00% 74 1492
522 0 0 100.00% 74 1492
32 0 7 100.00% 74 1492
135 0 69 100.00% 74 1492
97 1 3 98.98% 74 1492
512 0 82 100.00% 74 1492
303 0 86 100.00% 74 1492
2474 7 1028 99.72% 74 1494
815 0 964 100.00% 74 1497
2786 0 1 100.00% 74 1497
1051 0 0 100.00% 74 1497
^C 502 0 0 100.00% 74 1497
Detaching...
使用 cachetop 可以按进程追踪 Page Cache 命中率:
代码语言:javascript复制$ sudo cachetop-bpfcc
14:20:41 Buffers MB: 86 / Cached MB: 2834 / Sort: HITS / Order: descending
PID UID CMD HITS MISSES DIRTIES READ_HIT% WRITE_HIT%
14237 mazhen java 12823 4594 3653 52.6% 13.2%
14370 mazhen ldd 869 0 0 100.0% 0.0%
14371 mazhen grep 596 0 0 100.0% 0.0%
14376 mazhen ldd 536 0 0 100.0% 0.0%
14369 mazhen env 468 0 0 100.0% 0.0%
14377 mazhen ldd 467 0 0 100.0% 0.0%
14551 mazhen grpc-default-ex 466 0 0 100.0% 0.0%
14375 mazhen ldd 435 0 0 100.0% 0.0%
14479 mazhen ldconfig 421 0 0 100.0% 0.0%
14475 mazhen BookieJournal-3 417 58 132 60.0% 6.1%
...
mmap系统调用
系统调用 mmap是最重要的内存管理接口。使用 mmap 可以创建文件映射,从而产生 Page Cache。使用 mmap 还可以用来申请堆内存。glibc 提供的 malloc,内部使用的就是 mmap 系统调用。 由于 mmap 系统调用分配内存的效率比较低, malloc 会先使用 mmap 向操作系统申请一块比较大的内存,然后再通过各种优化手段让内存分配的效率最大化。
mmap 根据参数的不同, 可以从是不是文件映射,以及是不是私有内存这两个不同的维度来进行组合:
- 私有匿名映射
在调用 mmap(MAP_ANON | MAP_PRIVATE)
时,只需要在进程虚拟内存空间分配一块内存,然后创建这块内存所对应的 vm_area_struct 结构,这次调用就结束了。当访问到这块虚拟内存时,由于这块虚拟内存都没有映射到物理内存上,就会发生缺页中断。 vm_area_struct关联文件属性为空,所以是匿名映射。内核会分配一个物理内存,然后在页表里建立起虚拟地址到物理地址的映射关系。
- 私有文件映射
进程通过 mmap(MAP_FILE | MAP_PRIVATE)
这种方式来申请的内存,比如进程将共享库(Shared libraries)和可执行文件的代码段(Text Segment)映射到自己的地址空间就是通过这种方式。
如果文件是只读的话,那这个文件在物理页的层面上其实是共享的。也就是进程 A 和进程 B 都有一页虚拟内存被映射到了相同的物理页上。但如果要写文件的时候,因为这一段内存区域的属性是私有的,所以内核就会做一次写时复制,为写文件的进程单独地创建一份副本。这样,一个进程在写文件时,并不会影响到其他进程的读。
私有文件映射的只读页是多进程间共享的,可写页是每个进程都有一个独立的副本,创建副本的时机仍然是写时复制。
- 共享文件映射
进程通过 mmap(MAP_FILE | MAP_SHARED)
这种方式来申请的内存。在私有文件映射的基础上,共享文件映射就很简单了:对于可写的页面,在写的时候不进行复制就可以了。这样的话,无论何时,也无论是读还是写,多个进程在访问同一个文件的同一个页时,访问的都是相同的物理页面。
- 共享匿名映射
进程通过 mmap(MAP_ANON | MAP_SHARED)
这种方式来申请的内存。借助虚拟文件系统,多个进程的 vm_area_struct 会关联到同一个虚拟文件,最终映射到同样的物理内存页,实现进程间共享内存的功能。
mmap
的四种映射类型,和上面介绍的 /proc/meminfo
内存指标之间的关系:
私有映射都属于 AnonPages,共享映射都是 Page cache。前面讨论过,共享的匿名映射 Shmem,虽然没有关联真实的磁盘文件,但是关联了虚拟内存文件,所以也属于 Page Cache。
私有文件映射,如果文件是只读的话,这块内存属于 Page Cache。如果有进程写文件,因为这一段内存区域的属性是私有的,所以内核就会做一次写时复制,为写文件的进程单独地创建一份副本,这个副本就属于 AnonPages 了。
写在最后
Page Cache 机制涉及了进程空间,文件系统,内存管理等多个内核功能,Page Cache 就像一条线将这几部分串在了一起。因此深入理解 Page Cache 机制,对学习内核会有很大的帮助。