ᕕ( ᐛ )ᕗ Jimyag's Blog

虚拟内存:从地址翻译到缺页

虚拟内存经常被一句话概括成:每个进程看到的是自己的连续地址空间,操作系统再把这些地址映射到真实物理内存。

这句话没错,但只记住它不够。虚拟内存不是单独一个「地址转换」技巧,而是一整套机制:它让进程隔离成为可能,让 malloc 可以延迟占用物理内存,让 fork 可以不立刻复制整个进程,让 mmap 可以把文件当内存访问,也让内核可以在内存压力下回收、换出、重新装入页面。

更重要的是,虚拟内存会影响性能。很多程序表面上只是读写一个指针,底层可能触发页表遍历、缺页异常、磁盘 I/O、TLB flush、跨 NUMA 节点访问,甚至把一次普通内存访问放大成系统级停顿。

下面按 Linux 和 x86-64 的常见实现,把虚拟内存从地址空间一直讲到性能和观测。

为什么需要虚拟内存

先从一个反事实开始:如果进程直接使用物理地址,会发生什么?

  1. 进程之间无法自然隔离。一个进程的越界写可能直接覆盖另一个进程的数据。
  2. 每个进程都要知道当前机器哪些物理地址空闲,分配内存会变成全局协调问题。
  3. 程序装载、共享库、动态分配、文件缓存、共享内存这些机制很难统一。
  4. 物理内存不足时,内核缺少一个稳定入口来回收、换出、再装入数据。

虚拟内存把问题换成了两层:

  • 进程只看到虚拟地址。
  • 内核和硬件负责把虚拟地址翻译成物理地址。

这样每个进程都可以拥有一套独立的地址命名空间。两个进程都可以访问 0x400000,但它们的页表可以把这个虚拟地址映射到完全不同的物理页。进程之间是否共享内存,也由内核显式安排,而不是由物理地址碰巧相同决定。

所以虚拟内存至少提供了四类能力:

  • 隔离:每个进程有独立地址空间。
  • 保护:页表项里有读、写、执行等权限位。
  • 延迟:虚拟地址范围可以先存在,物理页可以等第一次访问时再分配。
  • 统一:匿名内存、文件映射、共享内存、swap、page cache 都可以放进同一套 fault 和 reclaim 模型。

进程看到的地址空间

一个 Linux 进程看到的是一段虚拟地址空间,而不是物理 RAM 的真实布局。以常见 4 级页表的 x86-64 为例,虽然寄存器是 64 位,但常见模式下实际参与地址翻译的是低 48 位。低半部分通常给用户态,高半部分给内核态。用户进程可用的虚拟空间量级可以到 128 TiB,远大于普通机器的物理内存。

虚拟地址空间里通常有这些区域:

  • text:程序指令,通常可读、可执行、不可写。
  • data:已初始化的全局变量和静态变量。
  • BSS:零初始化的全局变量和静态变量,二进制里不需要真的存一堆零。
  • heap:动态分配区域,小对象分配常和 brk / program break 有关。
  • mmap region:共享库、文件映射、匿名大块分配、共享内存常在这里。
  • stack:函数调用栈,保存局部变量、返回地址、保存的寄存器等,通常向低地址增长。

Linux 里可以看一个进程的映射:

1
cat /proc/<pid>/maps

这里列出来的是 VMA,也就是 virtual memory area。VMA 是内核对一段虚拟地址范围的描述:起止地址、权限、是否 private/shared、文件偏移、背后文件等。它说的是「这段地址范围对进程来说是否合法、语义是什么」。

但 VMA 不是页表项。一个 VMA 可以覆盖很大的地址范围,其中很多页可能还没有物理页。比如 mmap 一个 10 GiB 文件,maps 里能看到这段虚拟范围,但 RSS 不会立刻增加 10 GiB。只有进程实际访问到某个页,内核才需要把对应文件页带入内存并建立页表映射。

