上一篇把缺页异常拆成了 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 处理

页表是个介于“复制”和“共享”之间的特例。子进程必须拥有自己的页表根,否则后续 mprotectmmap、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
shared read-only state + write intent -> private copy

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>

static void report_faults(const char *who, const char *stage) {
struct rusage r;
if (getrusage(RUSAGE_SELF, &r) != 0) {
fprintf(stderr, "getrusage: %s\n", strerror(errno));
return;
}
printf("[%s %d %s] minflt=%ld majflt=%ld\n",
who, getpid(), stage, r.ru_minflt, r.ru_majflt);
}

static void pause_with(const char *who, const char *stage) {
report_faults(who, stage);
printf("[%s %d] check /proc/%d/smaps then press enter\n",
who, getpid(), getpid());
int c;
do { c = getchar(); } while (c != '\n' && c != EOF);
}

int main(void) {
size_t page = (size_t)sysconf(_SC_PAGESIZE);
size_t npages = 1024;
size_t length = page * npages;

char *base = mmap(NULL, length,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (base == MAP_FAILED) {
fprintf(stderr, "mmap: %s\n", strerror(errno));
return 1;
}

for (size_t i = 0; i < npages; i++) {
base[i * page] = (char)i;
}
report_faults("parent", "after parent populated all pages");

pid_t pid = fork();
if (pid < 0) {
fprintf(stderr, "fork: %s\n", strerror(errno));
return 1;
}

if (pid == 0) {
pause_with("child", "right after fork, before any write");
for (size_t i = 0; i < npages / 2; i++) {
base[i * page] = (char)(i ^ 0xAA);
}
pause_with("child", "after child wrote half pages");
return 0;
}

pause_with("parent", "right after fork, parent has not written");
waitpid(pid, NULL, 0);
pause_with("parent", "after child finished");

munmap(base, length);
return 0;
}

编译运行:

1
2
cc -Wall -Wextra -O2 cow_demo.c -o cow_demo
./cow_demo

在另一个终端按提示查看两个 PID 的 smaps

1
grep -E 'Size|Rss|Pss|Shared_Clean|Shared_Dirty|Private_Clean|Private_Dirty|Anonymous' /proc/<pid>/smaps | head -40

smaps 里有很多映射段,挑那段大小为 length 的匿名映射看。

读取实验结果

预期看到的结构是:

第一阶段,父进程已经把 1024 页全写一遍,匿名映射段的 Rss 应该接近 4MB,Private_Dirty 也接近 4MB。PssRss 接近相等,因为这段映射当前只属于一个进程。

第二阶段,fork 之后但任何一方还没写。父子两个 PID 的同一段映射,Rss 都接近 4MB,但页面变成共享只读:Shared_Clean 占比上升,Private_Dirty 大幅下降。Pss 大约是 Rss 的一半,因为这段物理内存被两个进程对半分摊。两份页表都存在,但物理页只有一份。

第三阶段,子进程写完前 512 页之后:

  • 子进程的这段映射里,前 512 页变成 Private_Dirty(COW 后独占脏页),后 512 页仍是 Shared_Clean
  • 父进程同段映射里,前 512 页变成 Private_CleanPrivate_Dirty,因为它们不再被共享、子进程已经写出了自己的副本;后 512 页继续是 Shared_Clean 或转回 Private(视具体内核版本和 exclusive 优化)。具体落到哪个字段,要按实际 smaps 输出读,不要硬背。
  • 子进程的 minflt 会增加大约 512,这是写保护异常触发的 COW fault。父进程的 minflt 几乎不变。

第四阶段,子进程退出后,父进程独占整段映射,Pss 重新接近 Rss

把这几条接起来:fork 本身不会让物理内存翻倍,因为页面共享;之后的写入会按 4KB 粒度逐步把共享页面转成私有脏页,每一页对应一次 minor fault 和一次实际复制。

模式提炼:从共享转私有的边界永远是 PTE

1
2
parent PTE: writable -> read-only (on fork)
child write -> WP fault -> allocate + copy + writable PTE

COW 不是用户态可见的“复制函数”,而是一组 PTE 权限变换。fork 把所有可写共享 PTE 改成只读,写入触发写保护异常,异常路径分配新页、复制内容、把写入方的 PTE 改回可写。整个过程对程序透明,但每一步都落到具体页面和具体 PTE。

只看 RSS、PSS 这种聚合数字看不出谁触发了 COW。看 smaps 里的 Shared/Private 分布,配合 minflt 增量,才能定位是谁、在什么时候、用什么粒度做的复制。

核心代码:COW 判定逻辑

do_wp_page() 中判断是否需要复制的关键路径(mm/memory.c,极简化):

1
2
3
4
5
6
7
8
9
10
11
12
static vm_fault_t do_wp_page(struct vm_fault *vmf)
{
struct folio *folio = page_folio(vmf->page);

/* 如果只有一个映射者,直接复用(reuse) */
if (folio_reuse_one_vma(folio, vmf->vma)) {
wp_page_reuse(vmf); /* 把 PTE 改成可写,不复制 */
return 0;
}
/* 多个映射者:必须复制 */
return wp_page_copy(vmf); /* 分配新页 → 复制 → 安装新 PTE */
}

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 的 RssPssShared_CleanPrivate_Dirty,以及 getrusageru_minflt。把数字画成一张随时间变化的表。

第二,把子进程写入数量从 npages / 2 改成 npages,再改成 npages / 4,比较 Pss 在子进程结束时的回归速度。

第三,给父进程也加一段写入(写后 256 页),观察是父子各自的 Private_Dirty 都增加,还是某一方继续看到 Shared_Clean。把结果与“写入方触发复制”这条规则对照。

第四,把 MAP_PRIVATE | MAP_ANONYMOUS 换成对一个大文件的 MAP_PRIVATE,父进程不写,fork 后子进程写一半。比较 smaps 中的 Private_DirtyAnonymous 字段,理解“MAP_PRIVATE 文件映射的 COW 写入会从文件页变成匿名页”。

第五,阅读 mm/memory.cdo_wp_page 的快路径条件(围绕“是否独占”相关判断),写一段不超过 200 字的说明,解释独占判断如何让 COW 在某些场景下零复制。

系列导航

参考资料