ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux内存管理进阶:VMA、缺页异常、SLUB与页面回收

Linux内存管理进阶:VMA、缺页异常、SLUB与页面回收

这个系列写到第6篇,内存管理部分也进入第二篇了。上一篇我们聊了物理内存初始化、页表建立和伙伴系统,把“物理页面怎么分配”这条主线捋了一遍。这一篇我想把视角从 Linux 内核源代码里换到另一条更贴近日常调试的路径上:用户进程的虚拟地址空间是怎么被描述出来的、缺页异常到底在解决什么问题、内核自身分配小对象为什么要另起炉灶、以及当整台机器内存告急时,内核靠什么把内存腾出来。

这四个问题分别对应 mm_struct/VMA、page fault、slab/slub、page reclaim。如果说上一篇解决的是“地皮怎么划”,这一篇就是“房子怎么建、物业怎么管、垃圾怎么清”。适合已经大概了解页表和伙伴系统、想往内存管理层再走一步的同学,也适合遇到内存泄漏、swap 占用高、进程 RSS 居高不下时想系统排查问题的人。源码细节我会按某个长期支持分支的习惯来讲,碰到新旧版本差异会单独点出来。

1. 从 mm_struct 到 VMA:进程地址空间的内核账本

在 Linux 内核源代码里,一个用户进程能看到的地址空间并不是 CPU 直接给的,而是内核替它维护出来的一张“虚拟地图”。每个进程的 task_struct 里有一个 mm 指针,指向它的 mm_struct;内核线程没有用户地址空间,所以 mm 为空,只有 active_mm 这种临时借用地址空间的说法。mm_struct 可以看成整个进程地址空间的根对象,所有关于“这个进程能访问哪些地址、这些地址对应什么文件或匿名内存”的信息,都以它作为入口。

1.1 mm_struct 里到底记了什么账

打开 include/linux/mm_types.h,task_struct 里那个 mm_struct 字段非常多,但真正关键的就那么几组。一组是 pgd 指针,指向进程的顶层页目录,代码切换进程上下文时,加载 CR3 的就是它。一组是地址区间描述:mmap_base 表示 mmap 区域起始地址,start_brk 和 brk 围出堆的范围,start_code/end_code、start_data/end_data 描述代码段和数据段,start_stack 描述栈。还有 arg_start/end、env_start/end 记录命令行参数和环境变量的位置。最后是一堆统计字段和锁:map_count 是 VMA 数量,rss_stat 是常驻内存统计,mmap_lock 保护整个地址空间的遍历和修改。

这里有个容易看晕的地方:mm_users 和 mm_count 是两回事。mm_users 表示有多少个进程共享这个地址空间,比如调用 fork 后的父子进程,或者用 CLONE_VM 创建的线程,都会把 mm_users 加一。而 mm_count 是这个 mm_struct 结构的引用计数,就算 mm_users 变成零,也不代表 mm_struct 能被直接释放,因为可能还有内核代码正在使用这个 mm。只有当 mm_count 也归零,整个结构才能真正销毁。所以读代码时看到 mmgrab/mmget 别混淆,grab 管的是结构生命周期,get 管的是地址空间使用者数量。

1.2 VMA 的查找、合并与分裂

mm_struct 是总账,真正描述每一段地址空间的是 vm_area_struct,简称 VMA。每个 VMA 用 vm_start 到 vm_end 这个半开区间表示一段连续、属性一致的虚拟地址,比如程序文本段是一个只读可执行的私有文件映射,堆是一个匿名可写私有映射,栈是另一段。VMA 里有 vm_flags 描述读写执行权限和共享属性,vm_page_prot 是根据 flags 换算好的页表权限,vm_ops 是指向页面处理回调的指针,vm_file 和 vm_pgoff 用于文件映射。

