第 03 章 虚拟内存与 MMU:进程眼里的”整片内存”是怎么骗出来的

墨:接着第 2 章,hello 已经跑起来了,正要访问一个变量 int x = 42。这一刻,一个最魔幻的事发生了:hello 以为自己独占了一整片从 0 到 2^48 的地址空间,但物理内存其实被成千上万个进程挤着用。是谁、怎么把”每个进程都觉得自己独占”骗出来的?这一章拆穿这个魔术——虚拟内存、MMU、页表、缺页。

你:魔术我知道是”虚拟地址→物理地址”的转换。但我想知道的是:这转换一次要多久?它和那些 vm 参数到底啥关系?

墨:问到点子上了。转换不是免费的——它要走页表、要走 TLB、最坏情况要走四级页表再踩一次 DRAM。而”什么时候真正分配物理页""匿名页换不换出""留多少空闲防碎片”,全是 vm.* 参数在管。这章把转换讲透,再把参数钉上去。


3.1 为什么需要虚拟内存:一个”善意的谎言”

物理内存就那么多(比如 64GB),但进程们动辄想要”几 TB 的地址空间”、还要互相隔离。虚拟内存干三件事:

  1. 隔离:进程 A 看不到进程 B 的内存(地址空间独立),一个野指针写崩自己,不影响别人。
  2. 抽象:每个进程看到统一的、从 0 开始的平坦地址空间,不用管物理内存实际在哪、碎成什么样。
  3. 超售:所有进程申请的虚拟内存总和,可以远超物理内存(靠 swap + 按需分配兜底)。

墨:第 2 章讲了”按需分页”——mmap 只给虚拟地址。那”真正给物理页”是什么时候?

你:缺页中断的时候。CPU 第一次访问某虚拟页,页表里没映射,触发缺页,内核才分配物理页、建立映射。所以”申请虚拟内存”和”占用物理内存”是两个时刻。

墨:对,这是理解 overcommit 的关键前提。先记住:虚拟地址是”许诺”,物理页是”兑现”,兑现发生在缺页时。内核可以在”许诺”阶段很宽松(overcommit),因为知道不是所有人都会同时来”兑现”。


3.2 进程的虚拟地址空间布局

一个 x86-64 进程的地址空间(48 位可用,典型布局,自上而下):

0xFFFFFFFFFFFFFFFF  ┌─────────────┐
                    │   内核空间   │  ← 所有进程共享同一份内核映射(高 128TB)
                    ├─────────────┤ 0xFFFF800000000000
                    │             │
                    │  stack 栈   │  ← 向下增长,放局部变量/函数调用帧
                    │      ↓      │
                    │     ...     │
                    │      ↑      │
                    │  mmap 区    │  ← 动态库、mmap 文件/匿名映射
                    │             │
        (随机 gap,ASLR)        │
                    │  heap 堆    │  ← brk() 向上增长,放 malloc 的长寿对象
                    │      ↑      │
                    ├─────────────┤
                    │  .bss/.data │  ← 全局变量(未初始化/已初始化)
                    │  .text 代码 │  ← 只读可执行,放指令
0x0000000000000000  └─────────────┘
  • 代码段(.text):指令,R-X。
  • 数据段(.data/.bss):全局变量,RW-。
  • 堆(heap):malloc 大块、brk/mmap 扩,向上长。
  • mmap 区:动态库、文件映射、mmap 匿名内存(线程栈常在这),向下长。
  • 栈(stack):函数调用帧、局部变量,向下长。
  • 内核空间:每个进程地址空间顶部这 128TB 是内核映射,所有进程一样(因为内核代码/数据要随时能访问),但用户态不能进(权限位挡着,进就段错误)。

墨:栈和堆”相向而行”,会不会撞上?

你:理论会(经典的栈溢出/堆溢出撞车),但中间隔着 mmap 区和巨大的地址空洞,正常撞不上。而且 ASLR 把各区域基址随机化,让”猜地址”变难(安全)。

墨:ASLR 就是第 2 章提到的 randomize_va_space——加载时随机化栈/堆/mmap 基址。它是安全特性,但代价是”每次地址布局不同”,给调试器和某些性能分析带来麻烦(比如基于绝对地址的profile)。又是”安全↔可调试”的权衡。


3.3 MMU 与多级页表:转换是怎么发生的

CPU 执行 mov eax, [x],x 是虚拟地址。谁把它变成物理地址?MMU(内存管理单元,CPU 里的硬件),查页表。

3.3.1 分页:一切以”页”为单位

物理内存被切成固定 页帧(page frame),通常 4KB。虚拟地址空间也被切成同样大小的虚拟页。页表记录”虚拟页号 → 物理帧号”的映射。不是按字节映射(那表大到爆),是按页映射。

3.3.2 为什么多级:x86-64 的四级页表

48 位虚拟地址,若用单级页表(每页 4KB=2^12,页内偏移 12 位,剩 36 位页号),页表项 8 字节,需要 2^36 × 8 = 512GB 的表——光存页表就比内存大。所以必须多级(像书的”章→节→页”索引,只建用到的分支):

x86-64 用 4 级页表(PGD→PUD→PMD→PTE),48 位地址拆成:

[9位 PGD][9位 PUD][9位 PMD][9位 PTE][12位 页内偏移]
  • PGD(页全局目录):每进程一个,存在物理内存,其物理地址存在 CR3 寄存器。
  • 查 PGD 得 PUD 基址 → 查 PUD 得 PMD → 查 PMD 得 PTE → PTE 里是物理帧号 + 权限位(R/W、U/S、Present)。
  • 拼上 12 位偏移 → 物理地址。

墨:所以”一次地址转换”最坏要走 4 次内存读页表?那岂不是很慢?

