从内核世界角度看mmap内存映射的本质(原理)

2023-10-05 04:29

本文基于内核5.4版本源码讨论

之前有很多读者给作者留言,希望作者写一篇文章介绍一下mmap内存映射相关的知识体系。之所以还没有开始写,是因为mmap系统调用看似简单,实际上并不简单。 ,可以说是一个非常复杂的系统调用。

如果想要正确清晰地还原mmap背后的技术本质,还是有一定难度的,因为mmap是一个单一的系统调用,可以撬动整个内存管理系统、文件系统和页表。系统、分页符等大量的背景知识,涉及的知识面广、复杂。

好在作者已经在《聊聊 Linux 内核》系列文章中详细介绍了这一整套背景知识,所以现在是时候开始写了,不过你不用担心,虽然涉及到的背景知识是相对较小。还有很多,不过在后面的相关章节中,笔者会再给大家讲解一下。

在上一篇文章《一步一图带你构建 Linux 页表体系》中,作者向大家介绍了内存映射最核心的内容——页表系统。通过循序渐进的方式,将整个页表系统的演变过程展示给大家,并且在这个过程中,整个页表系统逐渐被揭示出来。

本文内容还是内存映射相关。这次笔者将带大家了解页表的核心系统,并介绍页表外围的内存映射知识。核心目的是彻底恢复给大家。内存映射背后的技术本质将由浅入深地为大家讲解得透彻、清晰。

在正式开始今天的内容之前,笔者首先提出几个问题供大家思考。建议您带着这些疑问阅读下面的内容。让我们共同努力,层层拨开这些迷雾。慢慢打开它,直到恢复内存映射的本质。

  1. 既然我们讨论虚拟内存和物理内存的映射,那么首先你必须有虚拟内存,而且你还必须有物理内存。在此基础上,我们就可以讨论两者之间的映射,而物理内存是怎么来的,作者已经通过之前的文章《深入理解 Linux 物理内存分配全链路实现》介绍的很清楚了,那么虚拟内存从哪里来呢?内核分配虚拟内存的过程是怎样的?

  2. 我们知道内存映射是以物理内存页为单位进行的。在内存管理中,内存页主要分为两种:一种是匿名页,一种是文件页。这个作者已经解释过了《一步一图带你深入理解 Linux 物理内存管理》我在这篇文章里已经说过很多次了。根据物理内存页的类型分类,内存映射自然分为两种:一种是虚拟内存到匿名物理内存页的映射,另一种是虚拟内存到文件页的映射。关于文件映射,大家或多或少都在网上看到过这样的讨论——“内存文件映射可以将磁盘上的文件映射到内存中,这样我们就可以通过读写内存来读写磁盘文件”。对于这个讨论,不熟悉内存管理和文件系统的同学可能会觉得这句话很神奇。他们可能会有这样的疑问:内存是内存,磁盘上的文件是文件。这是两个完全不同的事情。不同的东西,为什么说读写内存就相当于读写磁盘上的文件呢?内核中的内存文件映射到底发生了什么?我们常说的内存映射,它到底映射的是什么?

  3. 在上一篇文章中,作者只向大家展示了整个页表系统以及页表系统的一步步演化过程。然而,进程创建后,内核只会为进程分配一个全局页。页目录表就是PGD(Page Global Directory)。此时进程虚拟内存空间中只有一张顶级页目录表,即上图所示的四级页表系统中的上层页目录PUD(Page Upper Directory)、中层页目录PMD目录(Page Middle Directory)和一级页表不存在。那么上图所示的完整页表系统是何时、如何一步步构建起来的呢?

本文的主要目的就是为了解决以上问题,那么从哪里开始呢?想了想,笔者应该先从我们最熟悉、经常接触到的用户态下的内存映射系统调用mmap开始说吧~~~

1。内存映射系统调用mmap详解

#包括
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
// 内核文件:/arch/x86/kernel/sys_x86_64.c
SYSCALL_DEFINE6(mmap、无符号长整型、addr、无符号长整型、len、
无符号长整型、prot、无符号长整型、标志、
无符号长整型、fd、无符号长整型、关闭)

mmap 所谓内存中的内存映射,其实就是指虚拟内存。当调用mmap进行匿名映射(如堆内存分配)时,会将进程虚拟内存空间中的某个虚拟内存区域与物理内存结合起来。进程中的匿名内存页被映射。当调用mmap进行文件映射时,会将进程虚拟内存空间中的某个虚拟内存区域映射到磁盘上文件中的某个区域。

内存映射消耗的虚拟内存位于进程虚拟内存空间的什么位置?

作者在之前的文章《一步一图带你深入理解 Linux 虚拟内存管理》中详细介绍了进程虚拟内存空间的布局。在进程虚拟内存空间的布局中,有一个部分叫做 文件映射和匿名映射区 虚拟内存区域,当我们在用户态应用程序中调用 mmap 进行内存映射时,需要的虚拟内存为划分在这个区域。

文件映射和匿名映射的虚拟内存区域包含一段段虚拟映射区域。每当我们调用mmap进行内存映射时,内核都会在文件映射区和匿名映射区中划分一段。虚拟映射区域就出来了。这个虚拟映射区域就是我们申请的虚拟内存。

那么我们申请的虚拟内存有多大呢?这使用 mmap 系统调用的前两个参数:

  • addr:表示我们要映射的虚拟内存区域在进程虚拟内存空间中的起始地址(虚拟内存地址),但这个参数只是给内核一个提示。内核不一定要从我们指定的addr开始。虚拟内存区域是根据虚拟内存地址来划分的。内核在划分虚拟内存区域时只会优先考虑我们指定的addr。如果虚拟地址已被使用或者是无效地址,内核将自动选择一个合适的地址。地址用于划分虚拟内存区域。我们一般将addr设置为NULL,这意味着完全留给内核来帮助我们确定虚拟映射区域的起始地址。

  • length:解决了从进程虚拟内存空间中的什么地方划分虚拟内存区域的问题。那么我们要申请多大的虚拟内存呢?这就是长度参数的作用。如果是匿名映射,则length参数决定了我们要映射的匿名物理内存的大小。如果是文件映射,则length参数决定了我们要映射的文件区域的大小。

addr,长度必须按照PAGE_SIZE(4K)对齐。

如果我们通过mmap来映射磁盘上的文件,那么我们需要使用参数fd来指定要映射的文件的描述符(文件描述符),使用参数offset来指定文件映射的偏移量文件中的区域。

在内存管理系统中,物理内存是以内存页为单位组织的。在文件系统中,磁盘中的文件以磁盘块为单位进行组织。内存页和磁盘块大小一般都是4K大小,所以这里的偏移量也必须按照4K对齐。

文件映射区和匿名映射区中的虚拟映射区本质上都是虚拟内存区。它们与进程虚拟内存空间中的代码段、数据段、BSS段、堆、栈并不相同。任何差异都由内核中的 struct vm_area_struct 结构表示。下面我们将进程空间中的这些虚拟内存区域称为VMA。

进程虚拟内存空间中的所有VMA在内核中都有两种组织形式:一种是双向链表,用于高效地遍历进程VMA。这个VMA双向链表是有序的,所有VMA节点都在双向链表中。排列顺序是虚拟内存从低地址到高地址。

另一种是使用红黑树来组织,用来高效的在进程空间中查找VMA,因为进程虚拟内存中不仅有代码段、数据段、BSS段、堆、栈空间。虚拟内存区域VMA,特别是在数据密集型应用进程中,文件映射和匿名映射区域也会包含大量VMA。进程的各种动态链接库映射的虚拟内存都在这里,在进程运行过程中进行处理。匿名映射和文件映射所需的虚拟内存也在这里。内核需要频繁地对进程虚拟内存空间中的这么多VMA进行增删改查。因此,需要这样的红黑树结构,以方便内核的高效搜索。

//进程虚拟内存空间描述符
结构mm_struct {
// 连接进程空间中所有VMA的双向链表
struct vm_area_struct *mmap; /* VMA 列表 */
//管理进程空间内所有VMA的红黑树
结构 rb_root mm_rb;
}
//虚拟内存区域描述符
结构体vm_area_struct {// mm_struct->mmap双向链表中vma的前驱节点和后继节点
struct vm_area_struct *vm_next, *vm_prev;
// mm_struct->mm_rb红黑树中的vma节点
struct rb_node vm_rb;
}

