虚拟内存 入门

你 curl 进程碰的每个指针都是虚拟地址,每次访问都被翻译成物理地址 —— 每秒几十亿次。5 个 section 搭出画面:为啥要有它、页表、TLB、缺页,以及速查表。

01

为啥要有虚拟内存

每个进程看到私有的 64 位地址空间,其中 128 TB 可用。机器里并没有 128 TB 的 RAM。这个错觉一次买到 3 样东西。

没有它,每个进程直接寻址物理 RAM,第一个坏掉的是隔离。下面进程 A 的 8 个虚拟页散落在机器的页帧里,进程 B 的页住在 A 叫不出名字的页帧上,A 最后两页哪儿都没映射。拖滑块走过 A 的页,再越过它映射的末尾:

A 的虚拟页 0

注意 A 表达不出什么。它构造不出任何指到B 的页帧的虚拟地址,因为A 的表里没有 entry 指向那里。隔离不是内核每次访问都跑的检查,而是映射的缺席,硬件白送强制执行。

没有映射的那次读大声失败:没有区域覆盖这个地址,进程在肇事指令处吃 SIGSEGV。大声是好情况。安静的那种落进了进程确实拥有的映射 —— 硬件无从反对,损坏在别处冒出来。

第二样是地址空间规划。共享一个物理空间,每个程序都得挑别人不要的地址,两个都想要 0x400000 的就没法同时跑。这里两个照样都链接在那儿 ——程序 A 和 程序 B —— 滑块移动加载器把 B 丢在哪:

程序 B 在物理页帧 11

因为 B 编译时用的地址是虚拟的,加载器从不改写二进制:填一张页表然后跳过去。ASLR 几乎免费也是这个原因,同一个二进制的两份副本能共享同一组只读页帧、而两边都以为自己拥有 0x400000,也是。

第三样是 overcommit。程序保留的远比它们碰的多:64 GB 机器上 200 GB 的 JVM 堆很平常。只有保留量里碰过的那部分才花一个物理页帧。把滑块往上拖,看保留量始终跟开始时一样宽:

保留 200 GB,已触碰 0 GB

看超过 72 GB —— RAM 加交换区 —— 之后发生什么。malloc 那时没失败,因为它承诺的只是地址空间;失败晚一点到,到在内核满足不了的缺页上,而且落在 OOM killer 挑中的进程头上,不一定是超额保留的那个。这就是 vm.overcommit_memory=0 的代价。

这些都不免费。访问本身从 L1 出来约 1 ns;翻译开销是那部分必须走页表的访问,每次约 100 周期。抬高每千次访问里未命中的数量:

每千次访问 0 次未命中

注意开销超过正事有多快。每千次 32 次未命中 —— 3.2% 的未命中率,对大工作集毫不稀奇 —— 翻译就跟它翻译的那次访问一样贵。这条曲线就是 TLB 存在的全部理由。页表自身再加约映射大小的 0.2%,那是账单里小的一半。

02

页表 —— 硬件真正读的东西

内核为每个进程维护一棵表的树,把虚拟页映射到物理页帧。翻译还没被缓存的访问,CPU 每次都要走它。

内存按页管理 —— 固定大小、对齐的块,常见 CPU 上是 4 KB。翻译在页粒度上发生,所以最低 12 位原样穿过去,它上面4 个 9 位索引字段决定选哪一页。一个字节一个字节地拖,看真正在动的是什么:

虚拟地址 +0 字节

注意偏移在变时页帧不变。连续 4096 个地址共用一次翻译,这正是翻译负担得起的原因:硬件每页做一次,不是每字节做一次。跨过 4 KB 边界,PT 索引进一格,页帧跟着换。

那为啥要树?每个虚拟页一个 entry 的平表需要 236 个 entry、每个 8 字节。把平表跟映射同样 4 MB 的4 级树一起加宽:

32 位虚拟地址

48 位下平表是每进程 512 GiB —— 而且不管这进程映射 4 GB 还是 4 KB 都是 512 GiB, 因为平数组要为每个可能被问到的地址留槽。树只在有映射的地方分配节点:这里 5 个 4 KB 页, 1 个 PML4、1 个 PDPT、1 个 PD、2 个 PT。

x86-64 把这 36 个索引位花成 4 个 9 位字段,形状不是随便挑的:29 × 8 字节正好 4096,所以每张表自己就是一页。拖动轨道,一格走一级:

已解析 4 级中的 0 级

看load 计数。4 次相互依赖的 load —— 每个地址都从上一级刚返回的 entry里出来,内存级并行救不了。Linux 在 Ice Lake 加了第 5 级用于 57 位地址,只在 128 TB 以上才用,代价是第 5 次依赖 load。