这个区别很重要:

  • VMA 是承诺:这个虚拟地址范围属于进程,带着这些权限和来源。
  • 页表是现实:某个虚拟页当前是否真的映射到物理页。

很多内存现象都来自这两者的差异。VIRT 很大不一定说明进程真的占用了很多内存,RSS 才更接近当前驻留在物理内存里的页。

页和帧

虚拟内存不是按字节逐个映射的。按字节建表会非常离谱:128 TiB 用户地址空间,如果每个字节一个 8 字节表项,单个进程的映射表就要 1 PiB。

实际做法是按页映射。虚拟地址空间被切成固定大小的 page,物理内存被切成同样大小的 frame。常见基础页大小是 4 KiB。一个虚拟页可以映射到一个物理帧。

4 KiB 页下,一个虚拟地址可以拆成两部分:

1
虚拟页号 + 页内偏移

页内偏移需要 12 bit,因为 2^12 = 4096。地址翻译时,页内偏移不变;变化的是「虚拟页号」被翻译成「物理帧号」。

例如某个虚拟地址落在虚拟页 N 的第 500 个字节。页表把虚拟页 N 映射到物理帧 M 后,最终物理地址就是物理帧 M 的第 500 个字节。

连续虚拟页不需要映射到连续物理帧。进程看到一段连续数组,底层物理页可能散落在 RAM 的不同位置,甚至和其他进程的物理页交错在一起。页表把这种不连续性隐藏起来。

为什么需要多级页表

按页映射已经比按字节映射小很多,但如果做成平铺页表仍然很大。

128 TiB 用户空间,按 4 KiB 一页,大约有 2^35 个虚拟页。每个页表项 8 字节,平铺页表需要 256 GiB。更糟糕的是,每个进程都要有一份自己的页表。

问题在于:虚拟地址空间很大,但大部分区域是空洞。程序有代码段、堆、mmap 区、栈,中间大量范围没有映射。为这些空洞提前准备页表项是浪费。

多级页表就是为稀疏地址空间设计的树形结构。常见 4 级 x86-64 页表会把 48 位虚拟地址拆成:

1
2
3
4
5
9 bit PGD index
9 bit PUD index
9 bit PMD index
9 bit PTE index
12 bit page offset

每级 9 bit,表示每张页表有 512 个条目。一次页表遍历大致是:

1
2
3
4
5
6
CR3
  -> PGD[bits 47:39]
  -> PUD[bits 38:30]
  -> PMD[bits 29:21]
  -> PTE[bits 20:12]
  -> physical frame + offset[bits 11:0]

CPU 的 CR3 寄存器保存当前进程顶级页表的物理地址。上下文切换时,内核会切换当前地址空间,MMU 后续就按新的页表翻译地址。

多级页表的好处是按需分配。顶层某个条目对应的一大段地址完全没用,就不需要分配下一层页表。只有进程实际使用到某段虚拟地址时,内核才逐级补齐必要的页表页。

这也解释了为什么 mallocmmap 可以先给出一个虚拟地址范围,而不立刻为每个页准备完整映射。页表结构本身也可以按需建立。

页表项不只保存地址

页表项除了保存物理帧号,还保存状态和权限。常见重要位包括:

  • Present:当前页是否有有效物理映射。
  • Writable:是否允许写。
  • User/Supervisor:用户态是否可访问。
  • NX/XD:是否禁止执行。
  • Accessed:页是否被访问过,通常由硬件置位。
  • Dirty:页是否被写脏,通常由硬件置位。

这些位让硬件可以在每次内存访问时直接执行保护策略。代码页可以标成可执行但不可写,堆和栈可以标成可写但不可执行。写一个只读页、执行一个 NX 页、访问一个不存在的页,都会触发 fault,把控制权交给内核。

这里有一个容易混淆的点:fault 不总是错误。

如果进程写代码段,通常是 bug 或攻击,内核会给进程发信号,常见结果是崩溃。如果进程访问一个已分配但尚未建立物理页的堆地址,这也是 fault,但它是正常路径,内核会补上物理页后让指令重试。

