Lab: Copy-on-Write Fork for xv6
实验准备
切换到cow分支
ruby
$ git fetch
$ git checkout cow
$ make clean
Implement copy-on-write fork
需求
在xv6操作系统中,fork()系统调用会将父进程的用户空间内存完全复制到子进程。如果父进程较大,复制操作可能需要很长时间。更糟糕的是,这个工作通常是大部分都浪费的:在子进程中通常会紧跟着执行exec(),它会丢弃复制的内存,并且通常不会使用其中大部分内容。然而,如果父进程和子进程都在使用复制的页面,并且其中一个或者两者都对其进行写操作,则确实需要复制内存。
您的任务是在xv6内核中实现写时复制fork。如果修改后的内核同时成功执行cowtest和'usertests -q'程序,您就完成了。
The solution
整体思路
在实现写时复制(Copy-on-write,COW)的fork()时,你的目标是推迟分配和复制物理内存页面,直到实际需要(如果需要的话)。
COW的fork()仅为子进程创建一个页表,其中包含指向父进程物理页面的用户内存PTE。COW的fork()将父进程和子进程中的所有用户PTE标记为只读。当其中一个进程尝试写入这些COW页面时,CPU将强制引发页故障。内核页故障处理程序会检测到这种情况,为出现故障的进程分配一个物理内存页,将原始页面复制到新页面,并修改故障进程中的相关PTE,以引用新页面,此时新的PTE被标记为可写。当页故障处理程序返回时,用户进程将能够写入其页面的副本。
COW的fork()使释放实现用户内存的物理页面变得有些棘手。给定的物理页面可能会被多个进程的页表引用,只有当最后一个引用消失时,才应该释放该页面。在像xv6这样的简单内核中,这种处理方式相对简单明了,但在生产内核中,这可能很难做到正确;例如,参考Patching until the COWs come home。
具体实现
首先我们要解决物理页的引用计数,初步的想法是使用int数组进行统计,由于xv6的物理页大小为4096,故我们可以用页的物理地址除以4096对数组进行索引。
由于qume模拟的处理器是多核的,故我们需要考虑fork后多个进程并行,同时修改引用计数数组造成的竞态条件问题。我们可使用自旋锁保护引用计数数组免受多个进程的并行访问。
c
#define NPAGE (PHYSTOP/PGSIZE)
struct{
struct spinlock lock;//自旋🔓
int counting[NPAGE]; // 计数数组
}lock_counting;
我们在kernel/kalloc.c中初始化自旋锁以及计数数组。并定义回收物理页、分配物理页所需要进行的操作:
c
void
kinit()
{
initlock(&lock_counting.lock, "lock_counting");//初始化自旋🔓
...
}
void
freerange(void *pa_start, void *pa_end)
{
...
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE){
lock_counting.counting[(uint64)p/PGSIZE] = 1;//为了配合kfree逻辑而进行的初始化
kfree(p);//系统初始时回收所有物理内存
}
}
void
kfree(void *pa)
{
...
acquire(&lock_counting.lock);//上🔒
if(--lock_counting.counting[(uint64)pa/PGSIZE] == 0){//减少引用计数并判断是否需要回收
release(&lock_counting.lock);//解🔓
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
else release(&lock_counting.lock);//解🔓
}
void *
kalloc(void)
{
...
acquire(&lock_counting.lock);//上🔒
lock_counting.counting[(uint64)r/PGSIZE] = 1;//分配物理页时需要初始化引用计数
release(&lock_counting.lock);//解🔓
return (void*)r;
}
为了判断虚拟页到物理页的映射是否是COW映射,我们可以使用第三级页表的PTE中的RSW(为软件保留)位来完成此操作。在kernel/riscv.h中添加以下宏定义:
c
#define PTE_COW (1L << 8)//COW标记位,用于判断cow导致Store page fault的情况
接下来是在kernel/vm.c修改进程内存的拷贝函数,使得fork时子进程不拷贝父进程物理内存,而只是将其虚拟页映射到父进程物理页上。
c
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
//char *mem;
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");
if((*pte & PTE_W)){//父进程拥有PTE_W
*pte &= ~PTE_W;//清除父进程PTE_W
*pte |= PTE_COW;//添加cow标记位
}
pa = PTE2PA(*pte);
flags = PTE_FLAGS(*pte);
//if((mem = kalloc()) == 0)//不分配物理内存
// goto err;
//memmove(mem, (char*)pa, PGSIZE);
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){//将子进程虚拟页映射到父进程物理页上
//kfree(mem);
goto err;
}
acquire(&lock_counting.lock);//上🔒
lock_counting.counting[pa/PGSIZE]++;//增加对应物理页的引用计数
release(&lock_counting.lock);//解🔓
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
然后是对Store page fault的处理,在kernel/trap.c中进行修改:
c
void
usertrap(void)
{
...
else {//异常处理
if(r_scause() == 15){//Store/AMO page fault
uint64 va = r_stval();//出错的虚拟地址
if(va >= MAXVA)goto err;//超最大虚拟地址
pte_t *pte = walk(p->pagetable, va, 0);
if((*pte & PTE_COW) == 0) goto err;//非cow情况
uint64 pa = PTE2PA(*pte);
uint flags = PTE_FLAGS(*pte);
acquire(&lock_counting.lock);//上🔒
if(lock_counting.counting[pa/PGSIZE] > 1){//多个进程引用该页面
lock_counting.counting[pa/PGSIZE]--;//减少引用记数
release(&lock_counting.lock);//解🔓
char *mem;
if((mem = kalloc()) == 0)//物理内存实在不够
setkilled(p);
else{
memmove(mem, (char*)pa, PGSIZE);
va = PGROUNDDOWN(va);//下取整以对齐
//将子进程虚拟页映射到新物理页上并修改子进程页表PTE标志位
if(mappages(p->pagetable, va, PGSIZE, (uint64)mem, (flags | PTE_W) & ~PTE_COW) != 0){
kfree(mem);
setkilled(p);
}
}
}
else {
release(&lock_counting.lock);//解🔓
*pte |= PTE_W;//减少到只有一个进程引用时恢复写权限
*pte &= ~PTE_COW;//取消cow
}
}
else{
err:
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
setkilled(p);
}
}
if(killed(p))
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
由于kernel/vm.c下的copyout函数会对物理页进行写操作,我们希望这个写操作的效果是针对当前进程的而不希望影响到引用该物理页的其他进程,故我们需要为当前进程分配新的物理页。
c
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (dstva - va0);
if(n > len)
n = len;
struct proc *p = myproc();
pte_t *pte = walk(myproc()->pagetable, va0, 0);
uint flags = PTE_FLAGS(*pte);
acquire(&lock_counting.lock);//上🔒
if(lock_counting.counting[pa0/PGSIZE] > 1){//多个进程引用该页面
lock_counting.counting[pa0/PGSIZE]--;//减少引用记数
release(&lock_counting.lock);//解🔓
char *mem;
if((mem = kalloc()) == 0)//物理内存实在不够
setkilled(p);
else{
memmove(mem, (char*)pa0, PGSIZE);
if(mappages(p->pagetable, va0, PGSIZE, (uint64)mem, flags) != 0){//将子进程虚拟页映射到新进程物理页上
kfree(mem);
setkilled(p);
}
else memmove((void *)(mem + (dstva - va0)), src, n);
}
}
else {
release(&lock_counting.lock);//解🔓
memmove((void *)(pa0 + (dstva - va0)), src, n);
}
if(killed(p))
exit(-1);
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return 0;
}