
从内核世界透视 mmap 内存映射的本质(源码实现篇)
5.1 may_expand_vm 检查映射的虚拟内存是否超过了内核限制
进程地址空间中对虚拟内存的用量是有限制的,限制分为两个方面:
- 对进程地址空间中能够映射的虚拟内存页总数做出限制。
- 对进程地址空间中数据区的虚拟内存页总数做出限制。
这里的数据区,在内核中定义的是所有私有,可写的虚拟内存区域(栈区除外):
以上两个方面的限制,我们可以通过修改 /etc/security/limits.conf
文件进行调整。
内核对进程地址空间中相关区域的虚拟内存用量限制依然保存在 task_struct->signal_struct->rlim 数组中,我们可以通过 RLIMIT_AS 以及 RLIMIT_DATA 下标进行访问。
当前进程地址空间中已经映射的虚拟内存页数保存在 mm_struct->total_vm 中,数据区(私有,可写)已经映射的虚拟内存页数保存在 mm_struct->data_vm 中。
may_expand_vm 函数的核心逻辑就是判断经过本次 mmap 映射之后(mmap 需要映射的虚拟内存页数为 npages),mm->total_vm + npages 是否超过了 rlimit(RLIMIT_AS) 中的限制,mm->data_vm + npages 是否超过了 rlimit(RLIMIT_DATA) 中的限制。如果超过,那么本次 mmap 内存映射流程在这里就会停止进行。
5.2 内核的 overcommit 策略
正如前边笔者所介绍到的,内核的 overcommit 策略会影响到进程申请虚拟内存的用量,进程对虚拟内存的申请就好比是我们向银行贷款,我们在向银行贷款的时候,银行是需要对我们的还款能力进行审计的,我们抵押的资产越优质,银行贷款给我们的也会越多。
同样的道理,进程再向内核申请虚拟内存的时候,也是需要物理内存作为抵押的,因为虚拟内存说到底最终还是要映射到物理内存上的,背后需要物理内存作为支撑,不能无限制的申请。
所以进程在申请虚拟内存的时候,内核也是需要对申请的虚拟内存用量进行审计的,审计的对象就是那些在未来需要为其分配物理内存的虚拟内存。这也是符合常理的,因为只有在未来需要分配新的物理内存的时候,内核才需要综合物理内存的容量来进行审计,从而决定是否为进程分配这么多的虚拟内存,否则将来可能到处都是 OOM。如果未来不需要为这段虚拟内存分配物理内存,那么内核自然不会对虚拟内存用量进行审计。这取决于 mmap 的映射方式。
比如,这段虚拟内存是私有,可写的,那么在未来,当进程对这段虚拟内存进行写入的时候,内核会通过 cow 的方式为其分配新的物理内存,但是这段虚拟内存是共享的或者是只读的话,内核将不会为这段虚拟内存分配新的物理内存,而是继续共享原来已经映射好的物理内存(内核中只有一份)。
如果进程在向内核申请的虚拟内存在未来是需要重新分配物理内存的话,比如:私有,可写。那么这种虚拟内存的使用量就需要被内核审计起来,因为物理内存总是有限的,不可能为所有虚拟内存都分配物理内存。内核需要确保能够为这段虚拟内存未来分配足够的物理内存,防止 oom。这种虚拟内存称之为 account virtual memory。
而进程向内核申请的虚拟内存并不需要内核为其重新分配物理内存的时候(共享或只读),反正不会增加物理内存的使用负担,这种虚拟内存就不需要被内核审计。
由于大页内存都是被预先分配在大页内存池中的,所以针对大页的虚拟内存不需要被审计,另外如果这段虚拟内存 vma 设置了 VM_NORESERVE 标志的话,也不需要被内核审计。
所以 account virtual memory 特指那些私有,可写(private ,writeable)的虚拟内存区域,并且这些虚拟内存区域的 vm_flags 没有设置 VM_NORESERVE 标志位,以及这部分虚拟内存不能是映射大页的。
这部分 account virtual memory 被记录在 vm_committed_as 字段中,表示被审计起来的虚拟内存,这些虚拟内存在未来都是需要映射新的物理内存的,站在物理内存的角度 vm_committed_as 可以理解为当前系统中已经分配的物理内存和未来可能需要的物理内存总量。
每当有进程向内核申请或者释放虚拟内存(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 总量。
现在 account virtual memory 的概念我们清楚了,那么接下来就该来看一下,内核是如何对这部分虚拟内存的申请进行审计的(account)。
如果 accountable_mapping 函数返回值为 true,表示内核需要对当前进程申请的这部分虚拟内存进行审计,审计的逻辑封装在 __vm_enough_memory 函数中,返回 0 表示有足够的虚拟内存,返回 ENOMEM 表示虚拟内存不足。这里正是内核 overcommit 策略的核心实现。
我们可以通过内核参数 /proc/sys/vm/overcommit_memory
来调整 overcommit 策略 。
内核定义了如下三种 overcommit 策略:
OVERCOMMIT_GUESS 是内核默认的 overcommit 策略,在这种策略下,进程对虚拟内存的申请不能超过物理内存总大小和 swap 交换区的总大小 之和。
OVERCOMMIT_ALWAYS 策略下应用进程无论申请多大的虚拟内存,内核总是会答应,分配虚拟内存非常的激进。
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_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
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 合并。
is_mergeable_vma 函数用于判断两个 vma 是否能够合并:
在我们清楚了 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 中。
以上这两种基本布局,大家要好好记住,多看几眼,后面 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 合并的流程我们也清楚了,合并的条件也清楚了,接下来在看这部分源码就很简单了。
总结
到现在为止,笔者通过两篇文章,一篇原理,一篇源码,深入到内核世界中,将 mmap 内存映射的本质给大家呈现了出来,知识点比较密集且比较烧脑,因此笔者又画了一副 mmap 内存映射的整体思维导图方便大家回顾。
在原理篇中笔者首先通过五个角度为大家详细介绍了 mmap 的使用方法及其在内核中的实现原理,这五个角度分别是:
- 私有匿名映射,其主要用于进程申请虚拟内存,以及初始化进程虚拟内存空间中的 BSS 段,堆,栈这些虚拟内存区域。
- 私有文件映射,其核心特点是背后映射的文件页在多进程之间是读共享的,但多个进程对各自虚拟内存区的修改只能反应到各自对应的文件页上,而且各自的修改在进程之间是互不可见的,最重要的一点是这些修改均不会回写到磁盘文件中。我们可以利用这些特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段中。
- 共享文件映射,多进程之间读写共享(不会发生写时复制),常用于多进程之间共享内存(page cache),多进程之间的通讯。
- 共享匿名映射,用于父子进程之间共享内存,父子进程之间的通讯。父子进程之间需要依赖 tmpfs 中的匿名文件来实现共享内存。是一种特殊的共享文件映射。
- 大页内存映射,这里我们介绍了标准大页与透明大页两种大页类型的区别与联系,以及他们各自的实现原理和使用方法。
介绍完原理之后,在本文的源码实现篇中笔者花了大量的篇幅介绍了 mmap 在内核中的源码实现,其中最核心的两个函数是:
- get_unmapped_area 函数用于在进程虚拟内存空间中为本次 mmap 映射寻找出一段未被映射的空闲虚拟内存地址范围。其中笔者还为大家介绍了文件映射与匿名映射区在进程虚拟内存空间的布局情况。
- map_region 函数主要是对这段空闲虚拟内存地址范围进行映射,在映射过程中涉及到的重要内容有:
- 内核的 overcommit 策略
- vm_merge 合并的流程,其中涉及到 8 种合并场景和 2 中基本布局。
好了,本文的内容到这里就结束了,感谢大家的收看,我们下篇文章见~
文章转载自公众号:bin的技术小屋
