Learning Linux Kernel (Part 4) - Memory Management
In this chapter, we will continue to explore the memory management mechanism of the Linux kernel.
前言
在前面的章节中,我们已经对 Linux 内核的核心对象和机制有了初步的了解。我们从内核的启动过程讲起,介绍了内核是如何从一个简单的引导程序逐步构建出一个功能完整的操作系统的。在接下来的章节里,我们将继续深入探讨 Linux 内核的内存管理机制。如果你还没有阅读上一章节,建议先阅读 Linux Kernel (Part 3) - Task and Scheduler。
Disclaimer
我本次使用了 AI 来带我学习 Linux 内核。所以无论我后续怎么整理润色,本文的 first draft 肯定是 AI 告诉我的内容。如果你对此感到无法接受,或者觉得 AI 讲得不够好,你可以随时退出阅读,或者自己去看 Linux 内核的源码和文档。我的目标是通过 AI 来帮我梳理和总结内核的知识体系,而不是替代你自己去接触原始资料。
如果你能接受这个前提,那么我们就继续往下走了。
内存管理到底在解决什么问题?
学 Linux 内存管理,切忌一上来就陷入页表细节或者 malloc/free 的实现。更合适的方式是先按三层模型来理解它的全局结构:
- 第一层:进程看到的虚拟地址空间 —— 每个进程“以为”自己在用什么内存?
- 第二层:页表 + MMU/TLB —— CPU 怎么把虚拟地址翻译成真实的物理地址?
- 第三层:内核管理的物理页 —— 内核到底怎么分配、回收、缓存、搬运这些物理页?
这三层就是 mm 的主骨架。理解了它们各自的职责以及彼此的连接方式,后面学到的 mmap、fork、page fault、page cache、reclaim 等等,就都有了坐标可循。
从职责的角度看,mm 子系统本质上同时在做三件事:虚拟化(给每个进程一个看起来独立、连续、巨大的地址空间)、保护(让进程彼此隔离,限制读/写/执行权限)、复用(在有限的物理内存上按需分配、回收、缓存文件内容、swap、NUMA 放置)。所以 mm 不是“管 malloc 的模块”,而是一整套“地址空间 + 物理页 + 缓存 + 回收”系统。
虚拟内存
每个进程看到的是虚拟地址。当用户程序执行 char *p = malloc(4096); p[0] = 'A'; 时,它以为自己在操作某个内存地址——但这个地址不是物理 RAM 地址,而是当前进程地址空间里的一个虚拟地址。CPU 真正访问内存时,会通过 page table、MMU 和 TLB 把它翻译成某个物理页。
所以你要把内存访问理解成两步:虚拟地址 -> 页表翻译 -> 物理地址。这就是为什么两个进程都可以有地址 0x400000,但它们背后可以是完全不同的物理页——虚拟内存给了每个进程一个独立的地址空间幻觉,而翻译机制负责把幻觉“兑现”成真实的物理位置。
mm_struct 和 VMA
mm_struct
mm_struct 是一个进程的地址空间对象。它关心的是:这个进程有哪些虚拟内存区域、页表根在哪里、堆/栈/mmap 区的大致布局,以及各种和地址空间相关的元数据和统计。如果说 task_struct 代表执行流,那么 mm_struct 就是它看到的“虚拟内存宇宙”。如果多个 task 共享同一个 mm_struct,那就是线程语义——共享地址空间。
VMA
你不能把 mm_struct 理解成“一整块平坦大内存”。一个进程的地址空间其实是由很多段区域组成的,每段由一个 vm_area_struct(通常简称 VMA)来描述。你可以把一个 VMA 理解成:地址空间里一段连续的、具有统一属性的虚拟内存区域。
一个典型进程里常见的 VMA 包括:代码段、数据段、堆、栈、共享库映射、mmap() 映射的文件或匿名区。每个 VMA 会带上起止虚拟地址、权限(R/W/X)、是匿名内存还是文件映射、对应哪个 file/offset 等属性。所以一个进程的地址空间更像这样:
mm_struct
-> VMA #1: text/code
-> VMA #2: data/bss
-> VMA #3: heap
-> VMA #4: mmap file
-> VMA #5: stackplaintext理解 VMA 非常关键,因为以后你会发现 mmap、fork、page fault、文件映射、munmap 全都围绕 VMA 打转。
两者的关系
mm_struct 不是“页表本身”,而是地址空间的总控对象。它下面挂着 VMA 组织、页表根、各类统计/锁/引用计数。而 VMA 则在说:哪些虚拟地址范围存在,以及范围的属性是什么。你可以把它们记成:mm_struct = 地址空间总管,VMA = 地址空间里的具体分区。 后面你一旦搞清 fork 改了什么、mmap 加了什么、munmap 删了什么、page fault 查了什么,就会发现几乎都绕不开这两个对象。
页表、MMU 与 TLB
这是 mm 里最有“硬件接口感”的一层。最核心的一句话是:CPU 并不直接拿虚拟地址去访问 RAM,它会先通过 MMU 查 page table,命中或未命中 TLB,把虚拟地址翻译成物理地址,然后才真正访问内存。这三者你必须作为一组来理解。
Page Table
Page table 是“虚拟页 -> 物理页”映射规则的表。注意粒度是“页”级别,不是每个字节都单独映射。以 4KB 页为例,CPU 访问虚拟地址时会先把它拆成虚拟页号(VPN)和页内偏移(offset),然后查页表回答:这个虚拟页号映射到哪个物理页框(PFN)?权限是什么?这页在不在?能不能读/写/执行?页表项不只是“地址映射”,还带 present/valid、R/W/X 权限、user/supervisor、dirty/accessed 等保护和状态信息。
因为虚拟地址空间很大,平铺一张大表会浪费得离谱,所以现实中通常用多级页表——用树状结构按需展开。不需要的地址范围就不建那些页表页,本质上在解决大地址空间下页表空间开销太大的问题。
MMU
MMU(Memory Management Unit)是 CPU 里负责地址翻译和权限检查的那块硬件。CPU 执行 load/store/fetch 指令时,MMU 会根据当前页表根做地址翻译,同时检查权限。如果失败就触发 page fault 或 permission fault。所以页表是数据结构,MMU 是使用这套数据结构的硬件——地址翻译是 CPU 执行内存访问路径上的硬件能力,跟“软件查哈希表”完全不是一个层级的事情。
TLB
TLB(Translation Lookaside Buffer)是最关键也最容易被低估的东西。你可以把它理解成页表翻译结果的高速缓存。因为 CPU 不可能每次访存都一层层查多级页表——那会太慢——所以它把最近用过的“虚拟页号 -> 物理页号 + 权限”缓存到 TLB 里。TLB hit 时很快就拿到翻译结果,无需完整 page walk;TLB miss 时就要走页表查找,把结果再填回 TLB,成本更高。
很多内存访问性能问题,不只是 RAM 快不快,而是 TLB locality 好不好。TLB 对调度器也很重要:context switch 如果切换了地址空间(prev->mm != next->mm),当前 CPU 上缓存的旧地址翻译就不再有用,直接影响 TLB 命中率和地址空间切换成本。这也是为什么同进程线程切换通常比跨进程切换更轻——scheduler 和 mm 在 CPU 上其实通过 TLB 紧紧耦合。
CR3 / satp
CPU 必须知道当前该用哪套页表。这个“页表根”的位置通常由架构特定寄存器给出——x86 上是 CR3,RISC-V 上是 satp。切换地址空间时,内核本质上就是把当前页表根寄存器切到另一个 mm 的页表,这是进程切换比线程切换更重的一大原因。
权限检查发生在哪里
在 MMU 翻译过程中。不是“软件事后再看看有没有越权”。用户态访问某页时,如果页表项不允许 user 访问、或不允许写、或 NX 不允许执行,MMU 在翻译/访问时就会发现问题并产生 fault。虚拟内存保护不是“程序员自觉”,也不是“内核事后审计”,而是 CPU 硬件在执行访存时实时强制的。
不过这里需要一个重要的限定。更严谨的说法应该是:在架构语义上,CPU 会在访存时强制执行页表权限和地址空间保护。之所以要加这个限定,是因为现代 CPU 有 speculative execution、out-of-order、aggressive prefetch 等微架构行为。从“架构结果”上看,非法访问不会被正式提交;但在“微架构副作用”上,某些本不该暴露的信息可能通过 cache/predictor/buffer 状态泄漏出来——Spectre 和 Meltdown 类攻击的核心正在于此。它们不是说“权限机制在架构上完全失效了”,而是在权限检查真正收敛并提交前,CPU 的瞬时/speculative 行为已经留下了可侧信道观测的痕迹。
更具体地说,Spectre 更像是“欺骗分支预测器/speculation,让 CPU 瞬时执行本不该走的路径,再通过 cache side channel 泄漏数据”;Meltdown 则更像是“某些 CPU 上权限检查和数据取回的时序处理有缺陷,瞬时能把本不该读到的内核数据带进微架构状态——虽然架构上最终会 fault,但侧信道已经泄漏了信息”。所以可以这样记:架构上保护仍然成立,微架构上可能先泄漏、再被回滚。这也是后来 KPTI、retpoline、IBRS/IBPB、speculation barrier 等一系列软硬件修复存在的原因。
VMA 和 page table 的区别
这是初学者最容易混淆的点。VMA 说的是“这段虚拟地址范围是合法区域吗?语义是什么?”,page table 说的是“这个具体虚拟页当前映射到哪里?”。所以可能出现这种情况:VMA 已经存在,但某个具体页表项还没建立;第一次访问时触发 fault,内核再补上映射。这就是按需分配、懒加载、文件映射、COW 能成立的基础。VMA 是 zoning plan,page table 是当前具体住户表。
Page Walk
当 TLB miss 时,MMU 需要执行 page walk——按虚拟地址的若干段索引,一层层查多级页表,最终找到叶子页表项,取得物理页框号(PFN)和权限信息,再把翻译结果填进 TLB。如果在某一级就发现没有有效项或者权限不允许,那就触发 fault。所以 page walk 是页表查找的硬件化过程,它的频率和深度直接影响内存访问性能。
一次内存访问的完整路径
现在你可以把一次普通 load 想成这样:
CPU 执行 load [virtual address]
-> 先查 TLB
-> hit: 直接拿到物理页翻译
-> miss: MMU page walk 查页表
-> 若成功:填 TLB,再访问物理内存
-> 若失败:触发 page fault,进内核
-> 真正访问 cache / RAMplaintext这张图把 TLB、page table、MMU、page fault、内核介入时机全串起来了。真正的内存访问路径不是“CPU 直接问内存”,而是 CPU -> TLB/MMU -> cache/memory -> maybe kernel on fault。这就是为什么系统调优里常常会关心 TLB miss rate、page fault rate、NUMA locality、huge pages。
Huge Page
在讲 page fault 之前,值得先岔开说说 huge page,因为它和 TLB 的关系非常直接。
如果普通页是 4KB,那 huge page 通常是 2MB 甚至 1GB。核心动机就一个:降低 TLB 压力。TLB entry 数量很有限,如果全用 4KB 页,映射 1GB 内存需要大量页表项和 TLB entry;如果用 2MB 页,同样大小的内存只需要少得多的 TLB entry。因此 huge page 的收益通常是更少的 TLB miss、更少的 page walk、更少的页表内存开销,对数据库、JVM、大型 in-memory 服务、HPC、虚拟化等大内存连续访问的 workload 尤其明显。
Linux 里有两种常见的 huge page 路线。第一种是 hugetlb/hugetlbfs,属于“手动挡”——通常需要提前预留,更可控,适合你非常明确地知道自己要用大页且想要确定性的场景。第二种是 THP(Transparent Huge Pages),属于“自动挡”——内核尽量自动把合适的匿名内存折叠成大页,应用不用改代码。
但 huge page 不是白来的。它的代价包括:内存碎片更敏感(2MB 大页需要足够连续且对齐的物理内存,系统跑久了碎片化后分配更难);内部碎片(如果只用到其中一小部分,大页会浪费更多空间);fault/reclaim/migration 成本更大(一旦按大页做,fault 成本更高,回收更重,NUMA 迁移更重,COW 粒度也变粗);延迟可能更抖(尤其 THP 有时为了折叠、分裂、整理内存会带来 latency spike,所以很多 latency-sensitive 服务会谨慎看待 THP)。
特别值得记住的是 huge page 和 COW/fork 的关系:如果一个大页参与了 fork + COW,写时复制粒度变大,内核可能需要 split huge page,否则复制成本和浪费都会很夸张。一句话总结:huge page 用更粗的页粒度换更好的 TLB 覆盖和更低的翻译开销,但代价是更高的内存管理复杂性和潜在的碎片/延迟问题。
Page Fault
Page fault 在 mm 里如此核心,因为它是虚拟内存延迟分配/延迟映射的核心执行点。当 CPU 访问某虚拟地址时,如果页表里没有有效映射、权限不允许、需要处理 COW、或者需要把文件页真正拉进内存,就会产生 page fault,然后内核来决定这是合法但尚未兑现的访问(修复并继续),还是非法访问(SIGSEGV)。
Page fault 的四大类别
对于 page fault 的处理,内核大体上有四种不同的路径:
A. 匿名页按需分配(demand-zero fault)。 最常见的一类。典型来源是 malloc()、mmap(MAP_ANONYMOUS)、栈增长。地址空间先被“声明出来”,但物理页不立刻分配。VMA 已经存在,但 PTE 还没真正指向物理页,第一次访问时才 fault。比如 char *p = malloc(4096 * 1000); p[0] = 1;,malloc() 往往只是让用户态拿到一段可用虚拟地址,真正第一次写 p[0] 时才触发 fault。内核的处理大致是:找到对应的匿名 VMA,分配一个物理页,清零,建立 PTE,设置权限位,返回用户态让原指令重试。因为新分配给用户的匿名页语义上必须像全 0 一样开始(安全原因:不能把别的进程旧数据泄漏给你),所以这类页常被叫做 demand-zero page。栈增长本质上也属于这一路——栈 VMA 在那儿,真正往下踩到新页时 fault,内核确认是合法的栈增长,给它补页。
这里有一个值得一提的优化:在某些场景下,第一次“只读”访问匿名页时,内核可能先用共享的 zero page 做映射;等第一次写入时再真正分配私有物理页。
B. 文件页按需装入。 例如 mmap 文件后第一次访问、page cache 还没准备好时。内核需要把对应文件内容装进内存,再建立映射。这个我们在 page cache 那一节会详细展开。
C. COW fault(Copy-on-Write)。 例如 fork() 后父子共享页,页先被设成只读,某一方第一次写时 fault,内核复制页并给写入方新的可写页。
D. 非法访问 / 权限 fault。 访问不存在的地址、用户态碰内核地址、写只读页、执行 NX 页——这类 fault 不能被正常修复,通常以 SIGSEGV 或类似异常告终。
Fault 路径的关键步骤
第一步:先看 VMA。 内核一拿到 fault 地址,先回答:这个地址在当前 mm 里是否落在某个合法 VMA 内?不在的话,大概率是非法访问,直接走 bad fault 路径。在的话,再看这个 VMA 的权限和类型——允许读吗?允许写吗?是匿名页还是文件映射?是不是 COW 场景?VMA 是 fault 处理的第一道软件语义边界:硬件只知道“翻译失败/权限失败”,内核靠 VMA 判断这是合法的还是非法的、如果合法该怎么补救。
第二步:根据类型走不同修复路径。 匿名页第一次访问就分配物理页、清零、建页表项;文件映射就找到对应 file/offset、把页装进 page cache、建页表项;COW 就分配新页、拷旧页内容、改成新页可写映射;权限不允许就直接 bad fault。所以 page fault 不是一个单一路径,而是一个统一入口加多种后端处理逻辑。
第三步:修好页表,返回,重新执行那条指令。 这是 page fault 最优美的地方。如果 fault 可修复,内核修好映射/权限,返回用户态,CPU 重新执行原来那条失败的指令——这次成功。对用户程序来说,很多 fault 是完全“透明”的,它根本感觉不到,除了性能上可能慢一下。Page fault 不等于错误,很多 fault 是虚拟内存正常工作的一部分。
匿名页 Fault 与 COW Fault
COW 的机制
fork() 之后,最理想的实现不是立刻把所有物理页复制一遍——那太贵了。Linux 会做的是:父子先共享同一批物理页,对应页表项先改成只读,页的引用计数增加。逻辑上它们“看起来各自有一份”,真到某一方写时再复制。这就是 Copy-on-Write。
为什么要改成只读?因为内核必须“截获第一次写”。如果不改成只读,用户进程写下去时 CPU 不会 fault,内核就没机会知道“该复制了”。所以这里的只读不是因为语义上真的不允许写,而是内核故意用页保护制造一个可拦截的写 fault。
假设父子共享某页,某一方执行 p[0] = 42;。硬件看到当前 PTE 不允许写,于是 page fault 进内核。内核查 VMA 后发现:这段地址从进程语义上是可写的,只是当前 PTE 被设成只读,且属于 COW 场景。于是内核分配新页、复制旧页内容、让当前写入方的 PTE 指向新页并设成可写、旧共享页引用计数减一、返回用户态重试。父子从这一页开始分家,只有真正被写到的页才复制——这就是 fork() 可以便宜的原因。
COW 不只发生在 fork() 后的匿名页上。MAP_PRIVATE 文件映射本质上也有类似语义:读可以共享底层文件页,写入时不能直接改共享文件页,需要给写入方私有副本。所以 COW 是一种更通用的策略:先共享只读底层页,写时再私有化。
区别是什么?
这两个很容易混淆但本质不同。匿名页首次 fault:之前根本没有这页的私有物理页,现在第一次真正分配——是“首次兑现”。而 COW fault:之前已经有物理页,只是和别人共享,现在因为写入需要“拆分共享关系”——是“共享后分家”。
TLB 与 COW 的关系
这里有一个细节值得展开。TLB 里通常不只缓存 VPN -> PPN 的翻译,还会缓存权限信息(readable/writable/executable 等)。所以当 CPU 执行写操作时,如果页在 TLB 里已有 entry,CPU 可以直接根据 TLB 里的权限判断这是只读页、当前写入不允许,触发 protection fault。
那操作系统怎么“改 TLB”?最重要的结论是:操作系统通常不是直接去改某个 TLB entry 的内容,而是修改页表,让旧 TLB entry 失效,之后由硬件重新按新页表填 TLB。cache 改不过来的经典做法就是 invalidate/flush。x86 上有 invlpg 或 reload CR3,RISC-V 上有 SFENCE.VMA,ARM 上有对应的 TLBI 指令族。在 SMP 上还更麻烦,因为同一个进程可能在多个 CPU 上跑过,那些 CPU 的 TLB 里都可能缓存了旧权限,所以内核有时要做 TLB shootdown——通知别的 CPU 把对应地址空间/页的旧 TLB 项作废。这也是页表权限修改在多核上不便宜的原因之一。
和 NUMA / First-Touch 的关系
匿名页首次 fault 时,分配发生在 fault 当下。所以哪个线程、在哪个 CPU、在哪个 NUMA node 第一次碰这页,会直接影响这页最终落在哪个 node。这就是 NUMA 和 mm 的真正连接点之一——很多并行程序会让“将来谁用这块数据,谁先初始化它”,背后不仅是 cache locality,也是 NUMA first-touch。
为什么我们单独讲这两个 Fault
因为它们直接决定了几个常见的性能现象:大内存程序启动时不一定立刻吃满物理内存(因为大部分页还没被 first-touch);fork() 表面上很快(因为不立刻复制);某些工作负载在“第一次触摸内存”时有明显延迟(demand-zero fault 的开销);写放大可能按“页粒度”发生(COW 每次至少复制一整页)。例如一个进程 fork() 出很多子进程但几乎不写,共享成本很低;一旦每个子进程都大量写共享页,就会触发密集的 COW fault,性能开销随之上升。
你可能会问
“我写 C++ 的时候,所有人都告诉我说 ‘未初始化变量是 UB’,不能读它;但 Linux 内核里匿名页第一次分配时必须清零,感觉好像是‘未初始化变量默认从 0 开始’?这两者不是矛盾吗?”
这个问题值得单独拎出来说一下,因为它涉及两个不同层次的事情。
内核/硬件层: Linux 给用户进程一个全新的匿名物理页时,这页必须表现得像全 0 开始。原因主要是安全——不能把别的进程的旧数据泄漏给你——以及语义要求(BSS、新栈页、mmap(MAP_ANONYMOUS) 页都应从零开始)。
C/C++ 语言层: “未初始化变量是 UB” 说的是这个对象在语言语义上没有被初始化,编译器不保证你读它是合法的——即使底层那几个字节碰巧是 0,也不改变语言规则。物理字节碰巧是 0,不等于这个 C++ 对象在语言层被初始化了。
更关键的一点是:你平时遇到的大多数“未初始化变量”根本不是“新页第一次分配”。栈上的局部变量所在位置可能早就被之前的函数调用反复用过了,读到的不是“OS 刚给的新页”而是同一进程栈上的旧内容。malloc/new 得到的往往是进程自己以前 free 掉并被用户态 allocator 复用的堆块,不会自动清零(这就是为什么 calloc() 才保证零初始化)。就算字节真是 0,语言语义仍可能认为你在读 indeterminate value。
所以说如果用一句话总结的话,那就是:OS 保证新匿名页的字节语义通常从 0 开始;C/C++ 只在对象被正确初始化后才保证读取有定义。 两句话同时都对。
文件映射 Fault 与 Page Cache
Page Cache
Linux 里,文件内容常常先进入 page cache,再以不同方式暴露给进程。Page cache 是内核用物理内存缓存文件内容的地方——文件的某些页内容被读进 RAM 后,以后再次访问同一部分时就不必重新从磁盘读。这些缓存页按“文件 + offset -> page”的方式组织。Linux 内存管理不只服务匿名页(栈、堆),还大量参与文件缓存——内存里很多页不是“某个进程 malloc 出来的匿名页”,而是某个文件内容的缓存页。
read() 和 mmap()
read() 路径中,内核从 fd 找到 file,走 VFS/文件系统,看这段文件对应的页在不在 page cache。在的话直接从 page cache 拿数据,不在的话发起 I/O 把文件页装进 page cache,最后再把数据从 page cache 拷到用户 buffer。
mmap(file) 路径中,mmap() 本身通常只是在当前 mm 里建一个 file-backed VMA,记录这段地址对应哪个 file、对应文件哪个 offset、权限是什么。此时往往还没真正把文件内容都搬进内存。真正第一次访问时,CPU 发现对应页表项还没准备好,page fault 进内核;内核查 VMA 确认是合法的 file mapping,根据 file + offset 找对应 page cache 页(如果没有则发 I/O 读进来),然后把 page cache 页映射进进程页表,返回用户态重试原指令。
我们总结一下它们的路径:
file on disk
-> page cache page in RAM
-> read(): copy page cache data -> user buffer
-> mmap(): map page cache page -> user page tableplaintext它们的共同点是常常都依赖 page cache,都可能触发从磁盘读文件页。它们的区别是:read() 是 copy model——需要一次显式 copy 到用户空间;mmap() 是 mapping model——page cache 页直接映射进用户地址空间,少了一次 copy,但 fault 和页映射更重要。不要误解成“mmap() 永远 zero-copy 万能更快”——它只是把问题转成 fault、映射、页粒度行为、一致性/回写/失效等。
文件页 fault 为什么可能很慢
因为它可能意味着真正的磁盘 I/O。匿名页首次 fault 常常只需要分配和清零一个页;文件映射 fault 可能还要包含从磁盘读取文件内容的延迟。整条路径是:fault -> 进内核 -> 找 page cache -> miss -> 发磁盘 I/O -> 等 I/O 完成 -> 页进 page cache -> 建立页表映射 -> 返回用户态。
我们可以大致把文件页 fault 的成本分成两部分:内核处理 fault 的 CPU 成本(查 VMA、查 page cache、建页表项等)和磁盘 I/O 的等待成本(如果 page cache miss)。前者通常是几微秒级别,后者可能是毫秒级别甚至更高。所以文件映射的 page fault 性能可能非常不稳定,取决于 page cache 命中率和底层存储性能。
脏页与写回
如果是可写的 file-backed mapping,你改的是内存里的 page cache 页,不代表磁盘已经立刻写了。系统还需要标记页为 dirty,以后异步写回到磁盘。文件映射不是“我一写内存,磁盘就同步改了”,中间还有页缓存一致性、回写时机、msync() 等机制。先记住主线:page cache 页可能变脏,脏页以后要写回文件。
MAP_SHARED 与 MAP_PRIVATE
MAP_SHARED:你对映射页的修改语义上是共享的,最终应反映回底层文件,其它映射同一文件区域的人也可能看到变化。MAP_PRIVATE:你看到的是“私有视图”,写时触发 COW,修改不会直接改到底层共享文件页。这和前面讲的 COW 正好接上——file-backed mapping fault 不只是“从文件进内存”,还要看它是 shared 还是 private、写时是否需要私有化。
Page Cache 是 mm 和 VFS 的交界
从文件系统看,page cache 缓存的是文件内容。从 mm 看,page cache 页是物理页,可以被映射到用户页表、参与 reclaim、脏页回写、受 NUMA/huge page/page fault 路径影响。它两边都算,这也是为什么 mm 和 VFS 不是两章彼此独立的知识,而是中间有 page cache 这条大桥。
VMA 的统一抽象
理解匿名 VMA 通常更直觉——背后对应的就是“真实的内存”。但 file-backed VMA 可能让人“心里有点膈应”:操作系统是怎么表示“背后对应什么文件、从哪里开始读”的?
答案是:VMA 本身只表示“这段虚拟地址范围的规则”。不管哪种 VMA,至少都会有虚拟地址起点(vm_start)、终点(vm_end)、权限/标志(vm_flags)、属于哪个 mm。如果它是 file-backed,还会额外带上指向 struct file 的指针(vm_file)和文件页偏移(vm_pgoff)。所以 file-backed VMA 不是“背后直接塞了一堆文件内容”,而是一条地址区间到文件区间的映射规则。如果某个 fault 地址是 addr,对应的文件偏移大致就是 (addr - vm_start) + (vm_pgoff << PAGE_SHIFT)。
匿名 VMA 通常没有 vm_file,缺页时按匿名页逻辑补。它们之所以能统一成一个 VMA 抽象,是因为 VMA 本质上描述的是“这段地址范围应该怎么被解释和处理”,而不是“这段范围当前已经装了什么物理页”。当 page fault 发生时,内核先拿 fault 地址去找 VMA,找到后再看它是匿名还是 file-backed、是 shared 还是 private、权限是否允许,然后走不同的 fault handler。VMA 是“统一前台接口”,后面再分匿名和文件两套后端。
更底层一点,Linux 里文件缓存真正的核心对象不是单纯的 file,而是 address_space——某个文件内容在页缓存层的表示。Page cache 按 address_space + page offset 来组织页面。所以 file-backed fault 更准确的路径是:VMA 里有 vm_file -> vm_file 指向对应文件 -> 文件关联到它的 address_space -> address_space 里按 offset 找 page cache 页。
至于“page cache 是不是只给 file-backed 用”,大体上可以这么理解。但“file-backed”不要只狭义理解成“磁盘上的普通文件”——tmpfs/shmem 这类内存文件系统对象虽然“感觉像纯内存”,但在内核抽象上有 inode、有 address_space,能走 page cache 那套机制。匿名页本身不属于 page cache,但如果被换出到 swap,会进入另一个相关但不同的概念——swap cache。
物理页分配器
到这里你已经知道 page fault 最后经常要“分物理页”,page cache 也需要物理页。但内核不是随手从 RAM 里拿一块字节数组就完事的,它需要不同粒度的分配器。
Buddy Allocator
Buddy 分配器是 Linux 里最基础的物理页分配器,按 2 的幂次、按页为单位分配连续物理内存块(1 页、2 页、4 页、8 页……)。page fault 需要新页、page cache 需要新页,底层通常都会落到 buddy 这一层。
它之所以叫“buddy”,是因为它把空闲块按大小分层组织。需要更小块时就把一个大块一分为二,这两个一分为二出来的块互为 buddy;将来两个 buddy 都空闲了还可以合并回更大的块。核心思想是方便拆分、方便合并、适合页级别的大粒度分配。代价则是可能有外部碎片,且不适合频繁分配小对象。
Slab/SLUB
内核里有大量对象根本不是“整页整页”用的——task_struct、mm_struct、dentry、inode 等等。如果这些都直接向 buddy 要整页,会浪费很大。所以 Linux 在 buddy 之上又有 slab/slub 对象分配器(现代 Linux 主流通常是 SLUB),本质就是在从 buddy 拿来的页上再切小块给内核对象用。
Slab/SLUB 不是简单 malloc,而是按对象类型做 cache——task_struct 一种 cache、dentry 一种 cache、inode 一种 cache。好处是对象大小固定、分配/释放很快、布局更适合 CPU cache、可以复用已初始化过一部分的对象槽位。所以 slab/slub 不只是“切小块”,还是 typed object cache。
一个经典的配合路径:你要一个 task_struct -> kmem_cache_alloc(task_struct_cachep) -> 如果 slab cache 里有空对象槽直接拿,没有就去向底层申请新页 -> 底层落到 buddy allocator。而如果你本来就要的是页(fault 分匿名页、给 page cache 加页),往往直接走 buddy。
所以说,buddy 管页块,slab/slub 管小对象,slub 背后最终靠 buddy 提供页。
Reclaim、Swap 与 Page Cache 的竞争
到现在为止你看到的主要是“怎么建地址空间、怎么翻译、怎么 fault 补页、怎么分配新页”。但真实系统里最难的地方往往不是“分配”,而是内存不够时,谁该留下,谁该走。
一台 64GB RAM 的机器,这些内存可以被拿去放进程堆/栈/匿名页、文件缓存 page cache、slab/slub 内核对象等等。所以从系统角度看,问题不是“内存给进程用还是给文件系统用”,而是“哪些页现在最值得留在 RAM 里?”这就是 reclaim 的本质。
Reclaim
Reclaim 的最短定义:在内存压力下,内核回收一些页,把它们重新变成可分配的空闲页。它不是简单 free() 某个进程的内存,可能包括丢弃干净的 page cache 页、回写脏页后再回收、把匿名页换出到 swap、回收 slab cache,最后实在不行触发 OOM。
当分配路径发现可用空闲页太少时,会触发 reclaim。这里有两个关键角色:kswapd 是后台回收线程——内存刚开始紧了,后台先去回收一点,尽量别让前台分配线程卡住。Direct reclaim 则是前台自己上场——某个线程想分配页但真不够了,它自己被迫进入 reclaim 路径回收完再继续分配。后者对 latency 很伤,所以系统总希望更多工作由 kswapd 提前做好。
系统内存压力来时,内核不是乱扔页,而是在做某种排序:这个页最近常用吗?是 file-backed 还是 anon?是 clean 还是 dirty?回收代价高不高?将来再用到的概率高不高?心智模型就是“冷页先走,热页尽量留”,目标是近似保留 working set,把冷页赶出去。
值得注意的是,reclaim 不只盯着 page cache 和匿名页。内核对象缓存——dentry cache、inode cache、各类 slab cache——在内存压力下也可能被 shrink。所以内存竞争的完整图景是 anonymous pages vs file cache vs slab/kernel caches,只不过前两者通常是回收的主要目标。
页类型不同,代价不同
干净的 file-backed page cache 是最容易回收的。背后本来就有文件,如果这一页没有被修改(clean),直接丢掉内存里的缓存页就行,将来再访问时重新从文件读回来。这也是为什么 Linux 倾向于“把空闲内存尽量拿来做 page cache”——因为它有用,而且在需要时相对容易丢弃。
脏的 file-backed 页 不能直接丢,因为内存里的内容已经比磁盘新,直接扔会丢数据。需要先 writeback 到文件/磁盘,写回完成后才变成可回收的 clean 页。
匿名页 背后没有普通文件作后备存储,如果要从 RAM 里赶出去通常需要 swap out——把页内容写到 swap 空间,以后再需要时从 swap 读回。这就是 page cache 和匿名页竞争时一个非常核心的不对称性:clean file page 可以直接扔,anonymous page 通常要 swap 才能扔。
Linux 怎么把内存吃光了?
很多新手看到 buff/cache 很大就以为“Linux 怎么把内存吃光了”。其实内核的策略是:RAM 空着也是空着,不如拿来缓存文件内容,一旦应用真要内存,reclaim 时可以优先把缓存页让出来。Linux 的哲学不是“尽量保持空闲内存多”,而是尽量让空闲内存有用途,同时保证需要时能回收。上面我们说到,clean file page 是最容易回收的,内核就倾向于把空闲内存用来做 page cache,这样既有性能提升又不妨碍应用需要内存时 reclaim。buff/cache 大不代表“内存被吃光了”,而是“内核在积极利用空闲内存做缓存”,真正吃光了会看到 available 也变成 0。
Swap
Swap 就是匿名页被逐出 RAM 后的落脚地。当匿名页暂时不值得留在 RAM 但又不能像 clean file page 那样直接丢时,就把它写到 swap 区域(swap partition 或 swap file),以后再 fault 回来。Swap out 时把匿名页内容写到 swap、页表里改成“这页现在在 swap 上”、物理页释放回系统。以后再次访问时,CPU 发现页不在内存、对应的是 swap entry,触发 fault,内核从 swap 读回内存、重建映射。Swap 让“匿名页不在 RAM 里”这件事成为可恢复状态。
OOM
如果 reclaim 已经很努力但还是回收不出足够内存,最后可能进入 OOM killer——内核决定杀掉某些进程来释放内存,让系统从完全卡死边缘回来。完整链条是:
memory pressure -> kswapd/direct reclaim -> writeback/swap/page dropping -> still insufficient -> OOMplaintextReclaim 为什么会影响性能
因为它是慢路径,可能包含扫描页、检查引用/冷热、写回脏页、swap I/O、slab shrink、锁竞争。一旦进入强 reclaim / swap 抖动,典型症状就是:延迟飙升、throughput 掉下来、磁盘忙但业务没推进、CPU 花很多时间在内核回收路径、反复 fault in/reclaim out 形成 thrashing。这才是“内存压力”真正可怕的地方,不是单纯少了几页。
在 NUMA 下 reclaim 还更复杂——哪个 node 缺页、从哪个 node 回收、远程内存是否还能撑、local reclaim 和跨 node fallback 怎么平衡——所以真正服务器上的 mm 调优往往离不开 NUMA placement、page cache 行为、reclaim 统计和 swap 策略。
Page Fault 与 Reclaim
Page Fault 和 Reclaim 是 mm 里两个最核心的机制,前者负责把“需要的页”拉进来,后者负责把“不值得留的页”赶出去。它们在某种程度上是“相互对称”的:Page fault 是“缺什么补什么”,Reclaim 是“留什么丢什么”。
这个视角非常关键。Page fault 把“还没在 RAM 的页”拉进来,reclaim 把“现在不值得在 RAM 的页”赶出去。所以 mm 不是单向“越分越多”,而是一个循环系统:
fault in -> use -> become cold -> reclaim out -> future access -> fault in againplaintext这就是工作集管理的本质。两者围绕同一个核心问题:这页现在值不值得驻留?
一页内存的一生
把整个 mm 主线串成一条动态故事来走一遍,是建立完整心智模型最有效的方式之一。我们用一个最普通但足够真实的场景:
char *p = malloc(4096);
p[0] = 1;
// ... 很久不访问 ...
p[0] = 2;c第一步,malloc() 当下发生了什么。 很多人下意识以为 malloc(4096) 就是“向 OS 申请一页物理内存”。其实通常不是。用户态 allocator(如 glibc malloc)先做一层自己的管理——可能复用已有 heap 块,可能扩 brk,可能走 mmap,不一定立刻触发新的物理页分配。内核视角更常见的是当前进程的 mm 里多了一段合法虚拟地址空间,但具体那一页未必已经有物理页。malloc() 常常先分的是“地址空间使用权”,不是立刻兑现的物理页。
第二步,第一次访问:匿名页 fault。 执行 p[0] = 1; 时,CPU 做地址翻译发现这地址在合法 VMA 里但当前页表项还没指向物理页,触发 page fault。内核找到对应匿名 VMA,从 buddy 拿一个物理页,清零,建立页表映射,返回用户态重试。到这一步,这页真正“出生”了。mm_struct、VMA、page table、page fault、buddy allocator 第一次串起来。
第三步,后续反复访问:TLB/cache 主导。 页表建好后,后面很多访问不再进内核。典型路径是 TLB hit 或 miss、cache hit 或 miss、直接访问 RAM——全是 CPU/MMU/TLB/cache 的硬件路径。真正健康运行时,内核不应该反复参与每次访问。
第四步,如果 fork():进入 COW 世界。 这时进程调 fork(),这页不会被立刻复制两份——父子暂时共享同一物理页,PTE 改成只读,引用计数增加。某一方一写 p[0] = 42;,CPU 看到 PTE 不允许写,fault 进内核,识别出 COW 场景,分配新页、复制旧页内容、PTE 指到新页并设可写。父子从这一页开始分家。
第五步,很久不访问:变“冷”。 随着时间推移,如果这页很久没被访问且内存压力上来了,它在内核眼里就可能变成冷页。Reclaim 开始考虑它值不值得继续留在 RAM。
第六步,内存压力下:swap out。 系统真缺内存时,这页可能被 swap out——匿名页内容写到 swap、页表改成“在 swap 上”的编码、物理页释放回系统。这页的“逻辑存在”还在,但暂时不驻留在物理内存里了——从 resident 变成 non-resident,但仍属于这个进程的地址空间。
第七步,再次访问:swap-in fault。 后来程序执行 p[0] = 2;,CPU 发现页不在 RAM、对应的是 swap entry,page fault 再次发生。这次内核走的不是“匿名页首次 fault”,而是从 swap 读回一页、重新分配物理页、重建映射。同样叫 page fault,但来源完全不同——这就是为什么 page fault 只是“统一入口”,后端分叉很多。
如果换成文件映射场景(p = mmap(file, ...)),这页的旅程会有所不同:VMA 建立时记录 file + offset -> 第一次访问触发 file-backed fault -> 文件页进入 page cache -> 映射进用户页表 -> 很久不用后如果是 clean 可以直接从 RAM 丢掉 -> 将来再访问时如果页已不在 page cache 就重新从文件读回。文件页和匿名页的大差别正在于此:文件页很多时候“可丢弃,因为文件本体就是 backing store”;匿名页很多时候“必须 swap,除非整个进程退出”。
内存管理的框架
走完这一轮,你现在应该有一个完整的 mm 骨架了。每个角色各司其职:
| 角色 | 职责 |
|---|---|
mm_struct | 管理一个进程的地址空间 |
| VMA | 告诉内核每段地址合不合法、是匿名还是文件映射 |
| Page Table | 记录当前具体页是否映射、映射到哪 |
| MMU / TLB | 做硬件翻译和权限检查 |
| Page Fault Handler | 在映射缺失/权限需修复时补救 |
| Buddy Allocator | 给匿名页 fault、page cache 等提供物理页 |
| Slab / SLUB | 给内核小对象提供分配 |
| Page Cache | 缓存文件页 |
| Reclaim | 在内存压力下回收冷页 |
| Swap | 为匿名页提供后备存储 |
| Writeback | 为脏文件页回写磁盘 |