上图中的文件映射和匿名映射区域实际上包含了大量的VMA。这只是为了清楚地向大家展示内核中虚拟内存的组织结构,所以只画了一大片VMA来表示文件映射。对于匿名测绘区域,每个人都需要知道这一点。

mmap系统调用的本质是首先从进程虚拟内存空间中的文件映射区和匿名映射区划分出一块虚拟内存区VMA。这个VMA区域的大小由vm_start和vm_end表示,由mmap系统调用参数addr和length确定。

struct vm_area_struct {
无符号长 vm_start; /* 我们在 vm_mm 内的起始地址。 */
无符号长 vm_end; /* 结束地址后的第一个字节 */
}

然后内核会对这个VMA进行相关映射。如果是文件映射,内核会将我们要映射的文件以及要映射的文件区域在文件中的偏移量与VMA结构体中的vm_file和vm_pgoff进行匹配。关联是被映射的,它们是由mmap系统调用参数fd、offset决定的。

struct vm_area_struct {
结构文件* vm_file; /* 我们映射到的文件(可以为 NULL)。 */无符号长 vm_pgoff; /* PAGE_SIZE 中的偏移量(在 vm_file 内)*/
}

另外,文件映射和匿名映射区域中mmap映射的这个虚拟内存区域与进程虚拟内存空间中的其他虚拟内存区域是一样的,同样受到权限控制。

例如上图中进程虚拟内存空间中的代码段与磁盘上ELF格式可执行文件中的.text节(磁盘文件中各区域的单元组织结构)进行映射,存储的是程序执行机器代码,因此在可执行文件与进程虚拟内存空间之间映射文件时,需要指定该代码段的虚拟内存区域的权限为可读(VM_READ)和可执行(VM_EXEC) )。

数据段也是通过文件映射进来的。内核会将磁盘上ELF格式可执行文件中的.data段与data段进行映射。映射时需要指定该数据段的虚拟内存区域的权限是可读的。 (VM_READ),可写(VM_WRITE)。

与代码段和数据段不同,BSS段、堆、栈等虚拟内存区域不是从磁盘二进制可执行文件加载的。它们通过匿名映射的方式映射到进程虚拟内存空间。

BSS段存储程序未初始化的全局变量。该虚拟内存区域的权限为可读(VM_READ)和可写(VM_WRITE)。

堆用于描述进程在运行过程中动态申请的虚拟内存区域,因此堆也会具有可读(VM_READ)和可写(VM_WRITE)权限。在某些情况下,堆还具有可执行(VM_EXEC)权限,比如Java中的字节码存储在堆中,所以需要可执行权限。

堆栈用于保存进程运行时的命令行参数、环境变量以及函数调用时产生的堆栈帧。堆栈一般具有可读(VM_READ)和可写(VM_WRITE)权限,但也可以设置可执行(VM_EXEC)权限,但出于安全考虑,很少设置。

文件映射和匿名映射区域的情况变得更加复杂,因为文件映射和匿名映射区域包含大量的VMA,特别是在数据密集型应用进程中。我们每次调用mmap的时候,无论是匿名映射还是文件映射,都会在文件映射和匿名映射区域生成一个VMA,而这个mmap映射的VMA中的相关权限和flags是由mmap决定的prot和flags系统调用中的参数确定后,最终会映射到虚拟内存区域的VMA结构体中的vm_page_prot和vm_flags属性,它们指定了进程对该虚拟内存区域的访问权限和相关标志。

此外,进程运行时所依赖的动态链接库.so文件也通过文件映射将动态链接库中的代码段和数据段映射到文件映射和匿名映射区域。

struct vm_area_struct {
/*
* 此 VMA 的访问权限。
*/
pgprot_t vm_page_prot;
无符号长 vm_flags;
}

我们可以使用mmap系统调用中的参数prot来指定进程虚拟内存空间中映射的这个虚拟内存区域VMA的访问权限。它的值有以下四个值:

#define PROT_READ 0x1 /* 页可读取 */
#define PROT_WRITE 0x2 /* 页可写 */
#define PROT_EXEC 0x4 /* 页面可以被执行 */
#define PROT_NONE 0x0 /* 页面无法访问 */
  • PROT_READ表示虚拟内存区域后面映射的物理内存可读。

  • PROT_WRITE表示虚拟内存区域后面映射的物理内存是可写的。

  • PROT_EXEC 表示可以执行虚拟内存区域后面映射的物理内存中存储的内容。该内存区域常常存放用于执行程序的机器代码,例如进程虚拟内存空间中的代码段,并通过文件映射将动态链接库加载到文件映射和匿名映射区域中的代码段中。这些VMA的权限是PROT_EXEC。

  • PROT_NONE 表示该虚拟内存区域无法访问,无法读取、写入或执行。防护页面用于防止攻击。如果攻击者访问保护页,则会触发 SIGSEV 段错误。另外,指定PROT_NONE还可以提前为进程保留这部分虚拟内存区域。虽然无法访问,但是当后续进程需要时,可以通过mprotect系统调用来修改这部分虚拟内存区域的权限。

mprotect系统调用可以动态修改进程虚拟内存空间中任意虚拟内存区域的权限。

除了指定这个mmap映射的虚拟内存区域VMA的访问权限之外,我们还需要指定这个映射区域VMA的映射方法。 VMA的映射方法由mmap系统调用参数标志决定。内核为flags定义了大量的枚举值。下面,我就为大家挑选一些非常重要、核心的枚举值,解释一下它们的含义:

#define MAP_FIXED 0x10 /* 准确解释 addr */
#define MAP_ANONYMOUS 0x20 /* 不使用文件 */
#define MAP_SHARED 0x01 /* 共享更改 */#define MAP_PRIVATE 0x02 /* 更改是私有的 */

前面我们介绍了mmap系统调用的addr参数。该参数只是对内核的一个提示,并不是强制的。意思是我们希望内核能够根据我们指定的虚拟内存地址addr开始创建虚拟内存映射区域VMA。 。

但是如果我们指定的addr是非法地址,比如[addr, addr + length] 这个虚拟内存地址已经有映射关系了,那么内核会自动帮我们选择一个合适的。虚拟内存地址开始映射,但是当我们在mmap系统调用的参数flags中指定MAP_FIXED时,那么参数addr就变成必填的,如果[addr , addr + length] 这个虚拟内存地址已经有了映射关系,那么内核就会unmmap这个映射关系,然后根据我们的要求重新映射。如果addr是非法地址,内核会报错并停止映射。

操作系统以内存页为单位管理物理内存,内存页有两种类型:一种是匿名页,一种是文件页。根据内存页类型的不同,内存映射自然分为两种:一种是虚拟内存到匿名物理内存页的映射,另一种是虚拟内存到文件页的映射,也就是我们常说的为匿名。映射和文件映射。

当我们指定mmap系统调用参数标志为MAP_ANONYMOUS时,意味着我们需要进行匿名映射。由于是匿名映射,所以fd和offset这两个参数没有意义,fd参数需要设置为-1。当我们进行文件映射时,只需要指定fd和offset参数即可。

mmap创建的虚拟内存区域后面映射的物理内存是否可以在多个进程之间共享,分为两种内存映射方式:

  • MAP_SHARED 表示共享映射。通过mmap映射的内存区域在多个进程之间共享。如果一个进程修改了共享映射内存区域,其他进程就可以看到它。它用于多个进程。进程之间的通信。

  • MAP_PRIVATE 表示私有映射。通过mmap映射的内存区域是进程私有的,其他进程无法看到。如果是私有文件映射,多个进程对同一个映射文件的修改不会被写回磁盘文件

这里介绍的flags参数枚举值可以相互组合。通过这些枚举值我们可以组合出下面的内存映射方式。

2。私人匿名映射

MAP_PRIVATE | MAP_ANONYMOUS 表示私有匿名映射。我们经常使用这种映射方式来申请虚拟内存。例如,当我们使用glibc库中封装的malloc函数申请虚拟内存时,当申请的内存大于128K时,malloc会调用mmap使用私有匿名映射的方式申请堆内存。因为是私有的,所以申请的内存是进程独占的,不能在多个进程之间共享。

这里需要强调的是,mmap私有匿名映射仅适用于虚拟内存。内核只在进程虚拟内存空间中划分一个虚拟内存区域VMA,并初始化VMA的初始化属性。 mmap系统调用结束。这与物理内存无关。您将在以下章节中看到此过程。

