Skip to content

第六十二章:写时复制

元婴中期

涉及内核源码:

林小源站在内景之中,眼前是一片熟悉的景象——那些悬浮的 VMA 大陆,半透明的页表地面,还有远处那片不可触及的内核虚空。

但今天有些不同。他注意到自己脚下有两个人影——不,不是人影,是两团光。一团在左边,一团在右边,各自踩着一模一样的地面。

"那是你的父进程和子进程。"mm_struct 的声音从虚空中飘来,"刚刚执行了 。"

林小源蹲下身子,仔细看两团光脚下的地面。他愣住了——两团光踩的竟然是同一块地面。同一块物理页面,被两个人同时踩着。

"它们共享同一块地?"

"对。"mm_struct 说,"写时复制。Copy-on-Write。 的时候,子进程复制父进程的页表,但不复制物理页面。所有页面标记为只读,加上 COW 标志。"

林小源伸手触碰那块共享的地面。一股信息涌入脑海——页面的引用计数是 2,PTE 的 W 位被清除了,取而代之的是一个他没见过的标志位,散发着暗红色的光。

"COW 标志。"mm_struct 说,"PTE_COW。硬件不认识这个标志——这是内核自己定义的。当进程尝试写入一个只读页面时,硬件触发页 fault,内核检查 PTE,发现 COW 标志——然后才分配新页面、复制内容、更新页表。"

"所以 根本没有复制页面?"

"没有。"mm_struct 的声音里带了一丝得意,"只复制了页表。页表才多大?几十 KB。物理页面可能有几百 MB。你要是把几百 MB 全复制一遍, 得慢成什么样?"

林小源看着那两团光。它们踩着同一块地面,各自以为自己拥有这块地。只有当其中一个人试图挖地的时候,内核才会悄悄给它一块新地。

"这是一种欺骗。"他低声说。

"这是一种优化。"mm_struct 纠正他。

林小源盯着那块共享的地面看了很久。

突然,右边那团光动了——它举起手,朝地面拍了下去。

一瞬间,整个内景震动了。地面裂开了一道缝,一个低沉的声音从裂缝中传来:

"页 fault。"

林小源吓了一跳。一个身穿灰色铠甲的身影从裂缝中升起,面无表情,手里握着一把锤子。铠甲上刻着 ——这是页 fault 处理函数。

"写入 COW 页面。"do_wp_page 的声音冰冷而机械,"检查引用计数。"

它低头看了一眼地面,那块共享页面的引用计数浮现在空气中——refcount = 2

"引用计数大于 1。"do_wp_page 说,"需要复制。"

它举起锤子,猛地砸向地面。地面碎裂成两块——一块留在原地,一块被推向右边那团光。碎片重新组合,两块一模一样的地面出现在眼前。左边那块的引用计数变成了 1,右边那块也是 1。

"复制完成。"do_wp_page 说,"更新页表。清除 COW 标志,设置写标志。"

它转身看向林小源:"页面引用计数是共享的度量。refcount > 1,多个进程共享同一块物理页面,写入需要 COW。refcount == 1,独占,可以直接写入——不需要触发页 fault。"

林小源看着那两块分离的地面。它们曾经是一体的,现在各自独立。左边那团光可以自由地读,右边那团光可以自由地写。互不干扰。

"如果引用计数本来就是 1 呢?"他问。

do_wp_page 收起锤子:"那就不用复制。直接改 PTE 的写标志就行。省了一次页面分配和内存拷贝。"

它说完,沉回裂缝中,消失了。

林小源在内景中继续走着,看到了更多 后的景象。

有些子进程只读了一点数据就退出了——它们共享的物理页面从未被写入,COW 从未触发。那些页面的引用计数从 2 又变回 1,因为子进程退出时减少了引用计数。

"这些页面从头到尾没有被复制过。"他喃喃自语。

"这就是 COW 的精髓。"mm_struct 说,"不写不复制,写了才分离。"

但更让林小源震惊的是另一种场景——一个子进程 之后,立刻调用了

他看到那团光刚从父进程分裂出来,还没来得及做任何事,就被一股巨大的力量裹挟。它的整个地址空间被撕碎、替换,变成了一个全新的程序。之前共享的那些页面——全部释放,引用计数清零。

" + 。"mm_struct 说,"最常见的用法。shell 执行命令就是这样的——先 创建子进程,再 替换程序。由于 COW, 不需要复制任何物理页面。 会替换整个地址空间,所以之前共享的页面全部释放。"

"所以 COW 就是为了这个场景优化的?"

"主要场景。"mm_struct 说,"你想想——如果 要复制几百 MB 的物理页面,然后 立刻把它们全扔掉,那之前的复制不就白费了?COW 让 变得极快,也让 不需要处理那些从未写入的页面。"

林小源沉默了。他看着那些 + 的光团,每一个都只用了极短的时间就完成了进程创建和程序替换。如果没有 COW,这些操作会慢得多。

"他忽然明白过来——COW 是给 + 铺的路。"

