MIT 6.S081 Lab5

133 阅读8分钟

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出子进程后,不会立刻复制新的内存页,而是将虚拟地址指向父进程页面的地址,在父子进程任意一方要进行修改操作时,才会对内存真正复制。且内存页只有在所有引用都消失后才能被释放。

一共三个方面需要实现:

  1. fork时取消直接的内存复制,改为映射
  2. 在写页面的操作时感知cow,处理page fault
  3. 为内存页增加引用计数和生命周期管理

我们需要新增一些函数操作,在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实现了写入时才复制页面,还需要添加对页面的生命周期管理,保证只有在所有进程都不使用页面时,页面才能被释放,否则,子进程退出可能会错误地释放父进程的页面。

涉及到物理页的管理,需要下面这些操作:

  1. kalloc():分配物理页,引用计数设置为1
  2. krefpage():创建物理页的引用,引用计数加1
  3. kcopy_n_deref():将物理页面的一个引用实际复制到一个新的物理页面上(新页面的计数也为1),返回新页面,并将原来的页面计数减1
  4. 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