TLB:地址翻译也需要缓存

如果每次 load/store 都完整走四级页表,内存访问会非常慢。一次普通数据读取前,CPU 还要额外读多次内存来查页表。

TLB,也就是 Translation Lookaside Buffer,是 MMU 使用的地址翻译缓存。它缓存「虚拟页 -> 物理帧」的结果。一次内存访问通常先查 TLB:

1
2
3
4
5
6
7
TLB hit
  -> 直接得到物理帧

TLB miss
  -> 硬件执行 page table walk
  -> 如果页表项有效,回填 TLB
  -> 如果页表项无效或权限不允许,触发 page fault

TLB 的容量有限。工作集小、局部性好的程序,翻译结果容易留在 TLB 里。工作集巨大、随机访问很多、指针追逐严重的程序,可能频繁 TLB miss,即使所有数据都已经在 RAM 里,也会被页表遍历拖慢。

所以内存性能不只取决于 cache locality,也取决于 translation locality。一个大数组顺序扫描虽然会访问很多页,但访问模式规律,硬件预取、cache 和 TLB 行为通常更可控。哈希表随机探测、链表跳转、图遍历这类访问模式更容易把 cache miss 和 TLB miss 叠在一起。

Demand paging:先承诺,再兑现

进程调用 malloc 后,通常不会立刻拿到一堆已经占用物理 RAM 的页面。更常见的路径是:

  1. 分配器向内核申请或复用一段虚拟地址范围。
  2. 内核记录对应 VMA。
  3. 页表项可能还不存在,或者 present 位为 0。
  4. 程序第一次访问某个页时触发 page fault。
  5. 内核检查 fault 地址是否落在合法 VMA 内。
  6. 如果合法,分配物理帧、清零、安装 PTE。
  7. 返回用户态,CPU 重试刚才那条指令。

这就是 demand paging。它的意义是避免提前占用未使用的物理内存。程序申请 1 GiB,不代表它一定会触碰全部 1 GiB。只有真正访问过的页才需要兑现成物理页。

可以用一个小程序观察:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void) {
    size_t n = 1024UL * 1024 * 1024;
    char *p = malloc(n);
    sleep(10);
    memset(p, 1, n);
    sleep(60);
    return 0;
}

第一个 sleep 阶段看 RSS,通常不会接近 1 GiB。memset 写完整块内存后,再看 RSS 和 minor fault,会看到驻留页增加。这个实验说明:申请地址空间和占用物理页是两件事。

page fault 的几条路径

page fault 的共同入口是:MMU 无法完成当前访问,于是把控制权交给内核。但内核接下来怎么处理,取决于映射类型和页面为什么不在内存里。

可以先分两类内存:

  • anonymous memory:堆、栈、MAP_ANONYMOUS 映射,背后没有普通文件。
  • file-backed memory:可执行文件、共享库、普通文件 mmap,背后有文件。

再分两类缺失原因:

  • first access:第一次访问,还没建立物理页。
  • evicted/dropped:以前在内存里,后来被回收或丢弃。

组合起来就是四种常见路径:

类型 第一次访问 被回收后再访问
anonymous 分配新物理帧并清零,通常是 minor fault 从 swap 读回,major fault
file-backed 从文件或 page cache 装入,可能是 major fault 干净页可从文件重读,脏页需先写回或从后备存储恢复

minor fault 的关键是不用磁盘 I/O。比如页已经在内存里,只是当前进程还没建立 PTE;或者匿名页第一次访问时内核直接给一个清零页。

major fault 的关键是需要 I/O。比如映射文件的页不在 page cache,需要从磁盘读;或者匿名页此前被换出到 swap,现在要读回来。

这也是为什么 major fault 对延迟更敏感。一次内存访问如果落到 major fault,路径就从 CPU load/store 变成异常处理加磁盘 I/O。

swap、file-backed page 和 page cache