你:是,纯软件走四级页表要 4 次 DRAM 访问,每次 ~100ns,就是 400ns,比 L1 cache 的 1ns 慢 400 倍。所以硬件用了 TLB(快表) 缓存最近用过的”虚拟页→物理帧”,命中就 1 次不用走页表。

墨:对,TLB 是 MMU 的”缓存”。这是计算机系统的老套路——慢的路径前面加一层快缓存。下面讲 TLB,它和性能调优关系极大。

3.3.3 TLB:页表的缓存,未命中的代价

TLB(Translation Lookaside Buffer)是 MMU 里的 SRAM 小表,缓存近期虚拟页→物理帧。命中:1 个周期级延迟。未命中:走 4 级页表(page walk),几百 ns,且挤占 CPU 流水线和 cache。

⚠️ 坑(TLB 颠簸):如果你的程序工作集太大、地址跨度太散(比如随机访问一个超大的哈希表,跨度几 GB),TLB 装不下,每次访问都 miss,page walk 把 CPU 时间吃光——这叫 TLB thrashing。现象:perf 看 dTLB-load-misses 极高,CPU 其实在”查页表”而不是”算业务”。解法:用**大页(HugePages,2MB/1GB)**减少页表级数(2MB 页只需 3 级甚至 2 级页表,TLB 一项覆盖更大范围),或减少随机访问跨度。这正好连到后面 transparent_hugepage(/sys 里)和 NUMA 章。

3.3.4 TLB shootdown:多核下的痛

一个进程跑在 CPU0,改了页表(比如 munmap 一段),CPU1 上的 TLB 还缓存着旧映射。内核必须发 IPI(处理器间中断) 让 CPU1 作废(invalidate) 对应 TLB 项——叫 TLB shootdown。高频 mmap/munmap(如某些 JVM/allocator 行为)会触发大量 shootdown,拖慢多核。这是”微观视角”才看得到的坑。


3.4 缺页中断:四种”兑现”的剧本

CPU 访问虚拟页,MMU 查 PTE 发现 Present 位=0(页不在内存),触发 #PF(page fault) 异常,陷入内核。内核按”为什么不在”分四种剧本:

类型触发场景成本动作
Minor(次缺页)页在内存(如 page cache)但没映射进本进程低(不碰磁盘)建映射,秒回
Major(主缺页)页不在内存,要从磁盘/ swap 读高(IO)读盘→建映射
Anonymous访问堆/栈新页(brk/mmap 匿名)中(清零+建映射,不读盘)分配零页/写时复制
COW(写时复制)写只读映射(如 fork 后写)低-中复制一份再写

墨:你看,“缺页”不全是坏事——minor 缺页几乎免费,是所有程序正常运行的必要动作。只有 major 缺页(要读盘) 才是性能杀手。

你:对!所以”减少缺页”要分清减哪种。减 minor 没意义(正常),减 major 才有价值——让常用的页留在 page cache 里,别被换出去。这又回到 swappiness 了:它决定匿名页多容易被换出,换出了下次访问就是 major 缺页(从 swap 读,慢)。

墨:闭环!你现在能看懂 swappiness 为什么影响延迟了吧——它不是”开关性能”,是”控制 major 缺页的概率”。后面第 6、12 章把它和回收机制一起钉死。

3.4.1 COW:fork 为什么快

fork() 传统要复制整个地址空间,慢。现代用 写时复制(COW):fork 时父子共享同一物理页,PTE 标只读;谁先写,触发 COW 缺页,内核才复制那一页给写的一方。所以 fork 飞快(只复制页表,不复制数据),execve 前常配合 fork 用(shell 起命令的经典路径)。

📦 案例(COW 引发的”假内存暴涨”):某程序 fork 出子进程后,子进程遍历一大块父进程的内存(只读遍历却触发……不,只读不触发 COW)。但如果子进程写了那块内存,每一页都 COW 复制一份——父 10GB,子写一遍,物理内存瞬间 20GB,双双被 OOM 风险。诊断:看 ps 的 RSS 父子都涨,/proc/PID/smaps 的 Private_Dirty 暴涨。根因:以为 fork 是共享,其实写了就复制。正确理解 COW 边界,能避免”以为省内存实则翻倍”的事故。


3.5 一次内存访问的延迟分解(把微观量化)

把”访问一个变量”的延迟拆开,你才知道瓶颈藏哪:

L1d hit        ~1 ns      ← 理想
L2 hit         ~4 ns
L3 hit         ~10 ns
TLB hit+DRAM   ~100 ns    ← 页表已缓存、物理页在 DRAM
TLB miss(页走) ~200-400ns ← 走 4 级页表读 DRAM(page walk)
major 缺页     ~ms 级      ← 要从磁盘/swap 读(比 L1 慢 100 万倍!)

墨:看到最底那行了吗?一次 major 缺页(ms 级)比 L1 命中(ns 级)慢一百万倍。这就是为什么”内存够不够”和”page cache 热不热”对延迟是天壤之别。

你:对。所以”加内存”从来不是为了”能跑”,是为了”少 major 缺页、少 swap”——让常用页待在 DRAM 而不是被踢到磁盘。内存是 DRAM 和磁盘之间的那道墙。


3.6 这级和 vm 参数的瓜葛(重点)

