B10 —— 为什么 fork 很快:Copy-On-Write 机制
金句:不复制,只共享——直到真正需要的时候。
fork 的传统问题:大量内存复制
fork 系统调用创建新进程,传统实现需要复制父进程的整个内存空间:
传统 fork 的问题:
父进程内存:假设 1GB
fork() 执行时:
- 复制所有页表(PTE)
- 复制所有物理页面(1GB)
- 创建子进程的页表和页面
问题:
1. 时间:复制 1GB 内存 → 耗时 100~500ms
2. 空间:fork 后内存翻倍(2GB),即使子进程只用 10MB
3. 浪费:fork + exec 的经典 Unix 模式,子进程会立即丢弃复制的内存
这就是为什么传统 Unix 的 fork 是"昂贵"的操作。Copy-On-Write(COW)的核心思想
COW 的核心是:不复制内容,只复制页表的引用计数。
COW 的基本原则:
1. fork 时:
- 不复制物理页面
- 只复制父进程的页表(指向同样的物理页面)
- 把页面标记为"只读"(Read-Only)
- 父子共享同一物理页面
2. 父或子写入时:
- 触发 page fault(因为页面是只读的)
- OS 分配新物理页
- 复制内容到新页
- 更新页表,指向新页
- 把新页标记为"读写"
3. 结果:
- fork 很快(只复制页表,不复制页面)
- 实际复制延迟发生在第一次写入时
- 如果子进程只读内存(如 exec 后),永远不复制COW 的实现细节
fork 中的 COW 流程:
sys_fork() → copy_process() → copy_page_range()
copy_page_range() 的实现:
int copy_page_range(struct mm_struct *dst, struct mm_struct *src,
struct vm_area_struct *vma)
{
for each page in vma:
if (is_cow_page(page)) {
// COW 页面,共享但标记为只读
get_page(page); // 引用计数 +1
set_pte(pte, readonly); // 设置只读位
flush_tlb(); // 刷新 TLB
}
}
}
关键:
- 引用计数 > 1(共享),页面只读
- 写入触发 #PF(Page Fault)
- page fault handler 分配新页,复制内容,继续执行写入时的 Page Fault 处理:
do_page_fault():
// Page Fault 处理
if (!(vma->vm_flags & VM_WRITE)) {
// 写入只读页面 → COW!
// 1. 分配新物理页
new_page = alloc_page(GFP_KERNEL);
// 2. 复制旧页内容到新页
copy_page(new_page, old_page);
// 3. 更新页表,指向新页
set_pte(addr, new_page | R/W); // 可读写
// 4. 释放旧页的引用
put_page(old_page);
// 5. 继续执行写入指令
return;
}
// 不是 COW,可能是真正的错误
// 发送 SIGSEGVCOW 的好处
COW 的性能和空间优势:
场景 1:fork + exec(最常见)
父进程 500MB(代码+数据)
fork 后共享 500MB(只读)
exec() 丢弃所有页面,重新加载可执行文件
无 COW:500MB 复制 → 耗时 200ms,浪费 500MB
有 COW:0 复制(exec 前都是只读共享)→ 耗时 <1ms
场景 2:fork 后子进程只读
父进程 1GB
fork 后子进程只读(不做任何写入)
无 COW:1GB 复制 → 1GB 内存占用
有 COW:共享 1GB(只读)→ 0 额外占用
场景 3:fork 后子进程写入少量内存
父进程 1GB
fork 后子进程写入 4KB
无 COW:1GB 复制 → 1GB 额外占用
有 COW:只复制被写入的页(如 4KB)→ 额外 4KB
大量未修改的页面仍然共享
实际测试(fork + exec):
无 COW:~300ms(复制 1GB)
有 COW:<1ms(只复制页表)COW 的副作用和注意事项
COW 的副作用:
1. 内存峰值:
如果父进程 100% 使用内存(1GB),
fork 后的子进程如果立即大量写入,
会立即分配 1GB 新内存(COW 触发)
→ 内存峰值可能达到 2GB
2. 页面共享的限制:
- 只读页面(代码段)可以长期共享
- 写入后 COW,共享结束
- 内存碎片化(不同进程的页面在不同位置)
3. 虚拟内存统计的复杂性:
- /proc/PID/status 的 VmRSS 可能不反映实际内存
- COW 页面的引用计数 > 1 时,实际占用少
$ cat /proc/PID/smaps | grep -A 5 "Shared"
0000000000400000 4KB r-xp ... // 可执行代码,共享
... Shared_Clean: 4KB, Shared_Dirty: 0KB
$ cat /proc/PID/status | grep -i vm
VmRSS: 512000 kB // 包括 COW 共享的页(只计一份)
VmSwap: 0 kB // 没有 swap写时复制(Write-Through)和按需分配(Demand Paging)
fork 之后的内存分配有多种模式:
1. COW(Copy-On-Write):
- fork 时共享,只读
- 写入时复制
- 延迟复制,直到真正需要
2. 写时复制 vs 按需分配(Demand Allocation):
- fork + exec:COW 效果好(exec 前只读)
- fork + 立即写入:COW + 延迟复制
3. exec() 的内存处理:
- exec() 丢弃所有页面
- 按需加载可执行文件(mmap)
- 代码段按需分页(demand paging)
- 数据段按需分配(demand allocation)
fork + exec 的最优化:
fork 共享所有读页面
exec 丢弃所有页面,重新加载
结果:fork 几乎免费,exec 按需加载总结
- 传统 fork 的问题:复制整个内存空间,耗时 100~500ms,内存翻倍
- COW 核心:fork 不复制页面,只复制页表引用,标记只读,共享页面
- 写入触发:写入只读页面 → Page Fault → 分配新页 → 复制内容 → 继续执行
- COW 优势:fork 快(只复制页表),内存共享(0 复制),按需复制(延迟)
- 实际效果:fork + exec(无COW 300ms → 有COW <1ms),fork后只读(0 额外占用)
- 副作用:内存峰值可能翻倍,页面共享依赖只读属性
下篇预告(B11):fork 后子进程修改数据时具体发生了什么?Page Fault 的完整处理流程,以及 COW 时页面分配器的调用路径。
关注公众号「AI不着急」,回复”资料”获取内存学习路线图。
评论