内核在查找“某个地址属于哪个 VMA”时不能只靠链表,因为一个进程可能挂几万个 VMA,线性扫描太慢。老代码用红黑树 mm_rb 配合链表 mmap,find_vma 的语义就是找到第一个 vm_end 大于目标地址的 VMA。新版本里红黑树已经逐步被 maple tree 替代,原因很实际:红黑树按单个节点组织范围,对区间操作和 RCU 遍历不友好;maple tree 把节点组织得更紧凑,查一个地址或一段区间都比红黑树高效,还能减少内存占用。如果你在源码里同时看到 mm_rb 和 mm_mt 字段,说明正处于迁移过渡期。

VMA 的合并与分裂是理解地址空间膨胀的关键。当新插入的 VMA 和现有相邻 VMA 在 vm_ops、vm_flags、文件映射偏移上完全兼容时,内核会调用 vma_merge 把它们并成一个,避免 VMA 数量膨胀。反过来,当用户调用 mprotect 只修改中间一段属性,或者 munmap 只释放区间的一部分,内核就要调 split_vma 把原 VMA 切成两段。频繁 mmap/munmap 会让 VMA 数量居高不下,而 VMA 数量一多,所有缺页异常都会在 find_vma 上多花时间。这是个平时不太注意但实际很常见的性能隐患。

1.3 mmap 系统调用的落地路径

用户跟踪 mmap 时,入口实际经过多层封装后到达 do_mmap。第一步是调用 get_unmapped_area 找一段空闲的虚拟地址,默认策略是从 mmap_base 往下找足够大的 gap,同时带着 ASLR 随机偏移。第二步是检查 len 是否页对齐、是否超过 rlimit、区间是否有重叠,然后把用户传的 prot 和 flags 换算成内核的 vm_flags。第三步才创建或合并 VMA,把区间挂进 mm 的红黑树或 maple tree。

这里特别值得记住的是:do_mmap 返回时只是把“地图”登记好了,页表项依然全空的。mmap 一个 1GB 的文件,RSS 不会立刻增长 1GB,只有程序真的去读那些页,缺页异常才会逐页把数据填进物理内存。排查 RSS 虚高时,先分清是 VMA 多还是 PTE 真正映射了页,这两种情况的数据含义完全不同。VMA 多不代表物理内存用得多,但会让所有后续缺页变慢;PTE 映射多才代表真实占用。

2. 缺页异常:让虚拟地址真正落地的必经关卡

程序访问某个虚拟地址时,CPU 查页表如果发现 PTE 不存在、或者请求的读写权限不满足,就会触发架构相关的 page fault。x86-64 的异常错误码里有几个关键位:bit0 表示页是否存在,bit1 表示是否由写操作触发,bit2 表示是否来自用户态,bit4 表示是否取指触发。内核拿到 error_code 之后,第一件事就是判断这次异常到底是“合法缺页需要补页”还是“程序真的访问了不该碰的地址”。

2.1 缺页异常的分流逻辑

内核的主路径是 do_user_addr_fault 先确认地址在用户空间且来自用户态,然后调用 handle_mm_fault。handle_mm_fault 内部先做 find_vma,如果找不到任何 VMA 覆盖这个地址,直接给进程发 SIGSEGV,也就是最常见的段错误。如果 VMA 存在,但当前操作和 VMA 的 vm_flags 冲突,比如往只读映射页上写,这就不是缺页而是 protection fault,同样给 SIGSEGV。

正常缺页继续往下会走到 handle_pte_fault,根据 PTE 状态分成几类:pte_none 说明页表项完全空,匿名映射走 do_anonymous_page,文件映射走 do_fault;PTE 存在但只读且遇到写操作,走 do_wp_page 做写时复制;PTE 内容指向 swap 条目,走 do_swap_page 把页换回来。这里也是 minor fault 和 major fault 的分水岭:如果目标页已经在 page cache 或已经分配,不需要磁盘 IO,属于 minor fault;如果必须从磁盘读数据或从 swap 读回来,属于 major fault。你在 ps 里看到进程 majflt 持续增长,基本可以断定它在等 IO,这是判断性能瓶颈很直接的手段。

2.2 匿名页缺页与写时复制