这一章的微观机制,直接被这些 vm.* 管着(细讲见第 12-14 章,这里先点关系):

  • vm.overcommit_memory(0/1/2):控制”许诺虚拟内存时核不核物理”。=2 严格模式,大 mmap 可能直接 ENOMEM(宁可现在拒,不然后面 OOM);=0 启发式(默认,留点余量);=1 永远答应当前不在乎。这决定 3.1 节”许诺”阶段的宽松度。
  • vm.overcommit_ratio:=2 模式下,允许超卖到”物理内存 × ratio% + swap”的量。
  • vm.swappiness(0-100):控制回收时”优先换匿名页还是清 page cache”。高→匿名页易被换→下次访问 major 缺页(从 swap 读,慢);低→死守匿名页、清 cache→文件 IO 变慢。直接决定 major 缺页来源。
  • vm.min_free_kbytes:永远留多少空闲内存。留太少→分配时急找连续页→直接进回收/压缩甚至失败;留太多→可用内存虚减。影响”分配成功率”和”碎片”。
  • vm.zone_reclaim_mode(NUMA):某 NUMA 节点内存紧时,是否先”在本节点回收”还是”去别的节点取”。影响跨节点访问延迟(第 23 章 NUMA 细讲)。
  • THP(透明大页,/sys/kernel/mm/transparent_hugepage/):自动把 4KB 页合并成 2MB 大页,减少 TLB miss(3.3.3)。但合并要”规整”内存,可能引入分配时延迟尖刺——某些低延迟服务(如 Redis、数据库)反而关掉它。

墨:一张表你先存着——“微观动作 ↔ 管它的 knob”。这就是地图一和地图二的连接点。你后面调参,脑子里要有这张对应表,才知道”我调的是哪一级的微观行为”。

微观动作影响的参数
许诺虚拟内存overcommit_memory
匿名页换出→major 缺页swappiness
分配时找连续空闲页min_free_kbytes、zone_reclaim_mode、compact
TLB miss 多THP(大页)、zone_reclaim
page cache 被清swappiness、vfs_cache_pressure
回收压力dirty_*、min_free_kbytes

3.7 演化线小结

  • 问题:进程要”独占大片内存”的假象 + 物理内存稀缺且碎片化 + 转换不能太慢。
  • 设计:虚拟地址空间(隔离+抽象+超售)、分页(4KB 单位)、多级页表(省空间)、TLB(缓存转换)、按需分页+缺页(用才分配)、COW(fork 快)、大页(减 TLB miss)。
  • 演化规律:从 32 位 2 级页表(x86)演化到 64 位 4 级(x86-64,还要 5 级应对更大地址);从固定 4KB 页演化出 hugetlbfs 显式大页 → THP 自动大页(透明但带来分配尖刺,于是又演化出 defer/madvise 模式让应用自控);从”严格提交”演化出 overcommit 超卖(因为知道不会同时兑现)。每一步都是”新约束(地址空间变大/TLB 压力/延迟敏感)逼出结构变化”,且保持对老程序的兼容(32 位程序在 64 位上照样跑)。

3.8 思考题

🔧 思考题 1(TLB 与延迟):你的服务 CPU 利用率不高但吞吐上不去,perf 显示 dTLB-load-misses 占比异常高。请推断可能的原因和两种解法,并说明为什么”加内存”可能没用。

【参考答案】原因:工作集地址跨度太大/访问模式随机,TLB 装不下常用映射,每次访存都走 4 级页表 page walk(~200-400ns),CPU 时间耗在查页表而非算业务 = TLB thrashing。两种解法:① 用大页(2MB/1GB),一项目录覆盖更大地址范围,TLB 命中率飙升(4KB 页需多级,2MB 页常只需 2 级);② 改访问模式,减少随机大跨度(如把大哈希表分片局部化)。为什么”加内存可能没用”:TLB thrashing 是地址映射的 cache 命中率问题,不是物理内存容量问题;内存够但映射表缓存不住照样 miss。加内存只缓解”major 缺页”,不缓解”TLB miss”。定位要先看 dTLB-load-misses,不是先加内存。

🔧 思考题 2(major vs minor 缺页):监控看到进程的”缺页次数”很高,是否一定说明有性能问题?怎么区分该不该紧张?

【参考答案】不一定。缺页分 minor(页在内存只是没映射,建映射秒回,正常且便宜)和 major(页不在内存要从磁盘/swap 读,ms 级,贵)。该紧张的是 major 缺页率高(尤其持续高)。区分:用 ps 的 majflt(major fault)/ minflt(minor)、或 /proc/PID/stat 的 majflt 字段、或 perf 的 page-faults 配合 minor-faults/major-faults 事件。若 minflt 高 majflt 低 → 正常(程序正常运行的必要动作,不用管);若 majflt 高 → 页被换出/cache 未热,要查 swappiness、内存压力、page cache 命中率。所以”缺页高”要先分 major/minor 再判断,这正是 0.2 节”先观测定性”的实例。

🔧 思考题 3(overcommit 代价):设 vm.overcommit_memory=2(严格),某程序 mmap 申请 100GB 虚拟内存(机器物理 64GB+swap 8GB),会怎样?和 =0 默认模式的行为差异?这对应 3.1 节的哪句话?

【参考答案】=2 严格模式:内核按 物理×overcommit_ratio% + swap 算允许的总提交量(如默认 ratio=50 → 32GB+8GB=40GB 可提交)。程序申请 100GB 远超 40GB,mmap 直接返回 ENOMEM(分配失败)——宁可现在拒,不然后面真用时会 OOM 杀进程。=0 默认(启发式):先估算”空闲+可回收 cache - 32MB 余量”是否够,通常也拒超大的,但规则宽松些;=1 总是允许(不管物理,赌你用不到)。对应 3.1 节”虚拟地址是许诺,物理页是兑现,兑现发生在缺页时,内核可在许诺阶段宽松因为知道不会同时兑现”——=2 是把”宽松”收紧成”严格核算”,把 OOM 风险前移到分配时。

