第 01 章 虚拟内存:一场精心策划的谎言
“计算机科学中任何问题,都可以通过增加一个中间层来解决。” —— 但这句话的下半句从来没人说:“然后你会多出一堆新的问题。“
引子:一个 8GB 的进程,fork 一下只要 3 毫秒,这可能吗?
先给你看个反直觉的东西。
假设你有个 Go 服务,跑着跑着 RSS 涨到了 8GB(别管为什么,就当它缓存了很多东西)。现在你想做一个”热重启”——不中断服务的情况下,用新二进制替换旧进程。于是你调用系统调用,让子进程 fork() 出来,继承父进程的全部内存。
问题来了:把一个进程的 8GB 内存完整复制一份,需要多久?
你在心里估一下。8GB,按内存带宽 10GB/s 算,光是 memcpy 就得 0.8 秒起步,加上页表建立、TLB 刷新,给 1.5 秒不算冤枉它。
实际耗时:3 毫秒左右。
差了 500 倍。
为什么?因为 fork() 根本就没有复制内存。
它只是做了一件事:把页表复制了一份,然后把父子进程的所有可写页,都标记为只读,并且打上一个特殊的标记。之后谁要写这些页,就会触发一个 CPU 异常,内核收到异常,这时候才真正复制那一页——而且只复制那一页,4KB,只复制被写的那一页。
这个技术叫 写时复制(Copy-On-Write,COW)。
你看,这就是操作系统最擅长的把戏:它不解决问题,它把问题推迟到不得不解决的那一刻,然后祈祷那一刻不会到来。
——而且这个”祈祷”在大多数情况下还真就灵验了。因为 fork() 之后、exec() 之前,子进程通常什么都不写,直接就被新程序覆盖了。
好,这一章我们就来拆这个把戏,以及它背后那整套叫”虚拟内存”的东西。
我给你一句本章的总纲,你带着这句话往下读:
虚拟内存的本质,是内核给每个进程讲的一个谎言:“你独占了整个地址空间,而且你的内存是连续的。”
这个谎言有三个目的:隔离(你不能碰别人)、重定位(你不用知道自己被放在物理内存的哪个角落)、超售(你以为你有,其实你没有)。
维持这个谎言,需要付出三笔代价:页表的空间、地址翻译的时间、以及谎言被戳穿时的异常处理。
本章路线图
第一幕:为什么需要这个谎言
Q1 如果不用虚拟内存,直接访问物理内存会怎样?
Q2 让链接器在加载时重定位,不就解决了吗?
Q3 那"地址翻译"这个思路,具体怎么落地?
第二幕:页表 —— 谎言的账本
Q4 页表有多大?放得下吗?
Q5 多级页表为什么能省空间?它不是变慢了吗?
Q6 多级页表让每次内存访问变成 5 次,这能忍?
第三幕:TLB —— 给谎言加速
Q7 TLB 是什么?为什么它是整个虚拟内存的性能命门?
Q8 进程切换时 TLB 怎么办?
Q9 大页(Huge Page)为什么能提速,又为什么不总是有效?
第四幕:缺页 —— 谎言被戳穿的时刻
Q10 缺页异常有几种?各自的代价是多少?
Q11 写时复制到底是怎么实现的?
Q12 既然可以"用到才给",那能不能超售?
Q13 OOM Killer 是怎么挑"倒霉蛋"的?
第五幕:mmap —— 把谎言用到文件上
Q14 mmap 为什么据说比 read 快?
Q15 那 mmap 在什么情况下比 read 还慢?
第六幕:回收 —— 谎言的账单到期
Q16 页面不够了,内核该回收谁?LRU 够用吗?
Q17 swap 是救命稻草还是毒药?
第七幕:工程实战
Q18 RSS 一直涨,是内存泄漏吗?
Q19 Go 的内存分配器和虚拟内存是什么关系?
Q20 这套设计到底面临哪些无解的问题?
第一幕:为什么需要这个谎言
Q1 如果不用虚拟内存,直接让程序访问物理内存,会怎样?
我们先把时间拨回 1950 年代。那时候的计算机还真就是这么干的——程序直接用物理地址,你想访问内存,就往地址总线上放一个物理地址。
问题一:多程序共存时的地址冲突。
两个程序都要用到地址 0x1000。第一个程序在这儿放了个计数器,第二个程序在这儿放了个函数指针。两个程序同时跑,互相踩踏,谁先崩看运气。
你可能会说:那给每个程序划定地址范围不就行了?程序 A 用 0x0000~0x3FFF,程序 B 用 0x4000~0x7FFF。
行,那请你回答下一个问题:你怎么知道程序 A 有多大?
编译的时候不知道——因为程序可能动态申请内存。加载的时候也不知道——因为不同输入会导致不同内存用量。你只能按”最大可能用量”预留,然后浪费掉 70%。
而且这还没考虑:程序 A 跑完退出了,程序 B 也退出了,现在内存里有两个洞,一个新程序需要连续空间,但它比任何一个洞都大,却比两个洞加起来小。外部碎片问题,你怎么办?
问题二:完全没有保护。
程序 A 可以随手写 mov [0x5000], 0,而 0x5000 是程序 B 的栈。更糟的是,程序 A 可以写 mov [0xFFFF0000], ... 去捅硬件寄存器,直接把整台机器搞死。
在一个没有内存保护的系统里,任何一个有 bug 的程序都是一个内核级漏洞。你写个死循环往随机地址写数据,整台机器就得重启。
问题三:共享和重入变成噩梦。
三个进程都在用 libc,你希望 libc 的代码段在内存里只有一份(省内存)。但如果每个程序都以为自己从 0x0000 开始,那 libc 在进程 A 里的地址,跟在进程 B 里的地址就不一样。那份共享代码里如果有个函数指针,值是绝对的,那就乱套了。
停下来想一想:这三个问题,有没有一个统一的解法?
有。而且解法极其优雅:
不要让程序看到物理地址。给每个程序一套”假地址”,在硬件层面做一次翻译。
程序 A 看到的 0x1000 和程序 B 看到的 0x1000,可以是完全不同的两块物理内存。程序 A 永远看不到程序 B 的内存,因为它的页表里根本没有指向 B 的条目——想访问也访问不到,硬件会拦。
一次设计,三个问题全解决:
- 冲突:不存在了,大家的
0x1000各是各的。 - 保护:天然实现了,页表里没有的,就访问不了;页表里标了只读的,写了就异常。
- 共享:想共享,就让两个进程的页表条目指向同一块物理页。libc 的代码段就这么干的。
这就是虚拟内存。1959 年,曼彻斯特大学的 Atlas 计算机第一次实现了这个东西,比 Unix 早了十年,比 Linux 早了三十二年。
Q2 让链接器在加载时重定位,不就解决了吗?
好问题。你这是想到了另一条路:静态重定位。
具体做法是:程序编译链接时,假定自己从地址 0 开始。加载时,操作系统看哪儿有空地,比如从 0x400000 开始,就把程序里所有的绝对地址都加上 0x400000,然后加载。
这条路在特定条件下走得通,但有几个硬伤:
硬伤一:加载变慢。 你得扫描整个程序,找出所有绝对地址的引用,逐个修改。一个 200MB 的二进制,光是扫描就得几百毫秒。
硬伤二:代码段不可写了。 现代系统为了安全,代码段是只读的(这样缓冲区溢出就不能注入代码)。你不能在加载时去改只读的段。而且即使能改,改完了这段代码就不能和其他进程共享了——因为每个进程的重定位偏移不同,改出来的代码就不一样。共享 libc 的路直接堵死。
硬伤三:无法处理动态增长。 程序加载到 0x400000,占了 2MB。运行过程中堆要涨到 20MB,但 0x400000 + 2MB 往后的地址已经被别的程序占了。你怎么办?把整个程序搬走?搬走之后所有指针又全失效了。
硬伤四:无法超售。 这个我们后面会说,虚拟内存能做到”你以为你有 8GB,其实只有 2GB”,静态重定位做不到。
所以静态重定位只在很早的单道程序系统里用过。真正的答案是运行时动态翻译——也就是 MMU(内存管理单元)干的活。
关键区别在于:重定位是”改数据”,地址翻译是”改视角”。改数据是一次性的、侵入式的;改视角是持续的、非侵入式的。程序代码一个字节都不用动,从它自己的视角看,它依然拥有从 0 开始的完整地址空间。
Q3 那”地址翻译”具体怎么落地?页?段?还是别的?
这里历史上吵过很久,主要有两派:
段式(Segmentation):按逻辑单位划分。代码一段、堆一段、栈一段,每段大小可变,每段有基址和界限。地址 = 段号 + 段内偏移。
页式(Paging):按固定大小切块。把虚拟地址空间和物理内存都切成 4KB(典型值)的块,叫”页”(page)和”页框”(page frame)。地址 = 页号 + 页内偏移。
段式更符合人的直觉——程序员确实觉得”代码是一块、数据是一块”。但它有个致命问题:段大小可变,导致外部碎片。内存里跑着跑着,段和段之间全是大小不一的洞,明明总空闲够,就是分配不出一个连续的 3MB 段。这又回到了 Q1 的问题。
页式的天才之处在于:固定大小,消灭了外部碎片。因为所有块都一样大,任何一块空闲物理页都能满足任何一次分配请求。你不会产生”总空间够但分配不了”的情况。
代价是产生了内部碎片——进程要 5KB,你得给它 2 个页(8KB),浪费 3KB。但内部碎片是可控的(平均浪费半个页,页越小浪费越少),外部碎片是不可控的。
工程上永远是:用一个可控的小代价,换掉一个不可控的大代价。
x86-64 最后的选择是:页式为主,段式基本废弃(保留 FS/GS 两个段寄存器给 TLS 用,我们在第 7 章讲内存模型时会再碰到它)。
顺带一提,段页式结合(先分段再分页)在 x86 上是支持的,但 Linux 基本没用——它把所有段的基址都设成 0,等于”假装分段不存在”。这是一个很典型的工程决策:硬件提供了能力,但软件选择不用,因为用它的代价大于收益。
翻译过程是这样的:
虚拟地址 (64位,实际用 48 位)
┌──────────────┬──────────────┐
│ 虚拟页号 VPN │ 页内偏移 VPO │
│ (36 bit) │ (12 bit) │
└──────────────┴──────────────┘
│ │
│ └──→ 直接透传,不翻译
│ (这就是为什么页内偏移是连续的)
↓
查页表 → 物理页框号 PPN
│
↓
┌──────────────┬──────────────┐
│ 物理页框号 PPN│ 页内偏移 PPO │
└──────────────┴──────────────┘
物理地址
注意那个关键点:页内偏移不需要翻译。虚拟地址和物理地址的低 12 位是相同的。因为页是 4KB 对齐的,页内相对位置在翻译前后不变。
这个性质极其重要,它意味着 MMU 只需要翻译高位,低位直接透传。硬件实现上,这就是一根导线的事,不需要任何逻辑电路。好的设计总是让最频繁的操作变得最廉价。
第二幕:页表 —— 谎言的账本
Q4 页表有多大?放得下吗?
来算笔账。
32 位地址空间,4GB,页大小 4KB:
4GB / 4KB = 2^32 / 2^12 = 2^20 = 1,048,576 个页表项(PTE)
每个 PTE 假设 4 字节(32 位,存物理页框号 + 标志位):
1,048,576 × 4B = 4MB
所以每个进程需要 4MB 的页表。
听起来还好?那我们乘以进程数。一台跑着 100 个进程的机器:
4MB × 100 = 400MB
400MB 内存,纯粹用来存页表,一个字节的业务数据都没放。在 2000 年代,一台服务器可能就 1GB 内存。这就不能接受了。
到了 64 位,这个问题直接爆炸:
2^64 / 2^12 = 2^52 = 4,503,599,627,370,496 个 PTE
× 8 字节 = 32 PB
32 petabytes 的页表。这已经不是”浪费”的问题,是根本不可能。
所以单级页表在 64 位下彻底不可行。必须想办法。
在往下看之前,你先停一下,自己想 30 秒:一个进程真的用满了它的地址空间吗?一个 Go 服务,即使 RSS 有 8GB,它的虚拟地址空间是怎么分布的?
Q5 多级页表为什么能省空间?它不是变慢了吗?
这就是关键洞察了:绝大多数虚拟地址空间是空的。
随便看一个进程的地址空间布局(64 位 Linux):
0x0000000000000000 ┌─────────────────────┐
│ NULL / 未映射 │ ← 巨大一片空
0x000055xxxxxxxxxx ├─────────────────────┤
│ 代码段、数据段、堆 │ ← 几百 MB
├─────────────────────┤
│ │
│ 巨大的空洞 │ ← 几 TB 的空
│ │
0x00007fxxxxxxxxxx ├─────────────────────┤
│ mmap 区域 │ ← 动态库、文件映射
│ 栈 │
0x00007fffffffffff ├─────────────────────┤
│ 内核空间(不可访问)│
0xffffffffffffffff └─────────────────────┘
在 128TB 的用户地址空间里,一个普通进程可能只用了几百 MB。有效地址占比不到百万分之一。
既然是这样,为什么要为那些空地址建页表项?
多级页表的思路就是:把页表本身也分页,而且只为用到的部分分配。
x86-64 用的是 4 级页表(5 级是后来的扩展,先把 4 级讲清楚):
虚拟地址 (48 位有效)
┌────────┬────────┬────────┬────────┬─────────┐
│ PML4 │ PDPT │ PD │ PT │ offset │
│ 9 bit │ 9 bit │ 9 bit │ 9 bit │ 12 bit │
└────────┴────────┴────────┴────────┴─────────┘
│ │ │ │ │
│ │ │ │ └─→ 页内偏移,不翻译
│ │ │ └───────────→ 页表内索引(512 项)
│ │ └────────────────────→ 页目录内索引(512 项)
│ └─────────────────────────────→ 页目录指针表索引(512 项)
└───────────────────────────────────────→ PML4 表索引(512 项)
每一级 512 项 × 8 字节 = 4KB,正好一页
每一级索引 9 位,所以每级表有 2^9 = 512 个条目,每个条目 8 字节,512 × 8 = 4096 字节 —— 恰好一页。这不是巧合,是精心设计:让一级页表恰好占一个物理页,分配和回收都极其简单。
现在关键来了:如果某个 PML4 条目指向的整个 512GB 地址范围都是空的,那这个条目就标记为”不存在”,根本不需要分配下一级的 PDPT 表。
举个例子:你的 Go 服务只用了 800MB。
PML4 表: 必须存在 = 4KB(1 页)
├─ 条目 0 → PDPT 表 = 4KB(覆盖代码段所在区域)
│ └─ 若干条目 → PD 表 = 每个 4KB
│ └─ 若干条目 → PT 表 = 每个 4KB
└─ 其余 511 个条目:全标记为空,不分配任何东西
实际开销大概是:1 个 PML4 + 几个 PDPT + 十几个 PD + 几十个 PT ≈ 几十 KB。
对比单级页表的 32PB,节约了 9 个数量级。
代价呢?
你肯定已经看出来了:单次地址翻译,从”查一次表”变成了”查四次表”。也就是说,你程序里的一条 mov rax, [rdi],背后是 5 次内存访问(4 次查页表 + 1 次真正取数据)。
性能直接掉 5 倍。这个代价能忍吗?
Q6 多级页表让每次内存访问变成 5 次,这能忍?
当然不能忍。所以有了 TLB。
但在讲 TLB 之前,我想让你先体会一下”为什么多级页表仍然是正确的选择”。这里有个非常漂亮的论证:
多级页表的空间开销,正比于进程实际使用的内存量,而不是地址空间的大小。而它的时间开销,是一个常数(4 次查表)。
- 单级页表:空间 O(地址空间大小),时间 O(1)
- 多级页表:空间 O(实际使用量),时间 O(log 地址空间大小)
对于一个”地址空间巨大但实际用量小”的系统——也就是所有现代系统——多级页表完胜。
而那个 O(log n) 的时间开销,被 TLB 给摊销掉了。
记住这个模式:用一个常数因子的时间代价,换取数量级的空间收益,然后用缓存把这个时间代价再赚回来。
这个模式在操作系统里出现了无数次。后面你会在 dentry cache、page cache、slab allocator 里反复看到它。它是内核设计的肌肉记忆。
第三幕:TLB —— 给谎言加速
Q7 TLB 是什么?为什么它是整个虚拟内存的性能命门?
TLB(Translation Lookaside Buffer,快表)说白了就是页表的缓存。
它是一个放在 CPU 芯片上的、极小的、极快的关联存储器(CAM),缓存”虚拟页号 → 物理页框号”的映射。
典型规格(Intel Skylake 级别):
L1 dTLB: 64 条(4KB 页)/ 32 条(2MB 页) 延迟 ~1 周期
L1 iTLB: 128 条
L2 STLB: 1536 条统一 延迟 ~7-10 周期
对比一下主存访问延迟:约 200-300 个时钟周期。
所以:
| 情况 | 延迟 |
|---|---|
| L1 TLB 命中 | ~1 周期(几乎免费) |
| L2 TLB 命中 | ~10 周期 |
| TLB 未命中,走 4 级页表查询 | 4 × 300 = ~1200 周期 |
最坏情况差 1000 倍。
这意味着什么?意味着如果你的程序内存访问模式很糟糕,TLB 命中率低,你的程序可以慢好几倍,而且用 perf 看,CPU 使用率可能还挺高(都在忙着查页表)。
我见过真实的案例:一个服务从 4KB 页换成 2MB 大页之后,QPS 涨了 30%。什么代码都没改,就改了内存映射方式。
TLB 的覆盖范围(TLB reach)是个关键指标:
TLB reach = TLB 条目数 × 页大小
64 条 × 4KB = 256KB ← 用 4KB 页时,L1 dTLB 只能覆盖 256KB
32 条 × 2MB = 64MB ← 用 2MB 大页,覆盖范围扩大 256 倍
你的热点数据集如果是 256MB,用 4KB 页,L1 TLB 根本盖不住,每次访问都在 miss。换成 2MB 大页,一次命中管 2MB,miss 率直线下降。
这就是大页(Huge Page)能提速的根本原因——不是因为内存变快了,是因为翻译变少了。
Q8 进程切换时 TLB 怎么办?
这是个好问题,也是个大坑。
TLB 里缓存的是”虚拟页号 → 物理页框号”。但虚拟页号在不同进程里含义不同——进程 A 的 0x7f0000 和进程 B 的 0x7f0000 是两块不同的物理内存。
所以进程切换时,理论上必须清空整个 TLB(flush),否则就会访问到上一个进程的内存——这是严重的安全漏洞。
早期 x86 确实就是这么干的:切换 CR3 寄存器(存页表基址),硬件自动 flush 整个 TLB。
代价有多大?
切换之后,前几百次内存访问全部 TLB miss,每次 1200 周期。如果你的服务每秒上下文切换 10 万次,每次切换后平均 200 次 TLB miss:
100,000 次/秒 × 200 次 miss × 1200 周期 = 2.4 × 10^10 周期/秒
在 3GHz 的 CPU 上 = 8 个核心的全部算力
8 个核心,全用来处理 TLB miss。 什么业务都没干。
这显然不能接受。于是有了两个优化:
优化一:ASID(Address Space ID)
给 TLB 的每个条目打上”这是哪个进程的”标签。硬件上,每个进程分配一个 ASID(比如 12 位,4096 个)。切换进程时,改 ASID 寄存器,TLB 条目的匹配条件从”VPN 相同”变成”VPN 相同 且 ASID 相同”。
这样不同进程的 TLB 条目可以共存,不用 flush。
ASID 只有 4096 个,进程超过 4096 个怎么办? 内核会做 ASID 回收:当 ASID 耗尽时,把某个 ASID 分配给新进程,同时 flush 掉带那个旧 ASID 的所有条目(generation 计数器递增)。这是典型的”资源池 + 回退”设计——快路径覆盖 99% 的情况,慢路径保证正确性。
优化二:区分内核空间和用户空间
Linux 把所有进程的内核空间页表都映射到同一片物理内存(这是对的,内核本来就只有一份)。所以内核空间的 TLB 条目,在所有进程间是通用的——给它们打上一个”全局”标志(Global bit,PCID/CR4.PGE),进程切换时不 flush 这些条目。
64 位 Linux 的典型布局是用户空间 128TB + 内核空间 128TB,各占一半。也就是说,进程切换时至少保住了一半的 TLB 条目。
顺便说一句,这也是为什么 32 位时代”3G/1G 分割”(用户 3GB、内核 1GB)被嫌弃——内核空间占的比例越小,进程切换时 flush 的代价就越小,但内核能直接管理的内存也越少(32 位下内核只有 1GB 直接映射窗口,高端内存的管理变得极其复杂)。又是一个两难,又是一个权衡。
Q9 大页(Huge Page)为什么能提速,又为什么不总是有效?
前面说了大页的核心收益:TLB reach 扩大 256~512 倍。
但大页不是银弹,它有四个实打实的问题:
问题一:内部碎片变成灾难。
4KB 页时,平均浪费 2KB(半个页)。2MB 页时,平均浪费 1MB。
你的 Go 服务如果申请 3000 个 8KB 的小对象,用 4KB 页需要约 6000 页;用 2MB 大页,每个 8KB 对象如果独占一个 2MB 页,那是 6GB。当然内核不会这么蠢(小对象走 slab/mcache,不会单独占大页),但道理是这样:大页只对”大块连续使用”的内存友好。
问题二:缺页成本暴涨。
4KB 页的缺页异常,需要从磁盘读 4KB。2MB 大页的缺页异常,需要读 2MB(如果开启 THP 且需要清零,还要 memset 2MB)。
一次 2MB 的 memset,在现代 CPU 上大约 100-200 微秒。如果你的服务频繁分配释放内存,THP 的后台整理线程(khugepaged)会持续消耗 CPU。
这是 THP(Transparent Huge Page)最臭名昭著的问题:它让平均性能提升了,但让 P99 延迟变得极其抖动。因为 khugepaged 会在你不知道的时候,把一大片内存搬来搬去,还顺手 invalidate 一堆 TLB 条目,并且在这个过程中需要给 mmap_sem 加锁——一个进程的内存整理,会阻塞其他进程的内存分配。
Oracle、MongoDB、Redis、Elasticsearch、TiDB 的官方文档里,全都有一条:关闭 THP。 不是因为大页不好,是因为 THP 的”透明”带来的不确定性,比它带来的平均收益更贵。
问题三:COW 粒度变粗。
还记得开头那个 fork() 的例子吗?4KB 页时,写 1 字节,复制 4KB。2MB 页时,写 1 字节,复制 2MB。
如果你的服务用 fork() 做热重启(或者用 Redis 的 BGSAVE),而父进程有大量 2MB 大页,那 fork() 之后的第一次写操作,就可能触发 2MB 的复制。从 3 毫秒变回几百毫秒。
Redis 的 BGSAVE 延迟抖动,很大一部分就是这个原因。
问题四:内存回收的粒度问题。
回收一个大页,要么全回收,要么不回收。一个 2MB 大页里如果只有 4KB 是热点,其余是冷数据,你也得把整个 2MB 留在内存里。
那什么时候该用大页?
判断标准其实很清晰:
适合大页:
- 数据集大(GB 级)且访问相对均匀
- 有明显的"热数据"且热数据集中、连续
- 内存分配模式稳定,不频繁分配释放
- 典型场景:数据库 buffer pool(MySQL InnoDB、PostgreSQL shared_buffers)
大内存缓存(Redis、Memcached)
JVM 大堆
大文件的 mmap 映射
不适合 / 需谨慎:
- 延迟敏感且要求 P99 稳定(THP 会导致抖动)
- 内存分配模式碎片化
- 频繁 fork(BGSAVE、热重启)
- 典型场景:普通 Web 服务的堆内存
要用大页,正确的姿势是显式使用 hugetlbfs 或 madvise(MADV_HUGEPAGE),而不是全局开 THP。 让需要大页的组件(比如数据库的 buffer pool)自己申请大页,其余部分用普通页。
# 查看 THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
# 生产建议(延迟敏感服务)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag第四幕:缺页 —— 谎言被戳穿的时刻
Q10 缺页异常有几种?各自的代价是多少?
当 MMU 查页表,发现这个虚拟地址要么没有映射,要么权限不对,就会触发一个缺页异常(Page Fault),CPU 陷入内核,交给内核的缺页处理函数处理。
内核要判断这是哪种情况,因为处理方式完全不同:
类型一:Minor Fault(次要缺页)
页表项存在,但标记为”不在内存中”,或者权限是只读但你要写(COW 场景)。
关键点:数据已经在物理内存里了,不需要读磁盘。内核只需要:
- 分配一个物理页(如果是 COW,还要复制)
- 填好 PTE
- 刷新 TLB
- 返回用户态,重新执行那条指令
代价:约 1-5 微秒。
典型场景:
- 第一次访问
malloc分配的内存(惰性分配) - COW 页面的写操作
- 内存被换出到 swap 后又访问(注意:这个其实是 major,因为要读 swap 设备)
类型二:Major Fault(主要缺页)
数据不在物理内存里,需要从磁盘(或 SSD)读进来。
代价:
机械硬盘: ~10 毫秒 (寻道 + 旋转,10,000,000 纳秒)
SATA SSD: ~100 微秒
NVMe SSD: ~50-100 微秒
对比 minor fault 的 1-5 微秒,major fault 慢 20 到 10000 倍。
典型场景:
- 第一次读一个 mmap 的文件
- 程序第一次执行(代码段按需载入)
- 内存压力大到把代码段回收了,又要执行那段代码
类型三:Invalid Fault(无效缺页 / 段错误)
访问的虚拟地址压根没有映射,而且也不该有。
内核会发 SIGSEGV 信号给你。在 Go 里,如果你不管它,就是:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]
注意一个细节:空指针解引用不是”访问地址 0 出错”,而是”地址 0 没有映射”。Linux 故意把虚拟地址空间的前几页(默认至少 64KB,可通过 /proc/sys/vm/mmap_min_addr 配置)设为不可映射区域,这样任何对空指针及其附近偏移的访问都会立刻触发段错误,而不是悄悄成功。
这是个很聪明的设计:把最常见的编程错误,从”难以察觉的数据损坏”变成”立刻崩溃并给出明确信号”。
你在工程里也应该这么做。Go 里返回
nil, nil是个坏习惯,因为它把”出错了”伪装成”成功了但没有数据”。显式返回 error,让错误立刻暴露,跟内核把 0 地址设为不可访问是同一个思路。
顺带说一个反直觉的事实:
在 Go 里,nil map 的读操作是安全的(返回零值),但写操作会 panic。为什么读不 panic?
因为 Go 运行时在 mapaccess 里做了显式检查:
// src/runtime/map.go(简化)
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if h == nil || h.count == 0 {
return unsafe.Pointer(&zeroVal[0]) // ← 显式返回零值,不 panic
}
// ... 正常的哈希查找
}而 mapassign 没有这个保护,直接写 nil 指针 → 段错误 → Go 运行时捕获 SIGSEGV → 转成 panic。
你看,Go 运行时在这里其实就是扮演了”内核”的角色:它接管了硬件的异常,然后按自己的规则决定是转成 panic 还是悄悄处理。这个模式在第 19 章讲沙箱和用户态内核时还会再遇到。
再延伸一层:Go 运行时还”滥用”了缺页异常做别的事。
Go 的栈增长机制就用到了这个技巧。每个 goroutine 的栈初始只有 2KB,栈快用完时,编译器在函数开头插入的检查代码会调用 runtime.morestack(),分配更大的栈并搬移。
而更早的实现(分段栈)用的是另一种方案:在栈的末尾放一个”guard page”(不可访问的页),栈溢出时触碰这个页触发缺页异常,运行时捕获后扩展栈。后来因为”热分裂”问题(频繁在边界来回调用导致反复扩缩)改成了连续栈。
不管哪种实现,思路都一样:用硬件的异常机制,实现一个纯软件的功能。
Q11 写时复制到底是怎么实现的?
现在回头拆 fork()。
第一步:复制页表。
fork() 调用链是 copy_mm() → dup_mm() → dup_mmap(),遍历父进程的所有 VMA(虚拟内存区域),复制出一套一模一样的页表结构。
注意:复制的是页表(几十 KB),不是页表指向的物理内存(8GB)。
第二步:把所有可写页改成只读。
遍历父子进程的所有 PTE,凡是”可写”的,全部改成”只读”,并且标记为 COW 页。
这里有个技巧:怎么标记 COW?
x86-64 的 PTE 格式里,位 9-11 是”给操作系统软件使用”的保留位,硬件不关心。Linux 用这些位存软件的语义。但更常见的做法是:把 PTE 的写位(W)清零,同时保持 VMA 的可写标志不变。
这样,后续的判断逻辑就变成:
PTE 说"不可写",但 VMA 说"可写"
→ 权限不一致
→ 这不是真的非法访问,这是 COW!
用”两个状态的不一致”来表达第三种语义 —— 这是个很常见的技巧。你在工程里也用:比如用一个 state 字段和一个 flag 字段的组合表示一个特殊状态,而不是新增一个字段。
第三步:返回。
父子进程的 fork() 都返回,子进程拿到 0,父进程拿到子进程 pid。
第四步:子进程写内存。
子进程执行 x = 42(x 在某个可写页上)。
- MMU 查页表,发现 PTE 标记为只读 → 触发写保护异常
- CPU 陷入内核,
do_page_fault()被调用 - 内核检查 VMA:VMA 说这页可写
- 内核判断:这是 COW 场景
- 查这个物理页的引用计数
_refcount - 分配一个新的物理页
- 复制原页的内容(4KB,约 100-200 纳秒)
- 修改子进程的 PTE,指向新页,标记为可写
- 刷新 TLB(invlpg)
- 返回用户态,重新执行那条写指令
第五步:如果这页只有一个人引用呢?
内核维护着每个物理页的引用计数。如果发现这个页的引用计数是 1(说明 fork 之后,另一个进程已经把这个页释放了,或者整个页只有一个使用者),那就不复制了,直接把 PTE 改成可写。
这叫 COW 的引用计数优化。它省掉了大量无谓的复制。
完整的 COW 决策树:
写操作触发保护异常
│
├─ VMA 不可写? ──────→ SIGSEGV(真的写错了,段错误)
│
├─ PTE 不存在? ──────→ 按需调页
│ ├─ 匿名页:分配零页
│ └─ 文件页:从磁盘读
│
└─ PTE 存在但是 COW 页?
├─ 引用计数 == 1 ──→ 直接改成可写,不复制 ✓
└─ 引用计数 > 1 ──→ 分配新页 + 复制 4KB + 改 PTE + 刷 TLB
一个思考:为什么
fork()之后紧跟exec()那么快?因为
exec()会丢弃整个地址空间,换成新程序。fork()建的那套 COW 页表,一个物理页都没复制,就全被扔掉了。所以
fork()+exec()的组合,成本约等于”复制一份几十 KB 的页表 + 立刻丢弃它”。这就是 shell 启动一个新命令能在几毫秒内完成的原因。也正因如此,POSIX 后来加了
posix_spawn(),Linux 有了clone(CLONE_VM|CLONE_VFORK)——如果你确定 fork 之后要 exec,就别建那套页表了,直接共享地址空间直到 exec,父进程在 exec 之前挂起等待。为了一个”反正要扔掉”的东西花几十微秒,不值当。这个思路你在工程里也天天用:延迟初始化、惰性计算、写时复制的数据结构。Rust 的
Cow<T>、sync.Map里 dirty map 的惰性提升、strings.Builder的 copy-check——全是这个思想。
Q12 既然可以”用到才给”,那能不能超售(overcommit)?
能。而且 Linux 默认就是这么干的。
当你 malloc(8GB) 的时候,发生的是:
void *p = malloc(8UL * 1024 * 1024 * 1024);
// 返回非 NULL,看起来成功了底层做了什么?
malloc通过brk或mmap在堆里预留 8GB 的虚拟地址空间- 建立 VMA,一个 PTE 都没建
- 返回
一块物理内存都没分配。
只有当你真正写这些地址时,才会一页一页地触发缺页,一页一页地分配物理内存。
这就是为什么:一个刚启动就 malloc(8GB) 的进程,RSS 可能只有 3MB。
那如果一个进程真的把 8GB 全写了,而机器只有 4GB 物理内存呢?
这就是 overcommit(内存超售) 赌博:
- 如果所有进程都只申请不写(常见情况:程序申请了 100MB 的 buffer,实际只用了 10MB),那超售就是白赚
- 如果所有进程都把申请的内存写满了,物理内存不够,就必须杀进程
内核有三种策略(/proc/sys/vm/overcommit_memory):
0(默认):启发式超售
- 允许大部分分配,但拒绝明显不合理的请求
(比如单次申请超过 总内存 + swap 的总量)
- 这是绝大多数 Linux 发行版的默认值
1:永远允许
- malloc 永远成功,直到真的没物理内存了才触发 OOM
- 用于某些科学计算、稀疏矩阵、以及 Redis 这类
"我知道自己在干什么"的场景
2:严格模式(禁止超售)
- 分配量不能超过 swap + RAM × overcommit_ratio%
- 默认值 overcommit_ratio = 50
- 数据库(PostgreSQL 官方建议)常用这个
超售的经济学类比:
这跟航空公司超售机票是同一回事。飞机有 100 个座位,卖 105 张票,因为统计上总有 5 个人不来。代价是偶尔要赔钱给被挤下飞机的乘客。
内存超售的”赔钱”方式就是 OOM Kill。
为什么 Linux 默认允许超售?
因为 fork() 的存在。回想一下:fork() 出来的子进程,跟父进程共享所有页面(COW)。如果禁止超售,那 fork() 一个占用 50% 内存的进程,内核就必须保证”万一父子进程都把所有页写了”的极端情况——也就是需要两倍的内存。
这意味着任何进程都不能使用超过 50% 的内存,否则 fork() 就会失败。这显然荒谬。
所以:fork() + COW 的语义,本身就要求允许超售。 这是设计上的连锁反应——一个决定锁死了另一个决定。
有意思的是,这个逻辑反过来在计算机历史上还有个镜像:Windows 从来不允许真正的内存超售。它的 VirtualAlloc(MEM_COMMIT) 会真的在页表里记账,malloc 成功就意味着内存一定能兑现。代价是 Windows 上跑 fork() 语义的东西极其困难(这也是早期 Git 在 Windows 上慢的原因之一)。
工程迁移:这个设计思想在你的系统里怎么用?
overcommit 的本质是”承诺(promise)与兑现(delivery)分离”。你在很多地方都能用:
场景 承诺 兑现 内存分配 malloc 返回地址 写的时候给物理页 K8s 资源调度 request(承诺) limit(兑现上限) 数据库连接池 借出连接对象 真正建立 TCP 连接 消息队列 接收并 ack 消息 真正处理完 秒杀系统 发券给用户 真正扣库存 关键问题是:你能承受”兑现失败”吗? 内存兑现失败 = OOM Kill,库存兑现失败 = 超卖。所以秒杀系统通常选择”不超售”(预扣库存),而连接池通常选择”超售 + 超时失败”。
Q13 OOM Killer 是怎么挑”倒霉蛋”的?
内存真的耗尽了,内核必须杀掉某个进程来腾出内存。杀谁?
这就是 OOM Killer 的打分算法(oom_badness()),每个进程有个评分,范围 0-1000,分数越高越该死:
// 简化版(mm/oom_kill.c)
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
// 1. 基础分:占用内存的页数(RSS + swap + 页表)
points = get_mm_rss(p->mm)
+ get_mm_counter(p->mm, MM_SWAPENTS)
+ mm_pgtables_bytes(p->mm) / PAGE_SIZE;
// 2. 归一化到 0-1000
points *= 1000;
points /= totalpages;
// 3. 有 CAP_SYS_ADMIN 特权的进程,减刑 30%(保护系统服务)
if (has_capability_noaudit(p, CAP_SYS_ADMIN))
points -= points * 3 / 100;
// 4. 加上用户设定的调整值(-1000 ~ +1000)
points += p->signal->oom_score_adj;
return points;
}几个关键点:
规则一:占内存越多,越该死。
听起来合理——杀一个占 8GB 的进程,能腾出 8GB;杀一个占 10MB 的进程,只腾出 10MB,说不定还得再杀一个。
但这带来一个著名的问题:你最重要的服务(比如数据库),往往就是占内存最多的那个。 所以 OOM 一来,先把数据库杀了。
规则二:root 进程有 30% 的减刑。
因为系统上最关键的服务(sshd、systemd、init)通常跑在 root 下。
规则三:oom_score_adj 是唯一的逃生通道。
范围 -1000 到 +1000:
# 完全免疫 OOM Kill(值 -1000)
echo -1000 > /proc/<pid>/oom_score_adj
# 优先被杀(值 +1000)
echo 1000 > /proc/<pid>/oom_score_adjK8s 里,容器的 oom_score_adj 是根据内存 limit 自动算的——越接近 limit,分数越高。所以容器 OOM 时,杀的是容器内超内存的那个进程,而不是宿主机上的其他进程。
工程建议:K8s 里,一定要给关键 Pod 设置合适的
oom_score_adj。但更重要的建议是:如果你的服务有内存上限意识(Go 的
GOMEMLIMIT、JVM 的-Xmx、Rust 的自定义 allocator),就应该把它设成略低于容器 limit。这样服务会主动做 GC 来避免被 OOM Kill,而不是等内核来杀。主动降级永远好过被动杀死。这是第 16 章”背压”的核心思想,这里先埋个伏笔。
规则四:挑一个杀,杀完就停。
OOM Killer 的设计目标是”杀最少的进程,腾出最多的内存”,所以它只杀一个,杀完检查内存够了就收手。
但这也意味着:如果你的服务占内存特别大,那你就是那个常驻倒霉蛋,每次 OOM 都是你。
OOM 的另一种形态:cgroup OOM
在容器里,OOM 更常见的是 cgroup 级别的。容器内存用量达到 limit,内核杀掉容器内的进程,但宿主机可能还有大量空闲内存。
而且 cgroup v1 的 OOM 有个恶心的地方:它会锁住整个 cgroup 的 OOM 处理,如果内存压力持续,可能导致整个容器的所有进程都被卡住等待 OOM 处理完成,看起来像”假死”。cgroup v2 在这方面有改进。
第 15 章会详细讲容器内存。
第五幕:mmap —— 把谎言用到文件上
Q14 mmap 为什么据说比 read 快?
先对比两条路。
路径一:用 read() 读文件
f, _ := os.Open("data.bin")
buf := make([]byte, 64*1024)
n, _ := f.Read(buf) // 读到用户态 buf发生的事:
- 用户态调用
read()→ 陷入内核 - 内核检查 page cache,没命中 → 发起磁盘 I/O
- DMA 把数据从磁盘搬到内核态的 page cache
- 内核把数据从 page cache 拷贝到用户态的
buf - 返回用户态
关键:数据被拷贝了一次(内核 → 用户)。
路径二:用 mmap 读文件
f, _ := os.Open("data.bin")
data, _ := syscall.Mmap(int(f.Fd()), 0, length,
syscall.PROT_READ, syscall.MAP_SHARED)
defer syscall.Munmap(data)
// 直接访问,就像访问内存
_ = data[0]发生的事:
- 用户态调用
mmap()→ 建立 VMA,一个字节都没读 - 第一次访问
data[0]→ 缺页异常 - 内核发现这是文件映射页,发起磁盘 I/O,把数据读进 page cache
- 内核把进程的 PTE 直接指向 page cache 里的那个页
- 返回用户态,重新执行那条指令
关键:数据没有被拷贝。用户进程的虚拟地址,直接映射到 page cache 的物理页。
所以 mmap 的第一个优势:省掉了一次内存拷贝。
第二个优势:随机访问友好。
用 read() 随机读一个 2GB 文件的第 1GB 处,你得 Seek + Read,每次都是系统调用(约 1-2 微秒)。用 mmap,就是 data[1<<30],硬件帮你翻译地址,缺页了自动载入。连系统调用都省了。
第三个优势:天然的共享。
多个进程 mmap 同一个文件(MAP_SHARED),它们访问的是同一批物理页(page cache 里的同一批页)。进程间通信零拷贝,还自带持久化(脏页会被内核写回文件)。
这基本上就是一个”操作系统帮你实现的共享内存 + 自动落盘”。
第四个优势:可以映射超过物理内存的文件。
映射一个 100GB 的文件在 8GB 内存的机器上?完全没问题。因为映射的是虚拟地址,实际用到的页才载入内存,不用的页会被内核自动回收——而且回收成本极低,因为是文件映射页,干净页直接丢弃就行,下次缺页再从文件读,不需要写 swap。
Q15 那 mmap 在什么情况下比 read 还慢?
mmap 有三个臭名昭著的陷阱。
陷阱一:缺页异常本身很贵,而且无法批量处理。
read() 一次读 64KB,是一次系统调用 + 一次拷贝。
mmap 读 64KB(16 个页),可能触发 16 次缺页异常。
每次缺页异常的成本:
陷入内核 + 保存/恢复上下文: ~1000-2000 周期
页表操作 + TLB 刷新: ~500 周期
返回用户态: ~1000 周期
总计约 1-3 微秒
16 次缺页 = 16-48 微秒。而 read() 读 64KB,如果 page cache 命中,只要几微秒。
更糟的是,缺页异常不能在用户态批量处理。 你没法告诉内核”我要访问这 16 个页,你一次准备好”。
(补救措施:madvise(MADV_WILLNEED) 可以提前预读,MAP_POPULATE 可以在 mmap 时一次性建好所有页表。但这就失去了”惰性”的优势,等于把 mmap 变成了一次性全量加载,跟 read() 比就没优势了。)
陷阱二:TLB 压力。
mmap 一个 2GB 文件,用 4KB 页,需要 50 万个页表项。你的 TLB 只有 1500 条。TLB miss 率会非常高,每次 miss 就是 1200 周期。
这就是为什么 大页 + mmap 是黄金组合:2MB 页,2GB 文件只需要 1000 个页表项,L2 STLB 差不多能盖住。
陷阱三:写回的不确定性(最致命)。
这是 mmap 最大的坑,也是生产事故的常见来源。
用 MAP_SHARED 映射文件并写入后,数据什么时候落盘?不确定。
由内核的后台回写线程决定,受这些参数控制:
$ cat /proc/sys/vm/dirty_writeback_centisecs
500 # 每 5 秒唤醒一次回写线程(单位是 1/100 秒)
$ cat /proc/sys/vm/dirty_expire_centisecs
3000 # 脏页超过 30 秒就必须回写
$ cat /proc/sys/vm/dirty_ratio
20 # 脏页超过"可用内存"的 20%,触发同步回写(阻塞写入者!)
$ cat /proc/sys/vm/dirty_background_ratio
10 # 脏页超过 10%,后台异步回写这意味着:你的写操作可能在几微秒内返回,但数据要等 30 秒才落盘。
如果这时候掉电,数据就没了。(注意:进程被 OOM Kill 不会丢,因为脏页在内核的 page cache 里,内核还在,还会回写。只有掉电或内核 panic 才会丢。)
更隐蔽的问题:dirty_ratio 触发的同步回写。
当脏页超过可用内存的 20% 时,内核会让正在写入的进程同步等待回写完成(这叫 balance_dirty_pages)。也就是说:
你的服务用 mmap 疯狂写文件:
前 10 秒:飞快,写操作只是标记脏页,后台异步回写
第 11 秒:脏页超过 20%
→ 所有写操作立刻变成同步 I/O,进程被阻塞
→ P99 延迟从 1 毫秒飙到 500 毫秒
→ 上游超时重试,流量翻倍,情况更糟
这是一个非常典型的”平时一切正常,突然雪崩”的场景,而且是正反馈循环(越慢越堵,越堵越慢)。
我见过一个真实的案例:一个日志服务用 mmap 写文件,压测时 QPS 稳定在 5 万,P99 只有 2ms。上线后某次流量高峰,QPS 突然掉到 2000,持续了 30 秒,然后自动恢复。查了一周,最后发现是
dirty_ratio触发的同步回写——30 秒恰好是dirty_expire_centisecs的周期。教训:任何依赖内核后台回写的方案,都要把回写参数纳入监控和调优范围。 生产建议:对于重要服务,把
dirty_background_ratio调小(比如 5)、dirty_ratio调小(比如 10),让回写更平滑,避免”攒够了突然爆”。
还有一个陷阱:msync 的语义复杂。
想让 mmap 的数据立刻落盘,你得调 msync(addr, len, MS_SYNC)。但:
msync的粒度是页,你只想同步 100 字节,它也会同步整个页(4KB 或 2MB)MS_SYNC会阻塞直到写完,MS_ASYNC只是”建议”内核去写,不保证- 如果文件是通过 NFS 挂载的,
msync的行为跟本地文件系统完全不同 - Go 的标准库里没有封装
msync(得用syscall.Msync),很多人压根不知道要调
那到底什么时候用 mmap?
| 场景 | 推荐 | 理由 |
|---|---|---|
| 大文件、随机读、热数据集中 | mmap + 大页 | 省拷贝 + 省系统调用,TLB 友好 |
| 数据库存储引擎(BoltDB、LMDB) | mmap | 随机访问模式天然契合 |
| 顺序读大文件一次 | read() + 大 buffer | mmap 的缺页开销不划算 |
| 小文件(< 1MB) | read() | 完全用不上 mmap 的优势 |
| 需要精确控制落盘时机 | write() + fsync() | mmap 的 msync() 语义复杂 |
| 进程间共享只读数据 | mmap | 天然共享,零拷贝 |
| 网络 socket 传输 | 别用 mmap,用 sendfile | 见第 10 章 |
| RocksDB 这类 LSM 引擎 | mmap 要谨慎 | 它自己有 block cache,再套一层 page cache 是”缓存叠缓存” |
一句话总结 mmap 的适用条件:热数据集集中、访问模式随机、能接受不确定的落盘时机、且数据集大到值得优化。
顺带点破一个常见的误解:“mmap 零拷贝”这个说法只对了一半。
mmap 省掉的是”内核 page cache → 用户 buffer”这一次拷贝,但磁盘 → page cache 那次 DMA 拷贝还在(这是物理上不可避免的,数据总得从磁盘上出来)。真正的”零拷贝”是第 10 章讲的
sendfile/splice,那个连 page cache 都不经过用户态。
第六幕:回收 —— 谎言的账单到期
Q16 页面不够了,内核该回收谁?LRU 够用吗?
内存压力来了,内核要挑一些页回收掉。挑谁?
最直观的想法:LRU(Least Recently Used)——淘汰最久没被访问的页。
问题一:LRU 需要知道”谁最近被访问过”,这个信息怎么来?
页表项里有个 Accessed bit(A 位),硬件在每次访问页时会自动置位。内核定期扫描,把 A 位清零,下次扫描时看谁又被置位了,就知道谁最近被访问过。
但扫描整个页表太贵了(几十万到几百万个页,每扫一次几毫秒)。所以 Linux 用了个折中方案。
问题二:真正的 LRU 太贵,Linux 用的是”伪 LRU”。
内核维护两个链表(实际是每个 NUMA 节点、每个内存域各一套,简化来看就是两个):
active_list ──→ 最近被访问过多次的页(热页,不轻易回收)
inactive_list ──→ 最近只被访问过一次的页(候选回收页)
回收时只从 inactive_list 的尾部淘汰,完全不看 active_list。
页在这两个链表之间的迁移规则:
第一次访问 → 进入 inactive_list
在 inactive 中被再次访问 → 提升到 active_list(如果 active 不太长)
active 页老化 → 降级回 inactive_list(当 active 太长时)
从 inactive 尾部 → 被回收
这叫 双链表 LRU(也叫 LRU 2Q 的简化版)。它不精确——不知道 inactive_list 里谁最老,只知道它们都没 active 里的热——但成本极低:不用精确排序,只维护两个 FIFO 队列。
这个设计的精妙之处在于它的”抗扫描性”:
一个顺序扫描 100GB 文件的程序,会疯狂访问新页。如果用严格的 LRU,它会把所有热页挤出去——一次全表扫描就把缓存污染了(这叫 cache pollution)。
用双链表 LRU,扫描进来的页先到 inactive_list,只有被二次访问才会升级到 active。顺序扫描的页基本不会被二次访问,所以它们会在 inactive_list 里转一圈就被回收,不会污染 active_list 里的真热数据。
这就是”用一次访问不足以证明热度,两次才算”的思想。你在工程里做缓存时也应该这么想: 别让一次性访问污染你的热缓存。 Redis 的 LFU 淘汰策略(4.0+)、MySQL InnoDB 的 buffer pool 分代 LRU(young/old 区,old 区要存活一段时间才升级)——全是同一个思路。
问题三:文件页 vs 匿名页,回收成本完全不同。
这是关键:
文件页(page cache、mmap 的文件映射):
- 干净的(clean,没被修改):直接丢弃,下次缺页再从文件读。成本 ≈ 0
- 脏的(dirty):先写回磁盘,再丢弃。成本 = 一次磁盘写
匿名页(堆、栈、malloc 的内存、Go 的堆):
- 没有后备存储(backing store),不能直接丢弃
- 必须写到 swap 设备,下次缺页再从 swap 读回
- 成本 = 一次磁盘写 + 未来的一次磁盘读
所以内核需要一个倾向性参数:
$ cat /proc/sys/vm/swappiness
60 # 0-100
# 越大:越倾向于回收匿名页(积极用 swap)
# 越小:越倾向于回收文件页(保留匿名页在内存)swappiness=0:尽量不 swap,拼命回收 page cache(注意:0 不等于”永不 swap”,只是极度不倾向)swappiness=60:默认值,适度平衡swappiness=100:积极 swap
数据库服务器为什么建议 swappiness=1(甚至 0)?
因为数据库的 buffer pool 是匿名页,而且数据库自己知道哪些页热(它维护着自己的 LRU/LFU)。让内核把数据库的热页 swap 出去,等于用内核的粗略猜测,覆盖数据库的精确知识——数据库精心维护的缓存被内核的伪 LRU 破坏了。
而 page cache 回收掉问题不大,大不了多读一次磁盘,反正数据库自己也有缓存。
这个道理放到工程上同样成立:如果一个组件自己就有精确的领域知识,就别让上层用通用策略去覆盖它。
比如:你的缓存层自己做 LRU,就别让 Redis 的
allkeys-lru再来一遍(应该设noeviction让它报错,由应用层决定);Go 的 GC 自己管内存,就别再用GOGC=off+ 手动管理去干预。通用策略的正确用法是:在领域策略缺失的地方兜底,而不是在领域策略存在的地方抢方向盘。
Q17 swap 是救命稻草还是毒药?
这是个在工程上吵了二十年的话题,两边都有道理。
支持 swap 的理由:
一、它给内核提供了”可回收的匿名页”,避免直接 OOM。
没有 swap,内核只能回收文件页。如果你的服务是纯内存计算(比如 Redis、大堆 Java),几乎没有文件页可回收,那内存满了就只能 OOM Kill——没有任何缓冲,直接死。
有 swap,内核可以先把冷内存换出去,撑过峰值。
二、它让”申请了但不用”的内存真正被回收掉。
很多程序(尤其 JVM、某些 C++ 库、以及 Go 运行时的预留空间)会在启动时申请一大片内存,写一遍(初始化),然后就再也不碰了。这些页是”脏的匿名页”,没有 swap 就永远占着物理内存。
有 swap,内核可以把它们换出去,腾出内存给真正需要的部分。这在 K8s 里尤其重要——一个节点跑 30 个 Pod,每个 Pod 都有”初始化一次就不管”的内存,累积起来可能是几个 GB。
三、它提供了 OOM 的缓冲带。
内存压力上来时,先 swap,swap 也扛不住了再 OOM。这给了监控系统几十秒到几分钟的反应时间,也让”自动扩容”有机会生效。
反对 swap 的理由:
一、性能悬崖(cliff,不是斜坡)。
一旦开始 swap,性能不是线性下降,是断崖式下跌。因为 swap 出去的页往往马上又要用(局部性原理),于是换出 → 换入 → 换出,形成抖动(thrashing)。
抖动时,CPU 在等 I/O,I/O 在等 CPU,整个系统几乎不响应。这时候 top 里会看到:
%Cpu(s): 2.0 us, 8.0 sy, 0.0 ni, 0.0 id, 88.0 wa ← 88% 在等 I/O
二、延迟完全不可预测。
在 swap 的系统上,一次内存访问的延迟从 100 纳秒变成 10 毫秒(机械盘)或 100 微秒(SSD)。差了 1000 倍,而且你完全不知道哪次访问会踩雷。
对于 P99 延迟敏感的服务,这是不可接受的。你没法在 SLA 里写”P99 < 50ms,除非发生 swap”。
三、与 cgroup 的交互问题。
在容器里,swap 的行为很微妙。同一个 cgroup 里,某个进程疯狂 swap,会通过 I/O 争抢影响整个 cgroup 的其他进程。而且 cgroup v1 的 swap limit 是独立于 memory limit 的,配置极其反直觉(memory.memsw.limit_in_bytes 是”内存+swap”的总和,不是 swap 单独的)。
那到底该不该开 swap?
我的建议(也是业界现在的普遍共识,注意这跟十年前”数据库服务器一律关 swap”的建议不同):
应该开 swap,但要配好:
1. 大小: 小一点,2-4GB 即可
—— 目的不是当内存用,是为了回收冷页 + 缓冲 OOM
2. 位置: 必须在 SSD(NVMe 更好),绝不能在机械盘
3. swappiness:
- 普通应用服务器:10-30
- 数据库:1
- K8s 节点:0-10
(K8s 1.22 之前默认要求完全关闭 swap,
因为 kubelet 无法感知 swap,会导致调度失真;
1.22 之后有了 NodeSwap 特性,可以有限度开启)
4. 监控: 必须监控 si/so(swap in/out,vmstat 里看)
持续 > 0 就是严重告警信号
不应该开 swap 的场景:
- 超低延迟服务(P99 < 10ms,且要求稳定)
- 已经用 GOMEMLIMIT / -Xmx 精确控制内存的服务(它们会自己 GC)
- 内存用量完全可预测、且远低于物理内存的服务
关键认知:swap 的作用不是”扩展内存”,是”给内核一个回收匿名页的选项”。
一旦你开始依赖 swap 来跑超过物理内存的工作集,你就已经输了。它是缓冲带,不是第二内存。
顺便纠正一个流传很广的误解:“关掉 swap 能避免 OOM” —— 错。关掉 swap 只是把”慢慢恶化然后 OOM”变成了”直接 OOM”。
你省掉的不是 OOM,是 OOM 之前的那段挣扎时间。而对运维来说,那段挣扎时间(几十秒到几分钟)往往就是能否自动扩容成功、能否人工介入的区别。
第七幕:工程实战
Q18 RSS 一直涨,是内存泄漏吗?
这是我被问过最多的问题之一。答案通常是:不一定是,而且大概率不是。
先看清楚几个指标:
$ cat /proc/<pid>/status
VmPeak: 12345678 kB # 虚拟内存峰值
VmSize: 12345678 kB # 虚拟内存总量(VSZ)—— 基本没用,别看它
VmLck: 0 kB
VmPin: 0 kB
VmHWM: 8123456 kB # RSS 历史峰值(High Water Mark)
VmRSS: 7654321 kB # 实际驻留物理内存 —— 看这个
RssAnon: 6543210 kB # 匿名页 RSS(堆、栈、malloc)
RssFile: 1111111 kB # 文件页 RSS(page cache、mmap 的文件)
RssShmem: 0 kB # 共享内存RSS(Resident Set Size):当前在物理内存里的页数。 VSZ(Virtual Size):虚拟地址空间总大小,包含所有”申请了但没用”的、mmap 了但没访问的、以及共享库映射的部分。
VSZ 大是正常的。 一个 Go 服务 VSZ 可能有 20GB(因为 Go 运行时会预留大片地址空间给 heap arena),但 RSS 只有 200MB。看 VSZ 判断内存问题,纯属自己吓自己。
RSS 涨了,有五种可能:
可能一:真的是内存泄漏。
特征:RSS 单调上涨,GC 后不下降,最后直到 OOM。
怎么确认?看 Go 的堆指标:
import "runtime"
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("HeapAlloc=%d HeapInuse=%d HeapIdle=%d HeapReleased=%d\n",
m.HeapAlloc, m.HeapInuse, m.HeapIdle, m.HeapReleased)HeapAlloc:堆上”活着”的对象占的内存(GC 视角的活跃数据)HeapInuse:堆上所有 span 占用的内存(含内部碎片和已分配但未使用的)HeapIdle:堆上空闲的 span(还没还给 OS)HeapReleased:已经还给 OS 的
如果 HeapInuse 持续涨但 HeapAlloc 不高,说明 Go 认为这些内存还”在用”——那可能是泄漏,也可能是碎片。
常见的 Go 泄漏模式(这几个是真·泄漏):
// 1. goroutine 泄漏:channel 没关闭,接收者永远阻塞
func leak() {
ch := make(chan int)
go func() {
for v := range ch { // 永远等不到 close
process(v)
}
}()
// ch 没人关闭,goroutine 永远不退出
// 栈内存(每个至少 2KB,会增长)+ 闭包捕获的变量 全泄漏
}
// 2. 全局 map 只增不减
var cache = make(map[string]*BigStruct)
func put(k string, v *BigStruct) {
cache[k] = v // 从不 delete,也从不过期
}
// 3. time.Ticker 没 Stop
func tick() {
t := time.NewTicker(time.Second)
// 忘了 defer t.Stop()
// 函数返回后,t 的运行时定时器还持有引用,永不释放
for range t.C { ... }
}
// 4. 切片引用了大数组的一小部分(最隐蔽的)
func slice() []byte {
big := make([]byte, 100*1024*1024) // 100MB
return big[:10] // 返回 10 字节
// 但这 10 字节的底层数组是那 100MB!
// 只要返回值还活着,100MB 就释放不了
}
// 修复方式:
func sliceFixed() []byte {
big := make([]byte, 100*1024*1024)
small := make([]byte, 10)
copy(small, big[:10])
return small // 只持有 10 字节
}
// 5. context 泄漏:WithCancel 返回的 cancel 没调用
func handler() {
ctx, cancel := context.WithCancel(context.Background())
// 忘了 defer cancel()
// 父 context 会持有子 context 的引用,直到父 context 结束
go doSomething(ctx)
}可能二:Go 的 GC 故意不归还内存(最常见)。
Go 的 GC 有个参数 GOGC(默认 100),意思是”当堆增长到上次 GC 后存活堆的 2 倍时,触发下一次 GC”。
GC 完之后,堆里的空闲内存(HeapIdle)不会立刻还给操作系统,而是留在 Go 运行时的内存池里,等下次分配直接用。这是为了避免频繁 madvise/munmap 的系统调用开销——如果每次 GC 都把内存还给 OS,下次分配又要 mmap + 缺页,成本高得多。
归还的条件:空闲的 span 持续 5 分钟(Go 的 scavenger 默认周期)没被使用,Go 才会调用 madvise(MADV_DONTNEED) 把它还给内核。
所以你经常会看到:流量高峰时 RSS 涨到 8GB,流量降下来之后,RSS 还停在 6GB。 这不是泄漏,是 Go 觉得”反正我一会儿可能还要用,先攥着”。
验证方法:主动触发归还。
import "runtime/debug"
debug.FreeOSMemory() // 强制 GC + 立刻归还所有空闲内存给 OS如果调用之后 RSS 掉下来,那就不是泄漏。(注意:FreeOSMemory 有性能代价,别在生产频繁调用,只用于诊断。)
更好的做法(Go 1.19+):
debug.SetMemoryLimit(4 << 30) // 设置软内存上限 4GB
// 或环境变量:GOMEMLIMIT=4GiBGOMEMLIMIT 会让 GC 在接近上限时更激进地工作(更频繁 GC + 更积极归还),从而把 RSS 压在上限附近。这是 Go 官方对”容器内 OOM”问题的正式回答,第 15 章会详细讲。
可能三:page cache 占用。
你的服务读了很多文件,这些文件的内容留在 page cache 里。这部分内存在 RssFile 里体现,而且在内存压力下内核会自动回收,不算泄漏。
注意:Go 服务的 RssFile 通常很小(几十 MB),除非你大量用了 mmap。如果 RssFile 有好几个 GB,去查一下是不是 mmap 了大文件没释放。
可能四:内存碎片。
Go 的堆按 size class 分配(约 70 种规格:8B、16B、24B…32KB)。如果你分配的对象大小分布很散,会导致大量内部碎片。
更麻烦的是外部碎片:一个 8KB 的 span 里,只要有一个 8 字节的对象还活着,整个 span 就不能回收。如果你的服务长时间运行,对象生命周期差异很大,碎片会逐渐累积。
表现为:HeapInuse 远大于 HeapAlloc(比如 Inuse 4GB,Alloc 1.5GB),而且 FreeOSMemory() 也降不下来。
可能五:非 Go 内存(cgo、mmap、syscall)。
如果你用了 cgo(比如调 RocksDB、OpenSSL、OpenCV),那部分内存完全不受 Go GC 管理。runtime.MemStats 里看不到它,但 RSS 里能看到。
这是最容易被忽略的一种。判断方法:
RSS - (Go 的 HeapSys + Go 的栈 + runtime 元数据) = 非 Go 内存
如果差值很大,去查 cgo 调用。
诊断流程(记住这个,能帮你省很多时间):
1. 看 RSS 还是 VSZ?
└─ 只看 RSS。VSZ 大是常态,不用管。
2. RSS 是单调上涨还是锯齿形?
├─ 锯齿形(涨→GC→跌) → 正常,GC 在干活
└─ 单调上涨不回头 → 继续查
3. 看 RssAnon vs RssFile
├─ RssFile 涨 → page cache / mmap,不是泄漏(内核会回收)
└─ RssAnon 涨 → 继续查
4. Go 服务:对比 HeapInuse 和 HeapAlloc
├─ HeapInuse ≈ HeapAlloc → 堆内紧凑,去看 goroutine 数量
└─ HeapInuse >> HeapAlloc → 碎片 或 Go 未归还(看 HeapReleased)
5. 用 pprof 定位
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
- top:按累计分配排序
- 重点看 inuse_space(当前占用)而不是 alloc_space(历史累计)
- 看 goroutine profile:http://.../debug/pprof/goroutine?debug=1
6. 如果以上都正常,检查 cgo 和非 Go 内存
Q19 Go 的内存分配器和虚拟内存是什么关系?
Go 的运行时内存管理,本质上是在虚拟内存之上又加了一层抽象。理解它和内核的关系,能解释很多”为什么”的问题。
先说 Go 怎么向内核要内存:
Go 运行时
│
│ 需要一大块内存
↓
mmap() / sysAlloc() ← 一次性申请 64MB(heap arena)
│
↓
内核:建立 VMA,返回虚拟地址(物理内存还没分配!)
│
↓
Go 运行时把这块切成 span(8KB 页的倍数)
│
↓
span 按 size class 切成小对象
│
↓
用户代码 make() / new() → 从 span 里拿一块
│
↓
真正写这块内存时 → 缺页 → 内核分配物理页
关键点一:Go 用 mmap 而不是 brk。
为什么?因为 brk 分配的是连续的堆空间,释放的时候只能从顶部收缩(LIFO),中间释放了也还不了。而 mmap 可以任意位置分配和释放,灵活得多。
代价是 mmap 的 VMA 数量会变多。内核对单进程的 VMA 数量有限制:
cat /proc/sys/vm/max_map_count
65530 # 默认,Go 通过一次申请 64MB 大块来缓解关键点二:三级分配结构(跟内核 slab 一模一样)。
mheap(全局堆,有锁)
│ 管理所有 arena,向 OS 申请/归还
↓
mcentral(每个 size class 一个,有锁)
│ 管理该 size class 的所有 span
↓
mcache(每个 P 一个,完全无锁!)
│ 每个 P 独享的小缓存,包含所有 size class 的 span
↓
tiny allocator / small object / large object
这个结构和 Linux 内核的 slab allocator 几乎完全一致——都是”全局池 + 中央缓存 + 每 CPU 本地缓存”的三层结构,都是为了把全局锁竞争降到最低。
这不是巧合。这是”多核时代的内存分配”这个问题的收敛解。你在第 5 章讲锁的时候,会看到同样的模式在 Go mutex、futex、以及内核的 per-CPU 变量里反复出现。
这个模式值得你背下来,因为它是可迁移的: 任何多核共享资源的分配,正确解法都是”分层 + 本地缓存 + 批量流转”。 数据库连接池、线程池、对象池、消息队列的批量拉取——全是这个套路。
关键点三:对象大小的分类。
tiny (< 16B,且无指针) → 合并到一个 16B 的块里,极致省内存
small (16B ~ 32KB) → 按 size class 分配(约 70 种规格)
large (> 32KB) → 直接从 mheap 分配整页(8KB 的整数倍)
size class 的设计是个有趣的妥协:规格太多会增加管理开销和碎片,规格太少会浪费空间(向上取整)。Go 选了大约 70 种,使得最坏情况下的浪费率控制在 12.5% 左右。
对比一下:glibc 的 malloc 有更多规格,浪费率更低,但管理更复杂、更慢。Go 用 12.5% 的内存浪费,换取了更快的分配速度。 这是一个明确的取舍——Go 面向的是服务端程序,CPU 比内存贵。
关键点四:Go 的”地址空间预留”导致 VSZ 虚高。
Go 的堆由多个 64MB 的 arena 组成,而且是预留地址空间(先用 PROT_NONE 映射占位,需要时才 PROT_READ|PROT_WRITE)而不是立刻提交。所以你会看到:
一个 RSS 只有 200MB 的 Go 程序,VSZ 可能是 20GB
用 pmap -x <pid> 能看到大量的 [anon] 区域,很多是 PROT_NONE 的(不可访问,纯粹占位)。
这就是为什么绝对不能用 VSZ 判断 Go 程序的内存占用——你会被吓死,然后开始一场根本不存在的”内存泄漏调查”。
Q20 这套设计到底面临哪些无解的问题?
讲了二十个问答,现在该泼冷水了。虚拟内存这套设计,有几个问题是结构性的,不是优化能解决的。
问题一:TLB shootdown —— 多核时代的分布式一致性问题。
场景:CPU 0 修改了一个页表项(比如回收了一个页、或者 munmap 了一段内存),需要让所有其他 CPU 的 TLB 也失效。
怎么做?CPU 0 给所有其他 CPU 发处理器间中断(IPI,Inter-Processor Interrupt),让它们执行 invlpg 指令刷新对应的 TLB 条目,然后等待所有 CPU 确认完成。
CPU 0: 修改 PTE
│
├─→ IPI → CPU 1 ┐
├─→ IPI → CPU 2 ├─ 各自刷新 TLB,发 ACK
├─→ IPI → CPU 3 │
└─→ IPI → CPU 4 ┘
│
等待所有 ACK ← 这里在忙等(spin)!
│
继续
代价:
- CPU 0 在等 ACK 期间完全阻塞,什么也干不了
- 每个收到 IPI 的 CPU 都被打断,要保存上下文、执行中断处理、恢复
- 在 96 核的机器上,一次 shootdown 可能耗时 几十微秒
如果你的服务频繁 munmap / madvise(MADV_DONTNEED)(Go 的 scavenger 就会),TLB shootdown 会成为瓶颈。
优化手段:
- 延迟 shootdown:攒一批再刷(Linux 的
flush_tlb_mm_range有批量优化) - PCID(Process Context ID):x86 的硬件特性,让 TLB 条目带进程标签,减少刷新需求
- 减少不必要的
munmap:Go 1.16+ 改用MADV_DONTNEED而不是MADV_FREE,就是权衡后的选择
这就是”共享状态在多核下的同步成本”。它和你做微服务时遇到的”缓存一致性”是同一个问题,只是尺度从纳秒变成了毫秒。第 7 章讲缓存一致性协议时会再展开。
问题二:页表本身的开销在大规模部署下被放大。
现代机器上,一个 RSS 50GB 的 Java 进程,页表占用约 100MB(50GB / 4KB × 8B × 多级放大 ≈ 100MB+)。500 个容器,每个页表几十 MB,加起来几个 GB 纯页表。
而且 KSM(Kernel Same-page Merging,内核合并相同内容页面的技术)会加剧这个问题——它需要持续扫描页面、计算哈希、合并、然后 shootdown。在容器密度高的机器上,ksmd 本身就是一个可观的 CPU 开销。
问题三:Spectre / Meltdown —— 谎言被反向利用。
这是虚拟内存设计最严重的安全问题,2018 年爆发,影响几乎所有 1995 年之后的现代 CPU。
Meltdown 的原理,简单说:
CPU 的乱序执行会"推测"地执行后面的指令。
即使某条指令最终会因为权限检查失败而回滚,
它也可能已经把数据加载到了缓存里,而缓存不会回滚。
攻击代码:
mov rax, [kernel_address] ; 这行最终会触发异常(用户态不能访问内核地址)
; ↓ 但因为乱序执行,下面这行已经"偷跑"了
mov rbx, [rax * 4096] ; 访问一个由内核数据决定的地址
; ↓
; 虽然异常最终抛出,rax 的值被作废
; 但 [rax * 4096] 这个地址的内容已经进了 cache!
; 通过测量后续访问的 cache 命中时间(侧信道),
; 就能反推出 rax 的值,也就是内核内存的内容
根本原因是:虚拟内存的保护依赖”权限检查失败就不执行”,但 CPU 为了性能会推测执行,而推测执行留下的痕迹(缓存状态)在权限检查失败后没有被完全清除。
修复方案(KPTI,Kernel Page Table Isolation)的代价:
- 把内核页表和用户页表彻底分开,系统调用时要切换页表 + flush TLB
- 系统调用变慢 2-5 倍
- 某些 syscall-heavy 的工作负载性能下降 5-30%
这是一个深刻的教训:性能和安全的冲突,有时候不是”权衡”,而是”你以为的性能优化,开了一个你没想到的安全后门”。
推测执行是纯粹的性能优化,二十年来被视为理所当然。直到有人发现它能被利用来读取任意内存。
工程迁移:你的系统里有没有类似的”为了性能而留下的旁路”?
- 为了性能,把敏感数据放进了可以被遍历的缓存?
- 为了调试方便,在生产暴露了一个不鉴权的接口?
- 为了快,把权限检查放在了数据读取之后?
每一个”绕过正常路径的捷径”,都是一个潜在的安全漏洞。 捷径越多,攻击面越大。而且这类漏洞的特点是:它在功能测试里永远发现不了,因为功能上是对的。
问题四:NUMA —— 内存不再”等距”了。
在多路服务器上(2 个或 4 个 CPU 插槽),内存是分布在各个 CPU 旁边的:
┌─────────┐ ┌─────────┐
│ CPU 0 │ ←── QPI/UPI ──→ │ CPU 1 │
└────┬────┘ (较慢的互连) └────┬────┘
│ │
内存 A 内存 B
(本地,~80ns) (对 CPU0 来说远程,~130ns)
虚拟内存把这种差异彻底隐藏了——从虚拟地址上,你完全看不出这个页在本地节点还是远程节点。
内核的 NUMA balancing 会尝试把页迁移到访问它的 CPU 附近,但这个迁移本身有开销(复制页 + shootdown),而且判断可能出错(比如一个页被两个节点的 CPU 交替访问,就会来回搬,叫 page ping-pong)。
实践建议:
# 数据库、大内存服务,考虑绑定 NUMA 节点
numactl --cpunodebind=0 --membind=0 ./your-service
# 或者关闭内核的自动 NUMA 平衡(它可能帮倒忙)
echo 0 > /proc/sys/kernel/numa_balancing
# K8s 里可以给 Pod 配置 topology manager
# --topology-manager-policy=single-numa-nodeGo 1.21 之前没有 NUMA 感知的内存分配器,这在多路服务器上是个真实的性能损失(实测某些工作负载差异可达 20%)。
问题五:抽象的泄漏(Leaky Abstraction)。
最后回到本章的主题:虚拟内存是个谎言,而所有谎言都会泄漏。
- 你以为内存访问是均匀的,其实跨页访问有额外成本
- 你以为
malloc成功就有内存,其实要等真正写的时候才知道 - 你以为
write()返回就安全了,其实数据在 page cache 里躺了 30 秒(第 14 章) - 你以为进程间是隔离的,其实它们共享 page cache、共享 TLB 行为(侧信道)、共享 CPU 缓存(第 7 章)
- 你以为
free之后内存就还给系统了,其实 glibc 攥着不放
这就是为什么”精通”级别的系统程序员,必须知道抽象下面是什么。
你平时可以假装不知道,但出问题时,能救你的只有那些”本该被隐藏”的细节。
Joel Spolsky 的”泄漏抽象定律”:所有非平凡的抽象,在某种程度上都会泄漏。
这句话的完整含义是:抽象不是让你不用理解底层,而是让你在 99% 的情况下不用考虑底层。剩下那 1%,会决定你能不能解决问题。
思考题
思考题 1
一个 Go 服务,RSS 从启动时的 100MB 一路涨到 6GB,之后就稳定在 6GB 不涨了。GOGC=100,机器有 16GB 内存。这算内存泄漏吗?你会怎么验证?如果它部署在 K8s 里、容器 limit 是 7GB,你的判断和建议会有什么变化?
参考答案
大概率不是泄漏,但需要用数据确认,不能靠”稳定了就没事”来判断。
先看这个现象本身:RSS 涨到 6GB 后稳定,这个”稳定”是最关键的信号。真正的泄漏不会稳定——它会一直涨到 OOM。稳定在某个值说明存在一个平衡点,也就是”程序实际需要的工作集大小 + GC 策略决定的堆大小”。
为什么是 6GB 而不是更少?
因为 GOGC=100 意味着:每次 GC 后,堆增长到存活堆的 2 倍才触发下一次 GC。如果你的活跃数据(live heap)是 2GB,那 GC 的目标就是让堆在 2GB 到 4GB 之间波动。加上非堆内存(goroutine 栈、runtime 元数据、mmap 的文件、page cache),RSS 到 6GB 完全合理。
粗略估算公式:
RSS ≈ live_heap × (1 + GOGC/100) + 非堆内存 + page cache
= 2GB × 2 + 1GB + 1GB = 6GB ← 数量级对得上
验证步骤(按顺序做):
第一步,确认堆的真实大小(最关键)。
// 在服务里引入
import _ "net/http/pprof"go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap关键看两个指标的区别:
inuse_space:当前”在用”的内存 —— 这是真实的活跃数据alloc_space:历史上累计分配过的总量(含已回收)—— 这个大是正常的,别看它
如果 inuse_space 稳定在 2GB 左右,而 RSS 是 6GB,说明有 4GB 是 GC 认为”空闲”但还没归还给 OS 的内存。
第二步,确认 Go 有没有把空闲内存还给 OS。
cat /proc/<pid>/status | grep -E "VmRSS|RssAnon|RssFile"然后在代码里看 Go 自己的视角:
runtime.ReadMemStats(&m)
// m.HeapIdle:堆里空闲但还没归还的
// m.HeapReleased:已经归还给 OS 的
// m.Sys:Go 从 OS 申请的总量如果 HeapIdle 很大(几 GB)而 HeapReleased 很小,说明 Go 还攥着内存没还。这是正常的——Go 的 scavenger 要等空闲 span 闲置 5 分钟才归还,而且归还也是渐进的。
第三步,做决定性实验。
调一个只在内网点暴露的接口,里面调 debug.FreeOSMemory(),然后等 10 秒看 RSS:
- RSS 明显下降(比如从 6GB 降到 2.5GB)→ 确认不是泄漏,只是 Go 没归还
- RSS 几乎不变 → 有问题,继续查
第四步,如果 RSS 没降,查 goroutine。
curl http://localhost:6060/debug/pprof/goroutine?debug=1看 goroutine 数量和它们的栈。1 万个 goroutine 每个 2KB 栈基线 = 20MB;10 万个 = 200MB。但如果 goroutine 里有深递归或大局部变量,栈会涨到几 KB 甚至 1GB(Go 栈上限默认 1GB)。goroutine 泄漏是最常见的 Go “内存泄漏”。
第五步,查非 Go 内存。
pmap -x <pid> | sort -k3 -n | tail -20 # 看最大的映射块如果看到很大的 [anon] 块但 Go 的 MemStats.Sys 对不上,那可能是 cgo 分配的内存。
那你应该怎么做?
如果确认不是泄漏,只是 Go 攥着内存不还,有三个方向:
方案 A:设置 GOMEMLIMIT(推荐,Go 1.19+)
GOMEMLIMIT=5GiB ./your-service这让 GC 知道”我的预算是 5GB”,接近上限时 GC 会更激进(更频繁地 GC + 更积极地 scaveng),从而把 RSS 压在 5GB 以下。
注意两点:
- 它不是硬限制,是给 GC 的目标值,瞬时还是可能超过(比如一次 1GB 的大分配)
- 建议设置成容器 limit 的 70-80%,给非 Go 内存(栈、runtime、cgo、page cache)留余量
方案 B:调低 GOGC
GOGC=50 ./your-service # 堆增长到存活堆的 1.5 倍就 GC代价是 GC 更频繁,CPU 开销上升。典型的”用 CPU 换内存”。
方案 C:什么都不做(在机器内存充足时)
如果机器内存充足、服务是独占的,6GB 的 RSS 不影响任何东西,而且 Go 攥着内存反而能减少未来的分配开销(不用频繁向 OS 要内存,减少缺页和 mmap 系统调用)。
不要为了”看起来好看”去优化一个没有造成问题的指标。
延伸:如果容器 limit 是 7GB,判断怎么变?
变了,而且变得很危险。 6GB 距离 7GB 只有 1GB 缓冲,而 Go 的堆是会抖动的——流量尖峰、一次批量查询、一个大的响应体,都可能让堆瞬间涨 1-2GB,触发 cgroup OOM,整个 Pod 被杀。
这时候的意见是:
- 必须设置
GOMEMLIMIT,值设为 5GiB 左右(limit 的 70%) - 必须做压测验证:模拟流量尖峰,看 RSS 峰值能到多少
- 监控 RSS 而不只是 Go 的堆指标:
container_memory_working_set_bytes是 Prometheus 里该看的指标(它排除了可回收的 page cache,最接近 OOM 判断依据) - 设置告警阈值:RSS > 80% limit 就告警,留出人工介入时间
核心原则:主动降级 > 被动杀死。
让 Go 自己 GC 来压制内存(慢一点,但活着),好过让内核 OOM Kill(快,但整个 Pod 没了,请求全丢)。这就是第 16 章”背压”的思想,在这里先用了。
思考题 2
你在写一个缓存服务,需要缓存 10GB 的数据(比如图片特征向量,每条 4KB,共 250 万条)。有三种方案:
- A:全部放内存(假设机器有 16GB,容器 limit 14GB)
- B:用 mmap 映射一个 10GB 的文件
- C:自己实现 LRU,热数据放内存,冷数据落盘
请从 P99 延迟、内存占用、实现复杂度、崩溃恢复 四个维度分析,并说明你的选择依赖什么条件。
参考答案
这道题没有”标准答案”,答案完全取决于访问模式。我先把关键变量摆出来,再给结论。
第一个必须搞清楚的问题:访问模式是什么?
这是决定性的,比任何技术细节都重要。三种典型场景:
- 强局部性(Zipf 分布):80% 的访问集中在 20% 的数据上。典型的推荐系统、热门商品、短视频推荐。
- 均匀随机:每个数据被访问的概率基本相同。典型的 ID 精确查询(用户填 ID 查详情)。
- 扫描型:顺序遍历全部数据。典型的离线计算、全量导出。
方案 A(全内存):
P99 延迟: ★★★★★ 约 100ns(一次 map 查找),零抖动
内存占用: ★ 恒定 10GB+,无弹性,还要算上 Go 的对象头开销
实现复杂度: ★★★★★ 最简单,一个 map[string][]byte 搞定
崩溃恢复: ★ 进程挂了,10GB 缓存全丢,冷启动要预热
注意那个”Go 的对象头开销”:4KB 的数据在 Go 里用 []byte 存,实际占用 = 4KB(数据)+ 24B(slice header)+ 分配器的对齐和 span 开销 + map 的 entry 开销。250 万条,map 本身的开销就不小(map[string][]byte 每个 entry 约 50-60 字节额外开销)。10GB 的裸数据,在 Go 的 map 里可能占 12-13GB。
如果容器 limit 是 14GB,这就非常紧张了。
改进:用 map[uint64]uint32(ID → 偏移量)+ 一个巨大的 []byte arena,避免每个 value 一个对象。这是很多高性能缓存的标准做法。
方案 B(mmap 10GB 文件):
P99 延迟: ★★ 命中 page cache 时 ~100ns,未命中时 ~100μs(NVMe)
差 1000 倍,而且你不知道哪次会 miss
内存占用: ★★★★★ 自适应!内核自动管理,热数据自然留在内存
实现复杂度: ★★★★ 简单,但需要处理 msync、文件大小增长等问题
崩溃恢复: ★★★★★ 数据在文件里,重启后立即可用(page cache 会重新预热)
mmap 最大的优势是内存占用自适应:你不需要决定”哪些该留在内存”,内核的双链表 LRU 自动帮你做。热数据自然留在物理内存,冷数据自然被回收(因为是文件页,回收成本≈0)。
最大的风险是延迟的二态性:命中 page cache 是 100ns,未命中是 100μs。如果你的 P99 要求是 10ms,那 100μs 完全可以接受;如果要求 1ms,那就要小心了。
还有一个隐藏陷阱:mmap 的 10GB 数据,如果访问模式是均匀随机,而你的物理内存只有 14GB(还要留给 Go 堆、栈、page cache 的其他部分),那实际能留给这个文件的 page cache 可能只有 8-10GB。在均匀随机访问下,命中率可能只有 80-90%,而且会有持续的主要缺页。
方案 C(自实现 LRU + 冷热分离):
P99 延迟: ★★★★ 热数据 ~100ns,冷数据需要显式读盘(~100μs),
但你可以控制:只对"确定是冷数据"才走磁盘
内存占用: ★★★★ 可控,你可以精确设定"内存里放 4GB"
实现复杂度: ★★ 最复杂:要实现 LRU、序列化、落盘、预热、一致性
崩溃恢复: ★★★ 热数据丢,冷数据在盘上;需要预热逻辑
方案 C 的本质是:你自己实现了内核 page cache 的功能。那你要问的问题是:
内核做了二十年的 page cache,你觉得自己能在几周内做得比它更好吗?
大多数情况下答案是不能。所以方案 C 只有在你拥有内核没有的领域知识时才值得做。
什么情况下你拥有内核没有的知识?
- 你知道未来的访问模式:比如推荐系统知道接下来要给用户 A 推哪些物品,可以提前加载(预取)。内核只能靠历史推测。
- 你知道数据的业务价值:比如”VIP 用户的缓存条目绝不能被淘汰”。内核的 LRU 不知道什么是 VIP。
- 你的数据有强局部性且你知道边界:比如”只有最近 7 天的数据会被访问”,那你直接按时间分桶,比 LRU 精确得多。
- 你需要跨进程/跨机器的缓存语义一致性:内核的 page cache 是单机的。
如果没有这些知识,方案 C 就是在重复造一个更差的内核。
我的推荐(分场景):
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 强局部性 + 数据可重建 | A(全内存)+ 只缓存热的那 2GB | 你只需要缓存 20% 的数据就能获得 80% 的命中率,10GB 全放内存是浪费 |
| 均匀随机 + 延迟要求宽松(P99 > 50ms) | B(mmap) | 内核自动管理,简单可靠,崩溃可恢复 |
| 均匀随机 + 延迟要求严格(P99 < 5ms) | A(全内存)+ arena 优化 | 不能有磁盘 I/O 参与关键路径 |
| 数据不可重建 + 必须持久 | B(mmap) | 落盘由内核保证,不用自己写持久化逻辑 |
| 有预取知识(知道接下来访问谁) | C(自实现) | 内核不会预取,这是你唯一能赢过它的地方 |
| 扫描型(离线计算) | 别用缓存,直接流式处理 | 缓存对扫描型访问毫无用处,还会污染 |
具体到你的场景(图片特征向量,4KB × 250 万条),我倾向于:
首选方案 B(mmap),但要做三个改进:
-
分离索引和数据:把 4KB 的特征数据放 mmap 文件,把”图片 ID → 文件内偏移量”的映射放在 Go 的内存 map 里。
内存里:map[uint64]int64 // 250 万个条目 × 约 50B = 125MB,完全可接受 磁盘上:10GB 的特征数据文件(mmap)这样 Go 的堆只占几百 MB,剩下的内存全给 page cache,命中率最高。
-
用
madvise(MADV_WILLNEED)做主动预读:如果业务上能预测接下来要访问哪些(比如推荐结果已经算出来了,只是还没取特征),提前告诉内核。 -
用大页:
madvise(MADV_HUGEPAGE),让 mmap 区域用 2MB 大页。10GB / 2MB = 5000 个页表项,L2 TLB 能覆盖相当一部分,TLB miss 大幅下降。
不推荐方案 A 的理由:12-13GB 的实际占用 vs 14GB 的 limit,缓冲太小,一次 GC 抖动就可能 OOM。
不推荐方案 C 的理由:你没有内核没有的领域知识(特征向量的访问模式你预测不了),自己实现 LRU 只会更差。
一个额外的思考:如果数据涨到 100GB 呢?
这时候方案 A 彻底出局,方案 B 依然可用(mmap 不管文件多大),方案 C 的价值上升(因为冷热差距变大了,值得精细管理)。
这说明方案的选择依赖”数据规模和内存的比例”,而不是数据绝对值。 记住这个思考方式:看比例,不看绝对值。
思考题 3
面试官问你:“为什么 fork() 之后,子进程修改一个变量,父进程看不到?” 你说”因为 COW”。他追问:“那 COW 具体是怎么做到’写的时候才复制’的?硬件怎么知道这一页是 COW 页?” 请完整回答。
参考答案
这是个很好的追问,它能区分”背过概念”和”真的懂”的人。完整回答分四层:
第一层:fork 时的准备工作。
fork() 调用 copy_mm() → dup_mm() → dup_mmap():
- 为子进程分配新的
mm_struct(内存描述符)和新的页表(PML4) - 遍历父进程的每个 VMA,在子进程里创建对应的 VMA
- 复制页表项,但指向相同的物理页
- 遍历所有 PTE,把所有可写的页改成只读,并标记为 COW 页
关键点:VMA 保持可写,PTE 改成只读。这个”不一致”就是 COW 的标记。
// 简化逻辑(mm/memory.c)
static inline bool is_cow_mapping(vm_flags_t flags)
{
return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
// 不是共享的 且 允许写 → 这是私有可写映射,需要 COW
}
// copy_one_pte() 里
if (is_cow_mapping(vm_flags)) {
ptep_set_wrprotect(src_mm, addr, src_pte); // 清写位
pte = pte_wrprotect(pte); // 子进程的 PTE 也清写位
}第二层:硬件怎么”知道”。
答案是:硬件不知道。这是个常见的误解。
硬件只做它一直做的事:查页表,检查权限位。它看到 PTE 的 W(Writable)位是 0,而当前指令是写操作,于是触发 #PF(Page Fault)异常,把出错的虚拟地址放进 CR2 寄存器,然后陷入内核。
它不知道也不关心这是 COW 页。它只知道”权限不够”。
第三层:内核怎么判断。
内核的 do_page_fault()(x86 上是 handle_mm_fault())拿到 CR2 里的地址后:
- 找到这个地址属于哪个 VMA(
find_vma()) - 如果 VMA 不存在 → 这是非法访问 → 发
SIGSEGV - 如果 VMA 存在且可写 → 但 PTE 说不可写 → 这是 COW!→ 走
do_wp_page() - 如果 VMA 存在但不可写 → 真的写错了(比如写只读的代码段)→
SIGSEGV
// 简化的判断逻辑
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
if (!vmf->pte) {
// PTE 不存在
if (vma_is_anonymous(vmf->vma))
return do_anonymous_page(vmf); // 分配零页
else
return do_fault(vmf); // 文件映射,读盘
}
if (vmf->flags & FAULT_FLAG_WRITE) { // 是写操作
if (!pte_write(vmf->orig_pte)) // 但 PTE 不可写
return do_wp_page(vmf); // ← 走 COW 路径
vmf->flags |= FAULT_FLAG_WRITE;
}
...
}这个设计很巧妙:用”两级权限描述的不一致”来表达一个新语义。
PTE 是硬件看的(快,但信息少),VMA 是内核维护的(慢,但信息全)。两者不一致时,内核就有机会插入自己的逻辑。
第四层:do_wp_page 具体做什么。
// 简化(mm/memory.c)
static vm_fault_t do_wp_page(struct vm_fault *vmf)
{
// 1. 拿这个物理页的引用计数
struct page *old_page = vmf->page;
// 2. 关键优化:如果只有一个引用,不用复制!
if (PageAnon(old_page) && !PageKsm(old_page) &&
page_count(old_page) == 1 && ...) {
// 直接把 PTE 改成可写,复用这个页
wp_page_reuse(vmf);
return VM_FAULT_DONE;
}
// 3. 有多个引用,真的要复制
struct page *new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
// 分配一个新物理页
// 4. 复制内容(4KB,约 100-200ns)
copy_user_highpage(new_page, old_page, vmf->address, vma);
// 5. 修改 PTE:指向新页,标记为可写、脏
entry = mk_pte(new_page, vma->vm_page_prot);
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
// 6. 刷 TLB
set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
update_mmu_cache(vma, vmf->address, vmf->pte);
// 7. 旧页的引用计数减一
put_page(old_page);
}完整的时序图:
父进程 子进程
│ │
├─ fork() │
│ 复制页表,所有可写页 → 只读+COW │
│ │
│ PTE: 物理页 X, RO, COW │ PTE: 物理页 X, RO, COW
│ │
│ ├─ x = 42 (写操作)
│ │
│ ↓
│ MMU: PTE 是 RO,但指令是写 → #PF
│ ↓
│ 内核 handle_pte_fault()
│ ├─ 找 VMA:可写 ✓
│ ├─ 查 PTE:不可写
│ └─ → do_wp_page()
│ ├─ 引用计数 > 1
│ ├─ 分配物理页 Y
│ ├─ 复制 X → Y
│ └─ PTE: 页 Y, RW
│ ↓
│ 重新执行 x = 42 ✓
│ │
│ 读 x:还是物理页 X,值还是旧的 │ 读 x:物理页 Y,值是 42
│ ↑ 看不到子进程的修改 │
几个追问及回答:
Q:为什么不能直接在 fork 时判断”这页是不是共享的”,不共享就保持可写?
A:因为 fork 时你不知道未来谁会写。fork 时两页都是共享的(引用计数为 2),必须都设成只读。只有之后某个进程释放了这个页(比如 munmap 或进程退出),引用计数降到 1,那时候才可以在下一次缺页时”就地转正”。
Q:COW 的代价是什么?
A:三个:
- 时间:每次写 COW 页都要一次缺页异常(1-3 微秒)+ 4KB 复制
- 空间:如果父子进程都把所有页写了,最终会用到两倍内存(这正是 overcommit 的风险)
- TLB:每次 COW 都要
invlpg,多核下还要 shootdown
Q:什么情况下 COW 会失效(退化成全量复制)?
A:
- 大页:2MB 大页的 COW 要复制 2MB,不是 4KB
fork()后长时间运行:如果父子进程都长期存活并逐渐写遍所有页,最终会复制全部内存。Redis 的BGSAVE就是用这个原理——它 fork 一个子进程写 RDB,子进程只读;但父进程继续服务,写操作会触发 COW。如果写量很大,内存可能翻倍。
Q:那 Redis 的 BGSAVE 是怎么控制 COW 代价的?
A:没法完全控制,只能:
- 用物理机而不是容器(避免 cgroup 的额外限制)
- 关闭 THP(避免 2MB 粒度的 COW)
- 监控
latest_fork_usec和 fork 后的内存增长 - 在写量低峰期做 BGSAVE
- 最根本的:控制单实例内存大小(官方建议 Redis 单实例不超过物理内存的 50%,就是给 COW 留余量)
这道题想考察什么:
不是考你背没背过 COW,而是考你:
- 能不能分清”硬件做的”和”软件做的”(硬件只会报异常,语义是内核赋予的)
- 知不知道”用状态不一致表达新语义”这个设计模式
- 懂不懂内核里的”引用计数优化”这类工程细节
- 能不能说出代价(而不只是原理)
能答到第四层并说出代价,就是”精通”级别了。
思考题 4
你的 Go 服务部署在 32 核机器上,压测时发现:QPS 上不去,CPU 使用率只有 40%,perf top 显示大量时间花在 native_flush_tlb_multi 和 clear_page_erms 上。请分析可能的原因,并给出诊断和解决方案。
参考答案
这是一道实战题,症状非常典型:CPU 没跑满但性能上不去 + 内核函数占据热点。
第一步:读懂 perf 在告诉你什么。
两个函数:
native_flush_tlb_multi:多核 TLB shootdown。有人在频繁修改页表,导致要通知所有 CPU 刷新 TLB。clear_page_erms:清零一个物理页(ERMS 是 Enhanced REP MOVSB/STOSB,x86 的快速内存填充指令)。这是新分配页时的清零操作。
把这两个放一起看,结论就比较明确了:有代码在频繁地分配新内存(触发 clear_page),然后很快又释放(触发 unmap → TLB shootdown)。
第二步:为什么会这样?列出可能的原因。
可能一:Go 的 scavenger 过于激进(最常见)。
Go 运行时有个后台 goroutine(scavenger),定期把长时间空闲的 span 用 madvise(MADV_DONTNEED) 还给 OS。这个操作会:
- 解除页映射 → 需要 TLB shootdown
- 下次再分配时,重新缺页 → 内核分配新页并清零 →
clear_page_erms
如果你的服务内存分配模式是”周期性的大起大落”(比如每 30 秒处理一批大请求,分配 2GB,处理完释放),那 scavenger 会:
批次开始 → 分配 2GB → 大量 clear_page
批次结束 → GC 回收 → 空闲
5 分钟后 → scavenger 归还 → madvise → TLB shootdown
下个批次 → 又分配 2GB → 又大量 clear_page
↑ 完全在做无用功
诊断:
# 看 Go 的 GC 和 scavenger 统计
GODEBUG=gctrace=1 ./your-service
# 观察 scvg 行:
# scvg-1: 2048 MB released
# scvg-1: inuse: 100, idle: 2000, sys: 3000, released: 1500解决:
# 方案 1:告诉 Go 别还内存(但有内存上限保护)
GODEBUG=madvdontneed=0 # 改用 MADV_FREE(Go 1.16 默认值改过,注意版本)
# 方案 2(推荐):设置 GOMEMLIMIT,Go 会自动减少无谓的归还
GOMEMLIMIT=20GiB
# 方案 3:调整 GC 目标,让堆更稳定
GOGC=200 # 降低 GC 频率,堆更稳定,scavenger 更少触发可能二:大量短生命周期的大对象。
每次分配 > 32KB 的对象,Go 会直接从 mheap 分配整页(8KB 的整数倍),而且这些页是从 OS 新要的(不是复用),需要清零。
典型场景:
// 每次请求都分配一个大 buffer
func handler(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1<<20) // 1MB
// ... 用完就丢
}QPS 1 万,每次 1MB,那就是每秒 10GB 的分配!
诊断:
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
# 注意用 -alloc_space 而不是 -inuse_space
# 看谁在疯狂分配解决:
// 用 sync.Pool 复用大 buffer
var bufPool = sync.Pool{
New: func() interface{} {
b := make([]byte, 1<<20)
return &b
},
}
func handler(w http.ResponseWriter, r *http.Request) {
buf := bufPool.Get().(*[]byte)
defer bufPool.Put(buf)
// 使用 *buf
}可能三:cgo 调用返回大块内存。
如果用了 cgo(比如 RocksDB、图像处理库),C 侧 malloc 的大块内存不受 Go 管理,分配频繁时也会触发 clear_page。
诊断:看 RssAnon 和 Go 的 MemStats.Sys 的差值。
可能四:mmap/munmap 频繁调用。
如果你的代码(或依赖的库)频繁 mmap/munmap 文件,每次 munmap 都要 TLB shootdown。
诊断:
strace -p <pid> -e trace=mmap,munmap,madvise -c
# 统计系统调用频次可能五:THP 的 khugepaged 在后台搞事。
如果开了 THP,khugepaged 内核线程会:
- 扫描内存,找可以合并成 2MB 大页的连续 4KB 页
- 分配 2MB 页(
clear_page) - 复制 512 个 4KB 页的内容
- 修改页表 → TLB shootdown
诊断:
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] ← 如果是 always,嫌疑很大
# 看 khugepaged 的 CPU 占用
top -p $(pgrep khugepaged)解决:
echo never > /sys/kernel/mm/transparent_hugepage/enabled第三步:系统化的诊断流程。
按这个顺序,从最容易验证的开始:
# 1. 确认是内核态开销(已经有了 perf top 的证据)
perf top -g # 看调用栈,确认是谁触发的
# 2. 看系统级的 TLB 和缺页统计
perf stat -e dTLB-load-misses,dTLB-loads,page-faults -p <pid> -- sleep 10
# 如果 dTLB-load-misses / dTLB-loads > 5%,说明 TLB 压力大
# 3. 看缺页次数
ps -o minflt,majflt,cmdline -p <pid>
# 或者持续观察
watch -n 1 'ps -o minflt,majflt -p <pid>'
# 如果 minflt 每秒涨几十万,说明在疯狂缺页
# 4. 看 mmap/madvise 系统调用
strace -p <pid> -e trace=mmap,munmap,madvise -c
# 5. Go 侧看 GC 和 scavenger
GODEBUG=gctrace=1,madvdontneed=1 ./your-service 2>&1 | grep -E "scvg|gc "
# 6. 看 THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled第四步:解决方案(按优先级)。
优先级 1(几乎总是有效):
✓ 用 sync.Pool 复用大对象
✓ 设置 GOMEMLIMIT
优先级 2(如果内存分配模式是周期性大起大落):
✓ 调整 GOGC(调大,让堆更稳定)
✓ 检查是否真的需要频繁分配大内存,能不能流式处理
优先级 3(如果确认是 THP 问题):
✓ 关闭 THP
优先级 4(如果确认是 TLB 问题且数据集大):
✓ 对大的、长期存在的数据结构用大页
✓ 优化数据布局,提高局部性(比如把热数据打包在一起)
优先级 5(架构层面):
✓ 减少单进程的内存工作集,考虑分片
第五步:一个真实案例的复盘。
有个服务,32 核,QPS 卡在 3 万上不去,CPU 40%。perf 显示 25% 在 native_flush_tlb_multi,15% 在 clear_page_erms。
排查过程:
perf top -g看调用栈 → 发现是 Go 的sysUsed(scavenger 路径)GODEBUG=gctrace=1→ 看到scvg-1: 4096 MB released反复出现- 分析业务 → 服务每 60 秒从消息队列拉一批大批量任务(每个任务带 2MB 的附件数据),处理完释放
- 堆在 0.5GB 和 8GB 之间周期性震荡,scavenger 每 5 分钟归还一次,下个周期又要重新分配
解决方案:
// 1. 附件数据用流式处理,不整体读入内存
// 2. 必须缓存的元数据用 arena 预分配
// 3. 设置 GOMEMLIMIT=12GiB
// 4. GOGC 调到 200结果:QPS 从 3 万涨到 5.2 万,CPU 使用率从 40% 涨到 65%(CPU 用满了才是健康状态)。
这道题的元教训:
“CPU 用不满但性能上不去”是一个信号,它说明瓶颈不在你的业务代码,而在某个你没注意到的系统层。 遇到这种情况:
- 不要猜测,用
perf top看内核在干什么 - 内核函数名直接告诉你问题在哪(
flush_tlb= 页表操作,clear_page= 新页分配,_raw_spin_lock= 锁竞争) - 顺着调用栈往上找,找到触发它的用户态代码
这也回答了一个更大的问题:为什么要学操作系统?
因为当你的程序出问题时,80% 的疑难问题都出在”你以为不存在”的那一层。不懂虚拟内存,你看到 clear_page_erms 只会一脸茫然;懂了,你知道那是”在分配新物理页”,然后立刻就能推断出”有人在疯狂分配释放内存”。
思考题 5
有人说”虚拟内存就是加了一层间接层,用空间换时间”。这个说法对吗?如果不对,正确的表述是什么?
参考答案
这个说法基本是错的,而且是典型的”记住了结论但理解反了”。
我们逐句拆:
第一句:“虚拟内存就是加了一层间接层”—— 对,但没说到点子上。
加间接层是真的。但加间接层不是目的,是手段。目的是什么?是 Q1 里说的那三个:
- 隔离(进程间不能互相访问)
- 重定位(程序不用知道自己被放在哪)
- 超售(申请和使用分离)
如果你只记住”加了间接层”,你就不知道这个间接层为什么不直接干脆去掉(毕竟它显然有成本)。答案是:去掉它,你就得放弃上面三个能力中的至少一个。
第二句:“用空间换时间”—— 方向完全说反了。
虚拟内存的每一个核心设计,都是用时间换空间:
| 设计 | 节省了什么空间 | 付出了什么时间 |
|---|---|---|
| 多级页表 | 页表从 32PB 降到几十 KB | 单次翻译从 1 次查表变成 4 次 |
| 按需调页 | 不用把整个程序/文件读进内存 | 首次访问要缺页(major fault 可达 10ms) |
| 写时复制 | fork 不用复制整个地址空间 | 写操作要触发异常 + 复制 |
| 内存超售 | 能同时承诺超过物理内存的分配 | 兑现失败时要 OOM Kill |
| 页面回收 | 能运行超过物理内存的工作集 | 缺页 + 可能的 swap I/O |
全部都是”牺牲时间,换取空间”。
那为什么感觉上虚拟内存”很快”?因为 TLB 把时间代价又赚回来了。
所以完整正确的表述应该是:
虚拟内存用”时间换空间”,然后用”缓存(TLB)把时间代价再赚回来。
第三句:这个说法漏掉了最重要的东西——语义。
虚拟内存提供的不是性能优化,是语义保证:
- 隔离性:A 进程崩了不影响 B 进程
- 一致性:所有进程看到相同的地址空间布局
- 可控性:内核能在任何时候收回、迁移、换出任何一个页
这些语义是无价的,不是”用空间或时间能换”的。你用再多内存也买不到”进程隔离”。
那正确的表述应该是什么?
我给你三个层次,从短到长:
最短版(一句话):
虚拟内存是用地址翻译的时间开销,换取隔离、重定位和超售这三项能力,再用 TLB 缓存把时间开销摊销掉。
中等版(包含设计本质):
虚拟内存是一层”地址间接层”,它把程序看到的地址空间和物理内存解耦。这层解耦带来了三个能力:进程隔离(安全性)、位置无关(可重定位)、按需分配(超售)。代价是每次内存访问都要查表,这个代价通过 TLB 缓存被摊销到接近零。它本质上不是性能设计,是安全和抽象设计,性能是靠缓存补救回来的。
完整版(包含代价和边界):
虚拟内存是一套”用时间换空间、用缓存补时间”的资源虚拟化方案:
- 目标:隔离、重定位、超售
- 手段:地址间接层(MMU + 页表)
- 代价 1:查表时间 → 用 TLB 摊销
- 代价 2:页表空间 → 用多级页表压缩
- 代价 3:缺页处理 → 用预读、局部性、大页缓解
- 代价 4:延迟不确定性 → 无法完全消除(这是根本局限)
- 代价 5:抽象泄漏 → 侧信道(Meltdown)、NUMA 不可见、TLB shootdown
它在”地址空间巨大但实际用量小、访问有局部性”的假设下工作得极好。一旦这些假设不成立(比如均匀随机访问 100GB 数据、或者需要纳秒级确定性延迟),它就会成为瓶颈。
这道题真正在考什么?
考的是你能不能区分”机制”、“目的”和”代价”:
- 机制(mechanism):加了间接层,用 MMU 翻译
- 目的(goal):隔离、重定位、超售
- 代价(cost):时间、空间、不确定性
- 缓解(mitigation):TLB、多级页表、预读
只会说”机制”的人,是初学者;能说全四层的人,是能设计系统的人。
延伸:这个思考框架怎么用?
遇到任何技术,都问这四个问题:
| 技术 | 机制 | 目的 | 代价 | 缓解 |
|---|---|---|---|---|
| 虚拟内存 | 地址翻译 | 隔离/重定位/超售 | 查表时间 | TLB |
| 线程池 | 复用线程 | 避免创建销毁开销 | 队列延迟、上下文 | 合理 sizing |
| 缓存 | 空间冗余 | 避免重复计算/IO | 一致性问题 | 过期策略 |
| 消息队列 | 异步解耦 | 削峰、解耦 | 延迟、顺序 | 分区有序 |
| 微服务 | 进程拆分 | 独立部署、扩展 | 网络开销、分布式复杂度 | 服务网格 |
能填出这张表的人,才叫”会设计系统”。 后面十九章,我们就是在反复练这个。
思考题 6(开放题)
假设你回到 1960 年代,要给一台只有 32KB 内存、没有 MMU 硬件的机器设计一个多任务系统。你会怎么做?你的方案和虚拟内存相比,缺了什么?多了什么?
参考答案
这道题是开放题,没有标准答案。它的价值在于让你体会”设计是被约束塑造的”——虚拟内存不是因为”优雅”才被发明,是因为在那个约束下它是唯一可行的解。
在 32KB 内存、无 MMU 的约束下,你能做什么?
方案一:静态分区 + 静态重定位
把 32KB 切成固定的几块,比如:
0x0000 - 0x0FFF 操作系统(4KB)
0x1000 - 0x3FFF 任务 A(12KB)
0x4000 - 0x6FFF 任务 B(12KB)
0x7000 - 0x7FFF I/O 缓冲区(4KB)
每个任务在编译时就链接到自己的基址。切换任务就是切换一组寄存器。
问题:任务大小固定、无法动态增长、无法运行超过 12KB 的任务、任务数量固定。
方案二:交换(swapping)+ 静态重定位
只放一个任务在内存里,其余全在磁盘上。切换任务时,把整个任务写回磁盘,再读入新任务。
问题:切换成本极高(32KB 写到磁盘,机械盘要几十毫秒甚至几百毫秒)。这就是早期 IBM 系统的做法,切换一次要几百毫秒,所以根本不可能做抢占式多任务。
方案三:基址/界限寄存器(base and bounds)
给每个任务一对寄存器:基址寄存器(任务的物理起始地址)+ 界限寄存器(任务的最大地址)。
每次内存访问,硬件做:
物理地址 = 虚拟地址 + 基址寄存器
if (虚拟地址 > 界限寄存器) → 触发异常
这是 MMU 的”史前版本”,只需要两个寄存器,硬件极其简单。
好处:
- 任务可以放在内存任意位置(不需要编译时确定基址)→ 重定位解决了
- 越界访问会被硬件拦住 → 隔离解决了
问题(这些正是虚拟内存要解决的):
- 整个任务必须连续存放 → 外部碎片严重。跑几个任务之后,32KB 内存里全是大小不一的洞
- 无法动态增长 → 任务要增长,只能整体搬到更大的洞里(成本极高)
- 无法部分换出 → 要么全在内存,要么全在磁盘,粒度是整个任务
- 无法共享 → 两个任务要共享代码,做不到(除非代码是位置无关的,且划到公共区)
- 无法超售 → 承诺多少就是多少
方案四:段式管理(分段)
用多对基址/界限寄存器,每个段一对:代码段、数据段、栈段各一对。
这解决了”整体搬移”的问题——栈要增长,只需要改栈段的界限,不用动其他段。
这是真实存在过的方案(Intel 8086 就是这么干的,段寄存器 CS/DS/SS/ES)。
但外部碎片问题还在:每个段仍然需要连续内存。
你的方案缺了什么?对比虚拟内存:
| 能力 | 你的方案(分段) | 虚拟内存 |
|---|---|---|
| 重定位 | ✓ | ✓ |
| 隔离 | ✓ | ✓ |
| 共享 | 部分(整个段共享) | ✓(页级粒度) |
| 动态增长 | 部分(段内增长有限) | ✓(几乎无限) |
| 外部碎片 | ✗ 严重 | ✓ 无(固定页大小) |
| 部分换出 | ✗ 只能整段换出 | ✓(页级粒度) |
| 超售 | ✗ | ✓ |
| 按需加载 | ✗ 整段加载 | ✓(页级) |
| 硬件成本 | 低(几个寄存器) | 高(MMU + TLB + 页表遍历单元) |
核心差距是”粒度”:分段的管理粒度是”段”(几 KB 到几 MB,大小不一),分页的管理粒度是”页”(4KB,固定)。
固定大小消灭了外部碎片,细粒度实现了按需加载和部分换出。
你的方案多了什么?对比虚拟内存:
-
硬件成本低:几个寄存器 vs 一个完整的 MMU(含 TLB、页表遍历单元)。在 1960 年代,MMU 的成本是真实的阻碍——Atlas 计算机的内存管理硬件占了一大块机柜。
-
确定性:没有 TLB miss,没有缺页异常,每次访问都是确定的时间。对硬实时系统,这是优点。
这就是为什么今天的很多嵌入式实时操作系统(RTOS)故意不用虚拟内存。飞行控制系统、汽车 ABS、心脏起搏器——它们宁可要”确定的 100 纳秒”,也不要”通常 100 纳秒,偶尔 10 毫秒”。
-
无页表内存开销:32KB 内存里,页表可能就占去 1/8。虚拟内存在极小内存系统上是不划算的。
-
无 TLB shootdown:多核同步成本低。
这道题想让你体会什么?
第一,设计是被约束塑造的,不是被优雅驱动的。
虚拟内存看起来”优雅”,是因为我们站在 2026 年回头看。在 1960 年,它是被”内存太小、任务要共存、硬件太贵”这三个约束逼出来的唯一可行解。
约束变了,最优解就变了。 这就是为什么:
- 嵌入式系统不用虚拟内存(内存太小,页表不划算)
- 实时系统不用虚拟内存(不确定性不可接受)
- 通用服务器必须用虚拟内存(隔离是刚需)
第二,“更好”是相对于约束而言的,不是绝对的。
分段在今天看来是”过时技术”,但在 32KB 内存的机器上,它比分页更合适——因为页表的内存开销占比太高了。
第三,你可以反过来问:今天的什么约束,会让我们重新考虑虚拟内存?
几个正在发生的变化:
- 持久性内存(PMEM / CXL):内存和存储的界限模糊了,“页”还有意义吗?
- 异构计算(GPU / TPU / DPU):GPU 有自己的地址空间和页表,统一地址空间(UVM、ATS)正在出现
- 机密计算(SGX / TDX / SEV):内存加密让 TLB 和页表的管理更复杂
- 超大规模内存(TB 级):4KB 页在 TB 级内存下,页表开销和 TLB 压力重新变成问题
每一个约束的变化,都可能让某个”过时的设计”重新变得合理。 这就是为什么要学历史——历史不是让你背年代,是让你知道”哪些决定是被什么约束逼出来的”,这样当约束变化时,你能第一个意识到”该改了”。
最后一句:
大部分”最佳实践”,都是某个特定约束条件下的最优解。 记住它的约束,比记住它的结论重要一百倍。 因为结论会过时,约束变化的那一刻,恰恰是你的机会。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 01 章 · 虚拟内存:一场精心策划的谎言 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【核心谎言】每个进程都以为自己独占连续的地址空间 │
│ │
│ 【三个目的】 │
│ 隔离 → 页表里没有的,硬件拦 │
│ 重定位 → 程序不知道自己在物理内存哪 │
│ 超售 → 承诺与兑现分离 │
│ │
│ 【三笔代价】 │
│ 空间 → 页表(用多级页表压缩:32PB → 几十KB) │
│ 时间 → 查表(用 TLB 摊销:1200周期 → 1周期) │
│ 异常 → 缺页(用预读/大页缓解,但无法消除不确定性) │
│ │
│ 【四个关键机制】 │
│ 多级页表 → 只为用到的地址建表(用空间换空间的极致) │
│ TLB → 缓存翻译结果(局部性是它的命门) │
│ COW → 推迟复制(PTE只读 + VMA可写 = 状态不一致即语义) │
│ mmap → 用户态直接映射 page cache(省一次拷贝) │
│ │
│ 【五个无解问题】 │
│ TLB shootdown → 多核同步成本(96核上几十微秒) │
│ Meltdown → 推测执行留下缓存痕迹(KPTI 代价 5-30%) │
│ NUMA → 内存不再等距,虚拟内存把它藏了 │
│ 抽象泄漏 → 你以为均匀,其实不是 │
│ 不确定性 → 缺页/回写/swap 让延迟有二态性 │
│ │
│ 【可迁移的三条经验】 │
│ 1. 分层 + 本地缓存 + 批量流转 = 多核资源分配的解法 │
│ (Go mcache / 内核 slab / 连接池 / 线程池) │
│ 2. 用状态不一致表达新语义 │
│ (PTE只读+VMA可写=COW;同理 status+flag 组合) │
│ 3. 领域知识优先于通用策略 │
│ (数据库自己管缓存,就别让内核 swap 它) │
│ │
│ 【一句话】 │
│ 用时间换空间,用缓存把时间赚回来, │
│ 换来的不是性能,是隔离、重定位和超售这三个语义。 │
│ │
│ 【下一章预告】 │
│ 虚拟内存管的是"内存在哪", │
│ 下一章管"谁在用这些内存执行代码"—— │
│ 进程和线程,以及它们各自背着的账单。 │
└──────────────────────────────────────────────────────────────┘