每个 entry 8 字节:40 位页帧号,其余是权限和状态。放行的位和拒绝的位跟走表并行检查。切换访问,把这个 entry 逼进缺页:

读

注意缺页带的错误码说的是「试了什么」不是「哪里错了」: 第 0 位说页是 present,第 1 位说是写,第 2 位说来自用户态 —— 0x7。内核据此判断这是 bug 还是该悄悄处理的写时复制。Accessed 和Dirty 由 CPU 置位,页回收挑淘汰对象时读它们。

大页就是一个设了 PS 位的 entry, 它告诉 CPU 看到的已经是最终那页。把这位每次设高一级,看walk 停下的那一级往上爬:

4 KB 页 —— 不短路

两件事一起变好:少一次依赖 load,一个 TLB 项覆盖 2 MB 而不是 4 KB。账单在最后那行读数里。2 MB 页装 100 KB 的分配浪费 1.9 MB,而且没法按小块写时复制,fork() 之后写一个字节就复制整整 2 MB —— 所以 Linux 把透明大页发在 madvise 模式。

决定 CPU 走哪棵树的只有一个寄存器:x86 上 CR3,ARM64 上 TTBR0_EL1。它装顶层表的物理地址,改它就一次性改掉所有翻译。移动占着 CPU 的进程, 看同一个虚拟地址落到别处:

CR3 指向进程 A 的树

换掉 CR3 下面那棵树就是上下文切换对内存做的事,也是后 Meltdown 时代 KPTI 下每次进内核发生的事 —— KPTI 给每个进程两棵树,一棵映射内核一棵不映射,每次系统调用都切。 2018 年那波系统调用变慢,很大一部分在这。

03

TLB —— 翻译为啥能快

没有它,每次内存访问都是 5 次:1 次取数据,4 次走表。 TLB 是让这套方案负担得起的小缓存。

Translation Lookaside Buffer 缓存最近的页到页帧映射,跟 L1 查找并行进行,所以命中本质上不花钱,而未命中付一次走表再把结果装进去。拖动轨道,让 16 次访问跑过 8 个项:

第 0 次访问,共 16 次

注意哪些未命中躲不掉。第一次触碰必然未命中 —— 没人能提前装 —— 但靠后那次对第 17 页的不一样:它一直在里面,直到第 70 页把它挤出去,因为 8 个槽装不下这个循环碰的 9 个页。强制未命中随程序跑会减少;容量未命中不会,决定吞吐的是后者。

真实硬件分层回答:每次 load/store 探小的 L1 dTLB,后面是更大的 L2, 再没有才轮到硬件 page-walker, 而它自己也要穿过数据 cache 去读。把滑块往下移:

L1 dTLB

这些是 Intel 从 Skylake 到 Golden Cove 在每一层上的数字:4 KB 页约 64 个 L1 dTLB 项,L2 约 1500 项,页表在 cache 的一次走表约 100 周期,不在时几百周期。 Apple M 系列约 3000 个 L2 项,那些核心对大工作集从容的部分原因就在这。

项数乘页大小就是覆盖范围 —— TLB 一次能描述多少内存。把工作集推过覆盖线, 未命中率不会温柔退化,它掉下悬崖:

工作集 2.2 MB

工作集一超过覆盖范围, 随机访问命中的概率只有覆盖/工作集 —— 曲线是 1 − cov/ws, 工作集是覆盖两倍时已经 50%。1500 × 4 KB 差一点到 6 MB, 比同一颗芯片的 L2 数据 cache 还小:负载能完全装进 cache, 却仍把三分之一时间花在走页表上。

大页直接攻击覆盖那一项:项数不变,变的是每项描述多少。在同一条悬崖下切换页大小:

工作集 431 MB · 4 KB

同样 1500 项把覆盖线放在大约 6 MB、3 GB、1.5 TB。 Redis、PostgreSQL 的 shared buffers、JVM 堆和 HPC 数组要的不是更快的内存,而是把悬崖挪到自己工作集外面。用perf stat -e dTLB-load-misses,dTLB-loads 量:号称装得进 cache 的负载超过 1%,你踩着的就是这条悬崖。

TLB 缓存的是某一棵树的翻译,所以任何让翻译失效的事都要让副本失效 —— 单项 INVLPG,全部一次 CR3 重载。ASID 标签之前,每次上下文切换都全扔。拖动切换次数,对比没标签的 TLB和带标签的:

0 次上下文切换