🔧 思考题 4(进阶·COW 事故):一个 32GB 的 Java 服务,运维在凌晨对它 fork 了个子进程做内存快照(子进程只读遍历),结果物理内存从 32GB 飙到 60GB+,触发节点内存告警。请用 COW 机制解释为什么会”翻倍”,并指出诊断要看哪个文件、以及正确做法。

【参考答案】① 原因:fork 用 COW,父子共享物理页(PTE 只读)。但 JVM 快照工具遍历时若读取不触发 COW(只读确实不复制)——问题在于 JVM 的 GC/某些读操作或工具内部写了页面(如标记、或 JVM 自己也并发改内存),每写一页触发 COW 缺页 → 内核复制该页给写方 → 父子各一份。32GB 被写遍 → 物理 64GB。这是”以为只读共享,实则写了就复制”的 COW 陷阱。② 诊断:/proc/PID/smaps 看 Private_Dirty(私有脏页,COW 复制出来的)是否暴涨;/proc/PID/stat 的 rss 父子都涨;dmesg 看有无 OOM。③ 正确做法:别用 fork 做快照——用 C-group 限制子进程内存、或用 /proc/PID/mem 只读 dump、或用支持只读 COW 快照的文件系统/虚拟机快照、或让子进程纯只读不触碰可写映射(但 JVM 难保证)。核心是:fork+写=翻倍,大内存进程慎用 fork,改用语义明确的快照机制。


墨:第 3 章把内存的”魔术”拆穿了——虚拟地址、MMU、四级页表、TLB、四种缺页,外加一整张”微观动作↔vm 参数”对应表。下一章我们追着 printf 那行 write,看它怎么从用户态跨进内核——系统调用和上下文切换的成本,很多人低估了它。

3.4 页表、TLB 与 vm 参数的微观对应(预演)

墨:第 3 章把 MMU/页表/TLB 讲完了。这一节把”这些硬件机制”和”你后面要调的 vm 参数”先建立一张对应表,让你带着”微观动作”去读第 12 章,而不是死背参数。

微观动作触发的机制对应 /proc/sys/vm 参数
访问未映射虚拟页缺页中断(匿名/文件/mmap)—(分配走 buddy/slab)
分配物理页时内存紧水位线检查 → direct reclaimmin_free_kbytes/watermark_scale_factor
写脏页积压到阈值前台被逼同步刷盘dirty_ratio/dirty_background_ratio
匿名页太多、内存紧换出到 swapswappiness
跨 NUMA node 分配本地性 vs 回收权衡zone_reclaim_mode
申请大页但碎片多compaction 同步整理transparent_hugepage/defrag
fork 后父子写同一页COW 缺页复制—(行为由 MMU 保证)
TLB 未命中频繁page walk 吃 CPU大页(nr_hugepages/THP)减级数

你:所以第 12 章每个参数,背后都对应一个”硬件/内核的微观动作”?

墨:正是。参数不是孤立的旋钮,它是”某个微观动作发生时,内核该怎么取舍”的开关。 你带着这张表去读第 12 章,会发现每个参数都能在”程序运行的某一刻”找到它的影子——这就是”懂微观才懂参数为什么这么设计”。比如 swappiness 不是凭空来的,它的存在是因为”匿名页太多、内存紧”这个微观动作需要有人决定”换还是留”;defrag=always 也不是闲得慌,它对应”申请大页但碎片多”时”同步整理还是异步整理”的取舍。参数和意义是一一绑定的,你拆开看就懂了。

(第 03 章 完)


3.9 深度现场:mmap 与写时复制的两个真实账单

墨:第 03 章讲了虚拟内存、MMU、缺页中断、写时复制。这一节两个只有懂”地址空间怎么兑现成物理页”才会中招的真实事故——它们都源于”以为 mmap/fork 便宜,却没算缺页和 COW 的账”。

3.9.1 事故 A:mmap 一个 50GB 文件随机读,性能反而比 read 差

📦 案例:某分析程序要随机读一个 50GB 的文件。工程师想"mmap 多快啊,免去拷贝",于是 mmap 整个文件再随机访问。结果比老代码 pread 还慢,且偶尔出现几十毫秒卡顿。

命中:根因在缺页中断的代价。mmap 确实省了”内核→用户”的拷贝,但首次访问每个 4KB 页都会触发一次缺页中断(minor fault,第 03 章 3.4),内核要把文件对应页映射进来。50GB 文件随机访问 = 海量缺页中断,而且若开了 THP,缺大页时还会触发同步 compaction(第 18/23 章的冻进程),于是出现几十毫秒卡顿。而老代码 pread 走 page cache + 预读,顺序性友好,反而更稳。

引申:① 顺序读大文件,read/pread + 内核预读往往比 mmap 快且稳;mmap 适合”反复随机访问同一小块热数据”的场景。② mmap 50GB 还要求虚拟地址空间够大(32 位直接 GG,64 位没事但 VMA 数量受 vm.max_map_count 约束,第 12 章),且会长期占用 page cache 不释放,挤占别的缓存(第 22 章)。

3.9.2 事故 B:fork + 共享内存写入,写时复制把内存吃爆

📦 案例:某服务 malloc 了一大块缓冲区(比如 8GB),fork 出子进程去做序列化/压缩。父进程继续写这块内存,结果系统内存暴涨、触发 OOM。

命中:根因是 写时复制(Copy-On-Write,第 03 章 3.4 第四种剧本)。fork 时父子共享同一物理页,只有当任一方写入时才复制该页。父进程持续写那 8GB → 8GB 页被逐页 COW 复制成两份 → 实际占用从 8GB 飙到 16GB,叠加其他进程直接 OOM。工程师以为”fork 不复制内存很便宜”,忘了自己后续在写。

