Copy on Write 原理:fork 背后的内存优化魔法
一句话结论(30s)
Copy on Write 的本质是操作系统「懒惰求值」思想的典范,因为 fork + exec 场景中子进程通常立即 exec 丢弃继承的内存,所以 fork 只复制页表、共享物理页而不复制内容;关键机制是清除页表项的写权限位,真正写入时触发保护故障、由 do_wp_page 按需分配新页框。权衡:以「每次实际写入多一次缺页中断 + 复制一页」的代价,把 fork 从昂贵的全量拷贝变成近乎零成本的建进程方式。
核心原理(2min)
主流程:fork 时深拷贝四级页表(PGD→PUD→PMD→PTE),但让子进程 PTE 指向与父进程相同的物理页框,双方 R/W 写权限位都清 0,物理页引用计数 +1;进程写入共享页时 MMU 检测到 R/W=0 触发写保护故障,经 do_page_fault → handle_mm_fault → do_wp_page 判断:引用计数 >1 则分配新物理页框复制内容并更新当前进程 PTE(R/W=1),原页框计数 -1;引用计数已为 1 则直接恢复写权限。该机制同样支撑 mmap 的 MAP_PRIVATE 私有映射与 malloc 的 demand-zero 零页。
底层深入(5-10min)
fork 的内存困境
fork 系统调用创建一个与父进程几乎完全相同的子进程。按照最朴素的实现方式,应该把父进程的全部物理内存复制一份给子进程。但这里有一个巨大的浪费:
// 典型的使用模式
pid_t pid = fork();
if (pid == 0) {
// 子进程:立即 exec 加载新程序
execve("/bin/ls", argv, envp);
} else {
// 父进程:等待子进程结束
waitpid(pid, NULL, 0);
}
在 fork + exec 的场景中,子进程从来不会真正使用继承自父进程的内存——它马上就要通过 exec 加载全新的程序镜像。如果 fork 时复制了全部物理内存(可能几百 MB 甚至几 GB),然后 exec 时又全部丢弃,这是不可接受的浪费。
思考:fork 为什么用 COW 而不是”fork 时就复制”?核心是”复制了也白复制”。
fork + exec是绝大多数场景——子进程马上exec换掉整个内存镜像,若 fork 时就拷贝父进程全部物理内存,那些内容下一秒就被丢弃。COW 的策略是”先共享、后拆分”:先让父子指向同一块物理页,等谁真的写了,再为写的那一方单独复制一页。把”全量立即复制”变成”按需延迟复制”,浪费才被消灭。
Copy on Write(写时复制)正是为了解决这个问题而诞生的。
核心思想:延后复制
Copy on Write 的核心策略可以概括为八个字:复制页表,共享物理页。
fork 时发生了什么
当调用 fork 时,内核执行以下操作:
1. 复制虚拟地址空间描述(mm_struct 和 VMA 树)
子进程获得与父进程相同的虚拟内存布局——代码段、数据段、堆、栈、mmap 区域的位置和大小完全一致。
2. 复制页表(不是复制物理页!) 这是 CoW 的核心。内核对父进程的每一级页表(PGD → PUD → PMD → PTE)进行深拷贝。但关键之处在于:子进程的页表项指向与父进程相同的物理页框,只是将双方页表项中的写权限位(PTE 的 bit 1, R/W bit)都清除。
fork 前:
父进程 PTE: [物理页框 X] (R/W=1, 可读可写)
fork 后:
父进程 PTE: [物理页框 X] (R/W=0, 只读) ← 写权限被清除
子进程 PTE: [物理页框 X] (R/W=0, 只读) ← 写权限被清除
3. 物理页框的引用计数加 1
每个共享的物理页框现在被两个进程引用,_refcount 字段记录了这个事实。
写入时发生了什么
当父进程或子进程试图写入某个共享页时,MMU 发现 PTE 的写权限位为 0,触发写保护故障(Protection Fault)。内核的缺页处理函数 do_wp_page 检测到这是 CoW 场景(页框的引用计数 > 1 且 VMA 有写权限):
进程写入 CoW 页
→ MMU 检查 PTE: R/W=0 → 触发保护故障(#PF,错误码包含写标志)
→ do_page_fault → handle_mm_fault → do_wp_page
→ 检测:页框引用计数 > 1 且 VMA 允许写?
→ 是:分配新物理页框,复制原始内容,更新当前进程的 PTE 指向新页框(R/W=1)
→ 原物理页框引用计数 -1(如果降至 0 则回收)
→ 不是(引用计数已为 1):直接恢复 PTE 的写权限即可
→ 返回,CPU 重新执行写入指令
关键细节:只有实际写入的那个进程会获得一个新页框。如果父进程先写,父进程获得副本;子进程的 PTE 仍然指向原始物理页框(且因为引用计数从 2 降到 1,子进程下次写入时直接恢复写权限,不再触发 CoW)。
思考:为什么是”写时复制”而不是”fork 时复制”?因为写入是”真正需要独占”的信号。fork 时父子还共享同一页,谁都没写就无需复制;一旦某个进程要写,就说明它要和对方”分家”了,这时才复制一页。而”引用计数降为 1”的优化更精妙——当只剩一个进程引用这页时,它想改就改,直接恢复写权限即可,根本不用复制,因为已经没有”别人”需要保护了。
CoW 如何节省物理内存
考虑以下场景:一个进程使用了 500MB 物理内存,然后 fork 了 10 个子进程。
没有 CoW:10 × 500MB = 5GB,瞬间爆炸。
有 CoW:初始仍然只有 500MB(10 个子进程共享同一套物理页)。只有当子进程真正修改页面时,才为修改的页面分配新的物理页框。如果每个子进程只修改了 10% 的内存,物理内存总消耗 = 500MB(共享基) + 10 × 50MB(各自修改的部分)= 1GB,节省了 80%。
思考:为什么 COW 能省这么多内存?因为它把”共享的部分”和”修改的部分”拆开了:没被改的页继续共享一份,只有真正被改的那几页才各自复制。省下的比例取决于”写多还是写少”——写得越少、省得越多。这正是”懒惰求值”思想在内存管理上的胜利:不为”可能发生”的复制买单,只为”真实发生”的修改付出代价。
页表复制的开销
CoW 虽然节省了物理内存,但页表的复制仍然需要时间和空间。对于一个使用了几 GB 虚拟内存的进程(即使 RSS 只有几百 MB),其 4 级页表结构可能占据数 MB(一个 PTE 页 4KB 可映射 2MB 物理内存)。fork 需要遍历并复制整个页表树。
现代 Linux 通过几项优化来减轻这个开销:
- 大页(HugeTLB / THP):使用 2MB 或 1GB 大页时,页表层级减少,复制页表的开销降低
- 惰性复制:某些页表项在子进程首次访问时才真正分配和建立映射
- vfork:如果确定子进程立即
exec,使用vfork可以完全避免页表复制(但父子进程共享栈,使用受限)
CoW 在 mmap 中的应用
CoW 不仅用于 fork,它也是 mmap 中 MAP_PRIVATE 标志的实现基础。当你以 MAP_PRIVATE 方式映射一个文件时:
- 初始时,映射的页被标记为只读且与文件页缓存共享物理页
- 写入时触发 CoW,分配新页框存放私有副本
- 修改不会回写到文件,其他进程也看不到这个修改
此外,malloc 在 brk 区域分配的内存起始时是 “demand-zero” 页——所有分配出去的虚拟地址都映射到同一个全零物理页(Zero Page),且标记为只读。只有当进程真正写入时,才通过 CoW 机制分配一个独立的、清零后的新物理页框。
总结
Copy on Write 是操作系统设计中”懒惰求值”思想的典范——推迟不必要的计算(物理内存复制)直到真正需要的时候。它让 fork 从一种昂贵到几乎不可用的操作,变成了创建新进程的理想方式。理解 CoW,不仅要理解 “复制页表不复制物理页” 的静态结构,更要理解 “写保护中断→do_wp_page→分配副本” 的动态流程。这个模式的优雅之处在于:它将一个昂贵的全量复制操作,转化为一系列按需的、增量的、只在实际修改时才触发的微小复制。
章末提问
追问 1:Copy on Write 的核心思想是什么?
回答思路:结论先行——“复制页表,共享物理页”,把复制推迟到真正写入时。fork 时深拷贝页表,但让子进程 PTE 指向与父进程相同的物理页框,并把双方写权限位清 0;当某一方真正写入时,MMU 检测到只读触发写保护故障,内核才为写入方分配新页框并复制内容。本质是”懒惰求值”——不为可能发生的复制买单。
追问 2:写入一个共享页时,触发 CoW 的完整流程是什么?
回答思路:结论先行——写保护故障 → do_wp_page → 判断引用计数 → 分配新页或恢复写权限。进程写入时 MMU 发现 PTE 的 R/W=0,触发保护故障(#PF),经 do_page_fault → handle_mm_fault → do_wp_page 判断:若页框引用计数 > 1 且 VMA 允许写,则分配新物理页、复制内容、更新当前进程 PTE(R/W=1)、原页框计数 -1;若引用计数已为 1,则直接恢复写权限。最后 CPU 重执行写入指令。
追问 3:共享页的引用计数已经降为 1 时,写入为什么不复制?
回答思路:结论先行——因为没有”别人”需要保护了,直接恢复写权限即可。CoW 复制新页框的目的,是让写的一方不污染共享该页的其他进程;当引用计数降为 1,说明只有当前进程还在引用这页,它改自己的页无需复制。内核在 do_wp_page 里正是用”引用计数是否 > 1”来决定是”复制”还是”直接恢复写权限”的。