匿名页没有天然的文件来源。堆和栈里的数据如果被回收,内核不能简单丢掉,否则进程下次访问会丢数据。要回收匿名页,通常需要把内容写到 swap,PTE 的 present 位清掉,并在 PTE 或相关内核结构里记录 swap 位置。下次访问时再 major fault,从 swap 读回。

文件页不同。干净 file-backed page 背后有原始文件,内核可以直接丢掉物理页。下次访问时,再从文件读回来。如果文件页被写脏,则需要先写回文件,或者在共享内存/tmpfs 这类场景走对应后备存储。

page cache 是 Linux 内存行为里非常核心的一部分。读取文件时,文件内容会进入 page cache;mmap 文件时,进程映射的通常也是 page cache 中的页。多个进程映射同一个文件的同一段,可能共享同一批物理页。

所以「free 内存很少」不一定是坏事。Linux 会把空闲内存用于 page cache,提高文件访问性能。更该看的是:

  • MemAvailable 是否还充足。
  • Dirty / Writeback 是否持续很高。
  • swap in/out 是否频繁。
  • major fault 是否持续出现。
  • 应用工作集是否超过了 RAM 或 page cache 能承载的范围。

page reclaim:内核怎么找冷页

物理内存总会用完,内核必须决定回收哪些页。理想策略是回收未来最不会被访问的页,但内核不可能知道未来,只能用近似信息。

硬件提供了 accessed bit 和 dirty bit。页被访问过,硬件可以把 accessed 位置上;页被写过,dirty 位会被置上。内核可以周期性检查这些位,估计哪些页最近被用过。

Linux 的 reclaim 不是简单随机淘汰。它会区分 anonymous 和 file-backed,也会维护 active/inactive 这类 LRU 近似列表。大致目标是:

  • 热页留在 active。
  • 冷页逐步移动到 inactive。
  • 回收时优先从更冷、更容易回收的页里选。

回收 file-backed clean page 成本低,可以直接丢。回收 dirty file page 要写回。回收 anonymous page 通常要 swap。回收后如果很快又被访问,就会 fault 回来。系统如果不断回收马上又需要的页,就进入 thrashing,CPU 时间和 I/O 都消耗在换入换出上,应用吞吐会急剧下降。

COW:fork 为什么可以快

fork() 会创建一个几乎和父进程一样的子进程。如果真的把父进程全部物理内存复制一遍,代价会很高。一个进程有几十 GiB 堆,fork 就会变成巨大的复制操作。

Copy-on-write 的做法是延迟复制:

  1. fork 时,子进程得到自己的页表。
  2. 父子页表先指向同一批物理帧。
  3. 对 private writable 映射,内核把父子双方的 PTE 都标成只读。
  4. 如果父或子只是读,继续共享物理页。
  5. 如果其中一方写,MMU 发现写只读页,触发 protection fault。
  6. 内核确认这是 COW fault,分配新帧,复制原页内容,更新写入方的 PTE 为可写。

这让 fork 本身很快,因为复制被推迟到了第一次写。Unix 里常见的 fork 后马上 exec 正是受益于这个机制:exec 会丢掉子进程原地址空间,很多父进程页面根本不会被复制。

COW 也不是免费。fork 后如果父子都大量写原本共享的私有页,复制成本最终还是会发生,只是分散到写入路径里。

mmap:把文件放进地址空间

普通 read 路径通常是:

1
磁盘/文件系统 -> page cache -> copy_to_user -> 用户 buffer

mmap 的思路是把文件的一段映射到进程地址空间。进程像访问内存一样读写这段地址,缺页时由内核把对应文件页带进 page cache,并建立 PTE。

这样做的好处是:

  • 避免一部分内核 buffer 到用户 buffer 的显式复制。
  • 多个进程映射同一文件时可以共享物理页。
  • 随机访问文件时可以直接用指针表达,不必手写 seek/read。