治本:① 若父进程 fork 后要写大块内存,别 fork,用线程 + 共享地址空间,或 fork 前先把大缓冲区设计成只读共享、只子进程写;② 或用 vfork/posix_spawn + exec 替代重 fork;③ 或预先 madvise(MADV_DONTFORK) 让该内存不在 fork 时共享。根因一句话:COW 把”看起来共享”变成”写时才复制”,你写了多少,就多占多少。

3.9.3 把两笔账收成一张表

你以为实际账单第几章的机制
mmap 大文件随机读很快海量缺页中断 + 可能 THP compaction 冻进程第03章缺页 / 第18/23章 THP
fork 不复制内存很省后续一写就 COW 复制成双倍第03章写时复制
mmap 不占内存长期占 page cache,挤占别处第22章缓存挤压

墨:这两个事故,一个源于”缺页的隐性成本”,一个源于”COW 的隐性复制”——都是”虚拟地址空间”这个善意谎言背后的真实账单。它们把第 03 章的”MMU/缺页/COW”从概念,变成了你部署前会主动算的一笔账:任何”看起来零成本”的 syscall,背后都站着 MMU 和一个等着结账的物理页。

3.9.4 一道送你的思考题

思考题(深入): 事故 A 里,如果把 THP 关掉(echo never > /sys/kernel/mm/transparent_hugepage/enabled),那”几十毫秒卡顿”会消失还是会换成另一种代价?请结合第 18/23 章说明。(参考答案:关 THP 后,不再有”凑 2MB 大页的同步 compaction 冻进程”,那几十毫秒尖峰会消失;代价是普通 4KB 页的 TLB 命中率下降、缺页次数更多但每次更轻——整体从”偶发大卡顿”变成”稳定略慢”,对延迟敏感服务通常更可接受,这也是为什么数据库/低延迟服务常关 THP。)


实战补遗一:mmap 大文件后,free 里那块”说不清的内存”

墨:你 mmap 了一个 10G 的文件做只读访问,free 一看 available 没少多少,但 top 里进程 RES 也不到 10G。你懵了:那 10G 到底算谁的内存?

你:应该是 page cache 吧,因为没真的读进去。

墨:对了一半。mmap 的文件映射页确实走 page cache(属于 SReclaimable),不计入你的 RSS——这是它和 malloc+read 最大的微观区别。但坑在:你以为”没读就不占内存”,可一旦随机访问这 10G 的很多页,page cache 就被塞进大量文件页,free 的 available 会肉眼可见地掉,而你的进程 RES 还是很小。这正好接第 3 章”MMU 与 page cache 的账”。

现象

某检索服务 mmap 了一组共 40G 的索引文件,机器 64G 内存。运行后 free 的 available 从 50G 掉到 8G,但进程 RES 才 2G。运维以为”内存泄漏”,echo 3 > drop_caches 一清 available 回来,但检索一跑又掉——典型的”文件页占 cache”。

推断(可能推错):先冤枉 RSS 和别的进程

第一推断:「进程 RSS 泄漏。」——pprof heap 只有 2G,对不上,推错。 第二推断:「别的进程吃内存。」——top 按 RES 排没有大户,也推错。都没看 SReclaimable(文件映射页)。

真相:文件映射页算 cache,不算 RSS

mmap 文件映射的页在内核里就是 page cache 的文件页,属于 SReclaimable,不计入进程 RSS(进程只持有”映射关系”,物理页直到访问才填)。随机访问越多,cache 里文件页越多,available 越少——但这是健康缓存,内存紧时内核自动回收,不影响正确性。它和”匿名页泄漏”本质不同:后者是 SUnreclaim,drop_caches 救不回(呼应第 22 章)。

调整:别因 available 掉就慌

  • 看 SReclaimable 区分”文件缓存”和”真泄漏”;
  • 若索引是热点且机器内存够,让它常驻 cache 反而快(下次访问不读盘);
  • 想控制:madvise(MADV_RANDOM) 告诉内核别预读,或访问完 posix_fadvise(POSIX_FADV_DONTNEED) 释放;
  • 撤回无脑 drop_caches——它让检索变慢(下次又得读盘)。

再观察

确认 SReclaimable 涨、SUnreclaim 稳后,撤回 drop_caches,让索引常驻,RT 反而降。

改进(权衡:拿什么换什么)

动作拿什么换什么
索引常驻 cache占内存检索不读盘、RT 降
MADV_RANDOM少预读cache 占用可控

🔧 思考题:为什么 mmap 读大文件通常比 malloc+read 更省 RSS,却更吃 page cache?

【参考答案】

read 要把文件内容拷进你 malloc 的匿名页(算 RSS,属 SUnreclaim 匿名内存);mmap 不拷贝,直接把文件页映射进地址空间,访问时内核用 page cache 里的文件页填充(算 SReclaimable)。所以 RSS 小、cache 占用大。好处:多进程可共享同一份文件页(cache 只一份),且内存紧时 cache 能回收;坏处:随机大文件访问会把 cache 撑大,看着 available 掉。理解这个区别,你就不会把”mmap 文件页”误判成泄漏——它是 cache,不是 leak。


实战补遗二:mmap 之后文件被截断/删除,访问映射到 SIGBUS 而非 SIGSEGV

墨:你 mmap 了一个 2G 的数据文件做只读解析,跑得好好的。半夜运维 > data 清空了它(或 rm 后新写入),你的进程直接崩,dmesg 里是 SIGBUS,不是你以为的 SIGSEGV。你懵不懵?

你:我读的是内存映射啊,文件没了不该是段错误吗?怎么是 BUS?