匿名页就是没有文件背景的页,典型的堆、栈、匿名 mmap。第一次访问匿名地址时,如果映射是只读,内核会十分取巧地映射一个全局共享的 zero page,因为读一页新分配的内存本来就是全零,没必要真给每个进程分配物理页。只有进程尝试写这个只读页,页表权限不够,才触发缺页,此时分配真正的页并拷贝零页内容,再改成可写。所以“读零页不占内存,写才占”是个很实用的观测点。

写时复制 COW 是 fork 能高效运行的核心。父进程 fork 出子进程后,两者的 PTE 都变成只读,但映射的还是同一物理页。某一方要写时,CPU 触发 do_wp_page,内核先判断这个物理页是不是只有一个进程映射,也就是 struct page 里的 _mapcount 是否为 0。如果只有一个映射,直接把这页的 PTE 改成可写就行,完全不用复制;只有多个映射才需要分配新页、拷贝旧页内容、更新 PTE、刷新 TLB。这也是理解 _refcount 和 _mapcount 区别的最好场景:_refcount 表示引用这个页的次数,_mapcount 表示有多少个 PTE 指向它。应用层面试常问“为什么 fork 之后写性能差”,答案就在这条路径里。

2.3 文件映射缺页的处理路径

文件映射缺页走 do_fault,往下再拆成 do_read_fault、do_cow_fault、do_shared_fault,区别就在 VMA 是私有还是共享、缺页是不是写操作。无论哪条,都会先通过 find_get_page 查 page cache。如果页已经在缓存里,直接映射它,这就是 minor fault;如果没在缓存里,需要分配一个新的 page cache 页,发起磁盘读,读完成后再建 PTE,这就是 major fault。

do_read_fault 用于私有只读的读缺页,直接把 page cache 页以只读方式映射。do_cow_fault 用于私有可写的缺页,内核会额外分配一个新页,把 page cache 里的内容拷过来,保证多个进程各自写各自,不会互相干扰。do_shared_fault 用于共享可写映射,直接把 page cache 页以可写方式映射,后续对页的修改通过 writeback 回写磁盘。这里还有个配套机制是 readahead,filemap_fault 发现连续缺页时会顺带把相邻页批量预读,所以顺序读一个文件时,page cache 往往是一块一块进来的,而不是一次一页。

3. slab/slub 分配器:内核自用的小对象供应链

前面聊的缺页和 VMA 都是面向用户进程的虚拟内存,接下来转到内核自身。内核在运行时要创建大量小对象,比如文件描述符表里那些 file 结构、目录项的 dentry、inode、task_struct。这些对象大小从几十字节到几百字节不等,如果每次都用伙伴系统按页分配,内存浪费率会高得惊人。为此内核维护了一套自己的对象缓存机制,也就是 slab 分配器,现代内核默认实现是 SLUB。

3.1 为什么不直接用伙伴系统

伙伴系统的最小分配单位是一页,通常 4KB。一个 file 结构可能只有 200 多字节,为了它分配一整页,剩下 95% 的空闲空间就浪费了。而且内核频繁创建和销毁这些对象,如果每次都要通过伙伴系统找连续页、再拆分成对象,频繁的页分裂和合并会带来严重的碎片问题。

更关键的是初始化成本。很多内核对象在创建时要做复杂的初始化,比如 inode 要初始化锁、链表、地址空间字段。如果对象能被缓存复用,下次分配时只需要重置少数字段,这比重新分配页面再逐字段构造快得多。内核里对应三种实现:老牌 SLAB 的 per-CPU 队列设计复杂,管理元数据占用大;SLUB 精简了 slab 结构,把空闲对象指针尽量塞进对象本身,调试支持也更完整;SLOB 面向嵌入式小内存,实现简单但容易碎片。现在大部分发行版默认开着 CONFIG_SLUB,所以在源码里看到 kmem_cache 相关操作时,实际底层都是 SLUB 的逻辑。

3.2 kmem_cache、per-CPU freelist 与对象复用

对象缓存的入口是 kmem_cache,由 kmem_cache_create 创建,名字比如“dentry”“inode”,创建时要指定对象大小、对齐参数和可选构造函数。每个缓存下面挂着若干 slab page,一个 slab page 被划分成固定大小的多个对象,空闲对象用 freelist 串起来。SLUB 里有个很妙的设计:如果对象本身大于一个指针,空闲对象的第一个字就用来存下一个空闲对象指针,这样不需要额外的管理数组,省内存。

