Lab:Copy-on-Write Fork for xv6
虚拟内存提供了一种间接性:内核可以通过将页表项标记为无效或只读来拦截内存引用,从而导致页面错误,并且可以通过修改页表项来改变地址的含义。在计算机系统中有一句谚语:任何系统问题都可以通过一层间接性来解决。延迟分配实验提供了一个例子。这个实验探索了另一个例子:写时复制 fork。
目前存在的问题:
在 xv6 中,fork()系统调用会将父进程的所有用户空间内存复制到子进程中。如果父进程很大,复制可能需要很长时间。更糟糕的是,这项工作通常在很大程度上是浪费的;例如,在子进程中先执行fork(),然后执行exec(),这将导致子进程丢弃复制的内存,可能大部分内存从未被使用过。另一方面,如果父进程和子进程都使用一个页面,并且其中一个或两个都对其进行写入操作,那么确实需要进行复制。
解决方案:
写时复制(COW)的 fork () 函数的目标是尽可能推迟为子进程分配和复制物理内存页,直到确实需要这些副本(如果需要的话)。COW 的 fork () 函数仅为子进程创建一个页表,用户内存的页表项(PTE)指向父进程的物理页。COW 的 fork () 函数将父进程和子进程中的所有用户 PTE 标记为不可写。当任一进程尝试写入这些 COW 页面之一时,CPU 将强制产生页面错误。内核的页面错误处理程序检测到这种情况,为出错的进程分配一页物理内存,将原始页面复制到新页面中,并修改出错进程中的相关 PTE,使其指向新页面,此时 PTE 被标记为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。
COW 的 fork () 函数使得释放实现用户内存的物理页面变得稍微复杂一些。一个给定的物理页面可能被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
Implement copy-on write
这一个实验需要我们实现fork的COW - copy on write机制,在进程fork出子进程后,不会立刻复制新的内存页,而是将虚拟地址指向父进程页面的地址,在父子进程任意一方要进行修改操作时,才会对内存真正复制。且内存页只有在所有引用都消失后才能被释放。
一共三个方面需要实现:
- fork时取消直接的内存复制,改为映射
- 在写页面的操作时感知cow,处理page fault
- 为内存页增加引用计数和生命周期管理
我们需要新增一些函数操作,在defs.h中定义:
// kalloc.c
...
void *kcopy_n_deref(void *pa);
void krefpage(void *pa);
// vm.c
...
int uvmcheckcowpage(uint64 va);
int uvmcowcopy(uint64 va);
1、Fork
vm.c中修改uvmcopy(),取消复制数据操作,改为添加映射,将子进程的页面直接指向父进程,并将页表项设置为不可写。
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");
pa = PTE2PA(*pte);
// 页面设置不可写,并标记为COW页(多个进程引用了该页面)
if (*pte & PTE_W)
{
*pte = (*pte & ~PTE_W) | PTE_COW;
}
flags = PTE_FLAGS(*pte);
// 将子进程的页面直接指向父进程
if (mappages(new, i, PGSIZE, (uint64)pa, flags) != 0)
{
goto err;
}
// 页面引用次数加一
krefpage((void *)pa);
// if((mem = kalloc()) == 0)
// goto err;
// memmove(mem, (char*)pa, PGSIZE);
// if(mappages(new, i, PGSIZE, (uint64)mem, flags) != 0){
// kfree(mem);
// goto err;
// }
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
其中的PTE_COW在riscv.h中定义
#define PTE_COW (1L << 8) // copy-on-write
现在对fork的处理就结束了,修改后的fork,父子进程在尝试修改页面内容时,均会因为页面不可写出现page fault,并进入usertrap(),我们接下来需要在usertrap中处理这种page fault。
2、处理page fault
在trap.c中修改usertrap,添加对page fault的检测,若是cow引起的,需要真正进行页面的复制操作。
void usertrap(void)
{
...
else if ((which_dev = devintr()) != 0)
{
// ok
}
else if ((r_scause() == 13 || r_scause() == 15) && uvmcheckcowpage(r_stval()))
{
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()是软件访问页表,不会触发缺页异常,所以需要添加检测代码,检查是否是一个cow页面,在vm.c中修改
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while (len > 0)
{
if (uvmcheckcowpage(dstva))
uvmcowcopy(dstva);
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if (pa0 == 0)
return -1;
n = PGSIZE - (dstva - va0);
if (n > len)
n = len;
memmove((void *)(pa0 + (dstva - va0)), src, n);
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return
在vm.c中实现uvmcheckcowpage()和uvmcowcopy(),用于检测cow页面以及实际的复制操作
// check if a page is a COW page
int uvmcheckcowpage(uint64 va)
{
pte_t *pte;
struct proc *p = myproc();
return va < p->sz && ((pte = walk(p->pagetable, va, 0)) != 0) && (*pte & PTE_V) && (*pte & PTE_COW);
}
// copy a COW page , make it writable
int uvmcowcopy(uint64 va)
{
pte_t *pte;
struct proc *p = myproc();
if ((pte = walk(p->pagetable, va, 0)) == 0)
{
panic("uvmcowcopy: pte should exist");
}
// call kcopy_n_deref to copy the page
uint64 pa = PTE2PA(*pte);
uint64 new = (uint64)kcopy_n_deref((void *)pa);
if (new == 0)
return -1;
uint64 flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW;
uvmunmap(p->pagetable, PGROUNDDOWN(va), 1, 0);
if (mappages(p->pagetable, va, 1, new, flags) == -1)
{
panic("uvmcowcopy: mappages failed");
}
return 0;
}
3、内存引用计数
现在我们的fork实现了写入时才复制页面,还需要添加对页面的生命周期管理,保证只有在所有进程都不使用页面时,页面才能被释放,否则,子进程退出可能会错误地释放父进程的页面。
涉及到物理页的管理,需要下面这些操作:
- kalloc():分配物理页,引用计数设置为1
- krefpage():创建物理页的引用,引用计数加1
- kcopy_n_deref():将物理页面的一个引用实际复制到一个新的物理页面上(新页面的计数也为1),返回新页面,并将原来的页面计数减1
- kfree():释放物理页面的一个引用,引用减1,若引用计数为0,则释放物理页
整个流程如下:
在父进程中创建了一个新的物理页,它此时计数为1,仅被父进程引用,父进程使用fork创建子进程后,页面引用计数加1,两者引用了同一个页面。若两者都不会修改该页面,那么在父子进程都退出时,会进行2次释放操作,第一次将页面计数减1,此时还是1,第二次再减1,此时为0,并执行真正的物理释放;若其中一个尝试修改改页面,会触发page fault并复制一份,复制出的新页面计数为1,原来的页面计数减1,此时都为1,进程能顺利执行。
在xv6中,kalloc()和kfree()都已经实现,仅需修改,kcopy_n_deref()和krefpage()需要新添加。
kalloc.c 修改如下
...
// 用于访问物理页引用计数数组
#define PA2PGREF_ID(p) (((p) - KERNBASE) / PGSIZE) /* 将地址转换为ref_id */
#define PGREF_MAX_ENTRIES PA2PGREF_ID(PHYSTOP) /* ref最大数量 */
struct spinlock pgreflock; /* pageref数组操作的锁 */
int pageref[PGREF_MAX_ENTRIES];
// 通过物理地址获得引用计数
#define PA2PGREF(p) pageref[PA2PGREF_ID((uint64)(p))]
...
void kinit()
{
initlock(&kmem.lock, "kmem");
initlock(&pgreflock, "pgref"); /* 初始化pgref锁 */
freerange(end, (void *)PHYSTOP);
}
...
void kfree(void *pa)
{
struct run *r;
if (((uint64)pa % PGSIZE) != 0 || (char *)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
acquire(&pgreflock);
if (--PA2PGREF(pa) <= 0)
{
// 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);
}
release(&pgreflock);
}
void *
kalloc(void)
{
...
if (r)
{
memset((char *)r, 5, PGSIZE); // fill with junk
PA2PGREF(r) = 1;
}
return (void *)r;
}
// 如果引用计数大于 1,则将页面的引用计数减 1,然后分配一个新的物理页面,并将该页面复制到新页面中(实际上是将一个引用转换为一个副本)
// 当引用计数已经小于或等于 1 时,不做任何操作,直接返回物理地址 pa。
void *kcopy_n_deref(void *pa){
acquire(&pgreflock);
if(PA2PGREF(pa) <= 1) { // 只有 1 个引用,无需复制
release(&pgreflock);
return pa;
}
// 分配新的内存页,并复制旧页中的数据到新页
uint64 newpa = (uint64)kalloc();
if(newpa == 0) {
release(&pgreflock);
return 0; // out of memory
}
memmove((void*)newpa, (void*)pa, PGSIZE);
// 旧页的引用减 1
PA2PGREF(pa)--;
release(&pgreflock);
return (void*)newpa;
}
// 为 pa 的引用计数增加 1
void krefpage(void *pa) {
acquire(&pgreflock);
PA2PGREF(pa)++;
release(&pgreflock);
}
这里添加自旋锁是为了防止多进程出现非预期的行为,例如父进程创建子进程并修改原页面时,两者都会修改页面的引用计数,若发生冲突,引用计数可能被错误设置,导致页面无法被释放,造成内存泄漏。加锁防止这类现象出现。
实验完成,make grade验证:
== Test running cowtest ==
$ make qemu-gdb
(7.8s)
== Test simple ==
simple: OK
== Test three ==
three: OK
== Test file ==
file: OK
== Test usertests ==
$ make qemu-gdb
(130.5s)
== Test usertests: copyin ==
usertests: copyin: OK
== Test usertests: copyout ==
usertests: copyout: OK
== Test usertests: all tests ==
usertests: all tests: OK
== Test time ==
time: FAIL
Cannot read time.txt
Score: 109/110