🐧 第五章 内存管理
📄 一句话概括

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 秒实验

  1. 写一个脚本先占 200 MB 再 fork,在孩子里只改 1 页,看两边 RSS 各多少。
  2. 同样的脚本改成孩子改 100 页,对比 RSS 的增长。
  3. grep -E 'pgfault|pgmajfault' /proc/vmstat,fork 前后各读一次算差值。

常见误区

  1. 【fork 会复制整个进程的内存】。它只复制页表,物理页共享;写的时候才复制那一页。
  2. 【COW 之后父子数据是两份】。没写之前是同一份;写了的那些页才分成两份。
  3. 【COW 让 fork 完全免费】。页表要复制、页表项要改成只读,这些也是成本——只是比拷内存便宜得多。
自测三问(点开对答案) 1. fork 时到底复制了什么? —— **复制页表(并把两边的页都设成只读)**,**物理页一页都没复制。****写的时候才复制那一页。** 2. 为什么【fork 之后立刻 exec】最省? —— **子进程一页都没写就换掉了,一次复制都不用做。****shell 起命令就是这个模式。** 3. 什么情况下 COW 不划算? —— **孩子几乎把所有页都改一遍的时候**——**最后付出的代价接近整份复制,还多了一层异常处理。**

相关笔记