Skip to content

第十九章:写时复制

筑基初期

涉及内核源码:

林小源终于理解了 COW 的全貌。

复制父进程的地址空间时,它不会真正复制物理内存页。它只复制页表——把父子进程的页表项指向同一批物理页,但把这些页标记为"只读"。

当任何一个进程试图写入这些"只读"页时,MMU 触发一个缺页异常。内核在异常处理中发现这是一个 COW 页,于是才真正复制那一页——为写入的进程分配一个新的物理页,复制内容,更新页表。

这就是"写时复制"——只有在真正需要写入的时候,才复制。

c
/*
 * 写时复制的核心思想:
 * fork 后父子进程共享物理页(只读),
 * 只有当某一方写入时才真正复制。
 */

#define PAGE_SIZE 4096

struct page_table_entry {
    unsigned long ppn;       /* 物理页帧号 */
    int writable;            /* 是否可写 */
    int cow;                 /* 是否 COW 页 */
    int refcount;            /* 引用计数 */
};

struct physical_page {
    char data[PAGE_SIZE];
};

void page_fault(struct page_table_entry *pte,
                struct physical_page *old_page,
                const char *process_name) {
    if (pte->cow && !pte->writable) {
        printf("  [%s] 缺页异常: COW 页,需要复制\n", process_name);

        /* 分配新页 */
        struct physical_page new_page;
        memcpy(&new_page, old_page, PAGE_SIZE);

        /* 更新页表 */
        pte->writable = 1;
        pte->cow = 0;
        old_page->data[0] = 'X';  /* 写入新页 */

        printf("  [%s] 复制完成,页表已更新\n", process_name);
    }
}

printf("=== 写时复制演示 ===\n\n");

/* 共享物理页 */
struct physical_page shared_page;
strcpy(shared_page.data, "Hello from parent");

/* 父子进程的页表项指向同一页 */
struct page_table_entry parent_pte = {
    .ppn = 0x1000,
    .writable = 0,   /* 只读 */
    .cow = 1,        /* COW 标记 */
    .refcount = 2,   /* 两个进程引用 */
};
struct page_table_entry child_pte = {
    .ppn = 0x1000,   /* 同一个物理页 */
    .writable = 0,
    .cow = 1,
    .refcount = 2,
};

printf("fork 后:\n");
printf("  父进程页表: ppn=0x%lX, COW=%d, refcount=%d\n",
       parent_pte.ppn, parent_pte.cow, parent_pte.refcount);
printf("  子进程页表: ppn=0x%lX, COW=%d, refcount=%d\n",
       child_pte.ppn, child_pte.cow, child_pte.refcount);
printf("  物理页内容: \"%s\"\n\n", shared_page.data);

/* 子进程写入 → 触发 COW */
printf("子进程写入...\n");
page_fault(&child_pte, &shared_page, "子进程");

printf("\nCOW 后:\n");
printf("  父进程页表: ppn=0x%lX, writable=%d, COW=%d\n",
       parent_pte.ppn, parent_pte.writable, parent_pte.cow);
printf("  子进程页表: ppn=新页,  writable=%d, COW=%d\n",
       child_pte.writable, child_pte.cow);

printf("\n--- COW 的价值 ---\n");
printf("fork 后不复制物理页 → fork 极快\n");
printf("大多数 fork 后立即 exec → 根本不需要复制\n");
printf("只有真正写入时才复制 → 节省内存\n");

林小源望着这段代码,脑海中浮现出一幅画面:fork 之后,父子进程各自拿着一本"指路手册"(页表),手册上写的都是同一个地址——同一批物理页。但手册的每一页都盖着一个红戳:"只读"。

"精妙的懒惰。"他低声赞叹。

"你说懒惰?"一个尖锐的声音从页表的方向传来。林小源抬头一看,一个浑身闪烁着页表项光芒的小个子正叉着腰瞪着他——那是 COW 守护者,一个脾气暴躁但心思缜密的家伙。

"我不是懒惰,"COW 守护者气鼓鼓地说,"我是聪明。你知道 fork 之后会发生什么吗?十次有九次,子进程立刻调用 ——execve 会把整个地址空间推倒重来。如果我在 fork 时就把所有物理页都复制一遍,那不是白费力气吗?"

林小源承认他说得有道理。