当进程开始访问这个虚拟内存区域时,发现没有任何物理内存与这个虚拟内存区域关联。这在内核中体现为该虚拟内存地址在页表中的PTE项为空。

或者PTE中的P位为0,表示虚拟内存还没有与物理内存映射。

关于页表相关知识,不熟悉的读者可以回顾一下作者之前的文章《一步一图带你构建 Linux 页表体系》

此时MMU会触发缺页异常(page failure)。这里的缺页错误是指物理内存页面不足。然后进程就会切换到内核态。在内核缺页中断处理程序中,为这一段虚拟内存区域分配相应大小的物理内存页面,然后将物理内存页面中的所有内容初始化为0。最后在中建立虚拟内存和物理内存的映射关系页表,页错误异常处理结束。

当缺页处理程序返回时,CPU会重新启动导致缺页异常的内存访问指令。此时MMU就可以正常翻译物理内存地址了。

除了为进程申请虚拟内存之外,

mmap 的私有匿名映射也用在 execve 系统调用中。 execve 用于在当前进程中加载​​并执行一个新的二进制执行文件:

#包括
int execve(const char* 文件名, const char* argv[], const char* envp[])

参数filename指定新的可执行文件的文件名,argv用于传递新程序的命令行参数,envp用于传递环境变量。

由于程序在当前进程中重新执行,当前进程的用户态虚拟内存空间就没用了。内核需要根据这个可执行文件重新映射进程的虚拟内存空间。

既然现在要重新映射进程虚拟内存空间,那么内核要做的第一件事就是删除并释放旧的虚拟内存空间,并清除进程页表。然后根据文件名打开可执行文件,解析文件头,确定可执行文件的格式。不同的文件格式需要不同的函数来加载。

Linux支持多种可执行文件格式,例如elf格式和a.out格式。 struct linux_binfmt 结构体在内核中用于描述可执行文件。它定义了用于加载可执行文件的函数指针load_binary,以及用于加载动态链接库的函数指针load_shlib。不同的文件格式指向不同的加载功能:

静态结构 linux_binfmt elf_format = {
.module = 这个模块,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};
静态结构 linux_binfmt aout_format = {
.module = 这个模块,
.load_binary = load_aout_binary,
.load_shlib = load_aout_library,
};

在load_binary中,会解析相应格式的可执行文件,并根据文件内容重新映射进程的虚拟内存空间。例如,虚拟内存空间中的BSS段、堆和堆栈的内容不依赖于可执行文件,因此在load_binary中使用私有匿名映射来创建BSS段、堆和新的虚拟内存空间。堆。

1 {IMG_11: Ahr0chm6ly9pbwcymdizlmnuymxvz3Muy2jsb2jsB2CVMJKWNZU2MC8yMDIZMJKWNZU2MDIZMDKXNZK0MC01NTQWNJI0NS5W bmc =/}

虽然在可执行二进制文件中定义了BSS段,但它只记录了文件中BSS段的长度,与相关内容没有关联。因此,BSS段也会使用私有匿名映射加载到进程虚拟内存空间中。 。

3。私有文件映射

#包括
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

当我们调用mmap进行内存文件映射时,通过指定参数flags为MAP_PRIVATE,然后指定参数fd为要映射文件的文件描述符,就可以实现文件的隐私性(文件描述符)。映射。

假设磁盘上有一个名为 file-read-write.txt 的磁盘文件。现在多个进程使用私有文件映射。从文件偏移量开始,将文件内容的长度映射到每个进程。在虚拟内存空间中,调用mmap后,相关内存映射内核数据结构关系如下图所示:

为了描述方便,我们指定映射长度length为4K,因为文件系统中的磁盘块大小为4K,而映射到内存的内存页恰好为4K。

当进程打开一个文件时,内核会为其创建一个struct file结构体来描述所打开的文件,并在进程文件描述符列表fd_array数组中找到一个空闲位置分配给它,以及对应的低位位置该数组标记是我们在用户空间使用的文件描述符。

struct file结构与进程相关(fd的作用域也与进程相关)。即使多个进程打开同一个文件,内核也会为每个进程创建一个struct file结构,如上图所示。表示进程1和进程2都打开了同一个file-read-write.txt文件,那么内核会为进程1创建一个struct file结构,为进程2创建一个struct file结构。

磁盘上的每个文件在内核中都会有一个唯一的struct inode结构。 inode结构与进程无关。一个文件只对应内核中的一个inode。 inode结构用于描述文件的元信息。例如文件的权限、文件包含多少个磁盘块、每个磁盘块位于磁盘上的哪个位置等。

// ext4 文件系统中的 inode 结构
struct ext4_inode {
// 文件权限
__le16  i_mode;    /* File mode */
// 文件包含磁盘块的个数
__le32  i_blocks_lo;  /* Blocks count */
// 存放文件包含的磁盘块
__le32  i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
};

那么什么是磁盘块呢 ?我们可以类比内存管理系统,Linux 是按照内存页为单位来对物理内存进行管理和调度的,在文件系统中,Linux 是按照磁盘块为单位对磁盘中的数据进行管理的,它们的大小均是 4K 。

如下图所示,磁盘盘面上一圈一圈的同心圆叫做磁道,磁盘上存储的数据就是沿着磁道的轨迹存放着,随着磁盘的旋转,磁头在磁道上读写硬盘中的数据。而在每个磁盘上,会进一步被划分成多个大小相等的圆弧,这个圆弧就叫做扇区,磁盘会以扇区为单位进行数据的读写。每个扇区大小为 512 字节。

而在 Linux 的文件系统中是按照磁盘块为单位对数据读写的,因为每个扇区大小为 512 字节,能够存储的数据比较小,而且扇区数量众多,这样在寻址的时候比较困难,Linux 文件系统将相邻的扇区组合在一起,形成一个磁盘块,后续针对磁盘块整体进行操作效率更高。

只要我们找到了文件中的磁盘块,我们就可以寻址到文件在磁盘上的存储内容了,所以使用 mmap 进行内存文件映射的本质就是建立起虚拟内存区域 VMA 到文件磁盘块之间的映射关系 。

调用 mmap 进行内存文件映射的时候,内核首先会在进程的虚拟内存空间中创建一个新的虚拟内存区域 VMA 用于映射文件,通过 vm_area_struct->vm_file 将映射文件的 struct flle 结构与虚拟内存映射关联起来。

struct vm_area_struct {
struct file * vm_file;      /* File we map to (can be NULL). */
unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}

根据 vm_file->f_inode 我们可以关联到映射文件的 struct inode,近而关联到映射文件在磁盘中的磁盘块 i_block,这个就是 mmap 内存文件映射最本质的东西

站在文件系统的视角,映射文件中的数据是按照磁盘块来存储的,读写文件数据也是按照磁盘块为单位进行的,磁盘块大小为 4K,当进程读取磁盘块的内容到内存之后,站在内存管理系统的视角,磁盘块中的数据被 DMA 拷贝到了物理内存页中,这个物理内存页就是前面提到的文件页。

根据程序的时间局部性原理我们知道,磁盘文件中的数据一旦被访问,那么它很有可能在短期内被再次访问,所以为了加快进程对文件数据的访问,内核会将已经访问过的磁盘块缓存在文件页中。

一个文件包含多个磁盘块,当它们被读取到内存之后,一个文件也就对应了多个文件页,这些文件页在内存中统一被一个叫做 page cache 的结构所组织。

每一个文件在内核中都会有一个唯一的 page cache 与之对应,用于缓存文件中的数据,page cache 是和文件相关的,它和进程是没有关系的,多个进程可以打开同一个文件,每个进程中都有有一个 struct file 结构来描述这个文件,但是一个文件在内核中只会对应一个 page cache。

文件的 struct inode 结构中除了有磁盘块的信息之外,还有指向文件 page cache 的 i_mapping 指针。

struct inode {
struct address_space	*i_mapping;
}

page cache 在内核中是使用 struct address_space 结构来描述的:

struct address_space {
// 这里就是 page cache。里边缓存了文件的所有缓存页面
struct radix_tree_root  page_tree;
}

关于 page cache 的详细介绍,感兴趣的读者可以回看下 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 一文中的 “5. 页高速缓存 page cache” 小节。

