同一个地址,两个进程读出两个值 ------ 虚拟内存干的不是扩容,是给地址改名

写一个最普通的 C 程序,打印一个全局变量的地址:
c
int x = 0;
printf("%p\n", &x);
同时跑两份。两个进程都在自己那一份里给 x 赋了不同的值,但打印出来的地址一模一样。
而在各自的进程里,x 的值确实不同。
同一个数字,指向两个不同的东西。这不是 bug,这是 1962 年那批人做下的一个决定,我们至今每一纳秒都在为它付账。
一、它 1962 年不叫这个名字
先说一件几乎没人提的事:虚拟内存最初不叫虚拟内存。
它的名字是 one-level store(一级存储)。1962 年 12 月在曼彻斯特大学启用的 Atlas 计算机上,Tom Kilburn 的团队第一次把它做成能跑的硬件(更早的雏形是 1956 年 Güntsch 的博士论文里描述的一台机器)。而 "virtual memory" 这个说法,要到 1965 年才在 Corbató 的文章里出现。
为什么改名前叫"一级存储"?因为那才是它真正想解决的问题。
在 Atlas 之前,机器的存储分两层:主存是磁芯,容量小、速度快;辅存是磁鼓,容量大、慢得多 ------ 在 Atlas 上,磁鼓和处理器访问时间的比值约 2700:1。程序员的活儿之一,是手工把代码和数据切成块,写一套"overlay 序列",指挥它们在主存和磁鼓之间来回搬。
这件事极其耗时。Kilburn 他们估计,自动化之后程序员生产力能提升最多 3 倍。
所以他们做的是一个"把两层存储伪装成一层"的机制 ------ 这才是 one-level store 这个名字的由来。
〔分析〕注意这里的关键:他们的目标不是"让程序用上比物理内存更大的内存",而是"让程序员不用知道物理内存在哪、还剩多少"。容量幻觉是手段,不是目的。
Denning 在 1970 年那篇经典综述里,把这件事的内核说得很准:
At the heart of their idea is the notion that "address" is a concept distinct from "physical location".
(他们想法的核心是:"地址"和"物理位置"是两个不同的概念。)
这一句话就是全部。剩下的一切,都是这句话的推论。
二、页表不是"映射表",是四级索引加一排权限位
要把"地址"和"位置"拆开,需要一张翻译表。这张表就是页表。
先看它长什么样。x86-64 上,一个虚拟地址(程序看到的地址)默认 48 位有效,被切成五段:
text
47 39 38 30 29 21 20 12 11 0
┌──────────┬──────────┬──────────┬──────────┬───────────┐
│ L4 索引 │ L3 索引 │ L2 索引 │ L1 索引 │ 页内偏移 │
│ 9 bit │ 9 bit │ 9 bit │ 9 bit │ 12 bit │
└──────────┴──────────┴──────────┴──────────┴───────────┘
↓ ↓ ↓ ↓
查第1级 查第2级 查第3级 查第4级 → 物理页号
每一级都是一张表,表里 512 项(2⁹),每项 8 字节 ------ 512 × 8 = 4096 字节,恰好一页。
〔分析〕这个"恰好一页"不是巧合,是设计要求:页表自己也得能按页分配、按页换出,否则管理它的机制会比它管理的对象更复杂。
走完四级,拿到物理页号,再拼上 12 位页内偏移,才是真正的物理地址。一次内存访问,背后是四次额外的访存。
但这张表真正的主角不是"映射",是每一个表项(PTE,page table entry)里那几个标志位。
先说明一点:下面会反复提到"缺页异常"。它指的是 CPU 查表时发现目标页不在物理内存里,于是抛出一个中断交给内核处理 ------ 它是正常流程的一部分,不是错误。具体三种结局见后文。
| 位 | 名字 | 作用 |
|---|---|---|
| 0 | Present | 这一页现在在物理内存里吗?为 0 就触发缺页异常 |
| 1 | R/W | 只读还是可写 |
| 2 | U/S | 0 = 只有内核能碰,1 = 用户态也能碰 |
| 5 | Accessed | 硬件在访问时置位,内核用它判断"这页最近用过吗" |
| 6 | Dirty | 硬件在写入时置位,内核用它判断"这页要不要写回磁盘" |
| 63 | NX | 这一页允许当指令执行吗 |
(位 3、4 是缓存策略相关的标志,与权限无关,这里略过。)
看第 2 位。这才是隔离的真身。
〔分析〕一个进程读不到别的进程的内存,不是因为它"被禁止访问",而是因为它自己那张表里根本没有对方的物理页。这是缺省拒绝(default deny)------ 没写进去的东西,硬件压根不知道往哪翻译。
所以"隔离"这件事的成本,不是每次访问都做一次权限判决,而是在建立地址空间的那一刻,就已经用一张表把世界切好了。
顺带一个细节:CPU 里有个寄存器 CR3,存着当前进程那张顶级表的物理地址。所谓"进程切换",很大程度上就是换一个 CR3。
上面说"具体三种结局见后文",就是这里。
为什么值得单说?因为"异常"这两个字太容易被读成"出事了"。实际上 CPU 查表发现 Present 位是 0,只是停下来把控制权交给内核 ------ 内核看完只有三种结局:
- 这一页从来没分配过 ------ 给一页新的、清零、继续跑。这就是
malloc之后第一次写内存时发生的事。 - 这一页被换到磁盘了 ------ 读回来、改表、继续跑。
- 这次访问本来就不该发生 ------ 发 SIGSEGV,杀掉进程。
第三种才是我们熟悉的"段错误"。
而 segment 这个词,来自分段(segmentation)------ 一个比页表更早、也更"符合程序员直觉"的内存模型:每个程序由若干段组成(代码段、数据段、栈段),段有名字、有长度、有权限,越界就是错误。
Unix 最后没选它。原因和这个系列里反复出现的那个理由一样:分段要求程序员知道段的存在(历史上有过纯分段、段页式等多种设计,Multics 就是段页式),而分页可以完全藏在硬件里,程序员一行代码都不用改。
〔分析〕代价是那个错误名字留了下来:今天的段错误和"段"没有半点关系,它只是一个页表项 Present=0 且权限也不允许。六十年前的架构选择,凝在了每个程序员都见过的那行报错里。
三、一次改名,白送五件事
把"地址"和"位置"拆开之后,下面这五件事就都不需要额外机制了 ------ 它们只是同一个动作的不同用法:
| 能力 | 怎么做到的 |
|---|---|
| 隔离 | 每个进程一张表,表里不写别人的物理页 |
| 稀疏 | 没写进表里的地址区间,不占任何物理内存 |
| 换页 | 把 PTE 的 Present 位清 0,物理页搬去磁盘,访问时触发缺页再搬回来 |
| 共享 | 两张不同的表,指向同一个物理页 |
| 持久化 | 让 PTE 直接指向磁盘上的文件(这就是 mmap,内存映射) |
第二行值得单独说,因为它解释了一个所有人都见过但很少想过的现象:
c
char *p = malloc(1UL << 30); // 申请 1 GB
// 返回非 NULL,立刻成功
这 1 GB 里一页物理内存都还没给 。malloc 只是把地址空间里的一段标成"已保留",真正的物理页要等你第一次写那一页时,才由缺页异常现给。
〔分析〕所以"内存不足"在现代系统里通常不是"分配失败",而是"你写到某一页时,系统发现给不出来了"。承诺和兑现,被拆成了两个时刻。
这两个数字你在 ps 和 top 里天天见,但经常被混着说:
- VSZ(virtual set size):地址空间有多大,包含所有"已保留但还没碰过"的区间。
- RSS(resident set size):真正占住了多少物理页。
上面那个 malloc 了 1 GB、只写了前 1 MB 的进程,VSZ 约 1 GB,RSS 只有几 MB。两者的差,就是"承诺了但还没兑现"的部分。
表格里的后两条,也值得单独说。
mmap 之所以能把一个文件"变成"内存,是因为页表本来就只认"页",不关心这一页背后是内存条还是磁盘文件。同一个机制,换个背后对象,就从内存变成了文件映射。
而这一步的后果比听起来大得多。读文件有两种方式:
c
read(fd, buf, n); // 方式一:把数据搬到你这里
p = mmap(NULL, n, ...); // 方式二:把你的地址接到数据那里
方式一要两次拷贝(磁盘 → 页缓存 → 你的缓冲区,页缓存是内核替所有进程缓存文件内容的那块内存),方式二零次 ------ 缺页异常直接把文件的那一页映射进你的地址空间,你读 p 就是读那个页。
〔分析〕这个差别正是全文主题的又一次显形:read 处理的是"位置",mmap 处理的是"名字"。 名字不需要搬运,只需要指向。所以零拷贝不是一项优化技巧,它是"把地址当名字"这个决定的直接推论。
四、代价链:每个解决方案都创造了下一个问题
现在看账单。这条链非常典型,值得完整走一遍。
第一环:翻译要查表。 四级页表,一次访问多四次访存。
第二环:所以加了 TLB。 TLB(translation lookaside buffer)是 CPU 里一个很小的缓存,专门存"最近翻译过的地址"。命中约 1 纳秒,未命中要走完整四级表的 50--500 个周期。
但 TLB 有个硬限制:它只有 64 到 2048 项 。按 4 KB 一页算,一个 1024 项的 TLB 最多只能覆盖 4 MB 地址空间。
一旦工作集超过 4 MB,TLB 就开始疯狂未命中。这是所有大内存程序性能断崖的第一嫌疑人。
还有一个隐形成本,只在多核上出现:TLB 是每核一份的。
一个进程在核 A 上改了页表(比如 munmap 了一段),核 B 的 TLB 里可能还留着旧翻译。内核必须发一个处理器间中断,让其他核把对应项作废 ------ 这叫 TLB shootdown(TLB 击落)。
核越多、映射越多,这件事越贵。这也是为什么 munmap 在大内存多核程序里经常比预期慢得多:它不只是在改一张表,它在给所有核发通知。
第三环:所以上了大页。 把页从 4 KB 改成 2 MB,同样一个 TLB 项能覆盖 2 MB,覆盖率一下从 4 MB 涨到 2 GB。数据库(PostgreSQL 的 huge_pages)、JVM、DPDK(一个绕开内核、直接收发包的高性能网络库)全在吃这个红利。
第四环:大页把 COW 打爆了。 这个坑我们在讲 fork 时见过:写时复制的粒度是"页",页大了,写一个字节就要复制一整页。4 KB 页复制 4 KB,2 MB 页复制 2 MB ------ COW 的代价被放大了 512 倍。Redis 官方文档因此明确要求关掉透明大页。
〔分析〕注意这条链的形状:为了修 A 引入 B,B 有副作用 C,为了修 C 引入 D,D 又把 A 的代价放大了。它们不是逐步逼近最优,是各自解决各自的局部问题,然后把账转嫁给下一层。
再看两个量级,感受一下缺页的代价(这些是数量级,随硬件差异很大,别当精确值用):
| 事件 | 耗时量级 |
|---|---|
| 正常访存(TLB 命中) | ~1 纳秒 |
| TLB 未命中(走四级表) | 50--500 个周期 |
| minor page fault(不需读盘,只补一页) | 1--10 微秒 |
| major page fault(要从磁盘读回来) | SSD 约 1 毫秒,机械盘约 10 毫秒 |
一次 major fault 比一次正常访存慢大约十万倍。 这就是为什么"内存不够、开始往磁盘换页(swap)"会让系统直接失去响应 ------ 它不是变慢,它是把 CPU 按在了磁盘的节奏上。
量级差这么大,于是"答应给内存"就成了一个策略问题。Linux 有三档(vm.overcommit_memory):
| 值 | 行为 |
|---|---|
| 0 | 启发式:大致估算,允许合理超卖(默认) |
| 1 | 总是答应,从不拒绝 |
| 2 | 严格:承诺总量不超过 swap + 物理内存 × 比例 |
默认是 0,所以 malloc 基本不会失败 ------ 失败要等到真正写那一页时才发生。而到那个时候,内核唯一能做的就是挑一个进程杀掉,这就是 OOM killer。
它按 oom_score 挑,大致是"占内存越多、越不重要"的越容易被选中。于是生产上常见的画面是:数据库被干掉,而一个刚起来的 shell 活着。
〔分析〕这是"承诺与兑现分离"最直接的后果:分配内存的接口是乐观的,回收内存的接口是暴力的。 中间没有"抱歉,能不能少用一点"这个选项。
还有一笔常被忽略的账:页表本身也要占内存。 如果不用四级结构,一个 48 位地址空间配 4 KB 页,扁平表需要 2³⁶ 个表项 ------ 每个进程半个 TB 的元数据。分级表的意义就在于让"没用到的地址区间"不必有表。
五、那又怎样
一、虚拟内存真正的遗产是"命名",不是"容量"。
任何系统里,一旦你把"名字"和"位置"拆开,隔离、共享、延迟分配、稀疏化这些能力就会自动长出来 ------ 而且不需要为每一项单独设计机制。
〔分析〕这个模式在别处反复出现:DNS 把域名和 IP 拆开,于是负载均衡和迁移免费了;容器镜像的 digest 把内容和标签拆开,于是同一层能被任意镜像复用;Kubernetes 的 Service 把服务名和 Pod IP 拆开,于是滚动更新不需要改调用方。它们都不是"新功能",是一次改名之后的必然结果。
二、mmap 可能是最被低估的一个系统调用。
它把"文件"和"内存"这两件事合并了:读文件不用 read,直接访问地址就行,缺页异常替你完成 I/O。所有现代数据库的缓冲池(buffer pool,把热数据页常驻在内存里的那一层)、所有语言的运行时堆、所有动态库加载,底下都是它。
三、隔离不是免费的,2018 年我们才知道它有多贵。
页表里那个 U/S 位,权限检查是在指令提交(retire)那一刻才做的。而 CPU 会投机执行 ------ 先猜着往下跑,权限不对再撤销。Meltdown 攻击利用的正是这个时间差:猜错了要回滚,但猜的过程已经把数据带进了缓存。
修复方案 KPTI(内核页表隔离)的做法很粗暴:用户态时,把内核的映射从页表里整体拿掉。代价是每次系统调用和中断都要换两次 CR3。这是 2018 年以后所有服务器的固定开销。
〔分析〕这件事的启示是:虚拟内存提供的隔离,是"逻辑上不可达",不是"物理上不可能泄露"。 前者靠软件约定,后者要靠硬件真的不碰。两者之间的缝隙,就是这类漏洞的产地。
四、容器并没有在页表这一层隔离内存。
这是个常见误解。namespace 改的是"你能看到什么"(PID、网络、挂载点),cgroup 限制的是"你能用多少",但容器里的进程和宿主机上的其他进程,用的是同一套页表机制、同一批物理页。
所以容器逃逸一旦拿到内核权限,内存是通的。
〔分析〕真要页表级隔离,得用虚拟机(KVM)或用户态内核(gVisor)------ 代价是每次系统调用多穿一层。这也是"容器够不够安全"这场争论的技术底:它换来的隔离是命名空间的 ,不是地址翻译的。
写在最后
回到开头那两行 printf。
两个进程打印同一个地址,是因为它们各自活在一套自己的"名字"里。这个数字对它们没有任何共同含义 ------ 它不是位置,只是一个编号。
而每次访问这个编号,CPU 都要走四趟表,把编号换成真实位置。走得多了会慢,于是有了 TLB;TLB 装不下,于是有了大页;大页太粗,于是 COW 被放大。
六十多年,这套东西一直在加层,但最底下那句话没变过 ------
虚拟内存从来没给过你更多内存。它只做了一件事:把"地址"从"位置"改成了"名字"。隔离、共享、换页、稀疏,全是这次改名的副产品。
参考资料
- IEEE Milestone, The Atlas Computer and the Invention of Virtual Memory, 1957--1962 ------ Atlas 的一级存储、paging 硬件、overlay 的历史背景,"生产力提升最多 3 倍"的估计,以及 "virtual memory" 一词最早可追溯到 Corbató 1965
- T. Kilburn, D. B. G. Edwards, M. J. Lanigan, F. H. Sumner, One-Level Storage System, IRE Transactions on Electronic Computers, EC-11(2), 1962 ------ 一级存储原始论文
- P. J. Denning, Virtual Memory, ACM Computing Surveys, 2(3), 1970 ------ "address 与 physical location 是两个概念"的经典表述