B10 —— 为什么 fork 很快:Copy-On-Write 机制

金句:不复制,只共享——直到真正需要的时候。


fork 的传统问题:大量内存复制

fork 系统调用创建新进程,传统实现需要复制父进程的整个内存空间:

Bash
传统 fork 的问题:

父进程内存:假设 1GB
fork() 执行时:
  - 复制所有页表(PTE)
  - 复制所有物理页面(1GB)
  - 创建子进程的页表和页面

问题:
  1. 时间:复制 1GB 内存 → 耗时 100~500ms
  2. 空间:fork 后内存翻倍(2GB),即使子进程只用 10MB
  3. 浪费:fork + exec 的经典 Unix 模式,子进程会立即丢弃复制的内存

这就是为什么传统 Unix 的 fork 是"昂贵"的操作。

Copy-On-Write(COW)的核心思想

COW 的核心是:不复制内容,只复制页表的引用计数

Bash
COW 的基本原则:

1. fork 时:
   - 不复制物理页面
   - 只复制父进程的页表(指向同样的物理页面)
   - 把页面标记为"只读"(Read-Only)
   - 父子共享同一物理页面

2. 父或子写入时:
   - 触发 page fault(因为页面是只读的)
   - OS 分配新物理页
   - 复制内容到新页
   - 更新页表,指向新页
   - 把新页标记为"读写"

3. 结果:
   - fork 很快(只复制页表,不复制页面)
   - 实际复制延迟发生在第一次写入时
   - 如果子进程只读内存(如 exec 后),永远不复制

COW 的实现细节

Bash
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 分配新页,复制内容,继续执行

Bash
写入时的 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,可能是真正的错误
  // 发送 SIGSEGV

COW 的好处

Bash
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 的副作用和注意事项

Bash
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)

Bash
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不着急」,回复”资料”获取内存学习路线图。

最后修改: 2024年7月15日

作者

评论

发表评论

您的邮箱地址不会被公开。