当我们理清了内存系统和文件系统这些核心数据结构之间的关联关系之后,现在再来看,下面这幅 mmap 私有文件映射关系图是不是清晰多了。

page cache 在内核中是使用基树 radix_tree 结构来表示的,这里我们只需要知道文件页是挂在 radix_tree 的叶子结点上,radix_tree 中的 root 节点和 node 节点是文件页(叶子节点)的索引节点就可以了。

当多个进程调用 mmap 对磁盘上同一个文件进行私有文件映射的时候,内核只是在每个进程的虚拟内存空间中创建出一段虚拟内存区域 VMA 出来,注意,此时内核只是为进程申请了用于映射的虚拟内存,并将虚拟内存与文件映射起来,mmap 系统调用就返回了,全程并没有物理内存的影子出现。文件的 page cache 也是空的,没有包含任何的文件页。

当任意一个进程,比如上图中的进程 1 开始访问这段映射的虚拟内存时,CPU 会把虚拟内存地址送到 MMU 中进行地址翻译,因为 mmap 只是为进程分配了虚拟内存,并没有分配物理内存,所以这段映射的虚拟内存在页表中是没有页表项 PTE 的。

随后 MMU 就会触发缺页异常(page fault),进程切换到内核态,在内核缺页中断处理程序中会发现引起缺页的这段 VMA 是私有文件映射的,所以内核会首先通过 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有缓存相应的文件页(映射的磁盘块对应的文件页)。

struct vm_area_struct {
unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}
static inline struct page *find_get_page(struct address_space *mapping,
pgoff_t offset)
{
return pagecache_get_page(mapping, offset, 0, 0);
}

如果文件页不在 page cache 中,内核则会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中,并增加页引用计数。

随后会通过 address_space_operations 重定义的 readpage 激活块设备驱动从磁盘中读取映射的文件内容,然后将读取到的内容填充新分配的内存页。

static const struct address_space_operations ext4_aops = {
.readpage       = ext4_readpage
}

现在文件中映射的内容已经加载进 page cache 了,此时物理内存才正式登场,在缺页中断处理程序的最后一步,内核会为映射的这段虚拟内存在页表中创建 PTE,然后将虚拟内存与 page cache 中的文件页通过 PTE 关联起来,缺页处理就结束了,但是由于我们指定的私有文件映射,所以 PTE 中文件页的权限是只读的。

当内核处理完缺页中断之后,mmap 私有文件映射在内核中的关系图就变成下面这样:

此时进程 1 中的页表已经建立起了虚拟内存与文件页的映射关系,进程 1 再次访问这段虚拟内存的时候,其实就等于直接访问文件的 page cache。整个过程是在用户态进行的,不需要切态。

现在我们在将视角切换到进程 2 中,进程 2 和进程 1 一样,都是采用 mmap 私有文件映射的方式映射到了同一个文件中,虽然现在已经有了物理内存了(通过进程 1 的缺页产生),但是目前还和进程 2 没有关系。

因为进程 2 的虚拟内存空间中这段映射的虚拟内存区域 VMA,在进程 2 的页表中还没有 PTE,所以当进程 2 访问这段映射虚拟内存时,同样会产生缺页中断,随后进程 2 切换到内核态,进行缺页处理,这里和进程 1 不同的是,此时被映射的文件内容已经加载到 page cache 中了,进程 2 只需要创建 PTE ,并将 page cache 中的文件页与进程 2 映射的这段虚拟内存通过 PTE 关联起来就可以了。同样,因为采用私有文件映射的原因,进程 2 的 PTE 也是只读的。

现在进程 1 和进程 2 都可以根据各自虚拟内存空间中映射的这段虚拟内存对文件的 page cache 进行读取了,整个过程都发生在用户态,不需要切态,更不需要拷贝,因为虚拟内存现在已经直接映射到 page cache 了。

虽然我们采用的是私有文件映射的方式,但是进程 1 和进程 2 如果只是对文件映射部分进行读取的话,文件页其实在多进程之间是共享的,整个内核中只有一份。

但是当任意一个进程通过虚拟映射区对文件进行写入操作的时候,情况就发生了变化,虽然通过 mmap 映射的时候指定的这段虚拟内存是可写的,但是由于采用的是私有文件映射的方式,各个进程页表中对应 PTE 却是只读的,当进程对这段虚拟内存进行写入的时候,MMU 会发现 PTE 是只读的,所以会产生一个写保护类型的缺页中断,写入进程,比如是进程 1,此时又会陷入到内核态,在写保护缺页处理中,内核会重新申请一个内存页,然后将 page cache 中的内容拷贝到这个新的内存页中,进程 1 页表中对应的 PTE 会重新关联到这个新的内存页上,此时 PTE 的权限变为可写。

从此以后,进程 1 对这段虚拟内存区域进行读写的时候就不会再发生缺页了,读写操作都会发生在这个新申请的内存页上,但是有一点,进程 1 对这个内存页的任何修改均不会回写到磁盘文件上,这也体现了私有文件映射的特点,进程对映射文件的修改,其他进程是看不到的,并且修改不会同步回磁盘文件中。

进程 2 对这段虚拟映射区进行写入的时候,也是一样的道理,同样会触发写保护类型的缺页中断,进程 2 陷入内核态,内核为进程 2 新申请一个物理内存页,并将 page cache 中的内容拷贝到刚为进程 2 申请的这个内存页中,进程 2 页表中对应的 PTE 会重新关联到新的内存页上, PTE 的权限变为可写。

这样一来,进程 1 和进程 2 各自的这段虚拟映射区,就映射到了各自专属的物理内存页上,而且这两个内存页中的内容均是文件中映射的部分,他们已经和 page cache 脱离了。

进程 1 和进程 2 对各自虚拟内存区的修改只能反应到各自对应的物理内存页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中,这就是私有文件映射的核心特点

我们可以利用 mmap 私有文件映射这个特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。

因为同一份代码,也就是同一份二进制可执行文件可以运行多个进程,而代码段对于多进程来说是只读的,没有必要为每个进程都保存一份,多进程之间共享这一份代码就可以了,正好私有文件映射的读共享特点可以满足我们的这个需求。

对于数据段来说,虽然它是可写的,但是我们需要的是多进程之间对数据段的修改相互之间是不可见的,而且对数据段的修改不能回写到磁盘上的二进制文件中,这样当我们利用这个可执行文件在启动一个进程的时候,进程看到的就是数据段初始化未被修改的状态。 mmap 私有文件映射的写时复制(copy on write)以及修改不会回写到映射文件中等特点正好也满足我们的需求。

这一点我们可以在负责加载 elf 格式的二进制可执行文件并映射到进程虚拟内存空间的 load_elf_binary 函数,以及负责加载 a.out 格式可执行文件的 load_aout_binary 函数中可以看出。

static int load_elf_binary(struct linux_binprm *bprm)
{
// 将二进制文件中的 .text .data section 私有映射到虚拟内存空间中代码段和数据段中
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
}
static int load_aout_binary(struct linux_binprm * bprm)
{
............ 省略 .............
// 将 .text 采用私有文件映射的方式映射到进程虚拟内存空间的代码段
error = vm_mmap(bprm->file, N_TXTADDR(ex), ex.a_text,
PROT_READ | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
fd_offset);
// 将 .data 采用私有文件映射的方式映射到进程虚拟内存空间的数据段
error = vm_mmap(bprm->file, N_DATADDR(ex), ex.a_data,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
fd_offset + ex.a_text);
............ 省略 .............
}

4. 共享文件映射

#include 
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

我们通过将 mmap 系统调用中的 flags 参数指定为 MAP_SHARED , 参数 fd 指定为要映射文件的文件描述符(file descriptor)来实现对文件的共享映射。

共享文件映射其实和私有文件映射前面的映射过程是一样的,唯一不同的点在于私有文件映射是读共享的,写的时候会发生写时复制(copy on write),并且多进程针对同一映射文件的修改不会回写到磁盘文件上。

而共享文件映射因为是共享的,多个进程中的虚拟内存映射区最终会通过缺页中断的方式映射到文件的 page cache 中,后续多个进程对各自的这段虚拟内存区域的读写都会直接发生在 page cache 上。

