Skip to content

cow:写时复制,把 fork 的复制推迟到真正写入时 ​

版本:cow,提交 cf0eb5bdaaa24f3a46a80d2702af3c9e260c2692。本页给出足以组织补丁的设计导读;配套 Python 模型可独立运行,但它不是 xv6 的最终补丁。先读虚拟内存与进程。

先算出优化价值 ​

父进程有 100 个用户数据页,朴素 fork 复制 409600 字节、额外占用 100 页。若子进程马上 exec,大部分复制完全没有被使用。COW 让父子页表暂时映射同一批只读物理页。假设之后只修改 3 页,数据复制量可降到 12288 字节;另有子页表、trapframe 等固定成本,并不等于 fork 零分配。

对用户的承诺仍是独立地址空间。父子都可能首先写,因此两边原可写 PTE 都必须去掉 W。只清子进程 W 会使父进程直接修改共享页,子进程看到本不应看到的数据变化。

补丁影响面 ​

位置要维持的关系
kernel/riscv.h使用 RSW 中的软件位区分 COW 与普通只读
kernel/kalloc.c每页引用数、加减操作、归零后回收
kernel/vm.c:uvmcopy共享用户叶页,权限转变,映射失败回滚
kernel/vm.c:vmfault区分已有 COW 映射与原有 lazy fault
kernel/vm.c:copyout内核写用户页同样先解决 COW
kernel/trap.c:usertrap沿已有 13/15 缺页入口检查参数与失败行为

uvmunmap 原本通过 kfree 释放页,若统一把 kfree 改为减引用,可让 exec、exit、fork 失败回滚沿原释放路径工作。但必须审查所有调用者,不要在某条路径手动减一次后又让 kfree 减第二次。

不变量先于代码 ​

第一,原来不可写的页永远不能因为 COW 获得写权限。第二,每个共享叶映射都占一个引用,包括共享只读文本。第三,引用数大于零的页绝不进入 freelist。第四,新页分配或安装失败时,旧映射与旧内容仍可用。第五,返回用户前不会残留可绕过新权限的旧 TLB 翻译。

在实际分配器里,kalloc 返回尚未挂入用户页表的页也需要一个“已拥有”引用,因此引用数不应机械地理解成任何瞬间都恰等于用户 PTE 数量。它是分配、共享和释放协议中的所有权计数。Python 模型简化掉临时持有者,注释里明确了差异。

第一步:建立可靠的引用计数 ​

使用按物理页索引的计数数组,例如 pa/PGSIZE,或减去 KERNBASE 后索引;两种方案都要验证索引范围。用专门的锁保护计数。kalloc 成功给出初始引用;共享时增加;kfree 先减少,只有到零才填充垃圾并挂回 freelist。

启动时 freerange 直接把一批页送进 kfree,这是常见陷阱:若计数初始为零,新的 kfree 会减成负数。需要为这条引导路径建立一致的初始所有权,或采用单独的入池过程。还要写下计数锁与 freelist 锁的顺序,避免一个路径先 A 后 B、另一个先 B 后 A。

第二步:修改 uvmcopy ​

2025 骨架允许惰性分配洞,遇到不存在的 PTE 或无效项会跳过;不要重新加上旧版本“所有页都必须存在”的 panic。对有效映射,保存其物理页和 flags。若原来可写,则转换成只读并打 COW;若已经 COW,必须保留 COW;若原来只读且非 COW,保持原样。

然后在子页表建立映射并增加共享所有权。无论选择先 retain 还是先 map,都要为下一步失败准备精确的撤销:失败位置以前已建立的子映射应统一解除,失败位置本身若尚未映射却已经 retain,需单独退回那一次引用。父 PTE 已转成 COW 但 fork 最终失败,可以保留为“单引用 COW”,后续写时恢复可写;不必为了视觉上还原状态而冒险进行第二套回滚。

第三步:在 vmfault 中识别 COW ​

原函数对 ismapped() 的页直接返回失败,所以 COW 识别必须放在这一拒绝之前。还应检查地址范围、PTE_V、PTE_U、COW 标记以及访问类型。usertrap 将 load fault 13 传 read=1、store fault 15 传 read=0。普通只读页的 store fault 不能进入分配可写页的路径。

以下是原创教学伪代码,不是已验证的 C 补丁:

text
resolve_cow(pagetable, va):
    找到有效、用户可访问、带 COW 标记的叶项
    若唯一引用:清 COW,恢复 W,完成必要翻译同步
    否则:
        申请新页;失败则保留原映射并返回失败
        复制旧页的 4096 字节
        将当前 PTE 换到新页,恢复 W,清 COW
        完成必要翻译同步
        释放当前映射对旧页的引用
    返回本进程现在可写的物理页

最简单的正确初版也可以在单引用情况下复制,先让语义正确,再加唯一引用优化。这个优化的正确性依赖 xv6 的执行模型与引用同步;不要把未经分析的 if(ref==1) 直接搬到多线程地址空间的生产内核。

2025 返回 trampoline 时执行 sfence.vma,帮助建立用户返回边界;仍需说明自己改变 PTE 后何时使用新映射。xv6 一个进程不会同时在多个 hart 执行,生产内核的跨核 TLB shootdown 是另一层工作。

第四步:copyout 不能漏掉 ​

现有 copyout 在 walkaddr 找不到页时调用 vmfault,但 COW 页是存在的,walkaddr 会返回物理地址;随后原始代码因为没有 PTE_W 而报错。因此仅修改 vmfault 还不够。copyout 应主动识别 COW,调用共同处理函数,重新取得物理地址后再复制。

跨页复制必须逐页重复检查。若从第一页末尾 2 字节开始写 4 字节,会影响两页,各自可能需要拆分。不要只为起始地址解决一次 COW。内核通过直接映射写物理页时,不会自动触发用户页表的写保护异常。

测试驱动的排错 ​

先运行 cowtest,再运行 usertests -q,最后 make grade。第一项 simple 失败常提示仍在 eagerly copy 或共享引用不完整;写后父子相互污染提示父 W 未清或 copyout 绕过;forkfork 后崩溃提示二次 fork 丢失 COW;反复运行后耗尽内存提示泄漏;只读文本可写提示 COW 与普通只读混淆。

额外测试:父先退出、子先退出、fork 后双方各写、已经 COW 的子再次 fork、内存耗尽时写 COW、read 系统调用将数据 copyout 到 COW 缓冲区、跨页 copyout。配套 test_models.py 已覆盖这些概念中的多项,真实异常、锁和页表分配失败仍需 QEMU 验证。

自测:为什么分配失败时不能先撤销原 PTE?

旧 PTE 是当前进程唯一可靠的内容入口。先删除再申请,一旦内存耗尽就需要额外恢复逻辑;若引用也提前减少,旧物理页可能已被重用。先准备新资源、成功后替换,可把失败保留在无副作用阶段。

来源:2025 COW 官方任务,固定 cow 提交中的 vm.c/trap.c/kalloc.c/user/cowtest.c。