mmap 不是总比 read 快。它把 I/O 成本藏进 page fault,错误也可能变成访问时的信号,比如文件被截断后访问映射区域可能触发 SIGBUS。大文件随机访问如果不命中 page cache,仍然会产生 major fault。简单顺序读写时,read / write 更容易控制缓冲、错误处理和 I/O 节奏。

两种常见映射语义:

  • MAP_SHARED:写入对其他映射同一区域的进程可见,并可能回写到文件。
  • MAP_PRIVATE:私有 COW 映射,写入不会修改原文件;第一次写对应页时复制出私有匿名页。

这也是虚拟内存模型统一的地方:文件 I/O、共享内存、私有写时复制,都可以通过 VMA、PTE、fault handler 和 page cache 组合出来。

pinned memory:不能随便回收的页

前面默认内核可以在内存压力下回收页。但有些页不能移动、不能换出。比如 DMA 设备正在从某段主机内存读写数据,如果内核中途把页换出或把物理帧重新分配给别人,设备就会访问错误地址。

这类内存可以被 pin,常见接口是 mlock() 或驱动框架里的 pin page 路径。GPU 数据传输也经常用 pinned host memory 来让 DMA 更直接、更容易和计算重叠。

代价是 pinned memory 会减少内核可回收内存。pin 太多会挤压 page cache 和普通匿名页,放大系统内存压力。

访问模式决定成本

虚拟内存和 CPU cache 都喜欢局部性,但关注层级不同。

CPU cache 通常以 cache line 为单位,比如 64 字节。顺序访问数组时,一个 cache line 会带来后续多个元素,硬件预取也更容易工作。

页表和 TLB 以 page 为单位,比如 4 KiB。一个 TLB entry 可以覆盖一个 4 KiB 页。顺序扫描虽然会跨很多页,但每页内会有连续访问;随机访问如果每次都跳到不同页,就可能频繁 TLB miss。

所以这些结构性能差异很大:

  • 连续数组:cache locality 和 TLB locality 都比较好。
  • 链表:节点可能分散,cache miss 和 TLB miss 都更多。
  • 哈希表随机探测:访问位置不可预测,工作集大时 TLB 压力明显。
  • 图遍历:指针跳转多,预取困难,NUMA 和 cache 行为都可能不稳定。

高性能系统里,压缩对象布局、减少指针跳转、批量处理相邻数据,不只是优化 cache,也是在优化 TLB 和 page fault 行为。

huge page:用更大的页减少 TLB 压力

4 KiB 页的好处是粒度细,浪费少;问题是大工作集需要大量 TLB entry 才能覆盖。

如果一个程序热访问 1 GiB 内存:

  • 4 KiB 页需要 262144 个页。
  • 2 MiB huge page 只需要 512 个页。
  • 1 GiB huge page 只需要 1 个页。

TLB entry 数量有限,huge page 可以显著扩大 TLB reach。数据库、大内存缓存、虚拟化、数值计算都可能受益。

但 huge page 也有代价:

  • 需要更大的连续物理内存,内存碎片严重时更难分配。
  • 粒度更粗,可能造成内部浪费。
  • Transparent Huge Pages 自动合并和拆分页面,可能在不合适的时机引入延迟。
  • 某些 workload 对延迟抖动比吞吐更敏感,需要谨慎打开或按 VMA 控制。

Linux 的 THP 可以通过 /sys/kernel/mm/transparent_hugepage/ 查看和配置。某个进程区域是否真的用了 THP,可以看 /proc/<pid>/smaps 里的 AnonHugePages

TLB shootdown:多核机器上的同步成本

每个 CPU core 通常有自己的 TLB。这样访问快,但带来一致性问题:如果内核修改了某个进程的页表,其他 core 的 TLB 里可能还缓存着旧翻译。

比如一个线程在 core 0 上执行 munmap,内核删除某段虚拟地址的 PTE。如果同一进程的另一个线程曾在 core 1 上访问过这段地址,core 1 的 TLB 可能还保留旧映射。只改页表不够,必须让相关 CPU 清掉旧 TLB entry。

