fork 不复制内存,为什么大进程还是会被它拖住

fork 创建子进程时并不复制父进程的物理内存,而是复制页表、让父子两边的页表项只读共享,等谁真正写入再复制那一页,这就是写时复制。它让 fork 看起来几乎免费,也是 Redis 快照、Python 多进程和 shell 执行命令的底座。但页表要逐项复制、父进程的 TLB 要刷新,大内存进程一次 fork 仍可能停顿几十毫秒;透明大页还会把复制粒度放大到 2 MB。读完你能说清 COW 的缺页路径、fork 的真实成本,以及什么时候该改用 vfork 或 posix_spawn,在页表、TLB 与写放大之间做对取舍。

是什么

fork 是 POSIX 提供的进程创建原语:调用一次,返回两次——父进程拿到子进程的 pid,子进程拿到 0。直觉上它像"把整个进程连内存带文件描述符复印一份",但现代内核并不真的复印内存:fork 只为子进程建立一套新的页表,让父子两边的页表项指向同一批物理页框,并把这两边的写权限一起关掉。谁先写,谁触发缺页,内核才把那一页复制出来、各自换成私有副本。这套"先共享、写时才复制"的机制就是写时复制(Copy-on-Write,COW)。

它出现的场景比想象中多:Redis 做 RDB 快照和 AOF 重写、Python 的 multiprocessing、nginx 拉起 worker、shell 执行一条命令、备份工具做一致性快照,背后都是 fork 加 COW。也正因为"看起来没复制内存",很多人把它当成零成本操作,直到线上出现几十毫秒的毛刺。

核心原理

先拆 fork 到底复制了什么。内核为新进程分配 task_struct、复制 mm_struct(地址空间的描述),然后逐级复制页表页:PGD、PUD、PMD、PTE。页表项里存的不是数据,而是"虚拟页号 → 物理页框号 + 权限位"的映射,所以复制页表只是抄目录,不搬书。同一时刻,物理页框的引用计数加一;父子两侧的 PTE 都清掉可写位,并记上"这是 COW 页"。

这里有个容易忽略的数字:页表本身也是内存。x86-64 上每个 4 KB 页对应一个 8 字节 PTE,映射 1 GB 内存光是最底层页表就要 2 MB,100 GB 就是 200 MB 量级,还没算中间层级。fork 要把这些页表页一页页分配、复制,映射越多、VMA 越碎,这段同步路径越长;在它返回之前,调用线程一直停在系统调用里。

第二笔账是 TLB。父进程的 PTE 刚刚被改成只读,CPU 里缓存的旧 translation 仍然认为可写,内核必须让相关 CPU 的 TLB 失效,多核机器上这意味着核间中断。映射范围越大、核越多,这一下越贵。

第三笔账在写入时。子进程(或父进程)第一次写某个 COW 页,CPU 触发保护错误,内核走写保护缺页路径:先通过反向映射找到所有指向这个页框的页表项,看它还有没有别的映射。如果只剩自己一个,直接把 PTE 改回可写,连复制都省了,这是常见的内核优化;如果还有别人共享,就分配一个新页框、拷贝 4 KB、更新 PTE、把旧页计数减一。一次缺页就是一次内存拷贝加两次页表操作,写多少页就付多少次。注意触发方不一定是子进程:fork 把父进程的 PTE 也改成了只读,父进程接着处理请求时,自己写自己原来的内存一样会缺页。

把这三笔账合起来,就能解释"fork 不复制内存却仍然慢":省掉的是物理页的大规模拷贝,没省掉页表复制、TLB 刷新,也没省掉之后陆续发生的 COW 缺页。更麻烦的是写放大——fork 之后父进程继续处理请求,每改一页就多占一页,快照期间 RSS 可能接近翻倍;如果容器内存上限只按单进程峰值设置,父子合计超限就会 OOM。

透明大页会把这个问题放大。开启 THP 后,内核用 2 MB 大页映射内存,页表项少了、TLB 命中高了,但 COW 的粒度也随之变成 2 MB:改一个字节,可能要把整个大页复制走。不同内核版本对 THP 的 COW 有优化,是否拆分、何时拆分并不一致,所以不能背一个固定结论,只能自己压测。Redis 官方文档长期建议对这类进程关闭 THP,原因就在这里。

import os, time

def touch(mb):
    buf = bytearray(mb * 1024 * 1024)
    for i in range(0, len(buf), 4096):   # 每页写一次,确保物理页真的分配
        buf[i] = 1
    return buf

for mb in (64, 256, 512):
    buf = touch(mb)
    t0 = time.time()
    pid = os.fork()
    if pid == 0:                          # 子进程立刻退出,不触发 COW
        os._exit(0)
    os.waitpid(pid, 0)
    print(f"{mb:>4} MB  fork={1000 * (time.time() - t0):.2f} ms")
    del buf

