fork 后写时复制页表在内存共享与 exec 之间的状态演变

这道题在面试里出现的频率很高,但真正能讲清楚的人不多。多数人背下来的答案是「fork 之后子进程和父进程共享物理页,页表项被标成只读,任何一方写入就触发缺页,内核复制一份新的物理页」。这段答案本身没错,可它只覆盖了从 fork 返回、到某一方第一次写入之间的那一小段。面试官一旦追问「exec 的时候这些页怎么办」「如果子进程还没写就 exec,父进程的页会不

这道题在面试里出现的频率很高,但真正能讲清楚的人不多。多数人背下来的答案是「fork 之后子进程和父进程共享物理页,页表项被标成只读,任何一方写入就触发缺页,内核复制一份新的物理页」。这段答案本身没错,可它只覆盖了从 fork 返回、到某一方第一次写入之间的那一小段。面试官一旦追问「exec 的时候这些页怎么办」「如果子进程还没写就 exec,父进程的页会不会被白复制一遍」「页表项上的只读位和 COW 的标记是同一个东西吗」,绝大多数人就卡住了。

真实内核里,一段匿名内存在 fork → 写时复制 → exec 这条链路上会经历至少四种状态:共享且可写、共享且写保护、私有且可写、被丢弃。页表项(PTE)的物理地址、present 位、writable 位、dirty 位在这些状态之间怎么迁移,决定了 fork 的实际开销、exec 的实际开销,以及一个经典问题的答案:fork + exec 这个组合是不是在浪费内存。下面按状态演变的顺序拆开讲,给出可以实际验证的内核行为、能跑的命令和真实的报错信息。

出发点:fork 返回瞬间的页表到底是什么样

先明确 fork 的实现路径。在 Linux 上,fork() 最终走到 kernel/fork.c 里的 copy_process(),其中调用 copy_mm(),后者再调用 dup_mm() 创建新的 mm_struct,然后由 dup_mmap() 遍历父进程的每个 vm_area_struct,用 copy_page_range() 逐级复制页表。关键点在最后一步:复制的是页表项,不是物理页。

对于私有匿名映射(MAP_PRIVATE | MAP_ANONYMOUS,也就是 malloc 大块、栈、.bss 的典型来源),copy_page_range() 在写子进程页表时,会调用 copy_present_pte()。这个函数做两件事:

  • 把父进程 PTE 里的物理页帧号(PFN)原样抄给子进程,也就是两边指向同一块物理页;
  • 清除 PTE 上的 _PAGE_RW(writable)位,让父子双方的 PTE 都变成只读。父进程的页表本身不做物理复制,但因为父子的页表在 fork 前共享页表页(在 PTE 页这一层是各自独立的,只是内容被清位),实际操作是先把父的 PTE 清写位,再拷贝一份给子。

注意一个容易被忽略的细节:COW 不是一个独立的标记位。x86-64 上没有「这是 COW 页」这种硬件标志。内核用「present 但不可写」来表达 COW:当 CPU 试图写一个 present 且只读的用户页时,会触发 page fault,错误码里 PF_W 位置 1,内核在 do_wp_page() 里判断这个 VMA 是私有可写的,就认定这是一次 COW,复制页、恢复写权限。所以判断一个页是不是 COW 页,靠的是「PTE 有没有写权限 + VMA 的 VM_WRITE 标志」,两个条件缺一不可。

与之相对,文件映射的只读代码段(.text)在 fork 后同样共享,但父进程的 PTE 本来就是只读的,写它会触发 SIGSEGV 而不是 COW。内核在 copy_present_pte() 里通过 is_cow_mapping() 区分:只有 VM_SHARED 未置位、且可写的私有映射才需要清 _PAGE_RW。这是回答「为什么 .text 在 fork 后不复制」的关键。

可以用下面的程序直接观察到 fork 后父子共享物理页:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/mman.h>

int main(void) {
    size_t len = 4096;
    /* 一块私有匿名映射,等价于 malloc 一大块后首次写入 */
    char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }

    snprintf(p, len, "parent-data");
    printf("parent vaddr=%p content=%s\n", (void *)p, p);

    pid_t pid = fork();
    if (pid == 0) {
        /* 子进程只读,不写,避免触发 COW */
        printf("child  vaddr=%p content=%s (no write)\n", (void *)p, p);
        _exit(0);
    }
    waitpid(pid, NULL, 0);
    return 0;
}

编译运行 gcc -O0 -o forkro forkro.c && ./forkro,父子打印出的虚拟地址相同(子进程是父进程地址空间的拷贝,VMA 偏移一致)。此时如果用 /proc/<pid>/pagemap 读取父子对应的 PFN,会发现完全相同——但读取 pagemap 需要 root 或 CAP_SYS_ADMIN,普通用户读到的是全零,这是内核 4.0 之后为防止 ASLR 熵泄漏而加的限制(pagemap 里的 PFN 字段对无权限用户清零)。面试里如果被问「怎么验证共享」,答 /proc/pid/smaps 里的 Shared_Clean/Private_Dirty 更稳妥,因为 smaps 只暴露聚合量,不暴露 PFN。