这个过程叫 TLB shootdown。内核会向可能持有旧翻译的 CPU 发送 IPI,让它们 flush 对应 TLB entry 或更大范围。同步 shootdown 必须等目标 CPU 完成 flush 才能继续,否则可能破坏隔离。

这会让一些操作在多核机器上变贵:

  • 大范围 munmap
  • 频繁 mprotect
  • 大量 COW fault
  • 页面迁移或 reclaim 导致的 unmap
  • 高性能 allocator 频繁向 OS 归还内存

这也是为什么很多 allocator 会缓存释放后的内存,减少热路径上的 munmap。不是因为内核调用本身一定慢,而是因为页表修改可能牵涉跨核 TLB 一致性。

NUMA:虚拟地址隐藏了物理拓扑

在多 socket 服务器上,内存不是均匀的。每个 socket 通常有本地内存,访问本地内存更快;访问另一个 socket 连接的远端内存要经过互联,延迟更高,带宽也可能受限。这就是 NUMA。

虚拟地址空间把这种拓扑隐藏了。两个相邻虚拟页,可能一个在 NUMA node 0,另一个在 node 1。进程通过指针看不出差别,但 CPU 访问延迟会不同。

Linux 默认的匿名页分配常可以理解成 first-touch:哪个 CPU 第一次触碰页面,内核就倾向于从该 CPU 所在 NUMA node 分配物理页。于是会出现一个常见问题:

  1. 主线程在 socket 0 初始化一个大 buffer,触碰了所有页。
  2. 内核把这些页放到 node 0。
  3. 后续 worker 线程分布在 socket 0 和 socket 1。
  4. socket 1 上的线程处理同一个 buffer 时,大量访问远端内存。

解决方向包括:

  • 让将来访问数据的线程自己 first-touch 对应分片。
  • taskset 或线程 affinity 固定 CPU。
  • numactlmbindset_mempolicy 设置内存策略。
  • 对跨 socket 共享的大块数据考虑 interleave,分散内存控制器压力。
  • 评估 automatic NUMA balancing 是否帮忙,注意它也会引入采样 fault 和迁移成本。

NUMA 问题的难点是它看起来像普通内存访问变慢。只有把线程运行位置和页面所在 node 放在一起看,才能确认。

Linux 上怎么观察

虚拟内存问题不能只看一个指标。最好从地址空间、驻留页、fault、reclaim、TLB、NUMA 分层看。

maps:看进程映射了什么

1
cat /proc/<pid>/maps

这里可以看到每个 VMA 的地址范围、权限、private/shared、文件偏移和文件名。用它回答:

  • 进程有哪些映射?
  • 哪些是 heap、stack、shared library、file mmap?
  • 某段异常大的 VIRT 来自哪里?

smaps:看每个 VMA 实际占了多少

1
cat /proc/<pid>/smaps

重点字段:

  • Size:VMA 虚拟大小。
  • Rss:当前驻留物理内存。
  • Pss:共享页按比例摊分后的大小。
  • Shared_Clean / Shared_Dirty:共享干净/脏页。
  • Private_Clean / Private_Dirty:私有干净/脏页。
  • Anonymous:匿名页。
  • Swap:已换出的大小。
  • AnonHugePages:匿名 huge page 使用量。

PSS 对多进程内存分析很有用。RSS 会把共享库页算到每个进程头上,简单相加会高估;PSS 会把共享页摊分。

meminfo 和 vmstat:看系统压力

1
2
cat /proc/meminfo
vmstat 1

/proc/meminfo 里常看:

  • MemAvailable:不严重影响系统时可用的内存估计。
  • Cached:page cache。
  • Dirty / Writeback:等待写回或正在写回的脏页。
  • AnonPages:匿名页。
  • SwapTotal / SwapFree:swap 情况。

vmstat 1 重点看:

  • si / so:swap in/out。
  • b:阻塞在 I/O 等待上的任务。
  • wa:I/O wait。

