第六十二章:写时复制
元婴中期涉及内核源码:
一
林小源站在内景之中,眼前是一片熟悉的景象——那些悬浮的 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 是给所有 铺的路。 + 只是最大的受益者。"
/*
* 写时复制(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");#include <stdio.h>
/*
* 写时复制(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;
};
int main() {
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");
return 0;
}道藏笔记
内核启示
写时复制(COW)是 的核心优化。 时子进程只复制父进程的页表,不复制物理页面——所有页面标记为只读加 COW 标志,引用计数加一。当子进程尝试写入时,硬件触发页 fault,内核发现是 COW 页面,就分配新页面、复制内容、更新页表,引用计数减一。
这招的好处是 极快——只复制几十 KB 的页表,不用复制几百 MB 的物理页面。更妙的是 + 的组合:子进程 fork 出来,execve 立刻替换整个地址空间,之前共享的页面全部释放——根本不需要复制。shell 执行命令就是这么干的。
判断页面是否需要 COW 很简单:refcount > 1 说明多个进程共享,写入需要 COW;refcount == 1 说明独占,直接改 PTE 写标志就行,省了一次页面分配和内存拷贝。COW 是"共享"和"独立"的平衡——不写不复制,写了才分离。
写时复制之试
fork 后父子先共享物理页,直到写入才复制;本章讲的这种机制叫什么?