第十九章:写时复制
筑基初期涉及内核源码:
一
林小源终于理解了 COW 的全貌。
当 复制父进程的地址空间时,它不会真正复制物理内存页。它只复制页表——把父子进程的页表项指向同一批物理页,但把这些页标记为"只读"。
当任何一个进程试图写入这些"只读"页时,MMU 触发一个缺页异常。内核在异常处理中发现这是一个 COW 页,于是才真正复制那一页——为写入的进程分配一个新的物理页,复制内容,更新页表。
这就是"写时复制"——只有在真正需要写入的时候,才复制。
/*
* 写时复制的核心思想:
* 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");#include <stdio.h>
#include <string.h>
/*
* 写时复制的核心思想:
* 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);
}
}
int main() {
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");
return 0;
}林小源望着这段代码,脑海中浮现出一幅画面: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 位无效时,访问这个页直接触发缺页——这是页表的基本保护。R、W、X 三位组合起来,决定了这个页能做什么、不能做什么。U 位控制用户态能不能访问——内核页一般不开 U,用户态一碰就缺页。A 和 D 是硬件帮你记的——谁访问过、谁修改过,内核不用自己追踪。"
"那 G 位呢?"
"G 是 Global——这个页在所有地址空间中都有效。内核的代码和数据在每个进程中都映射到相同的虚拟地址,所以设 G 位可以让 TLB 在切换进程时不用刷新这些条目。性能优化,细节决定成败。"
林小源把这些信息默默记下。页表不只是地址映射——它是一套完整的权限和行为控制系统,由硬件和内核共同维护。
道藏笔记
内核启示
写时复制(COW)是 fork() 的核心优化。
RISC-V 的页表项(PTE)里有一堆标志位各管各的:V(Valid)管页表项是否有效,R/W/X 分别管读写执行权限,U 管用户态能不能访问,A(Accessed)和 D(Dirty)是硬件自动设置的——被读过就置 A,被写过就置 D。
COW 的实现:
- fork 时,把父子进程的页表项
W位清零,设置 COW 标记 - 增加物理页的引用计数
- 当任一进程写入时,MMU 触发缺页异常
- 内核检查 PTE,发现是 COW 页
- 分配新页,复制内容,更新页表为可写
- 减少原物理页的引用计数
如果引用计数降为 0,说明没有进程再引用这个页,可以被释放。
COW 的本质是对"读多写少"的乐观假设。这种假设在大多数场景下成立——fork 后 exec 的情况尤其如此。
写时之试
fork 后父子进程先共享页面,直到写入时才真正复制;本章讲的这种机制叫什么?