墨:mmap 映射的是”文件背后的页”,不是一份独立拷贝。文件被截断(ftruncate/> 覆盖)后,映射区域里”超出新文件长度”的页没有 backing 了——你访问它们,内核无法再从文件填页,于是发 SIGBUS(总线错误:地址合法但底层介质没了),而不是 SIGSEGV(地址本身非法)。这是 mmap 最阴的生命周期坑。

现象:mmap 只读解析,文件被运维重写后进程崩

某服务 mmap 数据文件(只读、MAP_SHARED)做高频查询。运维发布新版本时,用 > datafile 清空再写(或 mv 新文件 + 旧文件被删)。服务访问旧映射区域(已被截断/覆盖的部分)→ 收到 SIGBUS → 未捕获直接 core。

推断(可能推错):先查”是不是野指针/内存踩”

第一推断:「SIGBUS 是内存踩 / 对齐错(某些架构)。」——去查代码有没有越界。但 x86 上 SIGBUS 少见,且稳定复现于”文件被改”时刻,不是随机踩。 第二推断:「mmap 没 MAP_POPULATE,缺页时崩?」——MAP_POPULATE 只影响预填,不解决”文件没了”问题。推错。

真相:mmap 与文件的”共生”生命周期

MAP_SHARED 映射的页,其”内容来源”是文件。文件被 ftruncate 到更短,或底层 inode 被释放(删了且无人持有),映射里超出文件当前长度的部分变成”无 backing page”。访问这些页:

  • 若文件只是被覆盖(> 清空再写):覆盖瞬间长度变 0,旧映射全失效,访问即 SIGBUS;
  • 若 rm 但服务还持着 fd:inode 还在(删除只去目录项),映射仍有效,直到文件被新写入截断——所以”删了还崩”常发生在后续写入时。

SIGBUS vs SIGSEGV:前者地址在映射内(合法 VA)但底层无数据;后者地址根本不在任何映射(非法 VA)。内核用 SIGBUS 表达”你想访问的页,文件给不出了”。

调整:让 mmap 与文件生命周期解耦

  • 发布时用 mv 新文件覆盖、保留旧文件句柄直到服务重新 mmap 新文件(避免旧映射失效);即:服务持 fd 期间别截断它;
  • 或只读场景用 mmap + 应用层捕获 SIGBUS(用 sigsetjmp 跳过坏页)做容错——复杂,不推荐;
  • 更简单:解析前把文件 cp 到临时副本再 mmap 副本,发布只动原文件,副本独立;
  • 监控:对关键映射文件,发布流程加”通知服务重新加载”而非”原地截断”。

再观察

发布流程改为”写新文件 + mv 覆盖 + 发信号让服务重新 mmap”,旧映射自然随旧 fd 失效、新映射指向新文件,SIGBUS 消失。

改进(权衡:拿什么换什么)

动作拿什么换什么
发布用 mv + 重载改发布流程mmap 不被截断
mmap 副本多占一份磁盘映射独立、不崩

🔧 思考题:MAP_PRIVATE(私有映射)也会 SIGBUS 吗?为什么 MAP_SHARED 和 MAP_PRIVATE 在这里表现不同?

【参考答案】

都会,但机制略不同。MAP_PRIVATE 是写时复制的私有视图,初始页仍 backing 于文件,文件被截断后访问超出部分同样无 backing → SIGBUS,和 MAP_SHARED 一样脆。MAP_SHARED 的区别在于”写会回写文件、多进程共享同一页”,生命周期上更明显地与文件绑定;但二者都依赖文件作为页的来源,文件没了都 SIGBUS。真正能避免的是”让文件在映射存活期不被截断”——无论 private/shared。这也呼应第 3 章:mmap 的页是”借”文件的,文件生命周期比你想的更绑定你的进程,别在热路径上映射一个会被运维动的文件;真要映射,就管好文件的生命周期(发布用原子 mv + 重载),别让”底层介质消失”这种事发生在你进程的访问路径上。


实战补遗三:TLB 与”缺页风暴”——大内存进程的隐形税

墨:前面讲了 MMU 把虚拟地址翻成物理地址要走页表。中间有个关键缓存叫 TLB(Translation Lookaside Buffer),在 CPU 里,极快但极小。你知道 TLB miss 的代价吗?

你:TLB miss 就要去内存里查多级页表,比命中慢几十倍?

墨:对,而且更阴的是——TLB 是按页(4K)缓存一条映射的。一个进程用了 100G 内存,假设全是 4K 页,就有约 2600 万条页表项,但 TLB 通常只能缓存几千条(比如 L1 DTLB 64 项、L2 TLB 几百到一两千项)。也就是说,你访问 100G 内存里不同页时,绝大多数访问都会 TLB miss,每次 miss 都要走 4 级页表(4 次内存访问),开销巨大。这就是”大内存进程的隐形税”。

真实事故:一个内存分析服务,数据量从 40G 涨到 120G 后,同样的算法耗时从 2 秒变成 9 秒,CPU cycles 没多算几条指令,但 IPC(每周期指令数)暴跌。profile 看 perf stat 的 dTLB-load-misses 涨了 10 倍。

推断:不是算法慢了,是内存访问的”翻译”开销暴涨——TLB 装不下 120G 对应的页表项,疯狂 miss,每次 miss 走 4 级页表把 CPU 泡在等待里,IPC 自然崩。

调法有两种思路:

  • 思路 A:减少页数——用大页。把 4K 页换成 2M 大页,页数从 2600 万降到 5 万,TLB 轻松装下,TLB miss 骤降。这正是为什么数据库、DPDK、大内存 JVM 都建议开大页(HugePages)。
  • 思路 B:减少跨页访问的”跳跃”——数据局部性优化,让热数据集中在少数页里,TLB 缓存命中率自然高。