这段代码不测 COW 缺页,只测 fork 本身的同步开销:随着映射变大,耗时上升,因为要复制的页表页更多。数值随机器、内核和内存压力变化,看趋势就好。

还有一层常被忽略:fork 复制的是文件描述符表,不是"文件本身"。父子两边的 fd 指向同一个打开文件描述(open file description),共享文件偏移和状态标志;子进程读走几个字节,父进程的偏移也跟着变。这和内存的 COW 是两条独立的线,面试里经常被放在同一题里问,答的时候要分开。

COW 也不是 fork 的专利。用 mmap(MAP_PRIVATE) 映射一个文件时,读走 page cache,写同样触发 COW,得到一份不属于文件的私有页;只是 fork 把这种"私有映射"一次性铺满了整个地址空间,所以最出名。

最后是 fork 与 exec 的经典浪费。shell 要跑一条命令,标准路径是 fork 出子进程再 exec 换掉地址空间;可 fork 已经把父进程的页表抄了一遍,exec 立刻又全部丢掉。于是有了 vfork:子进程共享父进程地址空间,父进程阻塞,直到子进程 exec 或 _exit。它不能拿来跑普通逻辑,因为子进程写内存会直接改到父进程。更现代的封装是 posix_spawn,glibc 在条件允许时会走 vfork/clone 的快路径,把"创建 + 执行"合成一次。

常见考点与误区

  • "fork 会复制父进程的内存":错。复制的是页表和进程结构,物理页共享到写入为止。但反过来说"fork 零成本"同样错:页表复制、TLB 失效、COW 缺页都在账上,大进程的 fork 是毫秒级的同步停顿。
  • "COW 之后父子进程共享变量,改一个另一个也会变":错。共享阶段是只读的,任何一方写都会得到私有副本。想让两边真的共享,要用 mmap(MAP_SHARED) 或 POSIX 共享内存,而不是指望 fork。
  • "多线程程序里 fork 很安全":危险。fork 只复制调用 fork 的那个线程,其他线程在子进程里凭空消失;它们持有的互斥锁、malloc 锁可能永远停在 locked 状态。子进程在 exec 之前只能调用 async-signal-safe 的函数,这是面试高频点。
  • "COW 一定省内存":不一定。读多写少才省;快照期间父进程写流量大,复制页会快速累积,RSS 短时翻倍,overcommit 或 cgroup 限制下直接 OOM。省的是"立刻复制",不是"永远不复制"。
  • "vfork 是更快的 fork,可以随便用":错。vfork 期间父子共享地址空间、父进程被挂起,子进程只能立刻 exec 或 _exit;写任何数据都是未定义行为。
  • "写时复制就是数据库的 MVCC":只是名字像。MVCC 靠版本链或 undo 保留旧版本给读事务,COW 靠页表只读加缺页复制来推迟物理拷贝,一个解决隔离级别,一个解决映射共享,不能混着答。
  • "fork 失败就是内存不够":不止。还可能撞上 RLIMIT_NPROC、pid 上限、页表分配失败,或 overcommit 策略拒绝。

实践建议

  • 大内存进程要 fork 做快照时,先留足余量:按"父进程写入量 × 快照时长"估算 COW 增量,而不是按数据总量。Redis 这类服务重点看 latest_fork_usec 和 COW 页数,把单实例内存控制在可接受范围内。
  • 对 COW 敏感的服务评估关闭 THP,或至少设成 madvise 模式并对关键映射显式 MADV_NOHUGEPAGE;改完必须用真实写入压测,不要只看 fork 耗时。
  • 只为执行外部命令时,优先 posix_spawn,不要在一个几百 MB 的进程里 fork 完再 exec;能复用长驻进程就别反复创建。
  • Python 里,大块只读数据用 fork 共享确实划算,但要意识到父进程后续写入会触发复制;多线程程序应改用 spawn/forkserver,从 Python 3.14 起 Linux 上 multiprocessing 的默认启动方式也已从 fork 改为 forkserver。
  • 观测别只看 RSS:/proc/<pid>/status 的 VmPTE、smaps_rollup 里的 Shared 与 Private、/proc/vmstat 的 pgfault,能区分"页表大"和"数据真的多"。容器里还要把父子的内存合计和 limit 对上。
  • 把 fork 当成一次有代价的运维动作:记录每次 fork 的耗时分布和 COW 增量,放到低峰执行;快照失败后的重试要有退避,否则连续 fork 会把内存和延迟一起推高。

一句话总结:fork 省下的是物理页的立刻复制,页表、TLB 与写放大这三笔账一笔都跑不掉。

相关阅读

← 返回文章志