第一次写入:缺页、复制与 PTE 的两次改写

子进程(或父进程)第一次写这块内存时发生的事,是整个 COW 机制最需要说准的部分,因为面试官往往从这里追问细节。

假设子进程执行 p[0] = 'x'。CPU 看到该页 present 但 writable=0,触发 page fault,错误码 PF_W 位有效。内核进入 exc_page_fault() → handle_mm_fault() → do_wp_page()。在 do_wp_page() 里:

  1. 先取页表锁(在 fork 之后、首次写之前,这块页的 page_count 至少是 2,即父子各一份引用);
  2. 调用 vm_normal_page() 判断这是匿名页;
  3. 检查是否可以复用:如果 page_count == 1(比如父进程已经退出或已经复制过),且这是唯一的映射者,那么直接把原页的 PTE 改成可写即可,无需复制;
  4. 否则调用 wp_page_copy():通过 alloc_pages_vma() 分配新页、复制 4KB 内容、把新 PFN 写进当前进程的 PTE 并置 _PAGE_RW、同时更新反向映射(rmap),把新页挂到对应的 anon_vma;
  5. 减少旧页的引用计数。旧页此时仍然只读,且仍然被另一进程映射,所以旧页的 PTE 不改(它仍然是 present + 只读),只是 page_count 从 2 变 1。

所以一次 COW 写操作,页表上的变化是:写方 PTE 从「只读 + 共享」变成「可写 + 私有」;另一方 PTE 不变,仍是「只读 + 共享」。这解释了为什么 COW 的成本可以精确地说是「一次 4KB 拷贝 + 两次 PTE 修改 + 一次 TLB 刷新」。TLB 刷新是通过 flush_tlb_page() 完成的,在多核上走 IPI,这也是一部分开销。

这里有个高频追问:父进程的 PTE 为什么在 fork 时就被清成只读了,而不是只清子进程的? 因为 fork 之后双方都可能写入,任何一方写都必须触发 fault,否则无法拦截。内核的做法是「先清父、再拷贝给子」(ptep_set_wrprotect() 之后再 set_pte_at()),代价是父进程自己也多了一次缺页,这是 fork 的固有成本。

另一个追问:fork 会不会复制页表页本身? 会。页表有四级(PGD/P4D/PUD/PMD/PTE),copy_page_range() 在每一级都分配新页表页并复制条目,所以即使不复制数据页,fork 也要为每个被映射的虚拟地址区间付出页表页的开销。一个 1GB 的映射,仅页表就需要 1GB/512 = 2MB 的 PTE 页(每 2MB 区域一个 PTE 页),fork 时这些页表页确实会被分配和复制。这就是为什么大内存进程 fork 即使不写也会明显变慢——主要成本在页表页分配和 rmap 遍历,不在物理内存拷贝。

要让子进程真正写入并触发 COW,可以把上面的程序改成子进程写一个字节,然后用 smaps 对比:

# 子进程写之前与之后,分别查看同一地址的 Private_Dirty 与 Shared_Clean
grep -A20 '/dev/zero' /proc/$PID/smaps | head -40

触发 COW 后,写方那份 smaps 里的 Private_Dirty 从 0 变成 4 kB,Shared_Clean 减少;不写的一方 Shared_Clean 保持 4 kB,Private_Dirty 保持 0。这两个字段是判断「这块内存有没有被复制」最直接的证据。

exec 期间的状态突变:旧页表被整体丢弃

如果子进程 fork 完不写内存,而是直接执行 execve(),那么 COW 的那份页表会被整块丢弃,物理页回落到父进程独占。这是面试里最容易答错的一环,因为很多人会设想成「exec 时也要复制/写时复制」。真实路径不是这样。

execve() 进内核后,由 do_execveat_common() 创建新的 linux_binprm,打开可执行文件,然后调用 bprm_execve() → exec_binprm(),最终走到 begin_new_exec() 里的 exec_mmap()。exec_mmap() 的核心动作是:

  1. 把 bprm->mm 准备好(新地址空间在 exec_mmap 之前已通过 mm_alloc() 创出来);
  2. 调用 mmput() 递减旧的 mm_struct 的引用;
  3. 把当前进程的 active_mm 切到新 mm,并 switch_mm_irqs_off() 切换 CR3(x86-64 上是 PCID 感知的,会带上新的 ASID)。切换 CR3 本身就是一次 TLB 语义上的整体失效;
  4. 旧 mm 的 mm_users 减到 0 后,exit_mmap() 被调用,遍历所有 VMA 执行 unmap_vmas() 和 free_pgtables(),把每个页表项清空(pte_clear())、递减每个物理页的 page_count,最后把页表页本身也释放回 buddy 分配器。