持续 siso 同时存在,通常说明系统在换入换出之间抖动。

perf:看 fault 和 TLB

1
2
perf stat -e page-faults,minor-faults,major-faults <command>
perf list | grep -i tlb

page fault 事件能帮助判断是否频繁走 fault 路径。major fault 高通常比 minor fault 更值得警惕,因为它意味着 I/O。

TLB miss 很多时候不经过内核,必须看硬件性能计数器。不同 CPU 暴露的事件名不同,可以用 perf list 找。Intel 上常见还会有 page walk 相关事件,用来判断 TLB miss 是否真的造成昂贵页表遍历。

一个典型判断是:

  • major fault 低,但 TLB miss/page walk 高:数据在内存里,但翻译缓存压力大,考虑 huge page 或改数据布局。
  • major fault 高:先看 page cache、swap、I/O 和工作集大小。

NUMA:看页面在哪个 node

1
2
3
numactl --hardware
numastat -p <pid>
cat /proc/<pid>/numa_maps

numactl --hardware 看机器拓扑和 node 距离。numastat -p 看进程页面分布。numa_maps 可以按 VMA 看页面落在哪些 node、使用了什么策略。

如果同样的 worker 做同样的活,有些线程稳定更慢,且机器是多 socket,NUMA placement 就应该进入排查列表。

排查时按问题问

虚拟内存的排查不要从「进程用了多少内存」开始,这个问题太粗。更好的问题是:

  1. 这段内存只是 reserved,还是已经 resident?
  2. RSS 主要来自 anonymous,还是 file-backed page cache?
  3. private dirty 是否异常增长,是否和 COW 或写入 mmap 有关?
  4. major fault 是否持续出现,是否在访问文件映射或 swap?
  5. 系统是否在 reclaim,si / so 是否持续非零?
  6. 数据都在 RAM 里但仍然慢,是否 TLB miss 或 page walk 很高?
  7. 多线程性能不均,是否 NUMA first-touch 或线程迁移导致远端访问?
  8. 热路径是否频繁 mprotect / munmap,是否触发 TLB shootdown 成本?

这些问题比单看 VIRT/RSS 更接近根因。

收束一下

虚拟内存可以分成四层看:

  1. 地址空间层:进程看到自己的 VMA,代码、堆、栈、mmap 都是虚拟范围。
  2. 翻译层:页表把虚拟页映射到物理帧,PTE 记录权限和状态。
  3. 硬件层:MMU 执行翻译,TLB 缓存翻译结果,权限错误和缺页会 fault。
  4. 策略层:内核决定什么时候分配、清零、读文件、写回、swap、COW、reclaim、迁移页面。

把这四层串起来,很多现象就变得一致:

  • malloc 后 RSS 没涨,因为只是拿到了虚拟地址范围。
  • 第一次写入变慢,因为发生了 minor fault 和清零分配。
  • fork 很快,因为父子先共享页,写时才复制。
  • mmap 大文件后 VIRT 很大,因为文件只是映射进地址空间。
  • major fault 高时程序慢,因为内存访问落到了 I/O。
  • RSS 不等于独占内存,因为共享库和 file-backed page 可以被多个进程共享。
  • huge page 能提升性能,是因为它扩大了 TLB 覆盖范围。
  • munmap / mprotect 在多核上可能慢,是因为要做 TLB shootdown。
  • NUMA 上同样的指针访问可能延迟不同,因为物理页所在 node 不同。

虚拟内存的核心不是「假装内存很大」,而是给进程、内核和硬件一个共同的内存管理协议。进程用虚拟地址表达意图,硬件快速执行常见路径,内核在 fault、reclaim、COW、mmap、NUMA 这些边界上介入。理解这条链路后,很多看起来像内存玄学的问题,都可以拆成具体的映射、驻留、缺页、回收和拓扑问题。

参考

#Linux #Operating System #Virtual Memory