调整:给这个服务预留 2M 大页(echo 61440 > /proc/sys/vm/nr_hugepages,约 120G),并改 mmap 用 MAP_HUGETLB。再观察:dTLB-load-misses 回落到原来量级,耗时回到 2.5 秒(大页本身有轻微管理开销,没完全回到 2 秒,但比 9 秒好太多)。

你:那透明大页(THP)为什么又被很多人劝退?前面讲 NUMA 时提过它后台合并会卡。

墨:对,THP 是”自动帮你把 4K 合并成 2M”,省得你手动预留。但它的问题在合并动作的代价:khugepaged 内核线程后台扫描、把连续的 4K 页合并成大页,合并时要做 page migration(搬内存),这个动作会短暂冻结相关内存、持锁,在大内存高吞吐服务上表现为偶发几十毫秒卡顿(就是第 18 章讲的”神秘卡顿”来源之一)。所以:

  • 数据库/JVM 这类”内存布局稳定、要极致稳定延迟”的 → 手动大页(HugePages),关 THP;
  • 通用业务、内存碎片不敏感、能接受偶发卡顿的 → 留 THP 图省事。

这条把第 3 章(MMU/TLB)和第 18 章(卡顿)、第 23 章(大页/NUMA)串起来了:TLB 容量有限 → 大页减少页数缓解 → 但 THP 的自动合并有卡顿副作用 → 稳定负载用手动大页。

🔧 思考题:你的进程用了 120G,但其中只有 20G 是”热数据”、100G 是偶尔访问的冷数据。全上 2M 大页划算吗?为什么?

【参考答案】

不划算,甚至更亏。原因:大页是不可拆分的整块(2M 一块),你 100G 冷数据即使只访问其中零散几页,也得整块 2M 占着 TLB 槽位、占着物理内存连续性。结果:宝贵的 TLB 槽位被冷数据的大页占满,热数据的 20G 反而在 TLB 里挤不进去——TLB 命中率可能比 4K 页还差(因为大页数虽少,但你浪费了槽位在冷数据上)。

正确姿势:只对热数据用大页。数据库的做法就是显式 mmap 热缓冲池(如 innodb_buffer_pool)到 HugePages,冷数据走普通 4K 页。或者用 madvise(MADV_HUGEPAGE) 对特定 mmap 区域建议大页、冷区域 MADV_NOHUGEPAGE——比全局 THP 精细。口诀:大页是”稀缺资源”,用在访问最密的少数内存上才回本,别无脑全量上。



实战补遗四:TLB shootdown 与多核扩展——大页为什么能救延迟毛刺

墨:老哥,你听过”大页能降延迟毛刺”吧?但你能说清楚为什么吗?不是”因为页表项少了”这种车轱辘话,是具体到 CPU 哪条流水线被救了。

你:页表项少了,TLB 命中率高,少做几次页表 walk?

墨:方向对,但还差最关键的半步——TLB shootdown。我给你拆一次多核服务的真实毛刺。

现象:一台 32 核机器跑内存密集型 Go 服务,P99 延迟周期性蹦到 50ms,但 CPU 利用率只有 40%,看起来”不忙却卡”。perf 抓到 tlb_flush 相关事件暴涨。

推断:是 page fault 太多导致 TLB miss。

可能推错:page fault 多没错,但根因不在”miss 本身慢”,而在跨核广播。现代 x86 上 TLB 是每核私有的,当你在一个核上改了页表(比如 munmap、mprotect、或 THP 分裂/合并),内核要给所有其他核发 IPI 让它们作废对应 TLB 项——这叫 TLB shootdown。被广播的核会短暂停下手头活去处理,造成别人也卡。

调整:用 perf stat -e tlb_flush.dtlb_thread,tlb_flush.stlb_any 看 shootdown 次数,再用 watch -n1 'cat /proc/interrupts | grep TLB' 看 TLB shootdown 中断(TLB 那列)是不是在涨。

再观察:根因是服务频繁分配/释放大块内存,4K 页下每次动几十万个页表项,shootdown 风暴持续。换成 THP(透明大页) 后,页表项数量降到 1/512,shootdown 量跟着降一个量级,P99 毛刺从 50ms 掉到 8ms。

改进:开 always 或 madvise 的 THP。但注意—THP 后台 khugepaged 合并时会有自己的停顿(见第 18 章),所以生产更常见是”对特定大内存段用 madvise(MADV_HUGEPAGE),而不是全局 always”。

🔧 思考题:既然大页这么好,为什么 Redis 官方和很多数据库反而建议关掉 THP?它救了 TLB,又在哪里捅了娄子?

【参考答案】

THP 救的是”页表项多 → TLB miss 多 + shootdown 风暴”,但它在”写时复制(COW)“场景会捅娄子:数据库/Redis 做 fork(BGSAVE、AOF rewrite)时,本来靠 4K 页的 COW 做到”只复制被改的页”,开销很小。开了 THP 后,内核为了合并大页,会把本来独立的 4K 页提前合并成 2M 大页,fork 时 COW 的粒度就从 4K 变成 2M——你改一个大页里任意一个 4K,整 2M 都要复制。结果 fork 后内存拷贝量暴涨,延迟和内存都炸。

所以通用建议是:内存密集型、少 fork 的服务(如你那个网关)开 THP 受益;频繁 fork 的数据库关 THP(设 never 或 madvise 且不对数据段用)。Go 服务一般不开 fork 模型,THP 多是正收益,但要用 madvise 精准控制,别全局 always 把 khugepaged 的合并停顿带进来。

补充:/proc/<pid>/numa_maps 和 smaps 里能看到 AnonHugePages 字段,确认大页到底用上没有。


