卷 III · 幻境CH 13深度 13/24

mmap 与写时复制

上一章的思路是「推迟到真的需要」。这一章把同一个思路用到复制上:先假装复制了,等谁真的动手写,再偷偷补上。这一招让 fork 一个 1 GB 的进程只要几百微秒,也让你的 100 个进程共享同一份 libc。

mmapCOWrefcount共享映射

mmap:把文件变成内存

先讲 mmap,因为写时复制是它的一个特例。

读文件的常规方式是 read():内核把数据从磁盘搬到 page cache,再拷贝到你的缓冲区。

mmap 换了个思路:别拷了,直接把那块 page cache 映射进我的地址空间。

int fd = open("data.bin", O_RDONLY);
char *p = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);

printf("%c\n", p[1000000]);   // 像访问数组一样访问文件
                              // ↑ 这一下可能触发一次缺页,去读盘

之后你就像操作数组一样操作这个文件。没有 read,没有拷贝——第一次碰某一页时触发缺页,内核把那一页读进来、映射好、重试你的指令(第 4 章那个 fault 语义又出现了)。

mmap 有两个正交的选项,四种组合各有各的用途:

MAP_PRIVATE(私有)MAP_SHARED(共享)
有 fd(文件映射)读到的是文件内容;你的写不会回到文件,而是触发 COW 变成你的私有副本。
用途:加载可执行文件和共享库
读写都直接作用在 page cache 上,会落回文件,其他映射同一文件的进程也看得见。
用途:进程间共享数据、数据库文件
无 fd(匿名映射)一块全新的清零内存,只属于你。
用途:大块 malloc 底下就是它
一块清零内存,可以和 fork 出的子进程共享。
用途:父子进程间的共享内存

注意左上角那格:你的程序本身就是这么被加载的/bin/ls 的代码段以 MAP_PRIVATE 映射进来——所以 100 个进程跑同一个程序,代码在物理内存里只有一份。libc 也一样。

写时复制:一个推迟的承诺

现在看 fork。第 6 章说过它「复制整个进程」,但如果真的逐字节复制 1 GB,那得花几百毫秒。实际上它只要几百微秒。

◆ COW 的三步
  1. fork 时:只复制页表(几万个条目),物理页一个都不复制。然后把父子两边的页表项全部标成只读,并给每个物理页的引用计数加 1。
  2. 读的时候:两边读到的是同一份物理内存。完全没问题,读不需要写权限。
  3. 某一方写的时候:页表项是只读的 → 触发 #PF 写保护异常 → 内核发现「这是个 COW 页(refcount > 1)」→ 分配一个新页、复制 4 KB、把这一方的页表项指向新页并改成可写 → 重试那条指令。

关键在第 3 步:只复制被写的那一页,不是整个地址空间。而且大部分页永远不会被写。

▶ 动手 · 写时复制演算

下面这台机器的引用计数是真的在加减、页表项是真的在改。操作顺序:先点 fork(),注意物理页总数没有变化,但两边的 RSS 之和翻倍了。然后点「父进程写第 2 页」,看那一页怎么分家。

RSS 之和为什么会超过物理内存

上面那台引擎里有个细节值得单独说,因为它是监控里最常见的误读之一。

fork 之后,父子进程的 RSS 各自都是 12 页,加起来 24 页。但物理内存里只有 12 页

因为共享的页被每个进程各算了一遍。RSS 的定义是「这个进程的地址空间里,有多少页在物理内存中」——它不管这一页是不是别人也在用。

⚠ 别把 RSS 加起来

ps 里所有进程的 RSS 加起来,你会得到一个远超物理内存的数字。这不是 bug,是 RSS 的定义使然。

共享库是最大的重复来源:libc 被几乎每个进程映射,它的几 MB 在每个进程的 RSS 里都算了一遍。

想知道真实占用,用 PSS(比例份额)——共享页按使用者数量均摊。第 10 章那张表列了四个指标的区别;Android 的 dumpsys meminfo 用的就是 PSS。

smem 这个工具能直接按 PSS 排序,比 ps 靠谱得多。

COW 的代价:那个著名的 Redis 问题

COW 听起来全是好处,但它有一个隐蔽的失效模式,而 Redis 的持久化是最经典的案例。

Redis 的 bgsave 是这么做的:

  1. fork() 出一个子进程。
  2. 子进程慢慢地把内存里的数据序列化写到磁盘上——它看到的是 fork 那一刻的一致快照
  3. 父进程继续正常服务,不受影响。

