一、那个深深刻在脑海里的直觉模型
在学习 Linux 内核时,我们对 fork + COW(写时复制)的直觉通常是这样的:
- 父进程拥有一个物理页 P0,mapcount = 1。
- fork()发生:父子进程共享 P0,页面被设为只读,mapcount = 2。
- 关键点:谁先写,谁就触发 COW。比如父进程先写,它发现页面被共享,于是 copy 出一个新页 P1。
- 结局:此时 P0 的 mapcount 降回 1。等子进程再写时,发现 P0 已经是它独占了,直接执行Reuse(复用)。
一句话总结:一个 COW,一个 Reuse。听起来逻辑严密,教科书也是这么写的。但在 Linux 6.17 的真实环境下,实验结果却“打”了我的脸。
二、诡异的实验:谁也没占到便宜
实验环境:Qemu SMP + Linux 6.17。
应用测试代码:
char *p = mmap(NULL, PAGE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); p[0] = 1; pid_t pid = fork(); p[0] = 2;在内核源码 mm/memory.c 中加入打印调试,实际 log 输出如下:
p done, p=0x7fc1faa1a000 [do_wp_page 3930]: 1712 do_wp_page flag 1a55, addr 7fc1faa1a000 [do_wp_page 4020]: 1712 call wp_page_copy with mapcount 2, addr 7fc1faa1a000 [do_wp_page 3930]: 1713 do_wp_page flag 1a55, addr 7fc1faa1a000 [do_wp_page 4020]: 1713 call wp_page_copy with mapcount 2, addr 7fc1faa1a000PID 1712(父进程)和 1713(子进程)全部触发了 copy。原本以为能被复用的原始页 P0,在 mapcount 归零后被系统回收了。
为什么?父子进程通过 barrier 同步写,难道不该是一个 copy、一个 reuse 吗?
三、内核深度解密:消失的“瞬间”
我们要去 mm/memory.c 的 do_wp_page 函数里找答案。内核判断是“拷贝”还是“复用”的逻辑核心如下:
1. 竞争的本质
先说清实验环境:QEMU 是 SMP,父子进程通过 barrier 同步写——两个写 fault 是在不同 CPU 上真正并行发生的,不存在谁先谁后的串行顺序。父进程触发写异常进入内核态后,在完成 P0 → P1 的拷贝并更新 PTE(页表项)之前,子进程已经在另一个 CPU 上触发了写异常。此时关键时刻到了:父进程的 COW 还没走完,PTE 还没改。
(顺带一提:即使在单核上,同样的窗口也存在——父进程在分配新页、拷贝页面时睡眠让出 CPU,子进程被调度运行后看到的 mapcount 同样是 2。SMP 的真并行只是让这个窗口更容易撞上,机制的判断点是同一个。)
子进程去看 P0 的 mapcount,依然是2。内核心想:“既然还有别人在用,那我也 copy 一个吧。”结果就是:大家都觉得自己不是最后一个使用者,大家都选择了“背锅”拷贝。
2. 时序决定命运
随后做了一组对比实验:在子进程写操作前加一个 sleep(1),强制拉开两者的距离。
char *p = mmap(NULL, PAGE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); p[0] = 1; pid_t pid = fork(); if (pid == 0) { /* child */ sleep(1); p[0] = 2; exit(0); } else { /* parent */ p[0] = 3; wait(NULL); }内核输出结果:
PID 1906: mmap done, p=0x7f1ed83ac000 [do_wp_page 3930]: 1906 do_wp_page flag 1a55, addr 7f1ed83ac000 [do_wp_page 4020]: 1906 call wp_page_copy with mapcount 2, addr 7f1ed83ac000 [do_wp_page 3930]: 1907 do_wp_page flag 1a55, addr 7f1ed83ac000 [do_wp_page 4004]: 1907 call wp_page_reuse with mapcount 1, addr 7f1ed83ac000结果:父进程 COW,子进程 Reuse。实验验证了我的猜想:Reuse 成功的前提是,第一个进程的 COW 流程必须完全走完,把旧页的 mapcount 彻底降下来。
补一句严谨性说明:复用判断严格来说不只看 mapcount,还涉及 refcount——如果页面被 GUP 等机制 pin 住,即使 mapcount 降到 1,也可能被迫走 copy。本实验中没有这类额外引用,所以正文按 mapcount 视角简化。另外,“双 copy”是概率性现象,多次运行并非每次都发生,取决于两个进程 fault 交错的时机——这正是下文结论的由来。
3. 重复跑 500 次:概率长什么样
一次命中说明不了“racy”到底有多 racy。于是把三种模式各跑了 500 次:
| 模式 | 双 copy | 单 copy + reuse | reuse 方 |
|---|---|---|---|
| who=0 屏障并发(父子同时写) | 463(92.6%) | 37(7.4%) | 子 24 / 父 13 |
| who=1 父先写(子 sleep 200ms) | 0(0%) | 500(100%) | 子 500 |
| who=2 子先写(父 sleep 200ms) | 1(0.2%) | 499(99.8%) | 父 499 |
三点值得说明:
- 屏障并发下,双 copy 不是“偶尔撞上”,而是默认结局(92.6%)。竞争窗口比直觉大得多——只要两个 fault 交错进同一个窗口,基本就是双 copy。
- 拉开 200ms 后,基本 100% 是单 copy + reuse,且 reuse 方总是后写的一方。对称性验证了上一节的解释:谁先走完 COW,谁就把 mapcount 降下来,后写的一方正好赶上 reuse。
- who=2 里那 1 次例外如实保留。200ms 间隔下仍有 1/500 双 copy,原因未深究(可能是调度噪声)。
四、总结
- Linux 的 COW 是“基于瞬时状态判断的竞争式实现(racy but correct)”。fork 后是发生 1 次还是 2 次 COW,是实现细节,与调度有关。重复跑的数据也印证了这一点:屏障并发下双 copy 占 92.6%,拉开 200ms 后基本 100% 是单 copy + reuse。
- 内核的正确性 vs 性能:内核首先保证的是数据的独立(Correctness),至于能不能 Reuse 节省那一页内存,取决于调度器脸色。
有时候我们请教前辈/大牛问题,他们有时候会说 “hmm, it's hard to say...”。当时不清楚为什么这么难说,现在想想,有些问题有时候确实还挺难说的……