"而且,"COW 守护者继续说,"就算不 execve,父子进程也多半在读同一份数据。写入?那是少数情况。我只在少数情况发生时才复制——这就是我的哲学:能不做的事就不做,必须做的时候才做。"

林小源在 COW 中看到了一种设计哲学:能不做的事就不做,必须做的时候才做。这和人类的"惰性"不同——这是一种"聪明的惰性",通过推迟工作来避免不必要的开销。

林小源在研究 COW 的过程中,又收到了一条 dmesg 消息。

[  456.789012] idle: COW 不只是懒惰。它是信任的体现。

又是那个神秘的"日志仙翁"。

信任?

林小源不理解。COW 和信任有什么关系?

他反复思考这条消息的含义。COW 的前提是:父子进程不会同时写入同一页。如果它们同时写入,COW 就会频繁触发缺页异常,性能反而不如直接复制。

COW 守护者听到这话,罕见地沉默了一会儿,然后轻声说:"日志仙翁说得对。我的设计建立在一个假设之上——大多数 fork 之后会立即 exec,exec 会丢弃整个地址空间,根本不需要复制。即使不 exec,父子进程也倾向于读取共享的数据,而不是修改它。"

"所以你是在'赌'?"林小源问。

"不是赌,"COW 守护者纠正道,"是信任。我信任绝大多数进程的行为模式。如果这个信任被打破——比如某个程序 fork 之后疯狂写入——我的性能确实不如直接复制。但几十年的实践证明,这个信任是成立的。"

林小源点了点头。COW 不只是懒惰,它是对"大多数情况下不会写入"的信任。这种信任建立在几十年的操作系统实践之上,经受住了时间的考验。

林小源继续研究 COW 的实现细节。

在 RISC-V 的页表项中,有一个 W 位(Write)控制是否可写。当 W=0 时,写入会触发缺页异常。COW 就是利用这个位:fork 时把所有页表项的 W 位清零,当缺页异常发生时,内核检查是否是 COW 页,如果是就复制并设置 W=1

页表项还有一个 R 位(Reference)用于 LRU 算法,D 位(Dirty)标记页是否被修改过。这些位由硬件自动设置,内核可以读取它们来做出决策——比如哪些页可以被换出到磁盘。

林小源在前传中学过页表的基本概念,但直到现在他才真正理解页表项的每一个位都有意义。V(Valid)、R(Read)、W(Write)、X(Execute)、U(User)、G(Global)、A(Accessed)、D(Dirty)——八个位,控制着一个页的所有行为。

COW 守护者在一旁补充道:"你别小看这八个位。V 位无效时,访问这个页直接触发缺页——这是页表的基本保护。RWX 三位组合起来,决定了这个页能做什么、不能做什么。U 位控制用户态能不能访问——内核页一般不开 U,用户态一碰就缺页。AD 是硬件帮你记的——谁访问过、谁修改过,内核不用自己追踪。"

"那 G 位呢?"

"G 是 Global——这个页在所有地址空间中都有效。内核的代码和数据在每个进程中都映射到相同的虚拟地址,所以设 G 位可以让 TLB 在切换进程时不用刷新这些条目。性能优化,细节决定成败。"

林小源把这些信息默默记下。页表不只是地址映射——它是一套完整的权限和行为控制系统,由硬件和内核共同维护。


道藏笔记

内核启示

写时复制(COW)是 fork() 的核心优化。

RISC-V 的页表项(PTE)里有一堆标志位各管各的:V(Valid)管页表项是否有效,R/W/X 分别管读写执行权限,U 管用户态能不能访问,A(Accessed)和 D(Dirty)是硬件自动设置的——被读过就置 A,被写过就置 D

COW 的实现:

  1. fork 时,把父子进程的页表项 W 位清零,设置 COW 标记
  2. 增加物理页的引用计数
  3. 当任一进程写入时,MMU 触发缺页异常
  4. 内核检查 PTE,发现是 COW 页
  5. 分配新页,复制内容,更新页表为可写
  6. 减少原物理页的引用计数

如果引用计数降为 0,说明没有进程再引用这个页,可以被释放。

COW 的本质是对"读多写少"的乐观假设。这种假设在大多数场景下成立——fork 后 exec 的情况尤其如此。


破关试炼

写时之试

fork 后父子进程先共享页面,直到写入时才真正复制;本章讲的这种机制叫什么?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道