"不。"mm_struct 说,"COW 是给所有 铺的路。 + 只是最大的受益者。"


c
/*
 * 写时复制(Copy-on-Write,COW)的实现:
 *
 * fork() 时:
 *   1. 子进程复制父进程的页表
 *   2. 所有页面标记为只读 + COW
 *   3. 页面的引用计数增加
 *
 * 写入时:
 *   1. CPU 尝试写入只读页面
 *   2. 触发页 fault
 *   3. 内核检查 PTE 标志
 *   4. 如果是 COW 页面:
 *      a. 分配新的物理页
 *      b. 复制内容
 *      c. 更新页表为可写
 *      d. 减少原页面的引用计数
 *   5. 进程继续执行
 *
 * COW 的好处:
 *   - fork() 不需要复制所有页面
 *   - 只有在写入时才复制
 *   - 如果 execve() 立即调用,根本不需要复制
 */

#define PTE_V (1 << 0)
#define PTE_R (1 << 1)
#define PTE_W (1 << 2)
#define PTE_COW (1 << 8)  /* 自定义 COW 标志 */

struct page {
    int refcount;
    char data[64];  /* 简化的页面数据 */
};

struct pte {
    unsigned long flags;
    struct page *page;
};

printf("=== COW — 写时复制的深层实现 ===\n\n");

/* 模拟物理页 */
struct page phys_page = { .refcount = 1 };
sprintf(phys_page.data, "Hello from parent");

/* 父进程的 PTE */
struct pte parent_pte = {
    .flags = PTE_V | PTE_R | PTE_W,
    .page = &phys_page,
};

printf("fork() 之前:\n");
printf("  物理页 refcount = %d\n", phys_page.refcount);
printf("  PTE flags: V=%d R=%d W=%d COW=%d\n\n",
       (parent_pte.flags & PTE_V) ? 1 : 0,
       (parent_pte.flags & PTE_R) ? 1 : 0,
       (parent_pte.flags & PTE_W) ? 1 : 0,
       (parent_pte.flags & PTE_COW) ? 1 : 0);

/* fork() — 子进程复制页表,标记为 COW */
printf("fork() 执行:\n");
printf("  子进程复制父进程的页表\n");
printf("  所有页面标记为只读 + COW\n\n");

/* 更新 PTE */
parent_pte.flags &= ~PTE_W;  /* 清除写标志 */
parent_pte.flags |= PTE_COW; /* 设置 COW 标志 */
phys_page.refcount = 2;       /* 引用计数增加 */

printf("fork() 之后:\n");
printf("  物理页 refcount = %d\n", phys_page.refcount);
printf("  PTE flags: V=%d R=%d W=%d COW=%d\n\n",
       (parent_pte.flags & PTE_V) ? 1 : 0,
       (parent_pte.flags & PTE_R) ? 1 : 0,
       (parent_pte.flags & PTE_W) ? 1 : 0,
       (parent_pte.flags & PTE_COW) ? 1 : 0);

/* 子进程尝试写入 → 触发 COW */
printf("子进程尝试写入 → 触发页 fault\n");
printf("  内核检测到 COW 标志\n");
printf("  分配新的物理页\n");
printf("  复制内容\n");
printf("  更新页表为可写\n");
printf("  减少原页面的引用计数\n\n");

/* COW 完成 */
struct page new_page = { .refcount = 1 };
sprintf(new_page.data, "Hello from parent");
phys_page.refcount = 1;

printf("COW 完成:\n");
printf("  原物理页 refcount = %d\n", phys_page.refcount);
printf("  新物理页 refcount = %d\n", new_page.refcount);
printf("  子进程现在有自己的页面\n");

printf("\n--- COW 的优势 ---\n");
printf("1. fork() 快速 — 只复制页表,不复制页面\n");
printf("2. 节省内存 — 共享页面直到写入\n");
printf("3. execve() 友好 — 如果立即 execve(),不需要复制\n");

道藏笔记

内核启示

写时复制(COW)是 的核心优化。 时子进程只复制父进程的页表,不复制物理页面——所有页面标记为只读加 COW 标志,引用计数加一。当子进程尝试写入时,硬件触发页 fault,内核发现是 COW 页面,就分配新页面、复制内容、更新页表,引用计数减一。

这招的好处是 极快——只复制几十 KB 的页表,不用复制几百 MB 的物理页面。更妙的是 + 的组合:子进程 fork 出来,execve 立刻替换整个地址空间,之前共享的页面全部释放——根本不需要复制。shell 执行命令就是这么干的。

判断页面是否需要 COW 很简单:refcount > 1 说明多个进程共享,写入需要 COW;refcount == 1 说明独占,直接改 PTE 写标志就行,省了一次页面分配和内存拷贝。COW 是"共享"和"独立"的平衡——不写不复制,写了才分离。


破关试炼

写时复制之试

fork 后父子先共享物理页,直到写入才复制;本章讲的这种机制叫什么?

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

以修仙之名,悟内核之道