这个设计非常聪明——用 COW 白嫖了一个「一致性快照」,不需要停机、不需要加锁。

但是:如果在子进程写盘的这段时间里,父进程一直在修改数据……

每修改一页,就分家一页。写得越多,共享的越少。极端情况下,内存占用真的会涨到接近两倍。

◆ 这里的教训是通用的

COW 的收益建立在一个假设上:fork 之后大部分页不会被写。

这个假设在「fork + 立刻 exec」的场景下完美成立(第 6 章的 shell),在「fork 出来只读数据」的场景下也基本成立。

但在「fork 之后父进程持续高频写入」的场景下完全不成立,此时 COW 不但没省钱,还额外付了每页一次缺页 + 一次 4 KB 复制的代价。

所以 Redis 的运维建议是:给它留出接近两倍内存的余量,或者把 vm.overcommit_memory 设成 1(不然 fork 可能直接失败,第 15 章会讲)。这不是 Redis 的 bug,是 COW 这个机制的固有边界。

¤ 价目表 · 本章一行

复制一个 4 KB 页(COW 真正分家的那一刻):约 2.5 μs——包含一次写保护缺页(约 1.5 μs)加上 4 KB 的内存拷贝。

对比一下:fork 一个 1 GB 的进程只要 几百 μs,而如果之后每一页都被写,总代价就是 262144 页 × 2.5 μs ≈ 0.65 s

COW 不是把成本消掉了,是把它拆散、推迟,并寄希望于大部分永远不会发生。当它发生时,你付的比一次性复制还多一点。

mmap 该用不该用

mmap 常被当成「更快的读文件方式」,但它是一个有明确适用边界的工具:

mmap 更好read/write 更好
随机访问大文件★ 是。少一次拷贝,而且只读用到的页
顺序读一遍就扔是。read 的预读更聪明,也不用建那么多页表项
多进程共享同一份数据★ 是。物理内存只有一份
小文件是。建映射本身有固定开销,几 KB 的文件不值当
需要精确的错误处理★ 是。见下面那个坑
文件可能被别人截断是。见下面那个坑
⚠ mmap 最大的坑:IO 错误变成了信号

read() 时,磁盘出错你会拿到一个返回值 -1errno,可以从容处理。

mmap 时,读盘发生在缺页处理里。如果那时磁盘出错,内核没有办法「返回一个错误」——因为你执行的只是一条 mov 指令,它没有返回值的概念。

于是内核只能给你发一个 SIGBUS。默认动作是直接杀掉进程

同样的事情发生在文件被截断之后:你映射了 100 MB,别人把文件 truncate 到 10 MB,你再访问后面的部分——SIGBUS,进程死。

要处理这种情况,你得注册 SIGBUS 处理器,而在信号处理器里能做的事极其有限(第 21 章)。这就是为什么关键路径上很多人宁可用 read。

↑ 回到应用层

「Android 的 APK 是怎么被加载的?」

你的 App 启动时,那些 .dex / .odex / .vdex / .art 文件不是被 read 进内存的,是被 mmap 进来的。

这带来几个直接的后果:

  • 启动快:不用等整个文件读完,用到哪页读哪页(按需分页)。
  • 多进程共享:framework 的 boot.art 被所有 App 进程映射,物理内存里只有一份。这和第 6 章 Zygote 的 fork 是配套的两招。
  • 内存压力下可以被丢弃:这些是文件映射的只读页,磁盘上有副本,所以内存紧张时内核可以直接丢掉它们、不需要写 swap。要用时再读回来。

最后一条解释了一个现象:后台待久了的 App 切回前台时会卡一下——不是它被杀了重启(那样会走完整的 Application.onCreate),而是它的代码页被回收了,正在一页页地重新缺页读回来。这在 ps 里表现为 maj_flt 突增。

这一章的一句话

mmap 把文件直接映射进地址空间,省掉一次拷贝并按需读取;写时复制则把「复制」推迟到真的有人动手写的那一刻,且只复制那一页——这让 fork 的代价几乎与进程大小无关。但两者都建立在同一个假设上:大部分页不会被写。假设不成立时(比如 Redis 边 bgsave 边高频写),成本会全额回来。

下一章:malloc 底下到底是什么?我们会造一台真的分配器,看它怎么把内核给的大块内存切碎——以及为什么切着切着就没法用了。