匿名页和 COW:fork 为什么便宜,写入为什么变贵
上一篇把缺页异常拆成了 fault 分派的几条路径。do_anonymous_page 处理首次访问匿名页,do_wp_page 处理写保护异常。这两条路径是 Linux VM 里两种延迟行为的核心:匿名页延迟到首次访问才分配物理页,COW 延迟到首次写入才复制。
核心问题可以压成一句话:
匿名页是没有文件 backing 的内存承诺;COW 把页面复制成本推迟到第一次写入。
问题从哪里来
malloc(1GB) 在多数 Linux 系统上不会失败,也几乎不消耗物理内存。fork() 一个驻留几 GB 的进程,能在毫秒级返回,不会发生几 GB 的复制。这两件事在朴素的“内存就是数组、复制就是按字节抄”的模型里都讲不通。
把它们拆开后:
malloc 返回的是用户态地址,背后通常是 brk 或匿名 mmap。这一步只是建立一段匿名 VMA,没有分配任何物理页面。第一次访问这段区间的某一页时,缺页异常走到 do_anonymous_page,从空闲页池里取一页、清零、填进 PTE。1GB 全分配但只访问 4KB 的程序,物理内存增长就只有 4KB。
fork 复制的是 mm_struct、VMA 集合和页表关系,但物理页面没有被立即复制。父子进程共享原来的物理页,对应 PTE 被改成只读。任意一方写入时,写保护异常进入 do_wp_page,复制出一份私有页面并把写入方的 PTE 改回可写。没有写入的页面继续共享,永远不复制。
这两个机制都不能从用户态指针看出来。它们藏在 VMA 标志、PTE 权限位和 fault 路径里。
匿名 VMA 与匿名页不是同一层
容易混淆的概念分一下:
| 概念 | 是什么 | 何时存在 |
|---|---|---|
| 匿名 VMA | 一段没有 vm_file 的虚拟地址区间 |
mmap(MAP_ANONYMOUS) 或 brk 后立刻存在 |
| 匿名页 | 物理页,挂在 anon_vma 反向映射上 |
第一次访问该 VMA 中虚拟页时才创建 |
| PTE | 页表项 | 物理页填上、PTE 置 present 时才有效 |
匿名 VMA 是承诺。它只占地址空间,不占物理内存。/proc/<pid>/maps 里一段匿名映射可以很大,但 smaps 里它的 Rss 可能很小。
匿名页是兑现。首次访问触发 do_anonymous_page,从空闲页池里拿一页、清零,再通过 anon_vma 系统把这页和原 VMA 关联起来。这条关联会在后续回收、swap、COW 路径里被反复用到。后面专门有一篇讲反向映射。
把这两层混读,会出现“malloc(1GB) 后 RSS 没涨”这类“反常识”的现象。其实并不反常,承诺和兑现本来就分开。
fork 复制的是什么
fork(2) 在 Linux 上不复制全部物理页面。复制的清单按对象分大致是:
| 父进程对象 | 复制策略 |
|---|---|
task_struct 主体 |
复制 |
| 信号、凭证、命名空间 | 复制或按引用计数共享 |
| 文件描述符表 | 默认复制 |
mm_struct |
复制(独立地址空间) |
| VMA 集合 | 复制(拷贝链条) |
| 页表 | 复制(建立新页表,但 PTE 内容大量共用 PFN) |
| 物理页面 | 不复制,按 COW 处理 |
页表是个介于“复制”和“共享”之间的特例。子进程必须拥有自己的页表根,否则后续 mprotect、mmap、fault 都会跨进程互相干扰。但页表里大量 PTE 的 PFN 字段,指向的是父进程已经持有的物理页。父子进程的两份页表里,可能有大量 PTE 指向同一个 PFN,写权限被同时去掉。
do_wp_page 利用这一点:当任何一方写入只读 PTE 时,检查该物理页是否独占(典型实现会看引用计数或新版本里的 exclusive 标志)。独占就直接放开写权限不复制;非独占就分配新页、复制数据、把写入方的 PTE 指过去。
“fork 便宜”指的是上述的一次性成本:复制 mm_struct 与页表、调整 PTE 权限。这个成本与地址空间大小相关,但不与匿名页驻留量相关,因此对一个 RSS 很大的进程也通常在毫秒量级。
“写入变贵”指的是 COW 阶段的累积成本:每一个被写入的共享只读页都要走一次写保护 fault,分配新页,复制 4KB(或 huge page 情况下 2MB),改 PTE。一个 fork 后大量写入的子进程会观察到 RSS 快速增长、minor fault 暴涨。
用 smaps 区分四类页面
/proc/<pid>/smaps 在每个映射下面附带详细统计。man-pages 当前版本对几个字段的描述很克制:
| 字段 | 原文 |
|---|---|
Rss |
the amount of the mapping that is currently resident in RAM |
Pss |
the process’s proportional share of this mapping |
Shared_Clean / Shared_Dirty |
the number of clean and dirty shared pages in the mapping |
Private_Clean / Private_Dirty |
the number of clean and dirty private pages in the mapping |
Anonymous |
shows the amount of memory that does not belong to any file |
Referenced |
indicates the amount of memory currently marked as referenced or accessed |
把这几个字段拼起来,可以读出 COW 状态:
| 页面状态 | 通常落在 | 含义 |
|---|---|---|
| fork 后未写入的页面 | Shared_Clean |
父子仍共享,未脏 |
| fork 后被本进程写过的页面 | Private_Dirty |
已经走过 COW,独占脏页 |
| 文件映射只读区段 | Shared_Clean / Private_Clean |
视映射类型 |
| 匿名映射 | Anonymous 计入;脏与共享按上面规则 |
Pss 把共享页面按引用次数分摊到每个进程。10 个进程共享 1MB 文件页面时,每个进程的 Pss 计入 100KB,每个进程的 Rss 都是 1MB。这个差别在容器和多进程服务里很重要:Rss 加起来会重复计算共享部分,Pss 加起来更接近真实物理占用。
模式提炼:共享只读 + 写意图 → 私有副本
1 | |
COW 是“延迟复制”的一种实现,触发条件是写意图。它的对偶是“立即复制”——MAP_PRIVATE 的文件映射也有 COW 行为,写入触发时同样会从 page cache 复制出私有页面。snapshot 文件系统、容器镜像分层、并发数据结构里的 path copy persistence,都是同一类。
| 系统 | 共享态 | 触发 | 复制粒度 |
|---|---|---|---|
| Linux fork | 父子共享 PTE 指向的物理页 | 写入触发 page fault | 4KB 或 huge page |
| ZFS/Btrfs 快照 | 多个快照共享 extent | 写入触发 CoW extent | extent |
| 容器镜像 | 多个容器共享只读层 | 写入触发 copy-up 到 RW 层 | 文件或块 |
| 持久化数据结构 | 多个版本共享节点 | 写入触发路径复制 | 节点 |
抓住这个共同模式,COW 就不再只是“fork 的优化”,而是一种通用的资源管理风格。
一个最小实验:fork 后写一半页面
下面这个程序映射 1024 页匿名内存,父进程全写一遍让所有页面 present,再 fork。父子进程都先暂停,提示读者去 smaps 查看;然后子进程写前 512 页,父进程不动;最后再暂停一次,便于对比。
1 | |
编译运行:
1 | |
在另一个终端按提示查看两个 PID 的 smaps:
1 | |
smaps 里有很多映射段,挑那段大小为 length 的匿名映射看。
读取实验结果
预期看到的结构是:
第一阶段,父进程已经把 1024 页全写一遍,匿名映射段的 Rss 应该接近 4MB,Private_Dirty 也接近 4MB。Pss 与 Rss 接近相等,因为这段映射当前只属于一个进程。
第二阶段,fork 之后但任何一方还没写。父子两个 PID 的同一段映射,Rss 都接近 4MB,但页面变成共享只读:Shared_Clean 占比上升,Private_Dirty 大幅下降。Pss 大约是 Rss 的一半,因为这段物理内存被两个进程对半分摊。两份页表都存在,但物理页只有一份。
第三阶段,子进程写完前 512 页之后:
- 子进程的这段映射里,前 512 页变成
Private_Dirty(COW 后独占脏页),后 512 页仍是Shared_Clean。 - 父进程同段映射里,前 512 页变成
Private_Clean或Private_Dirty,因为它们不再被共享、子进程已经写出了自己的副本;后 512 页继续是Shared_Clean或转回Private(视具体内核版本和 exclusive 优化)。具体落到哪个字段,要按实际smaps输出读,不要硬背。 - 子进程的
minflt会增加大约 512,这是写保护异常触发的 COW fault。父进程的minflt几乎不变。
第四阶段,子进程退出后,父进程独占整段映射,Pss 重新接近 Rss。
把这几条接起来:fork 本身不会让物理内存翻倍,因为页面共享;之后的写入会按 4KB 粒度逐步把共享页面转成私有脏页,每一页对应一次 minor fault 和一次实际复制。
模式提炼:从共享转私有的边界永远是 PTE
1 | |
COW 不是用户态可见的“复制函数”,而是一组 PTE 权限变换。fork 把所有可写共享 PTE 改成只读,写入触发写保护异常,异常路径分配新页、复制内容、把写入方的 PTE 改回可写。整个过程对程序透明,但每一步都落到具体页面和具体 PTE。
只看 RSS、PSS 这种聚合数字看不出谁触发了 COW。看 smaps 里的 Shared/Private 分布,配合 minflt 增量,才能定位是谁、在什么时候、用什么粒度做的复制。
核心代码:COW 判定逻辑
do_wp_page() 中判断是否需要复制的关键路径(mm/memory.c,极简化):
1 | |
folio_reuse_one_vma 检查 folio_mapcount 和 page_count 来判断是否独占。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/memory.c |
do_anonymous_page():首次访问匿名页 |
mm/memory.c |
do_wp_page()、wp_page_copy():COW 写保护异常处理 |
mm/memory.c |
copy_page_range()、copy_pte_range():fork 时复制页表 |
kernel/fork.c |
copy_mm()、dup_mm():地址空间复制 |
mm/rmap.c |
anon_vma 系统,连接匿名页与 VMA |
fs/proc/task_mmu.c |
smaps 字段如何从 VMA 与页表扫描得出 |
读 copy_pte_range 时可以带着两个问题:什么情况下 PTE 直接共享,什么情况下需要立即复制(pinned page、特殊设备映射、某些 huge page 路径)。这些“例外”解释了为什么 fork 在某些应用下比预期慢。
研究生迁移表
| Linux 概念 | 一般系统模式 |
|---|---|
| 匿名 VMA | 未兑现的地址承诺 |
| 匿名页 | lazy allocation 兑现的页面对象 |
| COW PTE | 共享态 + 写保护触发器 |
do_wp_page |
写时复制的物化点 |
Private_Dirty |
独占可修改副本 |
Pss |
共享资源的成本分摊视图 |
anon_vma 链 |
反向索引,回收时找到所有映射 |
写时复制不是只属于操作系统。数据库的 MVCC、版本化文件系统的快照、容器镜像的分层、函数式数据结构的持久化,都共享同一个核心思路:共享状态加上写意图触发器,把复制成本压到必须复制的那一刻。
常见误解
第一个误解是把 fork 的代价等同于父进程内存大小。fork 本身的代价主要在 mm_struct、VMA 与页表的复制,与匿名页驻留量相关性不强,但与地址空间结构和页表深度相关。子进程随后写入大量页面才是真正的物理内存增长来源。
第二个误解是把 Rss 直接当成物理占用。父子共享时,把两个进程的 Rss 相加会双重计算共享页面。多进程服务做容量评估应该看 Pss 或 cgroup 内存统计。
第三个误解是把 COW 当成 Linux 独有的优化。它是写时复制的一种实现,思路在数据库、快照文件系统、容器镜像、函数式数据结构里反复出现。
第四个误解是认为 do_wp_page 永远复制页面。若内核判断该物理页只被本进程独占(例如另一个共享方已经释放),可以直接放开写权限不复制。这条快路径让“反复 fork、子进程基本不写”的 worker 模式仍然便宜。
第五个误解是 MAP_PRIVATE 文件映射不走 COW。事实上它正是一种 COW:初始指向 page cache 中的文件页,写入触发复制出私有匿名页。后续 page cache 篇会再细看。
练习
第一,按文中代码跑 cow_demo,在四个阶段分别记录父子两个 PID 的 Rss、Pss、Shared_Clean、Private_Dirty,以及 getrusage 的 ru_minflt。把数字画成一张随时间变化的表。
第二,把子进程写入数量从 npages / 2 改成 npages,再改成 npages / 4,比较 Pss 在子进程结束时的回归速度。
第三,给父进程也加一段写入(写后 256 页),观察是父子各自的 Private_Dirty 都增加,还是某一方继续看到 Shared_Clean。把结果与“写入方触发复制”这条规则对照。
第四,把 MAP_PRIVATE | MAP_ANONYMOUS 换成对一个大文件的 MAP_PRIVATE,父进程不写,fork 后子进程写一半。比较 smaps 中的 Private_Dirty 与 Anonymous 字段,理解“MAP_PRIVATE 文件映射的 COW 写入会从文件页变成匿名页”。
第五,阅读 mm/memory.c 中 do_wp_page 的快路径条件(围绕“是否独占”相关判断),写一段不超过 200 字的说明,解释独占判断如何让 COW 在某些场景下零复制。
系列导航
- 上一篇:缺页异常:一次访问如何进入内核
- 本文:匿名页和 COW:
fork为什么便宜,写入为什么变贵 - 下一篇:文件映射和 page cache:文件内容怎样变成页面