因为映射文件的 page cache 在内核中只有一份,所以对于共享文件映射来说,多进程读写都是共享的,由于多进程直接读写的是 page cache ,所以多进程对共享映射区的任何修改,最终都会通过内核回写线程 pdflush 刷新到磁盘文件中。

下面这幅是多进程通过 mmap 共享文件映射之后的内核数据结构关系图:

同私有文件映射方式一样,当多个进程调用 mmap 对磁盘上的同一个文件进行共享文件映射的时候,内核中的处理都是一样的,也都只是在每个进程的虚拟内存空间中,创建出一段用于共享映射的虚拟内存区域 VMA 出来,随后内核会将各个进程中的这段虚拟内存映射区与映射文件关联起来,mmap 共享文件映射的逻辑就结束了。

唯一不同的是,共享文件映射会在这段用于映射文件的 VMA 中标注是共享映射 —— MAP_SHARED

struct vm_area_struct {
// MAP_SHARED 共享映射
unsigned long vm_flags;
}

在 mmap 共享文件映射的过程中,内核同样不涉及任何的物理内存分配,只是分配了一段虚拟内存,在共享映射刚刚建立起来之后,文件对应的 page cache 同样是空的,没有包含任何的文件页。

由于 mmap 只是在各个进程中分配了虚拟内存,没有分配物理内存,所以在各个进程的页表中,这段用于文件映射的虚拟内存区域对应的页表项 PTE 是空的,当任意进程对这段虚拟内存进行访问的时候(读或者写),MMU 就会产生缺页中断,这里我们以上图中的进程 1 为例,随后进程 1 切换到内核态,执行内核缺页中断处理程序。

同私有文件映射的缺页处理一样,内核会首先通过 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有缓存相应的文件页(映射的磁盘块对应的文件页)。如果文件页不在 page cache 中,内核则会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中。

然后调用 readpage 激活块设备驱动从磁盘中读取映射的文件内容,用读取到的内容填充新分配的内存页,现在物理内存有了,最后一步就是在进程 1 的页表中建立共享映射的这段虚拟内存与 page cache 中缓存的文件页之间的关联。

这里和私有文件映射不同的地方是,私有文件映射由于是私有的,所以在内核创建 PTE 的时候会将 PTE 设置为只读,目的是当进程写入的时候触发写保护类型的缺页中断进行写时复制 (copy on write)。

共享文件映射由于是共享的,PTE 被创建出来的时候就是可写的,所以后续进程 1 在对这段虚拟内存区域写入的时候不会触发缺页中断,而是直接写入 page cache 中,整个过程没有切态,没有数据拷贝。

现在我们在切换到进程 2 的视角中,虽然现在文件中被映射的这部分内容已经加载进物理内存页,并被缓存在文件的 page cache 中了。但是现在进程 2 中这段虚拟映射区在进程 2 页表中对应的 PTE 仍然是空的,当进程 2 访问这段虚拟映射区的时候依然会产生缺页中断。

当进程 2 切换到内核态,处理缺页中断的时候,此时进程 2 通过 vm_area_struct->vm_pgoff 在 page cache 查找文件页的时候,文件页已经被进程 1 加载进 page cache 了,进程 2 一下就找到了,就不需要再去磁盘中读取映射内容了,内核会直接为进程 2 创建 PTE (由于是共享文件映射,所以这里的 PTE 也是可写的),并插入到进程 2 页表中,随后将进程 2 中的虚拟映射区通过 PTE 与 page cache 中缓存的文件页映射关联起来。

现在进程 1 和进程 2 各自虚拟内存空间中的这段虚拟内存区域 VMA,已经共同映射到了文件的 page cache 中,由于文件的 page cache 在内核中只有一份,它是和进程无关的,page cache 中的内容发生的任何变化,进程 1 和进程 2 都是可以看到的。

重要的一点是,多进程对各自虚拟内存映射区 VMA 的写入操作,内核会根据自己的脏页回写策略将修改内容回写到磁盘文件中。

内核提供了以下六个系统参数,来供我们配置调整内核脏页回写的行为,这些参数的配置文件存在于 proc/sys/vm 目录下:

  • dirty_writeback_centisecs 内核参数的默认值为 500。单位为 0.01 s。也就是说内核默认会每隔 5s 唤醒一次 flusher 线程来执行相关脏页的回写。

  • drity_background_ratio :当脏页数量在系统的可用内存 available 中占用的比例达到 drity_background_ratio 的配置值时,内核就会唤醒 flusher 线程异步回写脏页。默认值为:10。表示如果 page cache 中的脏页数量达到系统可用内存的 10% 的话,就主动唤醒 flusher 线程去回写脏页到磁盘。

  • dirty_background_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_background_bytes。内核就会唤醒 flusher 线程异步回写脏页。默认为:0。

  • dirty_ratio : dirty_background_* 相关的内核配置参数均是内核通过唤醒 flusher 线程来异步回写脏页。下面要介绍的 dirty_* 配置参数,均是由用户进程同步回写脏页。表示内存中的脏页太多了,用户进程自己都看不下去了,不用等内核 flusher 线程唤醒,用户进程自己主动去回写脏页到磁盘中。当脏页占用系统可用内存的比例达到 dirty_ratio 配置的值时,用户进程同步回写脏页。默认值为:20 。

  • dirty_bytes :如果 page cache 中脏页占用的内存用量绝对值达到指定的 dirty_bytes。用户进程同步回写脏页。默认值为:0。

  • 内核为了避免 page cache 中的脏页在内存中长久的停留,所以会给脏页在内存中的驻留时间设置一定的期限,这个期限可由前边提到的 dirty_expire_centisecs 内核参数配置。默认为:3000。单位为:0.01 s。也就是说在默认配置下,脏页在内存中的驻留时间为 30 s。超过 30 s 之后,flusher 线程将会在下次被唤醒的时候将这些脏页回写到磁盘中。

关于脏页回写详细的内容介绍,感兴趣的读者可以回看下 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 一文中的 “13. 内核回写脏页的触发时机” 小节。

根据 mmap 共享文件映射多进程之间读写共享(不会发生写时复制)的特点,常用于多进程之间共享内存(page cache),多进程之间的通讯。

5. 共享匿名映射

#include 
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

我们通过将 mmap 系统调用中的 flags 参数指定为 MAP_SHARED | MAP_ANONYMOUS ,并将 fd 参数指定为 -1 来实现共享匿名映射,这种映射方式常用于父子进程之间共享内存,父子进程之间的通讯。注意,这里需要和大家强调一下是父子进程,为什么只能是父子进程,笔者后面再给大家解答。

在笔者介绍完 mmap 的私有匿名映射,私有文件映射,以及共享文件映射之后,共享匿名映射看似就非常简单了,由于不对文件进行映射,所以它不涉及到文件系统相关的知识,而且又是共享的,多个进程通过将自己的页表指向同一个物理内存页面不就实现共享匿名映射了吗?

看起来简单,实际上并没有那么简单,甚至可以说共享匿名映射是 mmap 这四种映射方式中最为复杂的,为什么这么说的 ?我们一起来看下共享匿名映射的映射过程。

首先和其他几种映射方式一样,mmap 只是负责在各个进程的虚拟内存空间中划分一段用于共享匿名映射的虚拟内存区域而已,这点笔者已经强调过很多遍了,整个映射过程并不涉及到物理内存的分配。

当多个进程调用 mmap 进行共享匿名映射之后,内核只不过是为每个进程在各自的虚拟内存空间中分配了一段虚拟内存而已,由于并不涉及物理内存的分配,所以这段用于映射的虚拟内存在各个进程的页表中对应的页表项 PTE 都还是空的,如下图所示:

当任一进程,比如上图中的进程 1 开始访问这段虚拟映射区的时候,MMU 会产生缺页中断,进程 1 切换到内核态,开始处理缺页中断逻辑,在缺页中断处理程序中,内核为进程 1 分配一个物理内存页,并创建对应的 PTE 插入到进程 1 的页表中,随后用 PTE 将进程 1 的这段虚拟映射区与物理内存映射关联起来。进程 1 的缺页处理结束,从此以后,进程 1 就可以读写这段共享映射的物理内存了。

现在我们把视角切换到进程 2 中,当进程 2 访问它自己的这段虚拟映射区的时候,由于进程 2 页表中对应的 PTE 为空,所以进程 2 也会发生缺页中断,随后切换到内核态处理缺页逻辑。