分配路径上最值得关注的是 per-CPU 缓存。每个 CPU 都有一个 kmem_cache_cpu,里面保存当前正在使用的 slab 页和下一个空闲对象。kmem_cache_alloc 时优先从当前 CPU 的 freelist 上拿一个对象,整个过程几乎是无锁的。只有当前 CPU freelist 空了,才需要到 node 级的 partial 链表去找可用的 slab,这一层才会有锁竞争。释放路径相反,kfree 先把对象丢回当前 CPU 的 freelist,当 freelist 满或者 slab 变空时再决定归还。这种“本地优先”的设计让大部分 kmalloc/kfree 不涉及跨 CPU 原子操作,也是 SLUB 明显比 SLAB 快的原因。

通用 kmalloc 分配也用同一套机制,只不过缓存名是按大小写的,kmalloc-8、kmalloc-16、kmalloc-32 一直到 kmalloc-8k 之类。调用 kmalloc(n) 时,内核根据 n 找到最合适的 size class,再从对应缓存取对象。所以 kmalloc 的返回值天然对齐到这个 size class 的对齐边界,对于很多驱动来说已经足够。

3.3 用 slabinfo 排查内核对象问题

实际排查内存问题时,/proc/slabinfo 是绕不开的文件。里面每一行对应一个缓存,第一列是缓存名,接着是当前活跃对象数、总对象数、对象大小、每个 slab 里的对象数、每个 slab 的页数等。配合 slabtop 命令可以按对象数和占用内存排序,短时间内就能看到某个缓存异常增长。

我处理过不少“内核内存只涨不降”的现象,十次里有八次是 dentry 或 inode 缓存被某类频繁文件操作顶起来了。这时候先看 /proc/meminfo 里 Slab 字段,再 slabtop 对比缓存名称,基本能定位到是内核哪些回调在疯狂创建对象。如果怀疑泄露,可以开 CONFIG_DEBUG_KMEMLEAK;它会周期扫描内核内存中的引用关系,打印出没有引用却仍被占用的对象地址和调用栈。定位到具体 kmem_cache_create 的调用点,再顺着创建路径找引用计数漏加的地方,比盲猜快得多。开启 SLUB_DEBUG 的 redzone 和 poison 功能还能抓到越界写和 use-after-free,但性能会有明显下降,适合测试环境。

4. 内存告急时的回收与交换:页面回收机制

系统内存不是无限的,分配器也不能等完全没内存才想办法。Linux 内核在设计时就留了一整套“回收”机制,核心思路是:在内存接近耗尽之前,把不常用的文件缓存释放掉,把匿名页换到交换空间,实在不行才动 OOM killer。页面回收是内存管理里牵扯最广的一块,因为它不仅处理用户页,还要协调内核缓存、块设备回写、内存 cgroup 等多个子系统。

4.1 两条回收触发渠道:kswapd 与直接回收

内核把物理内存按 zone 管理,每个 zone 有三个水位线 min/low/high。min 是最低底线,低于 min 后普通分配请求不能直接拿页,必须等回收。low 是唤醒后台回收线程 kswapd 的阈值,当 zone 空闲页数跌到 low 以下,kswapd 被唤醒,开始回收页直到水位重新回到 high 附近,然后睡眠。这套机制保证内存压力不是等到彻底耗尽才处理,而是提前开始“整理房间”。

但如果分配请求非常急,等不及 kswapd 慢慢回,内核就会在分配路径上同步执行 direct reclaim,直接调用 try_to_free_pages 一路进入 shrink_node。这个过程是阻塞的,进程会卡在页分配上,直接体现为调度延迟变高。还有一种更坏的情况是 direct reclaim 也拿不到足够页,此时内核会触发 out_of_memory,也就是 OOM killer,挑一个 oom_score 较高的进程杀掉释放内存。判断 OOM 能不能救回来,通常看日志里是否有 out_of_memory: Kill process 字样,配合 dmesg 和 oom_score 文件能很快确认杀的是谁。

