xv6-lab:Copy-on-Write Fork
在原来的xv6设计中,fork()
系统调用会将父进程的整个用户空间内存完整复制到子进程中,如果父进程占用大量内存,这个复制过程就会非常耗时;更糟糕的是,这个复制往往是浪费的 :例如,许多程序在 fork()
之后马上在子进程中调用 exec()
,这样子进程就会丢弃刚刚复制过来的内存 ,而这些内存大部分甚至从未被使用过。另一方面,如果父子进程都要访问某些页面,并且其中某一方或双方会写这些页面,那么对内存页进行复制就是有必要的。
我们可以借鉴之前延迟分配内存的思路,延迟复制fork()出的子进程的内存,只有当子进程或者父进程使用到了才进行复制并且在进程对应的页表分配映射。COW fork()
的目标是:延迟(甚至避免)为子进程分配和复制物理内存页,直到确实需要复制为止。
具体做法如下:
-
COW fork()
为子进程创建一个新的页表; -
子进程页表中的每个页表项(PTE)都指向父进程当前的物理页;
-
然后将父子两边的所有用户页都标记为"不可写"(只读)。
当父进程或子进程中的任意一个尝试对这些"写时复制"的页面执行写操作时:CPU 会触发一个页错误(page fault),内核的页错误处理程序识别出这种特殊情况后,就会为触发错误的进程分配一个新的物理页,将原来的页面内容复制到新页中,然后修改该进程的页表,将对应虚拟页映射到新物理页,并标记为可写。页错误处理函数返回后,用户进程就可以对它自己的副本页面进行写操作,而不会影响到另一个进程。
由于多个进程的页表可能引用同一个物理页面,所以在释放物理页面时要格外小心,只有当所有引用都消失时 ,该物理页面才能真正被释放。这意味着 xv6 的内核需要维护引用计数,以便知道某个页面是否还在被其他进程共享使用。
这里主要讲思路,代码可以参考这篇博客。
首先我们需要修改fork()
函数,使其不立刻为子进程复制内存并处理映射,首先修改 uvmcopy(),因为在原本的fork中就是调用了这个函数复制内存
c
// fork函数中的操作
// Copy user memory from parent to child.
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
freeproc(np);
release(&np->lock);
return -1;
}
在复制父进程的内存到子进程的时候,不立刻复制数据,而是建立指向原物理页的映射,并将父子两端的页表项都设置为不可写。
c
// kernel/vm.c
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
for(i = 0; i < sz; i += PGSIZE){
if((pte = walk(old, i, 0)) == 0)
panic("uvmcopy: pte should exist");
if((*pte & PTE_V) == 0)
panic("uvmcopy: page not present");
pa = PTE2PA(*pte);
if(*pte & PTE_W) {
// 清除父进程的 PTE_W 标志位,设置 PTE_COW 标志位表示是一个懒复制页(多个进程引用同个物理页)
*pte = (*pte & ~PTE_W) | PTE_COW;
}
flags = PTE_FLAGS(*pte);
// 将父进程的物理页直接 map 到子进程 (懒复制)
// 权限设置和父进程一致
// (不可写+PTE_COW,或者如果父进程页本身单纯只读非 COW,则子进程页同样只读且无 COW 标识)
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
goto err;
}
// 将物理页的引用次数增加 1
krefpage((void*)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
fork 后,父子进程的页表都指向同一份物理页。但我们需要区分哪些页是"普通共享",哪些页是"写时复制"。PTE_COW 就是用来标记"这个页是写时复制的",即"只要有写操作就要复制"。COW 页通常会去掉写权限(PTE_W),这样一旦进程写入该页,就会触发缺页异常(page fault)。内核在异常处理时,通过检查页表项的 PTE_COW 标志,判断是不是 COW 页。如果是 COW 页,内核就分配新物理页、复制内容、更新页表,恢复写权限。
过程如下:
- fork 时,父子进程的页表项都指向同一物理页,并加上 PTE_COW 标志,去掉 PTE_W。
- 某进程写入该页,触发缺页异常。
- 内核检查页表项,发现有 PTE_COW,说明是写时复制页。
- 内核分配新物理页,复制内容,更新页表项,去掉 PTE_COW,加上 PTE_W。
- 进程继续写入,互不影响。
所以我们还要在riscv.h
中定义这个标志位:
c
#define PTE_COW (1L << 8) // 是否为懒复制页,使用页表项 flags 中保留的第 8 位表示
// (页表项 flags 中,第 8、9、10 位均为保留给操作系统使用的位,可以用作任意自定义用途)
这样,fork 时就不会立刻复制内存,只会创建一个映射了。这时候如果尝试修改懒复制的页,会出现 page fault 被 usertrap() 捕获。接下来需要在 usertrap() 中捕捉这个 page fault,并在尝试修改页的时候,执行实复制操作。
我们还需要在usertrap
中添加对写COW页的检测,这样就会在修改页时进行实复制操作。
c
// kernel/trap.c
void
usertrap(void)
{
// ......
} else if((which_dev = devintr()) != 0){
// ok
} else if((r_scause() == 13 || r_scause() == 15) && uvmcheckcowpage(r_stval())) { // copy-on-write
if(uvmcowcopy(r_stval()) == -1){ // 如果内存不足,则杀死进程
p->killed = 1;
}
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
// ......
}
与之前的延迟分配优化一样,我们还需要修改copyout()
函数,这个函数在用户空间和内核空间中拷贝数据,此时可能会访问用户空间还未分配的虚拟页。如果目标虚拟页还没有分配物理内存,直接访问会导致缺页异常或出错。所以在真正访问虚拟地址前,先判断是否需要分配物理页,如果需要就分配,保证后续的内存访问是合法的。
C
// kernel/vm.c
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
if(uvmcheckcowpage(dstva)) // 检查每一个被写的页是否是 COW 页
uvmcowcopy(dstva);
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
// .......memmove from src to pa0
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
// ......
}
此时我们已经完成了大部分逻辑:在 fork 的时候不复制数据只建立映射+标记,在进程尝试写入的时候进行实复制并重新映射为可写。
接下来,还需要做页的生命周期管理,确保在所有进程都不使用一个页时才将其释放。
我们需要对物理内存进行添加引用技术的操作,所以我们要在kalloc.c
文件中定义一系列的新函数,用于完成在支持懒复制的条件下的物理页生命周期管理。支持了COW之后,由于一个物理页可能被多个进程(多个虚拟地址)引用,并且必须在最后一个引用消失后才可以释放回收该物理页,所以一个物理页的生命周期内,现在需要支持以下操作:
- kalloc(): 分配物理页,将其引用计数置为 1
- krefpage(): 创建物理页的一个新引用,引用计数加 1
- kcopy_n_deref(): 将物理页的一个引用实复制到一个新物理页上(引用计数为 1),返回得到的副本页;并将本物理页的引用计数减 1
- kfree(): 释放物理页的一个引用,引用计数减 1;如果计数变为 0,则释放回收物理页
一个物理页 p 首先会被父进程使用 kalloc() 创建,fork 的时候,新创建的子进程会使用 krefpage() 声明自己对父进程物理页的引用。当尝试修改父进程或子进程中的页时,kcopy_n_deref() 负责将想要修改的页实复制到独立的副本,并记录解除旧的物理页的引用(引用计数减 1)。最后 kfree() 保证只有在所有的引用者都释放该物理页的引用时,才释放回收该物理页。