当进程 2 开始处理缺页逻辑的时候,进程 2 就懵了,为什么呢 ?原因是进程 2 和进程 1 进行的是共享映射,所以进程 2 不能随便找一个物理内存页进行映射,进程 2 必须和 进程 1 映射到同一个物理内存页面,这样才能共享内存。那现在的问题是,进程 2 面对着茫茫多的物理内存页,进程 2 怎么知道进程 1 已经映射了哪个物理内存页 ?

内核在缺页中断处理中只能知道当前正在缺页的进程是谁,以及发生缺页的虚拟内存地址是什么,内核根据这些信息,根本无法知道,此时是否已经有其他进程把共享的物理内存页准备好了。

这一点对于共享文件映射来说特别简单,因为有文件的 page cache 存在,进程 2 可以根据映射的文件内容在文件中的偏移 offset,从 page cache 中查找是否已经有其他进程把映射的文件内容加载到文件页中。如果文件页已经存在 page cache 中了,进程 2 直接映射这个文件页就可以了。

struct vm_area_struct {
unsigned long vm_pgoff;     /* Offset (within vm_file) in PAGE_SIZE */
}
static inline struct page *find_get_page(struct address_space *mapping,
pgoff_t offset)
{
return pagecache_get_page(mapping, offset, 0, 0);
}

由于共享匿名映射并没有对文件映射,所以其他进程想要在内存中查找要进行共享的内存页就非常困难了,那怎么解决这个问题呢 ?

既然共享文件映射可以轻松解决这个问题,那我们何不借鉴一下文件映射的方式 ?

共享匿名映射在内核中是通过一个叫做 tmpfs 的虚拟文件系统来实现的,tmpfs 不是传统意义上的文件系统,它是基于内存实现的,挂载在 dev/zero 目录下。

当多个进程通过 mmap 进行共享匿名映射的时候,内核会在 tmpfs 文件系统中创建一个匿名文件,这个匿名文件并不是真实存在于磁盘上的,它是内核为了共享匿名映射而模拟出来的,匿名文件也有自己的 inode 结构以及 page cache。

在 mmap 进行共享匿名映射的时候,内核会把这个匿名文件关联到进程的虚拟映射区 VMA 中。这样一来,当进程虚拟映射区域与 tmpfs 文件系统中的这个匿名文件映射起来之后,后面的流程就和共享文件映射一模一样了。

struct vm_area_struct {
struct file * vm_file;      /* File we map to (can be NULL). */
}

最后,笔者来回答下在本小节开始处抛出的一个问题,就是共享匿名映射只适用于父子进程之间的通讯,为什么只能是父子进程呢 ?

因为当父进程进行 mmap 共享匿名映射的时候,内核会为其创建一个匿名文件,并关联到父进程的虚拟内存空间中 vm_area_struct->vm_file 中。但是这时候其他进程并不知道父进程虚拟内存空间中关联的这个匿名文件,因为进程之间的虚拟内存空间都是隔离的。

子进程就不一样了,在父进程调用完 mmap 之后,父进程的虚拟内存空间中已经有了一段虚拟映射区 VMA 并关联到匿名文件了。这时父进程进行 fork() 系统调用创建子进程,子进程会拷贝父进程的所有资源,当然也包括父进程的虚拟内存空间以及父进程的页表。

long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
......... 省略 ..........
struct pid *pid;
struct task_struct *p;
......... 省略 ..........
// 拷贝父进程的所有资源
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......... 省略 ..........
}

当 fork 出子进程的时候,这时子进程的虚拟内存空间和父进程的虚拟内存空间完全是一模一样的,在子进程的虚拟内存空间中自然也有一段虚拟映射区 VMA 并且已经关联到匿名文件中了(继承自父进程)。

现在父子进程的页表也是一模一样的,各自的这段虚拟映射区对应的 PTE 都是空的,一旦发生缺页,后面的流程就和共享文件映射一样了。我们可以把共享匿名映射看作成一种特殊的共享文件映射方式。

6. 参数 flags 的其他枚举值

#include 
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

在前边的几个小节中,笔者为大家介绍了 mmap 系统调用参数 flags 最为核心的三个枚举值:MAP_ANONYMOUS,MAP_SHARED,MAP_PRIVATE。随后我们通过这三个枚举值组合出了四种内存映射方式:私有匿名映射,私有文件映射,共享文件映射,共享匿名映射。

到现在为止,笔者算是把 mmap 内存映射的核心原理及其在内核中的映射过程给大家详细剖析完了,不过参数 flags 的枚举值在内核中并不只是上述三个,除此之外,内核还定义了很多。在本小节的最后,笔者为大家挑了几个相对重要的枚举值给大家做一些额外的补充,这样能够让大家对 mmap 内存映射有一个更加全面的认识。

#define MAP_LOCKED	0x2000		/* pages are locked */
#define MAP_POPULATE		0x008000	/* populate (prefault) pagetables */
#define MAP_HUGETLB		0x040000	/* create a huge page mapping */

经过前面的介绍我们知道,mmap 仅仅只是在进程虚拟内存空间中划分出一段用于映射的虚拟内存区域 VMA ,并将这段 VMA 与磁盘上的文件映射起来而已。整个映射过程并不涉及物理内存的分配,更别说虚拟内存与物理内存的映射了,这些都是在进程访问这段 VMA 的时候,通过缺页中断来补齐的。

如果我们在使用 mmap 系统调用的时候设置了 MAP_POPULATE ,内核在分配完虚拟内存之后,就会马上分配物理内存,并在进程页表中建立起虚拟内存与物理内存的映射关系,这样进程在调用 mmap 之后就可以直接访问这段映射的虚拟内存地址了,不会发生缺页中断。

但是当系统内存资源紧张的时候,内核依然会将 mmap 背后映射的这块物理内存 swap out 到磁盘中,这样进程在访问的时候仍然会发生缺页中断,为了防止这种现象,我们可以在调用 mmap 的时候设置 MAP_LOCKED

在设置了 MAP_LOCKED 之后,mmap 系统调用在为进程分配完虚拟内存之后,内核也会马上为其分配物理内存并在进程页表中建立虚拟内存与物理内存的映射关系,这里内核还会额外做一个动作,就是将映射的这块物理内存锁定在内存中,不允许它 swap,这样一来映射的物理内存将会一直停留在内存中,进程无论何时访问这段映射内存都不会发生缺页中断。

MAP_HUGETLB 则是用于大页内存映射的,在内核中关于物理内存的调度是按照物理内存页为单位进行的,普通物理内存页大小为 4K。但在一些对于内存敏感的使用场景中,我们往往期望使用一些比普通 4K 更大的页。

因为这些巨型页要比普通的 4K 内存页要大很多,而且这些巨型页不允许被 swap,所以遇到缺页中断的情况就会相对减少,由于减少了缺页中断所以性能会更高。

另外,由于巨型页比普通页要大,所以巨型页需要的页表项要比普通页要少,页表项里保存了虚拟内存地址与物理内存地址的映射关系,当 CPU 访问内存的时候需要频繁通过 MMU 访问页表项获取物理内存地址,由于要频繁访问,所以页表项一般会缓存在 TLB 中,因为巨型页需要的页表项较少,所以节约了 TLB 的空间同时降低了 TLB 缓存 MISS 的概率,从而加速了内存访问。

7. 大页内存映射

在 64 位 x86 CPU 架构 Linux 的四级页表体系下,系统支持的大页尺寸有 2M,1G。我们可以在 /sys/kernel/mm/hugepages 路径下查看当前系统所支持的大页尺寸:

要想在应用程序中使用 HugePage,我们需要在内核编译的时候通过设置 CONFIG_HUGETLBFSCONFIG_HUGETLB_PAGE 这两个编译选项来让内核支持 HugePage。我们可以通过 cat /proc/filesystems 命令来查看当前内核中是否支持 hugetlbfs 文件系统,这是我们使用 HugePage 的基础。

因为 HugePage 要求的是一大片连续的物理内存,和普通内存页一样,巨型大页里的内存必须是连续的,但是随着系统的长时间运行,内存页被频繁无规则的分配与回收,系统中会产生大量的内存碎片,由于内存碎片的影响,内核很难寻找到大片连续的物理内存,这样一来就很难分配到巨型大页。

