mmap 与写时复制
上一章的思路是「推迟到真的需要」。这一章把同一个思路用到复制上:先假装复制了,等谁真的动手写,再偷偷补上。这一招让 fork 一个 1 GB 的进程只要几百微秒,也让你的 100 个进程共享同一份 libc。
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,那得花几百毫秒。实际上它只要几百微秒。
- fork 时:只复制页表(几万个条目),物理页一个都不复制。然后把父子两边的页表项全部标成只读,并给每个物理页的引用计数加 1。
- 读的时候:两边读到的是同一份物理内存。完全没问题,读不需要写权限。
- 某一方写的时候:页表项是只读的 → 触发
#PF写保护异常 → 内核发现「这是个 COW 页(refcount > 1)」→ 分配一个新页、复制 4 KB、把这一方的页表项指向新页并改成可写 → 重试那条指令。
关键在第 3 步:只复制被写的那一页,不是整个地址空间。而且大部分页永远不会被写。
下面这台机器的引用计数是真的在加减、页表项是真的在改。操作顺序:先点 fork(),注意物理页总数没有变化,但两边的 RSS 之和翻倍了。然后点「父进程写第 2 页」,看那一页怎么分家。
RSS 之和为什么会超过物理内存
上面那台引擎里有个细节值得单独说,因为它是监控里最常见的误读之一。
fork 之后,父子进程的 RSS 各自都是 12 页,加起来 24 页。但物理内存里只有 12 页。
因为共享的页被每个进程各算了一遍。RSS 的定义是「这个进程的地址空间里,有多少页在物理内存中」——它不管这一页是不是别人也在用。
把 ps 里所有进程的 RSS 加起来,你会得到一个远超物理内存的数字。这不是 bug,是 RSS 的定义使然。
共享库是最大的重复来源:libc 被几乎每个进程映射,它的几 MB 在每个进程的 RSS 里都算了一遍。
想知道真实占用,用 PSS(比例份额)——共享页按使用者数量均摊。第 10 章那张表列了四个指标的区别;Android 的 dumpsys meminfo 用的就是 PSS。
smem 这个工具能直接按 PSS 排序,比 ps 靠谱得多。
COW 的代价:那个著名的 Redis 问题
COW 听起来全是好处,但它有一个隐蔽的失效模式,而 Redis 的持久化是最经典的案例。
Redis 的 bgsave 是这么做的:
fork()出一个子进程。- 子进程慢慢地把内存里的数据序列化写到磁盘上——它看到的是 fork 那一刻的一致快照。
- 父进程继续正常服务,不受影响。
这个设计非常聪明——用 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 的文件不值当 | |
| 需要精确的错误处理 | ★ 是。见下面那个坑 | |
| 文件可能被别人截断 | 是。见下面那个坑 |
用 read() 时,磁盘出错你会拿到一个返回值 -1 和 errno,可以从容处理。
用 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 底下到底是什么?我们会造一台真的分配器,看它怎么把内核给的大块内存切碎——以及为什么切着切着就没法用了。