5. 内存映射的本质
流程走到这里,我们就来到了 mmap 系统调用最为核心的部分了,在之前的内容中,内核已经通过 get_unmapped_area 函数为我们在进程地址空间中挑选出一段地址范围为 [addr , addr + len] 的虚拟内存区域供 mmap 进行映射。
注意:这里的 addr 并不一定是我们指定的映射起始地址。
现在我们只是确定了 [addr , addr + len] 这段虚拟内存区域是可以映射的,这段区域只是被内核先划分出来了,但是还未分配出去,在 mmap_region 函数中,需要为这段虚拟内存区域分配 vma 结构,并根据映射方式对 vma 进行初始化,这样这段虚拟内存才算真正的被分配给了进程。
而在进程虚拟内存空间中允许被映射的虚拟内存总量是有限制的,所以在 mmap_region 开始分配虚拟内存之前,内核需要通过 may_expand_vm 检查本次需要映射的虚拟内存页数 len >> PAGE_SHIFT 是否已经超过了进程地址空间中可以被映射的虚拟内存总量限制。
如果未超过,则内核可以顺利的进行后续的内存映射流程,如果已经超过,内核则需近一步考虑能否消减一下不必要的虚拟内存用量。那么什么可以算作是不必要的虚拟内存用量呢?
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
比如,我们在 mmap 系统调用的 flags 参数中指定了 MAP_FIXED,强制内核从我们指定的 addr 地址处开始映射。
这样一来,[addr , addr + len] 这段范围的虚拟内存就会有很大的可能与现有虚拟内存映射区 vma(上图中蓝色部分)发生重叠,因为这里我们指定的是强制映射 MAP_FIXED,所以内核会将这部分重叠的部分通过 do_munmap 函数先解除映射,然后建立新的映射关系,效果就是将这部分重叠的虚拟内存覆盖掉了。
由于这部分重叠的虚拟内存部分是之前已经分配出去的,本次映射不需要再重新申请,所以真实虚拟内存的用量需要减去这部分重叠的部分。
内核通过 count_vma_pages_range 函数计算出这部分重叠的虚拟内存页个数,然后用本次申请的虚拟内存页个数 len >> PAGE_SHIFT 减去重叠的页数就是本次映射真实的虚拟内存用量。
最后重新通过 may_expand_vm 函数判断是否超过进程地址空间中可以被映射的虚拟内存总量限制,如果依然超过,则返回 ENOMEM 异常。如果没有超过,则正式进入虚拟内存分配的流程。
说到虚拟内存的分配,我们不由的会想到进程的虚拟内存空间,每个进程的虚拟内存空间都是独立的,而且虚拟内存空间的容量非常巨大,在 64 位系统中进程的虚拟内存空间为 128T,在这么巨大的虚拟内存空间下申请虚拟内存,我们想当然的会认为,进程可以随意申请,随意折腾。
理论上是这样,但是事实上,虚拟内存说到底最终还是要映射到物理内存上的,背后需要物理内存作为支撑,如果进程申请的虚拟内存远远超过物理内存大小,那么在运行的过程中就会导致部分内存被 swap 来 swap 去,甚至频繁的发生 oom,导致性能下降严重。
进程申请虚拟内存的过程就好比我们向银行贷款一样,进程的虚拟内存空间好比是现实中的银行,虚拟内存空间中的虚拟内存非常庞大,银行里的钱也非常多,但这并不意味着我们要多少银行就会贷给我们多少,银行需要对我们的资产进行审计,我们的资产越多,银行给我们贷款也会越多,我们的资产越少,银行给我们的贷款也越少。
同样的道理,内核也会对进程申请的虚拟内存进行审计(account),物理内存空间越大,swap 交换区越大,进程能能够申请到的虚拟内存也就越多。内核对虚拟内存申请的审计(account)策略就是我们前面提到的 overcommit_memory 策略,后面的相关章节笔者会详细的介绍,这里大家只需要知道内核的这个 overcommit_memory 策略会影响到进程申请虚拟内存大小。
内核通过 accountable_mapping 函数来判断是否需要对进程申请的虚拟内存进行审计,这就好比我们去银行贷款,如果客户的信用值一般,银行就需要对客户进行审计,如果客户端的信用值很高,资产优质,那么银行就不需要对客户的贷款进行审计。进程对虚拟内存的申请也是一样。
如果需要对虚拟内存进行审计,那么内核接着会调用 security_vm_enough_memory_mm 函数根据 overcommit_memory 策略判断是否允许进程申请这么多的虚拟内存,如果不通过,则返回 ENOMEM 停止虚拟内存申请流程。如果通过则将虚拟内存分配给进程。
内核为进程分配虚拟内存的本质其实就是在进程的虚拟内存空间中,找出一段未被映射的空闲虚拟内存地址范围 [addr , addr + len],就像之前介绍的 get_unmapped_area 函数那样。
然后再 mmap_region 函数中为这段空闲的虚拟内存地址范围 [addr , addr + len],创建 vma 结构,并初始化 vma 相关的属性。然后将这个 vma 结构插入到进程的虚拟内存空间中。
内核为了精细化的控制内存的开销,避免创建没有必要的 vma 结构,内核会本着能省则省的原则,在创建新的 vma 之前,按照最大程度合并的原则,内核会尝试看能不能将当前寻找出来的空闲虚拟内存区域 [addr , addr + len] 与其前一个 vma 以及后一个 vma 进行合并,然后重新调整合并后的 vma 相关属性,比如:vm_start , vm_end , vm_pgoff,以及涉及到相关数据结构的改变。这样一来,内核就不需要为这段空闲虚拟内存创建新的 vma 了。
如果不能合并,内核则只能从 slab 缓存中拿出一个 vma 结构来描述这段虚拟内存地址范围 [addr , addr + len]。并根据 mmap 映射的这段虚拟内存区域属性初始化 vma 结构中的相关字段。
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
如果 mmap 进行的是文件映射,那么这里内核会将映射的文件与虚拟映射区关联起来。
vma->vm_file = get_file(file);
然后内核会通过 call_mmap 函数,将虚拟内存的相关操作函数映射成文件相关的操作函数,大家或多或少在网上看到过这样的论述——" 通过内存文件映射可以将磁盘上的文件映射到内存中,这样我们就可以通过读写内存来完成磁盘文件的读写 ",其实本质就在 call_mmap 函数中,因为经过该函数处理之后,虚拟内存相关的操作函数已经变成文件相关的操作函数了。
struct vm_area_struct {
struct file * vm_file; /* File we map to (can be NULL). */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}
struct vm_operations_struct {
vm_fault_t (*fault)(struct vm_fault *vmf);
void (*map_pages)(struct vm_fault *vmf,
pgoff_t start_pgoff, pgoff_t end_pgoff);
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
}
我们接着来看 call_mmap 函数,mmap 文件映射的本质就在这里:
static inline int call_mmap(struct file *file, struct vm_area_struct *vma)
{
return file->f_op->mmap(file, vma);
}
内核将文件相关的操作全部定义在 struct file 结构中的 f_op 属性中:
struct file {
const struct file_operations *f_op;
}
文件的操作与其所在的文件系统是紧密相关的,在 ext4 文件系统中,相关文件的 file->f_op 指向 ext4_file_operations 操作集合:
const struct file_operations ext4_file_operations = {
.mmap = ext4_file_mmap,
};
其中 file->f_op->mmap 函数专门用于文件与内存的映射,在这里内核将 vm_area_struct 的内存操作 vma->vm_ops 设置为文件系统的操作 ext4_file_vm_ops,当通过 mmap 将内存与文件映射起来之后,读写内存其实就是读写文件系统的本质就在这里。
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
........ 省略 ........
vma->vm_ops = &ext4_file_vm_ops;
........ 省略 ........
}
static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = ext4_filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = ext4_page_mkwrite,
};
如果 mmap 进行的是共享匿名映射,父子进程之间需要依赖 tmpfs 文件系统中的匿名文件对共享内存进行访问,当进行共享匿名映射的时候,内核会在 shmem_zero_setup 函数中,到 tmpfs 文件系统里为映射创建一个匿名文件(shmem_kernel_file_setup),随后将 tmpfs 文件系统中的这个匿名文件与虚拟映射区 vma 中的 vm_file 关联映射起来,当然了,vma->vm_ops 也需要映射成 shmem_vm_ops。
当父进程调用 fork 创建子进程的时候,内核会将父进程的虚拟内存空间全部拷贝给子进程,包括这里创建的共享匿名映射区域 vma,这样一来,父子进程就可以通过共同的 vma->vm_file 来实现共享内存的通信了。
这里可以看出 mmap 的共享匿名映射其实本质上还是共享文件映射,只不过这个文件比较特殊,创建于
dev/zero
目录下的 tmpfs 文件系统中。
int shmem_zero_setup(struct vm_area_struct *vma)
{
struct file *file;
loff_t size = vma->vm_end - vma->vm_start;
// tmpfs 中获取一个匿名文件
file = shmem_kernel_file_setup("dev/zero", size, vma->vm_flags);
if (IS_ERR(file))
return PTR_ERR(file);
if (vma->vm_file)
// 如果 vma 中已存在其他文件,则解除与其他文件的映射关系
fput(vma->vm_file);
// 将 tmpfs 中的匿名文件映射进虚拟内存区域 vma 中
// 后续 fork 子进程的时候,父子进程就可以通过这个匿名文件实现共享匿名映射
vma->vm_file = file;
// 对这块共享匿名映射区相关操作这里直接映射成 shmem_vm_ops
vma->vm_ops = &shmem_vm_ops;
return 0;
}
static const struct vm_operations_struct shmem_vm_ops = {
.fault = shmem_fault,
.map_pages = filemap_map_pages,
#ifdef CONFIG_NUMA
.set_policy = shmem_set_policy,
.get_policy = shmem_get_policy,
#endif
};
如果 mmap 这里进行的是私有匿名映射的话,情况就变得简单了,由于私有匿名映射并不涉及到与文件之间的映射,所以只需要简单的将 vma->vm_ops 设置为 null 即可。
流程走到这里,本次 mmap 映射所产生的虚拟内存区域 vma 结构就被初始化好了,整个内存映射的核心工作就此完成了,剩下要做的事情就是将这个 vma 结构插入到进程虚拟内存空间中。
经过前面的介绍我们知道,在进程的虚拟内存空间中,所有的 vma 结构是被两种数据结构来组织管理的。一种是 mm_struct->mmap 指向的链表结构,另一种是 mm_struct->mm_rb 指向的红黑树结构。
vma_link 要做的工作就是按照虚拟内存地址的增长方向,将本次映射产生的 vma 结构插入到进程地址空间这两个数据结构中。
static void vma_link(struct mm_struct *mm, struct vm_area_struct *vma,
struct vm_area_struct *prev, struct rb_node **rb_link,
struct rb_node *rb_parent)
{
// 文件 page cache
struct address_space *mapping = NULL;
if (vma->vm_file) {
// 获取映射文件的 page cache
mapping = vma->vm_file->f_mapping;
i_mmap_lock_write(mapping);
}
// 将 vma 插入到地址空间中的 vma 链表 mm_struct->mmap 以及红黑树 mm_struct->mm_rb 中
__vma_link(mm, vma, prev, rb_link, rb_parent);
// 建立文件与 vma 的反向映射
__vma_link_file(vma);
if (mapping)
i_mmap_unlock_write(mapping);
// map_count 表示进程地址空间中 vma 的个数
mm->map_count++;
validate_mm(mm);
}
除此之外,vma_link 还做了一项重要工作,就是通过 __vma_link_file 函数建立文件与虚拟内存区域 vma (所有进程)的反向映射关系。说起反向映射,笔者在之前的文章 《一步一图带你深入理解 Linux 物理内存管理》 中的 “6.1 匿名页的反向映射” 小节中为大家介绍过关于匿名页的反向映射过程,感兴趣的同学可以回看下。
匿名页的反向映射还是相对比较复杂的,文件页的反向映射就很简单了,在之前的文章中笔者曾介绍过,struct file 结构中的 f_maping 属性指向了一个非常重要的数据结构 struct address_space。
struct address_space {
struct inode *host; /* owner: inode, block_device */
// page cache
struct radix_tree_root i_pages; /* cached pages */
atomic_t i_mmap_writable;/* count VM_SHARED mappings */
// 文件与 vma 反向映射的核心数据结构,i_mmap 也是一颗红黑树
// 在所有进程的地址空间中,只要与该文件发生映射的 vma 均挂在 i_mmap 中
struct rb_root_cached i_mmap; /* tree of private and shared mappings */
}
struct address_space 结构中有两个非常重要的属性,其中一个是 i_pages ,它指向了我们熟悉的 page cache。另一个就是 i_mmap,它指向的是一颗红黑树,这颗红黑树正是文件页反向映射的核心数据结构,反向映射关系就保存在这里。
我们知道,一个文件可以被多个进程一起映射,这样一来在每个进程的地址空间 mm_struct 结构中都会有一个 vma 结构来与这个文件进行映射,与该文件发生映射关系的所有进程地址空间中的 vma 就挂在 address_space-> i_mmap 这颗红黑树中,通过它,我们可以找到所有与该文件进行映射的进程。
__vma_link_file 函数建立文件页反向映射的核心其实就是将 mmap 映射出的这个 vma 插入到这颗红黑树中。
static void __vma_link_file(struct vm_area_struct *vma)
{
struct file *file;
file = vma->vm_file;
if (file) {
struct address_space *mapping = file->f_mapping;
// address_space->i_mmap 也是一颗红黑树,上面挂着的是与该文件映射的所有 vma(所有进程地址空间)
// 这里将 vma 插入到 i_mmap 中,实现文件与 vma 的反向映射
vma_interval_tree_insert(vma, &mapping->i_mmap);
}
}
好了,mmap 内存映射最为核心的部分,到这里笔者就为大家介绍完了,映射原理我们清楚了,接下来我们跟着这副 mmap_region 流程图,来看源码实现就很清晰了:
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma, *prev;
int error;
struct rb_node **rb_link, *rb_parent;
unsigned long charged = 0;
// 检查本次映射是否超过了进程虚拟内存空间中的虚拟内存容量的限制,超过则返回 false
if (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {
unsigned long nr_pages;
// 如果 mmap 指定了 MAP_FIXED,表示内核必须要按照用户指定的映射区来进行映射
// 这种情况下就会导致,我们指定的映射区[addr, addr + len] 有一部分可能与现有映射重叠
// 内核将会覆盖掉这段已有的映射,重新按照用户指定的映射关系进行映射
// 所以这里需要计算进程地址空间中与指定映射区[addr, addr + len]重叠的虚拟内存页数 nr_pages
nr_pages = count_vma_pages_range(mm, addr, addr + len);
// 由于这里的 nr_pages 表示重叠的虚拟内存部分,将会被覆盖,所以这部分被覆盖的虚拟内存不需要额外申请
// 这里通过 len >> PAGE_SHIFT 减去这段可以被覆盖的 nr_pages 在重新检查是否超过虚拟内存相关区域的限额
if (!may_expand_vm(mm, vm_flags,
(len >> PAGE_SHIFT) - nr_pages))
return -ENOMEM;
}
// 如果当前进程地址空间中存在于指定映射区域 [addr, addr + len] 重叠的部分
// 则调用 do_munmap 将这段重叠的映射部分解除掉,后续会重新映射这部分
while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
&rb_parent)) {
if (do_munmap(mm, addr, len, uf))
return -ENOMEM;
}
/*
* 判断将来是否会为这段虚拟内存 vma ,申请新的物理内存,比如 私有,可写(private writable)的映射方式,内核将来会通过 cow 重新为其分配新的物理内存。
* 私有,只读(private readonly)的映射方式,内核则会共享原来映射的物理内存,而不会申请新的物理内存。
* 如果将来需要申请新的物理内存则会根据当前系统的 overcommit 策略以及当前物理内存的使用情况来
* 综合判断是否允许本次虚拟内存的申请。如果虚拟内存不足,则返回 ENOMEM,这样的话可以防止缺页的时候发生 OOM
*/
if (accountable_mapping(file, vm_flags)) {
charged = len >> PAGE_SHIFT;
// 根据内核 overcommit 策略以及当前物理内存的使用情况综合判断,是否能够通过本次虚拟内存的申请
// 虚拟内存的申请一旦这里通过之后,后续发生缺页,内核将会有足够的物理内存为其分配,不会发生 OOM
if (security_vm_enough_memory_mm(mm, charged))
return -ENOMEM;
// 凡是设置了 VM_ACCOUNT 的 VMA,表示这段虚拟内存均已经过 vm_enough_memory 的检测
// 当虚拟内存发生缺页的时候,内核会有足够的物理内存分配,而不会导致 OOM
// 其虚拟内存的用量都会被统计在 /proc/meminfo 的 Committed_AS 字段中
vm_flags |= VM_ACCOUNT;
}
// 为了精细化的控制内存的开销,内核这里首先需要尝试看能不能和地址空间中已有的 vma 进行合并
// 尝试将当前 vma 合并到已有的 vma 中
vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma)
// 如果可以合并,则虚拟内存分配过程结束
goto out;
// 如果不可以合并,则只能从 slab 中取出一个新的 vma 结构来
vma = vm_area_alloc(mm);
if (!vma) {
error = -ENOMEM;
goto unacct_error;
}
// 根据我们要映射的虚拟内存区域属性初始化 vma 结构中的相关字段
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
// 文件映射
if (file) {
// 将文件与虚拟内存映射起来
vma->vm_file = get_file(file);
// 这一步中将虚拟内存区域 vma 的操作函数 vm_ops 映射成文件的操作函数(和具体文件系统有关)
// ext4 文件系统中的操作函数为 ext4_file_vm_ops
// 从这一刻开始,读写内存就和读写文件是一样的了
error = call_mmap(file, vma);
if (error)
goto unmap_and_free_vma;
addr = vma->vm_start;
vm_flags = vma->vm_flags;
} else if (vm_flags & VM_SHARED) {
// 这里处理共享匿名映射
// 前面提到共享匿名映射依赖于 tmpfs 文件系统中的匿名文件
// 父子进程通过这个匿名文件进行通讯
// 该函数用于在 tmpfs 中创建匿名文件,并映射进当前共享匿名映射区 vma 中
error = shmem_zero_setup(vma);
if (error)
goto free_vma;
} else {
// 这里处理私有匿名映射
// 将 vma->vm_ops 设置为 null,只有文件映射才需要 vm_ops 这样才能将内存与文件映射起来
vma_set_anonymous(vma);
}
// 将当前 vma 按照地址的增长方向插入到进程虚拟内存空间的 mm_struct->mmap 链表以及mm_struct->mm_rb 红黑树中
// 并建立文件与 vma 的反向映射
vma_link(mm, vma, prev, rb_link, rb_parent);
file = vma->vm_file;
out:
// 更新地址空间 mm_struct 中的相关统计变量
vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);
return addr;
}
5.1 may_expand_vm 检查映射的虚拟内存是否超过了内核限制
进程地址空间中对虚拟内存的用量是有限制的,限制分为两个方面:
对进程地址空间中能够映射的虚拟内存页总数做出限制。
对进程地址空间中数据区的虚拟内存页总数做出限制。
这里的数据区,在内核中定义的是所有私有,可写的虚拟内存区域(栈区除外):
/*
* Data area - private, writable, not stack
*/
static inline bool is_data_mapping(vm_flags_t flags)
{
// 本次需要映射的虚拟内存区域是否是私有,可写的(数据区)
return (flags & (VM_WRITE | VM_SHARED | VM_STACK)) == VM_WRITE;
}
以上两个方面的限制,我们可以通过修改 /etc/security/limits.conf
文件进行调整。
内核对进程地址空间中相关区域的虚拟内存用量限制依然保存在 task_struct->signal_struct->rlim 数组中,我们可以通过 RLIMIT_AS 以及 RLIMIT_DATA 下标进行访问。
// 进程地址空间中允许映射的虚拟内存总量,单位为字节
# define RLIMIT_AS 9 /* address space limit */
// 进程地址空间中允许用于私有可写(private,writable)的虚拟内存总量,单位字节
# define RLIMIT_DATA 2 /* max data size */
当前进程地址空间中已经映射的虚拟内存页数保存在 mm_struct->total_vm 中,数据区(私有,可写)已经映射的虚拟内存页数保存在 mm_struct->data_vm 中。
struct mm_struct {
// 进程地址空间中所有已经映射的虚拟内存页总数
unsigned long total_vm; /* Total pages mapped */
// 进程地址空间中所有私有,可写的虚拟内存页总数
unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
}
may_expand_vm 函数的核心逻辑就是判断经过本次 mmap 映射之后(mmap 需要映射的虚拟内存页数为 npages),mm->total_vm + npages 是否超过了 rlimit(RLIMIT_AS) 中的限制,mm->data_vm + npages 是否超过了 rlimit(RLIMIT_DATA) 中的限制。如果超过,那么本次 mmap 内存映射流程在这里就会停止进行。
// 检查本次映射是否超过了进程虚拟内存空间中的虚拟内存总量的限制,超过则返回 false
bool may_expand_vm(struct mm_struct *mm, vm_flags_t flags, unsigned long npages)
{
// mm->total_vm 表示当前进程地址空间中映射的虚拟内存页总数
// npages 表示此次要映射的虚拟内存页个数
// rlimit(RLIMIT_AS) 表示进程地址空间中允许映射的虚拟内存总量,单位为字节
if (mm->total_vm + npages > rlimit(RLIMIT_AS) >> PAGE_SHIFT)
// 如果映射的虚拟内存页总数超出了内核的限制,那么就返回 false 表示虚拟内存不足
return false;
// 检查本次映射是否属于数据区域的映射,这里的数据区域指的是私有,可写的虚拟内存区域(栈区除外)
// 如果是则需要检查数据区域里的虚拟内存页是否超过了内核的限制
// rlimit(RLIMIT_DATA) 表示进程地址空间中允许映射的私有,可写的虚拟内存总量,单位为字节
// 如果超过则返回 false,表示数据区虚拟内存不足
if (is_data_mapping(flags) &&
mm->data_vm + npages > rlimit(RLIMIT_DATA) >> PAGE_SHIFT) {
/* Workaround for Valgrind */
if (rlimit(RLIMIT_DATA) == 0 &&
mm->data_vm + npages <= rlimit_max(RLIMIT_DATA) >> PAGE_SHIFT)
return true;
pr_warn_once("%s (%d): VmData %lu exceed data ulimit %lu. Update limits%s.\n",
current->comm, current->pid,
(mm->data_vm + npages) << PAGE_SHIFT,
rlimit(RLIMIT_DATA),
ignore_rlimit_data ? "" : " or use boot option ignore_rlimit_data");
if (!ignore_rlimit_data)
return false;
}
return true;
}
5.2 内核的 overcommit 策略
正如前边笔者所介绍到的,内核的 overcommit 策略会影响到进程申请虚拟内存的用量,进程对虚拟内存的申请就好比是我们向银行贷款,我们在向银行贷款的时候,银行是需要对我们的还款能力进行审计的,我们抵押的资产越优质,银行贷款给我们的也会越多。
同样的道理,进程再向内核申请虚拟内存的时候,也是需要物理内存作为抵押的,因为虚拟内存说到底最终还是要映射到物理内存上的,背后需要物理内存作为支撑,不能无限制的申请。
所以进程在申请虚拟内存的时候,内核也是需要对申请的虚拟内存用量进行审计的,审计的对象就是那些在未来需要为其分配物理内存的虚拟内存。这也是符合常理的,因为只有在未来需要分配新的物理内存的时候,内核才需要综合物理内存的容量来进行审计,从而决定是否为进程分配这么多的虚拟内存,否则将来可能到处都是 OOM。如果未来不需要为这段虚拟内存分配物理内存,那么内核自然不会对虚拟内存用量进行审计。这取决于 mmap 的映射方式。
比如,这段虚拟内存是私有,可写的,那么在未来,当进程对这段虚拟内存进行写入的时候,内核会通过 cow 的方式为其分配新的物理内存,但是这段虚拟内存是共享的或者是只读的话,内核将不会为这段虚拟内存分配新的物理内存,而是继续共享原来已经映射好的物理内存(内核中只有一份)。
如果进程在向内核申请的虚拟内存在未来是需要重新分配物理内存的话,比如:私有,可写。那么这种虚拟内存的使用量就需要被内核审计起来,因为物理内存总是有限的,不可能为所有虚拟内存都分配物理内存。内核需要确保能够为这段虚拟内存未来分配足够的物理内存,防止 oom。这种虚拟内存称之为 account virtual memory。
而进程向内核申请的虚拟内存并不需要内核为其重新分配物理内存的时候(共享或只读),反正不会增加物理内存的使用负担,这种虚拟内存就不需要被内核审计。
/*
* We account for memory if it's a private writeable mapping,
* not hugepages and VM_NORESERVE wasn't set.
*/
static inline int accountable_mapping(struct file *file, vm_flags_t vm_flags)
{
/*
* hugetlb 类型的大页有其自己的统计方式,不会和普通的虚拟内存统计混合
*/
if (file && is_file_hugepages(file))
return 0;
// 私有,可写,并且没有设置 VM_NORESERVE 的相关 VMA 是需要被 account 审计起来的。这样在后续发生缺页的时候,不会导致 OOM
return (vm_flags & (VM_NORESERVE | VM_SHARED | VM_WRITE)) == VM_WRITE;
}
由于大页内存都是被预先分配在大页内存池中的,所以针对大页的虚拟内存不需要被审计,另外如果这段虚拟内存 vma 设置了 VM_NORESERVE 标志的话,也不需要被内核审计。
所以 account virtual memory 特指那些私有,可写(private ,writeable)的虚拟内存区域,并且这些虚拟内存区域的 vm_flags 没有设置 VM_NORESERVE 标志位,以及这部分虚拟内存不能是映射大页的。
这部分 account virtual memory 被记录在 vm_committed_as 字段中,表示被审计起来的虚拟内存,这些虚拟内存在未来都是需要映射新的物理内存的,站在物理内存的角度 vm_committed_as 可以理解为当前系统中已经分配的物理内存和未来可能需要的物理内存总量。
// 定义在文件:/include/linux/mman.h
extern struct percpu_counter vm_committed_as;
static inline void vm_acct_memory(long pages)
{
percpu_counter_add_batch(&vm_committed_as, pages, vm_committed_as_batch);
}
static inline void vm_unacct_memory(long pages)
{
vm_acct_memory(-pages);
}
每当有进程向内核申请或者释放虚拟内存(account virtual memory )的时候,内核都会通过 vm_acct_memory 和 vm_unacct_memory 函数来更新 vm_committed_as 的值。
当我们使用 mmap 进行内存映射的时候,如果映射出的虚拟内存区域 vma 为私有,可写的,并且参数 flags 没有设置 MAP_NORESERVE 标志,那么这部分虚拟内存就需要被记录在 vm_committed_as 字段中。
vm_committed_as 的值最终会反应在 /proc/meminfo
中的 Committed_AS 字段上。用来记录当前系统中,所有进程申请到的 account virtual memory 总量。
static int meminfo_proc_show(struct seq_file *m, void *v)
{
struct sysinfo i;
unsigned long committed;
committed = percpu_counter_read_positive(&vm_committed_as);
show_val_kb(m, "Committed_AS: ", committed);
}
现在 account virtual memory 的概念我们清楚了,那么接下来就该来看一下,内核是如何对这部分虚拟内存的申请进行审计的(account)。
如果 accountable_mapping 函数返回值为 true,表示内核需要对当前进程申请的这部分虚拟内存进行审计,审计的逻辑封装在 __vm_enough_memory 函数中,返回 0 表示有足够的虚拟内存,返回 ENOMEM 表示虚拟内存不足。这里正是内核 overcommit 策略的核心实现。
我们可以通过内核参数 /proc/sys/vm/overcommit_memory
来调整 overcommit 策略 。
内核定义了如下三种 overcommit 策略:
#define OVERCOMMIT_GUESS 0
#define OVERCOMMIT_ALWAYS 1
#define OVERCOMMIT_NEVER 2
OVERCOMMIT_GUESS 是内核默认的 overcommit 策略,在这种策略下,进程对虚拟内存的申请不能超过物理内存总大小和 swap 交换区的总大小 之和。
if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
if (pages > totalram_pages() + total_swap_pages)
goto error;
return 0;
}
OVERCOMMIT_ALWAYS 策略下应用进程无论申请多大的虚拟内存,内核总是会答应,分配虚拟内存非常的激进。
if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
return 0;
OVERCOMMIT_NEVER 策略下,内核会严格控制进程申请虚拟内存的用量,虚拟内存的限制通过 vm_commit_limit 函数计算得出,一般情况下为 (总物理内存大小 - 大页占用的内存大小) * 50% + swap 交换区总大小
。所有进程申请到的虚拟内存总量不能超过该值。
vm_commit_limit 函数返回值体现在 /proc/meminfo
中的 CommitLimit 字段中。
注意:只有在 OVERCOMMIT_NEVER 策略下,CommitLimit 的限制才会生效
除此之外,内核会在 CommitLimit 的基础上为进程预留一部分内存,用于在紧急情况下做一些恢复的操作,这部分预留的内存包括两种,一种是 sysctl_admin_reserve_kbytes,另一种是 sysctl_user_reserve_kbytes。它们的大小均可以在 /proc/sys/vm
目录下相应的配置文件中进行调整,单位为 KB。
sysctl_admin_reserve_kbytes 表示当进程拥有 root 权限的时候,内核需要为 root 相关的操作保留一部分内存,这样可以使进程在任何情况下都可以顺利执行 root 权限的相关操作。
sysctl_user_reserve_kbytes 用于在紧急情况下用户恢复系统。比如系统卡死,用户主动 kill 资源消耗比较大的进程,这个动作需要预留一些 user_reserve 内存。
所以在 OVERCOMMIT_NEVER 策略下,进程可以申请到的虚拟内存容量需要在 CommitLimit 的基础上再减去 sysctl_admin_reserve_kbytes 和 sysctl_user_reserve_kbytes 配置的预留容量。
注意这里对虚拟内存申请的限制是针对所有进程已经申请到的虚拟内存总量 + 本次 mmap 申请的虚拟内存总和的限制。
// 用于检查进程虚拟内存空间中是否有足够的虚拟内存可供本次申请使用(需要结合 overcommit 策略来综合判定)
// 返回 0 表示有足够的虚拟内存,返回 ENOMEM 表示虚拟内存不足
int __vm_enough_memory(struct mm_struct *mm, long pages, int cap_sys_admin)
{
// OVERCOMMIT_NEVER 模式下允许进程申请的虚拟内存大小
long allowed;
// 虚拟内存审计字段 vm_committed_as 增加 pages
vm_acct_memory(pages);
// 虚拟内存的 overcommit 策略可以通过修改 /proc/sys/vm/overcommit_memory 文件来设置,
// 它有三个设置选项:
// OVERCOMMIT_ALWAYS 表示无论应用进程申请多大的虚拟内存,内核总是会答应,分配虚拟内存非常的激进
if (sysctl_overcommit_memory == OVERCOMMIT_ALWAYS)
return 0;
// OVERCOMMIT_GUESS 则相对 always 策略稍微保守一点,也是内核的默认策略
// 它会对进程能够申请到的虚拟内存大小做一定的限制,特别激进的申请比如申请非常大的虚拟内存则会被拒绝。
if (sysctl_overcommit_memory == OVERCOMMIT_GUESS) {
// guess 默认策略下,进程申请的虚拟内存大小不能超过 物理内存总大小和 swap 交换区的总大小之和
if (pages > totalram_pages() + total_swap_pages)
goto error;
return 0;
}
// OVERCOMMIT_NEVER 是最为严格的一种控制虚拟内存 overcommit 的策略
// 进程申请的虚拟内存大小不能超过 vm_commit_limit(),该值也会反应在 /proc/meminfo 中的 CommitLimit 字段中。
// 只有采用 OVERCOMMIT_NEVER 模式,CommitLimit 的限制才会生效
// allowed =(总物理内存大小 - 大页占用的内存大小) * 50% + swap 交换区总大小
allowed = vm_commit_limit();
// cap_sys_admin 表示申请内存的进程拥有 root 权限
if (!cap_sys_admin)
// 为 root 进程保存一些内存,这样可以保证 root 相关的操作在任何时候都可以顺利进行
// 大小为 sysctl_admin_reserve_kbytes,这部分内存普通进程不能申请使用
// 可通过 /proc/sys/vm/admin_reserve_kbytes 来配置
allowed -= sysctl_admin_reserve_kbytes >> (PAGE_SHIFT - 10);
/*
* Don't let a single process grow so big a user can't recover
*/
if (mm) {
// 可通过 /proc/sys/vm/user_reserve_kbytes 来配置
// 用于在紧急情况下,用户恢复系统,比如系统卡死,用户主动 kill 资源消耗比较大的进程,这个动作需要预留一些 user_reserve 内存
long reserve = sysctl_user_reserve_kbytes >> (PAGE_SHIFT - 10);
allowed -= min_t(long, mm->total_vm / 32, reserve);
}
// Committed_AS (系统中所有进程已经申请的虚拟内存总量 + 本次 mmap 申请的)不可以超过 CommitLimit(allowed)
if (percpu_counter_read_positive(&vm_committed_as) < allowed)
return 0;
error:
vm_unacct_memory(pages);
return -ENOMEM;
}
下面我们来看一下,OVERCOMMIT_NEVER 策略下,CommitLimit 的计算逻辑。
有两个内核参数会影响 CommitLimit 的计算,它们分别是 sysctl_overcommit_kbytes 和 sysctl_overcommit_ratio,可通过 /proc/sys/vm
目录下相应的配置文件中进行调整。
如果我们配置了 overcommit_kbytes (单位为 KB), CommitLimit (单位为页)的值就是 sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10) + total_swap_pages
。
如果我们没有配置 overcommit_kbytes,内核则会根据 overcommit_ratio 的值(默认为 50)计算 CommitLimit :(总物理内存大小 - 大页占用的内存大小) * overcommit_ratio % + total_swap_pages
。
overcommit_kbytes 的优先级要大于 overcommit_ratio
/*
* Committed memory limit enforced when OVERCOMMIT_NEVER policy is used
*/
unsigned long vm_commit_limit(void)
{
// 允许申请的虚拟内存大小,单位为页
unsigned long allowed;
// 该值可通过 /proc/sys/vm/overcommit_kbytes 来修改
// sysctl_overcommit_kbytes 设置的是 Committed memory limit 的绝对值
if (sysctl_overcommit_kbytes)
// 转换单位为页
allowed = sysctl_overcommit_kbytes >> (PAGE_SHIFT - 10);
else
// sysctl_overcommit_ratio 该值可通过 /proc/sys/vm/overcommit_ratio 来修改,设置的 commit limit 的比例
// 默认值为 50,(总物理内存大小 - 大页占用的内存大小) * 50%
allowed = ((totalram_pages() - hugetlb_total_pages())
* sysctl_overcommit_ratio / 100);
// 最后都需要加上 swap 交换区的总大小
allowed += total_swap_pages;
// (总物理内存大小 - 大页占用的内存大小) * 50% + swap 交换区总大小
return allowed;
}
5.3 vma_merge 函数解析
经过前面的介绍我们知道,当 mmap 在进程虚拟内存空间中映射出一段 [addr , end] 的虚拟内存区域 area 时,内核需要为这段虚拟内存区域 area 创建一个 vma 结构来描述。
而在创建新的 vma 结构之前,内核会在这里尝试看能不能将 area 与现有的 vma 进行合并,这样就可以避免创建新的 vma 结构,节省了内存的开销。
内核会本着合并最大化的原则,检查当前映射出来的 area 能否与其前后两个 vma 进行合并,能合并就合并,如果不能合并就只能从 slab 中申请新的 vma 结构了。合并条件如下:
area 的 vm_flags 不能设置 VM_SPECIAL 标志,该标志表示 area 区域是不可以被合并的,只能重新创建 vma。
area 的起始地址 addr 必须要与其 prev vma 的结束地址重合,这样,area 才能和它的前一个 vma 进行合并,如果不重合,area 则不能和前一个 vma 进行合并。
area 的结束地址 end 必须要与其 next vma 的起始地址重合,这样,area 才能和它的后一个 vma 进行合并,如果不重合,area 则不能和后一个 vma 进行合并。如果前后都不能合并,那就只能重新创建 vma 结构了。
area 需要与其要合并区域的 vm_flags 必须相同,否则不能合并。
如果两个合并区域都是文件映射区,那么它们映射的文件必须是同一个。并且他们的文件映射偏移 vm_pgoff 必须是连续的。
如果两个合并区域都是匿名映射区,那么两个 vma 映射的匿名页 anon_vma 必须是相同的。
合并区域的 numa policy 必须是相同的。关于 numa policy 的介绍,感兴趣的同学可以查看笔者之前的文章 《一步一图带你深入理解 Linux 物理内存管理》 第 “3.2.1 NUMA 的内存分配策略” 小节的内容。
要合并的 prev 和 next 虚拟内存区域中,不能包含 close 操作,也就是说 vma->vm_ops 不能设置有 close 函数,如果虚拟内存区域操作支持 close,则不能合并,否则会导致现有虚拟内存区域 prev 和 next 的资源无法释放。
can_vma_merge_after 函数用于判断其参数中指定的 vma 能否与其后一个 vma 进行合并。can_vma_merge_before 的逻辑也是一样,用于判断参数指定的 vma 能否与其前一个 vma 合并。
static int
can_vma_merge_after(struct vm_area_struct *vma, unsigned long vm_flags,
struct anon_vma *anon_vma, struct file *file,
pgoff_t vm_pgoff,
struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
// 判断参数中指定的 vma 能否与其后一个 vma 进行合并
if (is_mergeable_vma(vma, file, vm_flags, vm_userfaultfd_ctx) &&
is_mergeable_anon_vma(anon_vma, vma->anon_vma, vma)) {
pgoff_t vm_pglen;
// vma 区域的长度
vm_pglen = vma_pages(vma);
// 判断 vma 和 next 两个文件映射区域的映射偏移 pgoff 是否是连续的
if (vma->vm_pgoff + vm_pglen == vm_pgoff)
return 1;
}
return 0;
}
is_mergeable_vma 函数用于判断两个 vma 是否能够合并:
static inline int is_mergeable_vma(struct vm_area_struct *vma,
struct file *file, unsigned long vm_flags,
struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
// 对比 prev 和 area 的 vm_flags 是否相同,这里需要排除 VM_SOFTDIRTY
// VM_SOFTDIRTY 用于追踪进程写了哪些内存页,如果 prev 被标记了 soft dirty,那么合并之后的 vma 也应该继续保留 soft dirty 标记
if ((vma->vm_flags ^ vm_flags) & ~VM_SOFTDIRTY)
return 0;
// prev 和 area 如果是文件映射区的话,这里需要检查两者映射的文件是否相同
if (vma->vm_file != file)
return 0;
// 如果 prev 虚拟内存区域中包含了 close 的操作,后续可能会释放 prev 的资源
// 所以这种情况下不能和 prev 进行合并,否则就会导致 prev 的资源无法释放
if (vma->vm_ops && vma->vm_ops->close)
return 0;
// userfaultfd 是用来在用户态实现缺页处理的机制,这里需要保证两者的 userfaultfd 相同
// 不过在 mmap_region 中传入的 vm_userfaultfd_ctx 为 null,这里我们不需要关注
if (!is_mergeable_vm_userfaultfd_ctx(vma, vm_userfaultfd_ctx))
return 0;
return 1;
}
在我们清楚了 vma 之间的的合并条件之后,接下来我们来看一下 vma 的合并过程,整个合并过程其实还蛮复杂的,总共涉及到 8 种场景,不过大家别担心,笔者会带着大家从最简单的场景出发来逐渐演变。
经过前面内容的介绍,我们知道,通过 mmap 在进程地址空间中映射出的这个 area 一般是在两个 vma 中产生的,内核源码中使用 prev 指向 area 的前一个 vma,使用 next 指向 area 的后一个 vma,这个原则请大家务必牢记。
如果我们在 mmap 系统调用参数 flags 中设置了 MAP_FIXED 标志,表示需要内核进行强制映射,在这种情况下,area 区域有可能会与 prev 区域和 next 区域有部分重合。
如上图所示,如果 area 区域的结束地址 end 与 next 区域的结束地址重合,内核会将 next 指针继续向后移动一下,指向 next->vm_next 区域。保证 area 始终处于 prev 和 next 之间的 gap 中。
if (area && area->vm_end == end)
next = next->vm_next;
以上这两种基本布局,大家要好好记住,多看几眼,后面 8 种合并情况基本都是脱胎于这两个基本布局。
下面即将要介绍的这 8 种合并情况从总体上来讲会分为两个大的类别:
第一个类别是 area 的前一个 prev vma 的结束地址与 area 的起始地址 addr 重合,判断条件为:
prev->vm_end == addr
。第二个类别是 area 的后一个 next vma 的起始地址与 area 的结束地址 end 重合,判断条件为:
end == next->vm_start
。
其中这两个大的类别将会分别根据前面两个基本布局展开进行,下面我们来看源码中的 case 1 。
注意下面的 8 种 case,笔者按照从简单到复杂的顺序来展示。
case 1 是在基本布局 1 中,area 的起始地址 addr 与 prev vma 的结束地址重合,同时 area 的结束地址 end 与 next vma 的起始地址重合,内核将会删除 next 区域,扩充 prev 区域,也就是说将这三个区域统一合并到 prev 区域中。
case 1 在基本布局 2 下,就演变成了 case 6 的情况,内核会将中间重叠的蓝色区域覆盖掉,然后统一合并到 prev 区域中。
如果只是 area 的起始地址 addr 与 prev vma 的结束地址重合,但是 area 的结束地址 end 不与 next vma 的起始地址重合,就会出现 case 2 , case 5 , case 7 三种情况。
其中 case 2 的情况是 area 的结束地址 end 小于 next vma 的起始地址,内核会扩充 prev 区域,将 area 合并进去,next 区域保持不变。
case 5 的情况是 area 的结束地址 end 大于 next vma 的起始地址,内核会扩充 prev 区域,将 area 以及与 next 重叠的部分合并到 prev 区域中,剩下的继续留在 next 区域保持不变。
case 2 在基本布局 2 下又会演变成 case 7 , 这种情况下内核会将下图中的蓝色区域覆盖,并扩充 prev 区域。next 区域保持不变。
如果只是 area 的结束地址 end 与 next vma 的起始地址重合,但是 area 的起始地址 addr 不与 prev vma 的结束地址重合,同样的道理也会分为三种情况,分别是下面介绍的 case 4 , case 3 , case 8。
case 4 的情况下,area 的起始地址 addr 小于 prev 区域的结束地址,那么内核会缩小 prev 区域,然后扩充 next 区域,将重叠的部分合并到 next 区域中。
如果 area 的起始地址 addr 大于 prev 区域的结束地址的话,就是 case 3 的情况 ,内核会扩充 next 区域,并将 area 合并到 next 中,prev 区域保持不变。
case 3 在基本布局 2 下就会演变为 case 8 ,内核继续保持 prev 区域不变,然后扩充 next 区域并覆盖下图中蓝色部分,将 area 合并到 next 区域中。
好了,现在 vma 合并的流程我们也清楚了,合并的条件也清楚了,接下来在看这部分源码就很简单了。
struct vm_area_struct *vma_merge(struct mm_struct *mm,
struct vm_area_struct *prev, unsigned long addr,
unsigned long end, unsigned long vm_flags,
struct anon_vma *anon_vma, struct file *file,
pgoff_t pgoff, struct mempolicy *policy,
struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
// 本次需要创建的 VMA 区域大小
pgoff_t pglen = (end - addr) >> PAGE_SHIFT;
// area 表示当前要创建的 VMA,next 表示 area 的下一个 VMA
// 事实上 area 会在其 prev 前一个 VMA 和 next 后一个 VMA 之间的间隙 gap 中创建产生
struct vm_area_struct *area, *next;
int err;
// 设置了 VM_SPECIAL 表示 area 区域是不可以被合并的,只能重新创建 VMA,直接退出合并流程。
if (vm_flags & VM_SPECIAL)
return NULL;
// 根据 prev vma 是否存在,设置 area 的 next vma,基本布局 1
if (prev)
// area 将在 prev vma 和 next vma 的间隙 gap 中产生
next = prev->vm_next;
else
// 如果 prev 不存在,那么 next 就设置为地址空间中的第一个 vma。
next = mm->mmap;
area = next;
// 新 vma 的 end 与 next->vm_end 相等 ,表示新 vma 与 next vma 是重合的,基本布局 2
// 那么 next 指向下一个 vma,prev 和 next 这里的语义是始终指向 area 区域的前一个和后一个 vma
if (area && area->vm_end == end) /* cases 6, 7, 8 */
next = next->vm_next;
// 判断 area 是否能够和 prev 进行合并
if (prev && prev->vm_end == addr &&
mpol_equal(vma_policy(prev), policy) &&
can_vma_merge_after(prev, vm_flags,
anon_vma, file, pgoff,
vm_userfaultfd_ctx)) {
/*
* 如何 area 可以和 prev 进行合并,那么这里继续判断 area 能够与 next 进行合并
* 内核这里需要保证 vma 合并程度的最大化
*/
if (next && end == next->vm_start &&
mpol_equal(policy, vma_policy(next)) &&
can_vma_merge_before(next, vm_flags,
anon_vma, file,
pgoff+pglen,
vm_userfaultfd_ctx) &&
is_mergeable_anon_vma(prev->anon_vma,
next->anon_vma, NULL)) {
// 流程走到这里表示 area 可以和它的 prev ,next 区域进行合并 /* cases 1,6 */
// __vma_adjust 是真正执行 vma 合并操作的函数,这里会重新调整已有 vma 的相关属性,比如:vm_start,vm_end,vm_pgoff。以及涉及到相关数据结构的改变
err = __vma_adjust(prev, prev->vm_start,
next->vm_end, prev->vm_pgoff, NULL,
prev);
} else /* cases 2, 5, 7 */
// 流程走到这里表示 area 只能和 prev 进行合并
err = __vma_adjust(prev, prev->vm_start,
end, prev->vm_pgoff, NULL, prev);
if (err)
return NULL;
khugepaged_enter_vma_merge(prev, vm_flags);
// 返回最终合并好的 vma
return prev;
}
// 下面这种情况属于,area 的结束地址 end 与 next 的起始地址是重合的
// 但是 area 的起始地址 start 和 prev 的结束地址不是重合的
if (next && end == next->vm_start &&
mpol_equal(policy, vma_policy(next)) &&
can_vma_merge_before(next, vm_flags,
anon_vma, file, pgoff+pglen,
vm_userfaultfd_ctx)) {
// area 区域前半部分和 prev 区域的后半部分重合
// 那么就缩小 prev 区域,然后将 area 合并到 next 区域
if (prev && addr < prev->vm_end) /* case 4 */
err = __vma_adjust(prev, prev->vm_start,
addr, prev->vm_pgoff, NULL, next);
else { /* cases 3, 8 */
// area 区域前半部分和 prev 区域是有间隙 gap 的
// 那么这种情况下 prev 不变,area 合并到 next 中
err = __vma_adjust(area, addr, next->vm_end,
next->vm_pgoff - pglen, NULL, next);
// 合并后的 area
area = next;
}
if (err)
return NULL;
khugepaged_enter_vma_merge(area, vm_flags);
// 返回合并后的 vma
return area;
}
// prev 的结束地址不与 area 的起始地址重合,并且 area 的结束地址不与 next 的起始地址重合
// 这种情况就不能执行合并,需要为 area 重新创建新的 vma 结构
return NULL;
}
总结
到现在为止,笔者通过两篇文章,一篇原理,一篇源码,深入到内核世界中,将 mmap 内存映射的本质给大家呈现了出来,知识点比较密集且比较烧脑,因此笔者又画了一副 mmap 内存映射的整体思维导图方便大家回顾。
在原理篇中笔者首先通过五个角度为大家详细介绍了 mmap 的使用方法及其在内核中的实现原理,这五个角度分别是:
私有匿名映射,其主要用于进程申请虚拟内存,以及初始化进程虚拟内存空间中的 BSS 段,堆,栈这些虚拟内存区域。
私有文件映射,其核心特点是背后映射的文件页在多进程之间是读共享的,但多个进程对各自虚拟内存区的修改只能反应到各自对应的文件页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中。我们可以利用这些特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。
共享文件映射,多进程之间读写共享(不会发生写时复制),常用于多进程之间共享内存(page cache),多进程之间的通讯。
共享匿名映射,用于父子进程之间共享内存,父子进程之间的通讯。父子进程之间需要依赖 tmpfs 中的匿名文件来实现共享内存。是一种特殊的共享文件映射。
大页内存映射,这里我们介绍了标准大页与透明大页两种大页类型的区别与联系,以及他们各自的实现原理和使用方法。
介绍完原理之后,在本文的源码实现篇中笔者花了大量的篇幅介绍了 mmap 在内核中的源码实现,其中最核心的两个函数是:
get_unmapped_area 函数用于在进程虚拟内存空间中为本次 mmap 映射寻找出一段未被映射的空闲虚拟内存地址范围。其中笔者还为大家介绍了文件映射与匿名映射区在进程虚拟内存空间的布局情况。
-
map_region 函数主要是对这段空闲虚拟内存地址范围进行映射,在映射过程中涉及到的重要内容有:
- 内核的 overcommit 策略
- vm_merge 合并的流程,其中涉及到 8 种合并场景和 2 中基本布局。
好了,本文的内容到这里就结束了,感谢大家的收看,我们下篇文章见~