所以这就要求内核在系统启动的时候预先为我们分配好足够多的大页内存,这些大页内存被内核管理在一个大页内存池中,大页内存池中的内存全部是专用的,专门用于巨型大页的分配,不能用于其他目的,即使系统中没有使用巨型大页,这些大页内存就只能空闲在那里,另外这些大页内存都是被内核锁定在内存中的,即使系统内存资源紧张,大页内存也不允许被 swap。而且内核大页池中的这些大页内存使用完了就完了,大页池耗尽之后,应用程序将无法再使用大页。

既然大页内存池在内核启动的时候就需要被预先创建好,而创建大页内存池,内核需要首先知道内存池中究竟包含多少个 HugePage,每个 HugePage 的尺寸是多少 。我们可以将这些参数在内核启动的时候添加到 kernel command line 中,随后内核在启动的过程中就可以根据 kernel command line 中 HugePage 相关的参数进行大页内存池的创建。下面是一些 HugePage 相关的核心 command line 参数含义:

  • hugepagesz : 用于指定大页内存池中 HugePage 的 size,我们这里可以指定 hugepagesz=2M 或者 hugepagesz=1G,具体支持多少种大页尺寸由 CPU 架构决定。

  • hugepages:用于指定内核需要预先创建多少个 HugePage 在大页内存池中,我们可以通过指定 hugepages=256 ,来表示内核需要预先创建 256 个 HugePage 出来。除此之外 hugepages 参数还可以有 NUMA 格式,用于告诉内核需要在每个 NUMA node 上创建多少个 HugePage。我们可以通过设置 hugepages=0:1,1:2 ... 来指定 NUMA node 0 上分配 1 个 HugePage,在 NUMA node 1 上分配 2 个 HugePage。

  • default_hugepagesz:用于指定 HugePage 默认大小。各种不同类型的 CPU 架构一般都支持多种 size 的 HugePage,比如 x86 CPU 支持 2M,1G 的 HugePage。arm64 支持 64K,2M,32M,1G 的 HugePage。这么多尺寸的 HugePage 我们到底该使用哪种尺寸呢 ? 这时就需要通过 default_hugepagesz 来指定默认使用的 HugePage 尺寸。

以上为大家介绍的是在内核启动的时候(boot time)通过向 kernel command line 指定 HugePage 相关的命令行参数来配置大页,除此之外,我们还可以在系统刚刚启动之后(run time)来配置大页,因为系统刚刚启动,所以系统内存碎片化程度最小,也是一个配置大页的时机:

/proc/sys/vm 路径下有两个系统参数可以让我们在系统 run time 的时候动态调整当前系统中 default size (由 default_hugepagesz 指定)大小的 HugePage 个数。

  • nr_hugepages 表示当前系统中 default size 大小的 HugePage 个数,我们可以通过 echo HugePageNum > /proc/sys/vm/nr_hugepages 命令来动态增大或者缩小 HugePage (default size )个数。

  • nr_overcommit_hugepages 表示当系统中的应用程序申请的大页个数超过 nr_hugepages 时,内核允许在额外申请多少个大页。当大页内存池中的大页个数被耗尽时,如果此时继续有进程来申请大页,那么内核则会从当前系统中选取多个连续的普通 4K 大小的内存页,凑出若干个大页来供进程使用,这些被凑出来的大页叫做 surplus_hugepage,surplus_hugepage 的个数不能超过 nr_overcommit_hugepages。当这些 surplus_hugepage 不在被使用时,就会被释放回内核中。nr_hugepages 个数的大页则会一直停留在大页内存池中,不会被释放,也不会被 swap。

nr_hugepages 有点像 JDK 线程池中的 corePoolSize 参数,(nr_hugepages + nr_overcommit_hugepages) 有点像线程池中的 maximumPoolSize 参数。

以上介绍的是修改默认尺寸大小的 HugePage,另外,我们还可以在系统 run time 的时候动态修改指定尺寸的 HugePage,不同大页尺寸的相关配置文件存放在 /sys/kernel/mm/hugepages 路径下的对应目录中:

如上图所示,当前系统中所支持的大页尺寸相关的配置文件,均存放在对应 hugepages-hugepagesize 格式的目录中,下面我们以 2M 大页为例,进入到 hugepages-2048kB 目录下,发现同样也有 nr_hugepages 和 nr_overcommit_hugepages 这两个配置文件,它们的含义和上边介绍的一样,只不过这里的是具体尺寸的 HugePage 相关配置。

我们可以通过如下命令来动态调整系统中 2M 大页的个数:

echo HugePageNum > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

同理在 NUMA 架构的系统下,我们可以在 /sys/devices/system/node/node_id 路径下修改对应 numa node 节点中的相应尺寸 的大页个数:

echo HugePageNum > /sys/devices/system/node/node_id/hugepages/hugepages-2048kB/nr_hugepages

现在内核已经支持了大页,并且我们从内核的 boot time 或者 run time 配置好了大页内存池,我们终于可以在应用程序中来使用大页内存了,内核给我们提供了两种方式来使用 HugePage:

  • 一种是本文介绍的 mmap 系统调用,需要在 flags 参数中设置 MAP_HUGETLB。另外内核提供了额外的两个枚举值来配合 MAP_HUGETLB 一起使用,它们分别是 MAP_HUGE_2MB 和 MAP_HUGE_1GB。

    • MAP_HUGETLB | MAP_HUGE_2MB 用于指定我们需要映射的是 2M 的大页。
    • MAP_HUGETLB | MAP_HUGE_1GB 用于指定我们需要映射的是 1G 的大页。
    • MAP_HUGETLB 表示按照 default_hugepagesz 指定的默认尺寸来映射大页。
  • 另一种是 SYSV 标准的系统调用 shmget 和 shmat。

本小节我们主要介绍 mmap 系统调用使用大页的方式:

int main(void)
{
addr = mmap(addr, length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
return 0;
}

MAP_HUGETLB 只能支持 MAP_ANONYMOUS 匿名映射的方式使用 HugePage

当我们通过 mmap 设置了 MAP_HUGETLB 进行大页内存映射的时候,这个映射过程和普通的匿名映射一样,同样也是首先在进程的虚拟内存空间中划分出一段虚拟映射区 VMA 出来,同样不涉及物理内存的分配,不一样的地方是,内核在分配完虚拟内存之后,会在大页内存池中为映射的这段虚拟内存预留好大页内存,相当于是把即将要使用的大页内存先锁定住,不允许其他进程使用。这些被预留好的 HugePage 个数被记录在上图中的 resv_hugepages 文件中。

当进程在访问这段虚拟内存的时候,同样会发生缺页中断,随后内核会从大页内存池中将这部分已经预留好的 resv_hugepages 分配给进程,并在进程页表中建立好虚拟内存与 HugePage 的映射。关于进程页表如何映射内存大页的详细内容,感兴趣的同学可以回看下之前的文章 《一步一图带你构建 Linux 页表体系》。

由于这里我们调用 mmap 映射的是 HugePage ,所以系统调用参数中的 addr,length 需要和大页尺寸进行对齐,在本例中需要和 2M 进行对齐。

前边也提到了 MAP_HUGETLB 需要和 MAP_ANONYMOUS 配合一起使用,只能支持匿名映射的方式来使用 HugePage。那如果我们想使用 mmap 对文件进行大页映射该怎么办呢 ?

这就用到了前面提到的 hugetlbfs 文件系统:

hugetlbfs 是一个基于内存的文件系统,类似前边介绍的 tmpfs 文件系统,位于 hugetlbfs 文件系统下的所有文件都是被大页支持的,也就说通过 mmap 对 hugetlbfs 文件系统下的文件进行文件映射,默认都是用 HugePage 进行映射。

hugetlbfs 下的文件支持大多数的文件系统操作,比如:open , close , chmod , read 等等,但是不支持 write 系统调用,如果想要对 hugetlbfs 下的文件进行写入操作,那么必须通过文件映射的方式将 hugetlbfs 中的文件通过大页映射进内存,然后在映射内存中进行写入操作。

所以在我们使用 mmap 系统调用对 hugetlbfs 下的文件进行大页映射之前,首先需要做的事情就是在系统中挂载 hugetlbfs 文件系统到指定的路径下。

mount -t hugetlbfs -o uid=,gid=,mode=,pagesize=,size=,min_size=,nr_inodes= none /mnt/huge

上面的这条命令用于将 hugetlbfs 挂载到 /mnt/huge 目录下,从此以后只要是在 /mnt/huge 目录下创建的文件,背后都是由大页支持的,也就是说如果我们通过 mmap 系统调用对 /mnt/huge 目录下的文件进行文件映射,缺页的时候,内核分配的就是内存大页。

只有在 hugetlbfs 下的文件进行 mmap 文件映射的时候才能使用大页,其他普通文件系统下的文件依然只能映射普通 4K 内存页。

mount 命令中的 uidgid 用于指定 hugetlbfs 根目录的 owner 和 group。

pagesize 用于指定 hugetlbfs 支持的大页尺寸,默认单位是字节,我们可以通过设置 pagesize=2M 或者 pagesize=1G 来指定 hugetlbfs 中的大页尺寸为 2M 或者 1G。

size 用于指定 hugetlbfs 文件系统可以使用的最大内存容量是多少,单位同 pagesize 一样。

min_size 用于指定 hugetlbfs 文件系统可以使用的最小内存容量是多少。

nr_inodes 用于指定 hugetlbfs 文件系统中 inode 的最大个数,决定该文件系统中最大可以创建多少个文件。

当 hugetlbfs 被我们挂载好之后,接下来我们就可以直接通过 mmap 系统调用对挂载目录 /mnt/huge 下的文件进行内存映射了,当缺页的时候,内核会直接分配大页,大页尺寸是 pagesize

int main(void)
{
fd = open(“/mnt/huge/test.txt”, O_CREAT|O_RDWR);
addr=mmap(0,MAP_LENGTH,PROT_READ|PROT_WRITE,MAP_SHARED, fd, 0);
return 0;
}

这里需要注意是,通过 mmap 映射 hugetlbfs 中的文件的时候,并不需要指定 MAP_HUGETLB 。而我们通过 SYSV 标准的系统调用 shmget 和 shmat 以及前边介绍的 mmap ( flags 参数设置 MAP_HUGETLB)进行大页申请的时候,并不需要挂载 hugetlbfs。

在内核中一共支持两种类型的内存大页,一种是标准大页(hugetlb pages),也就是上面内容所介绍的使用大页的方式,我们可以通过命令 grep Huge /proc/meminfo 来查看标准大页在系统中的使用情况:

和标准大页相关的统计参数含义如下:

HugePages_Total 表示标准大页池中大页的个数。HugePages_Free 表示大页池中还未被使用的大页个数(未被分配)。

HugePages_Rsvd 表示大页池中已经被预留出来的大页,这个预留大页是什么意思呢 ?我们知道 mmap 系统调用只是为进程分配一段虚拟内存而已,并不会分配物理内存,当 mmap 进行大页映射的时候也是一样。不同之处在于,内核为进程分配完虚拟内存之后,还需要为进程在大页池中预留好本次映射所需要的大页个数,注意此时只是预留,还并未分配给进程,大页池中被预留好的大页不能被其他进程使用。这时 HugePages_Rsvd 的个数会相应增加,当进程发生缺页的时候,内核会直接从大页池中把这些提前预留好的大页内存映射到进程的虚拟内存空间中。这时 HugePages_Rsvd 的个数会相应减少。系统中真正剩余可用的个数其实是 HugePages_Free - HugePages_Rsvd

HugePages_Surp 表示大页池中超额分配的大页个数,这个概念其实笔者前面在介绍 nr_overcommit_hugepages 参数的时候也提到过,nr_overcommit_hugepages 参数表示最多能超额分配多少个大页。当大页池中的大页全部被耗尽的时候,也就是 /proc/sys/vm/nr_hugepages 指定的大页个数全部被分配完了,内核还可以超额为进程分配大页,超额分配出的大页个数就统计在 HugePages_Surp 中。

Hugepagesize 表示系统中大页的默认 size 大小,单位为 KB。

Hugetlb 表示系统中所有尺寸的大页所占用的物理内存总量。单位为 KB。

内核中另外一种类型的大页是透明大页 THP (Transparent Huge Pages),这里的透明指的是应用进程在使用 THP 的时候完全是透明的,不需要像使用标准大页那样需要系统管理员对系统进行显示的大页配置,在应用程序中也不需要向标准大页那样需要显示指定 MAP_HUGETLB , 或者显示映射到 hugetlbfs 里的文件中。

透明大页的使用对用户完全是透明的,内核会在背后为我们自动做大页的映射,透明大页不需要像标准大页那样需要提前预先分配好大页内存池,透明大页的分配是动态的,由内核线程 khugepaged 负责在背后默默地将普通 4K 内存页整理成内存大页给进程使用。但是如果由于内存碎片的因素,内核无法整理出内存大页,那么就会降级为使用普通 4K 内存页。但是透明大页这里会有一个问题,当碎片化严重的时候,内核会启动 kcompactd 线程去整理碎片,期望获得连续的内存用于大页分配,但是 compact 的过程可能会引起 sys cpu 飙高,应用程序卡顿。

透明大页是允许 swap 的,这一点和标准大页不同,在内存紧张需要 swap 的时候,透明大页会被内核默默拆分成普通 4K 内存页,然后 swap out 到磁盘。

透明大页只支持 2M 的大页,标准大页可以支持 1G 的大页,透明大页主要应用于匿名内存中,可以在 tmpfs 文件系统中使用。

在我们对比完了透明大页与标准大页之间的区别之后,我们现在来看一下如何使用透明大页,其实非常简单,我们可以通过修改 /sys/kernel/mm/transparent_hugepage/enabled 配置文件来选择开启或者禁用透明大页:

  • always 表示系统全局开启透明大页 THP 功能。这意味着每个进程都会去尝试使用透明大页。

  • never 表示系统全局关闭透明大页 THP 功能。进程将永远不会使用透明大页。

  • madvise 表示进程如果想要使用透明大页,需要通过 madvise 系统调用并设置参数 advice 为 MADV_HUGEPAGE 来建议内核,在 addr 到 addr+length 这片虚拟内存区域中,需要使用透明大页来映射。

#include 
int madvise(void addr, size_t length, int advice);

一般我们会首先使用 mmap 先映射一段虚拟内存区域,然后通过 madvise 建议内核,将来在缺页的时候,需要为这段虚拟内存映射透明大页。由于背后需要通过内核线程 khugepaged 来不断的扫描整理系统中的普通 4K 内存页,然后将他们拼接成一个大页来给进程使用,其中涉及内存整理和回收等耗时的操作,且这些操作会在内存路径中加锁,而 khugepaged 内核线程可能会在错误的时间启动扫描和转换大页的操作,造成随机不可控的性能下降。

另外一点,透明大页不像标准大页那样是提前预分配好的,透明大页是在系统运行时动态分配的,在内存紧张的时候,透明大页和普通 4K 内存页的分配过程一样,有可能会遇到直接内存回收(direct reclaim)以及直接内存整理(direct compaction),这些操作都是同步的并且非常耗时,会对性能造成非常大的影响。

前面在 cat /proc/meminfo 命令中显示的 AnonHugePages 就表示透明大页在系统中的使用情况。另外我们可以通过 cat /proc/pid/smaps | grep AnonHugePages 命令来查看某个进程对透明大页的使用情况。

总结

本文笔者从五个角度为大家详细介绍了 mmap 的使用方法及其在内核中的实现原理,这五个角度分别是:

  1. 私有匿名映射,其主要用于进程申请虚拟内存,以及初始化进程虚拟内存空间中的 BSS 段,堆,栈这些虚拟内存区域。

  2. 私有文件映射,其核心特点是背后映射的文件页在多进程之间是读共享的,多个进程对各自虚拟内存区的修改只能反应到各自对应的文件页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中。我们可以利用这些特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。

  3. 共享文件映射,多进程之间读写共享(不会发生写时复制),常用于多进程之间共享内存(page cache),多进程之间的通讯。

  4. 共享匿名映射,用于父子进程之间共享内存,父子进程之间的通讯。父子进程之间需要依赖 tmpfs 中的匿名文件来实现共享内存。是一种特殊的共享文件映射。

  5. 大页内存映射,这里我们介绍了标准大页与透明大页两种大页类型的区别与联系,以及他们各自的实现原理和使用方法。

在我们清楚了原理之后,笔者会在下篇文章为大家继续详细介绍 mmap 在内核中的源码实现,感谢大家收看到这里,我们下篇文章见~