实战补遗五:mmap SIGBUS 与 COW 翻倍——文件映射的两种”意想不到”

墨:老哥,你用 mmap 映射文件后,访问某段内存可能直接让进程 崩溃(SIGBUS) 吗?

你:……内存访问崩进程?不该吧,最多段错误。

墨:普通越界是 SIGSEGV,但 mmap 映射的文件被截断后,访问超出新末尾的页会收到 SIGBUS(总线错误)——这不是段错误,是”这块内存背后没文件了”。这节讲 mmap 的两个坑:SIGBUS 和 COW 翻倍。

现象:一个服务 mmap 了一个 1G 的数据文件做只读索引,运行几天后进程莫名收到 SIGBUS 崩溃。排查发现是运维用 > data.file 或 truncate 把这个文件截短了,而进程还 mmapped 着旧大小,访问被截掉的那段就 SIGBUS。

推断:以为是代码越界访问(SIGSEGV)。

可能推错:SIGBUS 和 SIGSEGV 不同。SIGSEGV 是”访问了没映射的虚拟地址”(权限/范围错);SIGBUS 在 mmap 场景是”访问的虚拟地址映射存在,但它背后的文件页已经不存在了”——典型就是文件被 truncate 到更小、或 mmap 了文件末尾之外的偏移(mmap 长度超过文件实际大小,访问超出文件末尾的页)。内核没法把这段虚拟地址对应到任何文件页,给进程发 SIGBUS。

调整:两种触发:

  1. 文件被截断:另一个进程 truncate/> 重定向把文件变小,你的 mmap 还指着旧范围。修法:别在运行时截断被 mmap 的文件;或用 ftruncate 前先 munmap;或 mmap 时多映射一页、访问前先 stat 确认文件大小。
  2. mmap 长度超过文件大小:mmap(NULL, len, ..., fd, 0) 里 len 比文件大,末尾那页”无文件支撑”,访问即 SIGBUS。修法:mmap 长度取 min(len, filesize),末尾不足一页的部分单独处理。

再观察:COW 翻倍是另一个 mmap 坑:当 mmap 用 MAP_PRIVATE(私有,写时复制),你对映射页做写操作,内核会复制这一页生成你的私有副本——如果疯狂写映射的大文件,等于把”只读文件页”逐个拷成”匿名私有页”,物理内存占用翻倍(原 page cache 页 + 你的 COW 副本)。本想省内存用 mmap 共享,结果写了反而比 read 进 buffer 更费内存。要写该用 MAP_SHARED(改的是文件本身,不复制)或干脆别 mmap 可写。

改进:

  • mmap 只读索引:用 MAP_PRIVATE 且绝不写映射页(写就 COW 翻倍);文件别在运行时截断。
  • 要改文件内容:用 MAP_SHARED + msync 落盘,别 MAP_PRIVATE 写。
  • 防 SIGBUS:mmap 长度对齐文件大小;访问前 stat 校验;或 madvise(MADV_RANDOM) + 捕获 SIGBUS 优雅退出。
  • Go 里 syscall.Mmap 返回的 []byte,写了 MAP_PRIVATE 映射 = 踩 COW 翻倍坑;Go 的 mmap 库默认行为要看清。

🔧 思考题:为什么 MAP_PRIVATE 下”写一页”会复制整页(COW),而不是只复制改的那几个字节?这跟”用 mmap 替代 read 省内存”的初衷是怎么冲突的?

【参考答案】

COW(写时复制)以页(通常 4K)为最小单位,不是字节。原因在 MMU:页表里一项管一整页的映射,硬件只能”整页改权限/换物理页”,没法标记”这一页里第 100 字节是私有的、其余还是共享的”。所以你改 MAP_PRIVATE 映射里的哪怕 1 个字节,内核必须复制整 4K 页作为你的私有副本,把页表项指向新副本,原页留给其他共享者。这是硬件页式管理的固有限制(也是为什么大页下 COW 代价更大,见第 3 章 TLB 实战补遗)。

冲突在哪:你用 mmap 的初衷是”多个进程/同一进程多次访问共享同一份文件页,省内存、且改了还能反映到文件”。但 MAP_PRIVATE 语义是”我的修改只对我自己可见、不影响文件也不影响别人”——这语义强制 COW。于是:

  • 只读访问:所有进程共享同一 page cache 页,确实省内存(mmap 优势成立)。
  • 一旦你写了某页:该页 COW 成你的私有匿名副本,原 page cache 页还在 + 你的副本也在 = 这份数据在物理内存里出现两份。你写满整个 1G 映射文件,物理内存占用从 1G 变 2G(原缓存 + 全私有副本),比”read 进自己 buffer”还费——因为 read 进 buffer 也只有 1 份你的副本,没有”还留着原缓存”那份。

所以:MAP_PRIVATE 只读才省内存;要写请用 MAP_SHARED(写直接落在文件页、不复制,多进程改同一页也一致,但要 msync 控制落盘时机)。Go 里很多”用 mmap 做高效读取”的库默认 MAP_PRIVATE,你只要保证只读就安全;一旦手痒往映射里写(比如原地改索引),立刻 COW 翻倍且改动不落盘(重启丢失),双重坑。SIGBUS 则提醒你:mmap 是”文件内容的窗口”,窗口背后的文件变了(截断/扩大),窗口就崩。

延伸:文件被 truncate 但 mmap 还在,这是”文件映射 vs 文件生命周期”的经典竞态。数据库/索引服务要热更新数据,正确姿势是”新文件新 mmap + 旧 mmap 延迟 munmap(引用计数归零再释放)“,而不是原地 truncate 被映射的文件。