有了地址空间标识符(Intel 的 PCID,2010 年 Westmere),每个活下来的项都带着来自哪棵树的标签,于是切换只是停止使用另一个进程的项,不必删掉。剩下的是容量压力。这是 2010 年代上下文切换变便宜的最大单一原因,也是 KPTI 在缺 PCID 的 CPU 上那么疼的原因。

04

缺页 —— 以及 Linux 拿它干的事

缺页是 CPU 要一个页表里没有的翻译时发生的事。 Linux 把它当钩子,不当错误路径。

present 位没置上或违反权限,CPU 就陷入。内核从 CR2 读出错地址,在进程的虚拟内存区域表里查,然后要么修好重跑那条指令,要么放弃。切换缺页的种类,看它要多少代价:

次缺页

3 种结局相差 5 万倍。次缺页根本不碰存储 —— 找一个或清零一个页帧就返回,几千周期。主缺页必须读,代价就是设备的代价。非法的那种不修:没有区域覆盖这个地址,进程以 SIGSEGV 结束。

按需分页建在这上面。malloc 一个 GB 只把范围标成允许然后返回;没碰过的不落地。走过这块分配,看常驻集在正被触碰的那一页后面一次一次缺页地长起来:

1 GB 里已触碰 0 MB

别看兆字节,看缺页数: 每兆 256 次,一个 4 KB 页一次。分配完 1 GB 就立刻填满的服务要付大约 262,000 次进内核 —— 这正是分配器复用 arena、MAP_POPULATE存在、以及该拿 RSS 而不是 VSZ 告警的原因。

mmap 是同一套机制对着一个文件。映射建起来是空的,所以每一页的第一次读会缺页到存储,之后每次读都是普通内存。走过对一个 8 页文件的 12 次读,最后 4 次重访已读的页:

正在读第 0 页

因为干 I/O 的是首次触碰,代价不在代码说它在的地方:源码里没有 read(),只有偶尔花 78 µs 的解引用。这让 mmap 适合对热文件反复随机访问,不适合单次顺序扫 —— 那里带预读的 read() 更快。

fork() 复制页表不复制页:两边每个页帧都变只读,引用计数加一。拖动写次数,看共享的页帧一个一个裂成私有副本:

fork() 之后写了 0 页

只有被写过的页花钱,还共享的一分不花 —— 所以 fork 一个几 GB 的进程能瞬间完成。镜像就是失败模式:父进程在子进程活着时写遍整个堆,就复制整个堆。而且复制发生在写缺页上,内存是那时候才提交的 —— fork 可以在早就返回之后才 OOM。

页帧不够时内核回收:挑最冷的页, 写进交换区,把 entry 标成不 present,释放页帧。把工作集拖过 RAM 的大小,看平均访问代价怎么动:

工作集 4 GB,内存 8 GB

看倍数,别看图。3 次访问里 1 次落在交换区, 不会让程序慢三分之一 —— 会慢 400 倍,因为两个代价是 80 ns 和 100 µs。这就是交换区是安全阀的原因,也是症状不是温和变慢、而是好几秒没反应的原因。

交换区也用完之后,内核不再挑页而开始挑进程。OOM killer 按占用和oom_score_adj 打分,除非你事先说过,它会挑数据库而不是日志搬运工。

05

速查

3 个该张口就答的问题,以及 5 个危险信号。

一次内存访问到底花多少?

完全取决于答案在哪儿找到,跨度 6 个数量级。下面每一格都是一次内存访问,滑块走过正在付账的那一格:

第 1 种结局,共 7 种

值得背的是第 4 格到第 5 格那一跳: TLB 未命中还在纳秒,缺页已经是微秒。这道坎以上是硬件问题,用页大小调;以下是 I/O 问题,用内存预算调。

mmap 到底干什么?

它建一个由文件或匿名零页支撑的虚拟内存区域, 把范围标成 not-present。每页第一次访问缺页,把数据读进页帧并接上翻译。用于大文件、共享库、共享内存,以及大块 malloc。

大页什么时候帮忙,什么时候帮倒忙?

瓶颈是 TLB 覆盖而不是带宽时帮忙。小分配上帮倒忙,滑块说明为啥 ——每个申请不到 2 MB 都得付一整页,剩下的是浪费:

分配 4 KB

那份浪费不是唯一代价:频繁 fork 时写一个字节要复制 2 MB。

  • 保留一大块却从不触碰。不花 RAM,花页表内存和 TLB 压力。
  • 反复 mmap/munmap 小区域。每次一个系统调用、一次页表修改、每核一次 shootdown。
  • 对大范围用 mlock。为了秘密,不是为了快。
  • 大量写之后才 fork()。刚写过的页都是等着下次写的复制。
  • 不管 vm.overcommit_memory。默认值把分配失败变成稍后对别的进程的 OOM 杀。