注意关键点:exit_mmap() 走的是正常的对匿名页的引用计数递减,没有发生任何数据拷贝。子进程从未写过那些页,它们仍然是父进程的物理页,只是子进程的 PTE 被清空、引用计数减一。父进程那边的 PTE 依然是「只读 + 共享」,直到父进程自己写入、page_count 变成 1,才在缺页里被就地改成可写而无需复制(do_wp_page() 里的 page_count == 1 快路)。

所以「fork + exec 会不会白复制内存」的准确答案是:不会复制数据页,但会付出页表页复制、rmap 遍历、以及一次完整的地址空间销毁/重建的代价。在 execve 前,子进程的页表页是真实被分配和复制的(这部分成本已经花掉);execve 时这些页表页又被释放。一进一出就是 fork+exec 模式被诟病的根源。这不是内存拷贝的浪费,是页表分配、反向映射维护和 TLB 冲刷的浪费。

可以用 strace -c -f 观察一个 fork+exec 程序,但更直观的是看 /proc/<pid>/status 里的 VmPTE:在 fork 出的子进程 exec 前读一次,exec 之后(同一个 pid,exec 不换 pid)再读一次,VmPTE 会显著下降,因为旧页表被释放、新程序刚开始只有很少的映射。这个对比能直接向面试官证明「exec 丢弃的是页表而不是复制物理页」。

还有一个容易混淆的相邻机制:vfork() 和 posix_spawn()。前者完全不复制页表,子进程直接借用父进程的地址空间(父进程在此期间被挂起),所以就是为 fork+exec 场景设计的;后者在 glibc 里可能用 clone(CLONE_VM | CLONE_VFORK) 实现,也可能退回 fork,取决于平台和参数。面试如果能主动提到「现代 libc 用 posix_spawn 替代 fork+exec,正是为了绕开页表复制的开销」,说明不是只会背教科书。

页表项字段的逐状态对照

把上面的演变整理成表,面试时可以直接口述这一栏。以 x86-64 的 4 级页表、4KB 页为例,PTE 中与本题相关的位有:_PAGE_PRESENT(bit 0)、_PAGE_RW(bit 1)、_PAGE_USER(bit 2)、_PAGE_DIRTY(bit 6)、_PAGE_ACCESSED(bit 5)。注意 _PAGE_RW 是「可写」,清 0 表示只读,这与某些教材里把 RW 理解成「写保护位」相反,容易答错。

阶段 写方 PTE 非写方 PTE 物理页引用计数 谁的钱包在出血
fork 之前 present, RW=1 —(单一进程) 1 —
fork 返回,双方都未写 present, RW=0 present, RW=0 2 页表页复制、PTE 清位、TLB 冲刷
子进程写入 present, RW=1,指向新 PFN present, RW=0,旧 PFN 2 → 1(旧页) 一次 4KB 拷贝 + 新页分配
子进程 execve 旧 PTE 被 pte_clear 清空 present, RW=0,旧 PFN 1(旧页归父进程独占) exit_mmap 遍历 + 页表页释放
父进程在子 exec 后写入 present, RW=1(原 PFN,无需复制) — 1 无拷贝,仅 PTE 改位

表格最后一行是很多人漏掉的优化:当 page_count == 1 时,do_wp_page() 不会分配新页,而是直接复用原页并把 PTE 改成可写。这也是为什么 fork 后父进程单独活着、子进程早退的场景,开销几乎可以忽略。

另外需要区分 _PAGE_DIRTY。COW 复制的页是匿名页,dirty 位由软件维护,在 fork 时新页被标为 dirty,因为它含有用户数据。而文件映射的页在 COW 时要经过 do_wp_page() 里的 page_mkwrite 回调(对文件系统页)或直接复制(对 tmpfs 页)。如果面试被问「COW 会不会写回磁盘」,答案是不会,因为 COW 只发生在私有映射上,而私有映射的修改本来就不回写;共享映射(MAP_SHARED)根本不参与 COW,写直接落到 page cache,由回写线程统一处理。

常见误答与真实报错

这一节列几个现场能验证的坑,面试被追问时直接用现象答。

误答一:fork 之后写内存「会被操作系统拦截下来,把整段内存都拷贝一份」。 不是整段,是单个页,粒度 4KB。写 1 个字节和写 4096 个字节触发的拷贝量一样,但跨页的写入会触发多次缺页、多次拷贝。可以用一个循环跨页写 1MB 来验证 COW 次数,perf stat -e page-faults ./prog 里 minor fault 的数量约等于写入涉及的页数。

误答二:把 COW 的只读位和普通只读映射混为一谈。 如果对一个真的只读映射(mmap(PROT_READ))写入,报错是:

$ ./writero
Segmentation fault (core dumped)

内核发 SIGSEGV,错误码里 PF_PROT 位有效但 PF_W 位也有效——区别在于 do_wp_page() 里检查 VMA 的 VM_WRITE,没有就返回 VM_FAULT_SIGSEGV。而 COW 页的 VMA 有 VM_WRITE,所以走复制路径。同一个「只读 PTE」,结局完全不同,靠的就是 VMA 标志。

误答三:以为 exec 的时候子进程会「把父进程的内存还回去」。 子进程从来不拥有父进程的物理页产权,它只有一份 PTE 引用。exit_mmap() 递减的是引用计数,父进程的页一个字节都没动。如果你在 exec 之后读父进程的 smaps,会发现那块内存的 Shared_Clean 从 4 kB 降回 0(因为唯一共享者走了),Private_Dirty 不变,这只是统计口径变化,数据没动过。

一个真实的排查场景:很多服务在 fork 之后内存 RSS 暴涨,其实不是 COW 复制了数据,而是子进程大量写入了父进程的堆。用 /proc/<pid>/smaps_rollup 对比 fork 前后,如果 Private_Dirty 猛增而 Shared_Clean 下降,说明子进程确实在写 COW 页;如果两者都不变而 RSS 却上去了,那要看 Private_Clean(可能是新 mmap 的匿名页首次触碰缺页)。另一种可能是 MADV_DONTFORK 没设,大页或 DMA 缓冲被继承。

还有一类报错值得记住:在开启了 vm.overcommit_memory=2 的机器上,fork 可能因为 CommitLimit 不足而失败,返回 ENOMEM,此时 dmesg 里能看到:

fork: Cannot allocate memory

原因是 COW 虽然不立即复制页,但内核在 fork 时要为可能的最大复制量做 overcommit 记账(严格模式下 vm_enough_memory() 按 VMA 大小计),所以「fork 不开销内存」这个说法在 overcommit 严格模式下是不成立的。这也是为什么有些数据库文档要求关掉 overcommit 严格模式。

小结与 FAQ

把整条链路压缩成一句话:fork 把父子双方的写权限都关掉以共享物理页,首次写入在缺页里按页粒度复制;exec 不复制任何数据页,只是销毁旧地址空间、释放旧页表、把引用计数还给仍在映射该页的进程。页表的演变方向是「共享可写 → 共享写保护 → 或私有可写、或被清空」,物理页的归属则通过 page_count 和 rmap 精确追踪。

FAQ

Q:COW 页在页表里有没有专门标志位?
A:Linux 在通用 PTE 里没有「COW」位。判断依据是 PTE present 且不可写,同时 VMA 带 VM_WRITE 且非 VM_SHARED。部分架构会用软件位(如 PTE 的 _PAGE_BIT_SOFTW1)记录额外状态,但不能依赖。

Q:fork 之后读内存会触发 COW 吗?
A:不会。读只要求 present 位,PTE 已 present,CPU 不检查 RW 位。只有写才触发 fault。

Q:fork 后子进程 exec,父进程的物理页会被复制吗?
A:不会。exec 走 exit_mmap(),只清 PTE、减引用计数,没有数据拷贝。父进程随后写入时会发现 page_count == 1,直接在原地恢复写权限。

Q:为什么子进程 exec 后父进程写入不复制?
A:因为 do_wp_page() 的快速路径检查 page_count == 1,无其他映射者即可复用。判断在 wp_page_copy() 之前。

Q:vfork 和 fork 的页表差异在哪?
A:vfork 不复制页表,clone(CLONE_VM) 让子进程与父进程共享同一 mm_struct,父进程被挂起直到子进程 execve 或退出。省掉了页表复制,但子进程在 exec 前修改内存会影响父进程,非常危险,只适合立刻 exec 的场景。

Q:怎么在不 root 的情况下确认两块内存共享物理页?
A:看 /proc/<pid>/smaps 里对应 VMA 的 Shared_Clean 是否增长,或 Shared_Dirty。直接读 PFN 需要 root 或 CAP_SYS_ADMIN;普通用户读 pagemap 的 PFN 字段会被清零。更严格的做法是用 mincore() 只能看出是否驻留,看不出共享。

Q:透明大页(THP)下 COW 的粒度还是 4KB 吗?
A:不完全是。THP 映射的是 2MB 的 PMD 级页。对 THP 页做 COW 时,内核在 do_huge_pmd_wp_page() 里可能整页复制 2MB,也可能在 /sys/kernel/mm/transparent_hugepage/enabled 为 madvise 且应用显式 MADV_NOHUGEPAGE 时退回 4KB。这是 fork 后内存暴涨的一个常见来源,排查时建议先用 grep AnonHugePages /proc/<pid>/smaps | head 看一下。