fork 不复制内存:父子先共享所有页并设成只读,谁写谁触发复制那一页。于是 fork 的代价从【进程多大】变成【改了多少】。实测 100 页的父进程 fork 之后改 30 页,只多花 30 页。
3.2 讲 fork 时说它【复制家当不复制内容】,这一节把那个说法坐实——COW 是虚拟内存最漂亮的一个应用。
一、fork 到底做了什么
朴素的想法:fork 就是把父进程的内存整份拷一份给子进程。一个占 1 GB 的进程 fork 一次就要再拷 1 GB——这显然不可接受。 实际做法:父子两边的页表都指向同一批物理页,同时把两边的这些页都设成只读。 这一步几乎不花钱:只改了页表项,一页数据都没动。 然后谁来写,谁就触发保护异常,内核这时候才真复制那一页。 所以叫【写时复制(Copy On Write)】——不写不复制。
二、实测省多少
父进程 100 页,子进程改 30 页。 不做 COW:200 页(整份复制)。 做 COW:130 页(100 页共享 + 30 页复制)——省 35%。 再 fork 一代:孙进程改 10 页。不做 COW 是 300 页,做 COW 是 140 页——省 53%。 进程越 fork 越多,差距越大——因为绝大多数页从头到尾没人写过。
三、一次写,内核干的四步
一、fork 时把两边页表都设成只读,指向同一批物理页。 二、谁写谁触发保护异常——注意这不是缺页,是【权限错误】。 三、内核复制那一页,给写的一方换一个新的物理页号。 四、把这一页改回可写。另一方的仍然是只读。 关键在第三步:复制的粒度是【一页】,不是【整个进程】。
四、这带来两个真实后果
一、【fork 之后立刻 exec】是最省的做法:子进程一页都没写就换掉了,一次复制都不用做。这就是 shell 起命令的标准做法。 二、【你以为复制了,其实还共用着】:任何【先 fork 再让孩子改数据】的程序,都要意识到这一点。 改是安全的(有 COW 兜着),但【内存占用】会随着改动而增长,不是一次到位。 还有一个坑:COW 的页是只读的,所以【靠写一个字节来判断页是否共享】会触发复制,把你想省的钱又花掉了。
五、动手 Demo:写时复制——fork 为什么不贵
这一节把 COW 的账算成人能看懂的数字:父子共享 100 页,孩子改 30 页,物理页总数是多少。
npar, child_w = 100, 30
plain = npar * 2 # 不做 COW:整份复制
cow = npar + child_w # 做 COW:只复制被写的页
print("不做 COW", plain, "页")
print("做 COW", cow, "页")
print("省了", 100 - 100.0 * cow / plain, "%")
# 再 fork 一代:孙进程改 10 页
print("二代 不做 COW", plain + npar, "页")
print("二代 做 COW", cow + 10, "页")
实跑输出:
--------------------------------------------------------------------
写时复制——fork 为什么不贵
--------------------------------------------------------------------
① 父进程 100 页,fork 一个子进程,子进程改 30 页
做法 物理页总数 省了多少
------------------------------------------------------
不做 COW,fork 就整份复制 200 页 —
做 COW,只复制被改的 130 页 35%
② 再 fork 一代:孙进程改 10 页
做法 物理页总数 省了多少
-------------------------------------
不做 COW 300 页 —
做 COW 140 页 53%
**进程越 fork 越多,差距越大**——**因为绝大多数页从头到尾没人写过。**
③ 一次写,内核在背后干的四步
步骤 动作 页表怎么变
------------------------------------------------------------
一 fork 时把两边页表都设成只读 指向同一批物理页
二 谁写谁触发保护异常 硬件拦下来的
三 内核复制那一页 给写的一方换新页号
四 把这一页改回可写 另一方的仍然只读
**注意第三步是【一页一页复制】,不是【整个进程复制】。**
**所以 fork 的代价从【进程多大】变成【改了多少】。**
④ 这带来一个真实的坑
你以为 实际上
-------------------------------------------------
fork 之后父子各有一份数据 没写之前是同一份
子进程改了父进程看不见 成立,但代价是复制
数据是拷贝过去的 只有被写的页才拷
**所以【fork 之后立刻 exec】是最省的做法**——
**子进程一页都没写就换掉了,一次复制都不用做。**这就是 shell 起命令的方式。
一代省 35%,二代省 53%——共享的比例随着 fork 次数上升而上升。 这就是为什么【预加载一份数据再 fork 多个工作进程】是服务器的经典套路(nginx、PostgreSQL 都这么干)。 注意 Fork 的数量不是越多越好:如果每个孩子都改很多页,最后还是要付出接近整份复制的代价。 判断标准:孩子改的比例小,COW 划算;孩子几乎全改,不如不用。
📋 Linux 上看 COW 的代价:/proc/<pid>/status 里的VmRSS 与 /proc/vmstat 里的 pgfault(缺页)一起看——fork 之后孩子一写,RSS 就涨上去了。
30 秒实验
- 写一个脚本先占 200 MB 再 fork,在孩子里只改 1 页,看两边 RSS 各多少。
- 同样的脚本改成孩子改 100 页,对比 RSS 的增长。
grep -E 'pgfault|pgmajfault' /proc/vmstat,fork 前后各读一次算差值。
常见误区
- 【fork 会复制整个进程的内存】。它只复制页表,物理页共享;写的时候才复制那一页。
- 【COW 之后父子数据是两份】。没写之前是同一份;写了的那些页才分成两份。
- 【COW 让 fork 完全免费】。页表要复制、页表项要改成只读,这些也是成本——只是比拷内存便宜得多。
自测三问(点开对答案)
1. fork 时到底复制了什么? —— **复制页表(并把两边的页都设成只读)**,**物理页一页都没复制。****写的时候才复制那一页。** 2. 为什么【fork 之后立刻 exec】最省? —— **子进程一页都没写就换掉了,一次复制都不用做。****shell 起命令就是这个模式。** 3. 什么情况下 COW 不划算? —— **孩子几乎把所有页都改一遍的时候**——**最后付出的代价接近整份复制,还多了一层异常处理。**- ☐ 5.15 Swap
相关笔记
- fork()函数 — 那次【复制家当】的调用
- 虚拟内存的应用 — COW 也是改页表
- Swap — 页可以被挪走
- 🐧 Linux是怎样工作的 · 索引