4.2 LRU 链表和 shrinker:什么页能被回收

每个内存节点的 lruvec 里维护了几条关键链表:active/inactive 的匿名页、active/inactive 的文件页,外加一条 unevictable 链表。文件页如果干净,回收时直接丢掉就行;脏文件页要先写回磁盘;匿名页如果没有 swap 空间,回收基本无能为力,因为根本没有地方可以腾数据。这也是为什么 swap 空间在内存压力大的环境里依然有意义——它给了匿名页一个被换出的去处。

回收的具体路径从 shrink_node 进入,扫描 inactive 页并调用 shrink_page_list,逐个判断页的状态,决定是释放、回写还是换出。active 页不会直接释放,需要先从 active 降级到 inactive,这个机制叫 shrink_active_list,相当于给高频访问页一个“保护期”。除了用户页,内核对 dcache、inode cache 等也要回收,机制是 shrinker 回调:各子系统注册 shrink_control 里的回调函数,回收时统一调用 shrink_slab,把可释放的内核缓存压出去。真正高压场景下,/proc/meminfo 里 Slab 字段会明显下降,这就是 shrinker 在工作。

顺带一提,回收和内存规整 compaction 经常一起出现。回收解决的是“没有足够空闲页”,但即使有足够总量,也可能碎片化到分配不出 order-4 以上的连续大页,比如 THP 和 CMA 场景。内核会调用 compaction 把已分配页通过 migrate 挪到一起,形成连续空闲区间。调试时如果大页分配总是失败,先是看碎片指数,再考虑是 compaction 没来得及做,而不是单纯加内存。

4.3 swap、swappiness 和内存 cgroup 的取舍

匿名页换出走 do_swap_page 配套的路径,内核先把页面内容写入 swap 设备,再在 PTE 里记录交换条目;访问该地址时再触发 do_swap_page 把内容读回来。这个一出一进的代价是磁盘或闪存 IO,所以在回收顺序里文件页通常优先被牺牲,毕竟干净文件页丢了还可以从文件系统再读,而匿名页没有后备源。

vm.swappiness 影响的是扫描比例,默认 60 表示在回收扫描时对匿名页和文件页的权重接近均衡。设置为 0 并不是禁用 swap,而是“几乎不扫描匿名页”,文件页压力大时依然可能触发回收。很多服务器教程建议把 swappiness 调到 10,本质是希望系统少做换入换出,保留匿名页在内存里;但如果进程本身内存需求巨大且没有高频热数据,极端地调 0 反而更容易过早触发 OOM。判断依据不要看 swap 用了多少,要看 majflt 是否持续增长。swap used 稳定增长后就稳定下来,说明内存压力已经缓解;majflt 飙升才是真正需要警惕的信号。

内存 cgroup 的场景也值得知道。cgroup v2 的 memory.max 不是限制分配,而是限制回收:当 cgroup 内内存用量超过上限,内核会在那个 cgroup 内触发回收,回收不掉就杀掉 cgroup 内得分最高的进程。所以容器里经常出现“系统空闲但容器被杀”的现象,本质是 cgroup 自己的水位线没有空间,而系统级内存还够用。这条路径和全局回收共用 shrink_node_memcgs 核心逻辑,只是把目标限定在某个 memcg 上。

如果让我给一个最实用的读码建议:不要从 alloc_pages 开始啃,那是物理页分配的出口,看着全是宏和 ifdef,新手很容易迷路。从 do_user_addr_fault 沿 handle_mm_fault 往下读,先盯住一次最小规模的匿名缺页,看一遍 PTE 从无到有、从只读到可写的过程,再逆着回看分配出口,整个内存管理的主干就串起来了。我在实际调试时还有个习惯:遇到内存问题先看 /proc/meminfo 和 /proc/slabinfo 两个文件,从现象反推内核路径,基本都能在前三分钟找到怀疑对象。这套思路放在任何版本的内核源码上都成立,下一篇如果需要,我们可以沿着内存 cgroup 这条线,把容器场景里的回收策略单独拆开聊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进