MIT 6.S081 Lab6 cow fork

454 阅读7分钟

#Head BLog

本人掘金的专栏文章链接,欢迎阅读!

  1. MIT-6.S081 xv6-labs-2020
  2. MIT-6.S081 xv6 book
  3. 启智好文
  4. C++
  5. Linux

这是本人的 CSDN 博客之分类专栏链接,欢迎点击阅读!

  1. MIT-6.S081 xv6-labs-2020
  2. MIT-6.S081 xv6 book
  3. 启智好文
  4. C++
  5. Linux

#Source

  1. MIT-6.S081 2020 课程官网
  2. Lab6: copy-on-write fork 实验主页
  3. MIT-6.S081 2020 xv6 book
  4. B站 - MIT-6.S081 Lec10 Multiprocessors and locking

#My Code

  1. Lab6: copy-on-write fork 的 GitHub
  2. xv6-labs-2020 的 GitHub 总目录

#Motivation

Lab6: copy-on-write fork 主要是想解决父子进程在 fork 时的冗余拷贝问题。具体表现为,旧版本的 fork 会将父进程中的所有用户空间内存复制到子进程中,此时,如果父进程非常大,那复制可能很耗时。更糟的是,子进程或许会在 fork 之后的 exec 运行时,丢弃之前辛辛苦苦拷贝的内容。如此一来,更是得不偿失

在开始实验之前,一定要阅读 xv6-6.S081 的第五章节 Locking 及 kernel/spinlock.hkernel/spinlock.c

#Solution

为此,xv6 设计了一种新的 fork 拷贝机制(类似于 Lab5: lazy page allocation 的延迟分配策略)

在 fork 阶段,子进程不用一股脑拷贝父进程页表的所有信息,而是记录下父进程的第一张页表(常识:通过第一张页表,可以揪出所有页表),并将第一张页表(包含的所有 PTE )设为不可写且标记COW位(用于之后缺页中断的判断环节)。仅此而已,巧妙地省去了巨大的拷贝工作

在之后的进程运行中,若子进程需要父进程的某个页块,再进行拷贝工作(也不迟!)

#Implement copy-on write

#Motivation

实现新的 fork 拷贝机制

#Solution

首先,从 kernel/proc.c:fork() 出发,在 fork() 中,子进程会调用 kernel/vm.c:uvmcopy() 函数拷贝父进程的内存空间。这是问题的所在,也是千头万绪的第一根线。其实,为了实现新的 fork 拷贝机制,我们就要从这个函数( kernel/vm.c:uvmcopy() )入手,作为突破点才行

#S1 - uvmcopy() 限制 PTE 为只读

好,明白了其重要性后,开始动手。根据 Lab6: copy-on-write fork 实验主页 的第一个提示(关于如何修改 kernel/vm.c:uvmcopy() ),停止原先分配新页表的工作,取而代之的是,仅仅建立页表与物理地址的映射关系即可。同时,将旧的页表(所有 PTE) 设为不可写,这非常关键。这就相当于保护现场,留个存档,使任何人都不能修改它,只有当没人再会用的它的时候,方才恢复其可写的状态。具体代码如下,

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);
    // flags = PTE_FLAGS(*pte);
    // if((mem = kalloc()) == 0)
    //   goto err;
    // memmove(mem, (char*)pa, PGSIZE);
    // if(mappages(new, i, PGSIZE, (uint64)mem, flags) != 0){
    //   kfree(mem);
    //   goto err;
    // }

    pa = PTE2PA(*pte);
    /** 标记子进程和父进程不可写,这也会导致父进程失去对该PTE写入的权限 */
    *pte &= (~PTE_W);
    *pte |= PTE_COW;

    flags = PTE_FLAGS(*pte);
    if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0)
      goto err;
    
    /** 告诉引用计数向量,在第(uint64)pa/PGSIZE页块处,有进程正在引用 */
    incr_ref((char*)pa);
  }
  return 0;

 err:
  uvmunmap(new, 0, i / PGSIZE, 1);
  return -1;
}

其中的 PTE_COW 标记位是我自己添加的,在 kernel/risc.h 中,

/** 在PTE中添加COW标记位,其中第8位和第9位是预留位,可用! */
#define PTE_COW (1L << 8)

以及 incr_ref() ,皆在告诉 xv6 ,目前还有多少双眼睛在盯着该 PTE ,提醒它暂时不要释放此物理页块。根据 Lab6: copy-on-write fork 实验主页 的第三个提示可知,我们需要引入引用计数机制(类似于 C++ 的共享指针)来管理物理页块,我简单翻译一下,

kalloc() 时,初始化引用,计数为1,表明该页块有进程在用;当有进程 fork 时,引用计数加1,表明又多了一位;当 kfree() 时,如果还有进程在用,则自走自的,不释放该物理页块;反之则释放该页块

#S2 - 使 kmem 支持多进程

可以自定义引用计数结构体,需要注意的是,要用锁来实现互斥功能,以期达到多进程之间有序竞争的效果。我选择在 kernel/kalloc.c 中定义引用计数等一系列结构体和函数,

/** 引用计数块 */
typedef struct {
  struct spinlock lock;
  int count;
} memref;

#define MEMREFS PHYSTOP/PGSIZE

/** 引用计数向量,监测每个页块 */
memref memrefs[MEMREFS];

之后,修改添加 kernel/kalloc.c:kinit()kernel/kalloc.c:kfree()kernel/kalloc.c:kalloc() 等函数。针对 kernel/kalloc.c:kinit() ,无非就是简单地初始化引用计数向量,给向量的每个引用块都配上互斥锁,

void
kinit()
{
  /** 初始化引用计数向量 */
  for(int i=0; i<MEMREFS; i++) 
    initlock(&(memrefs[i].lock), "memrefs");

  initlock(&kmem.lock, "kmem");
  freerange(end, (void*)PHYSTOP);
}

kernel/kalloc.c:kalloc() 中,将分配的页块打上标记,表明有进程正在使用,

void *
kalloc(void)
{
  struct run *r;

  acquire(&kmem.lock);
  r = kmem.freelist;
  
  if(r) {
    /** 初始化引用计数,刚alloc完,引用必然为1 */
    uint32 i = (uint64)r/PGSIZE;
    acquire(&(memrefs[i].lock));
    memrefs[i].count = 1;
    release(&(memrefs[i].lock));

    kmem.freelist = r->next;
  }
  release(&kmem.lock);

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}

同理,在 kernel/kalloc.c:kfree() 中,适时释放物理页块(提示3已经说的很清楚了)。唯一需要注意的是,记得放锁!放锁有很多技巧,我选择的是 goto ,自认为很巧妙,代码不会显得很臃肿,

void
kfree(void *pa)
{
  struct run *r;

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  /** 释放页块时需要解引用 */
  uint32 i = (uint64)pa/PGSIZE;
  acquire(&(memrefs[i].lock));
  memrefs[i].count--;

  if(memrefs[i].count > 0) 
    goto notzero;

  // 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);

notzero:
  release(&(memrefs[i].lock));
}

最后,补充一下 incr_ref() 计数累加函数的具体实现,

/** 新分配的页块,也要增加引用 */
int
incr_ref(void* pa)
{
   if(((uint64)pa%PGSIZE)!=0 || (char*)pa<end || (uint64)pa>=PHYSTOP)
    return -1;

  uint32 i = (uint64)pa/PGSIZE;
  acquire(&(memrefs[i].lock));
  memrefs[i].count++;
  release(&(memrefs[i].lock));
  return 1;
}

当然,别忘了在 kernel/defs.h 中添加新增函数的声明,以便于其他 .c 文件能看到

#S3 - usertrap() 深拷贝

接下来,就到了重要环节了。上面所做的修改 kernel/vm.c:uvmcopy() 等事情并没有直接分配内存,只是做了简单的预载工作(类似于用指针指向了一块内存,但并没有实际的深拷贝行为)

当子进程用到父进程页表中的内容时,会发生缺页中断(因为子进程的页表中并没有该内容),需要在 kernel/trap.c:usertrap() 中顺利捕捉到缺页中断,并且能够 handle 拷贝工作。我建立的框架如下,

void
usertrap(void)
{
  ...
  
  uint64 scause = r_scause();
  if(scause == 8){
    ...
  } else if(scause==13 || scause==15) {
    /** 缺页中断 */
    uint64 va = r_stval();

    /** 是否为copy-on-write page fault */  
    if(!iscow(p->pagetable, va)) 
      goto rest;

    if(!cowcopy(p->pagetable, va))
      goto killing;      

    /** 顺利结束,handle缺页中断 */
    goto rest;

  killing:
    p->killed = 1;

  rest:
    ;
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
   ...
  }

 ...
}

发生缺页中断后,会首先判断此次中断是否为 copy-on-write 类型的缺页中断。如果是,则进行父子进程对应内容的拷贝工作;反之则直接忽略

来看一下,如何判断是否为 copy-on-write 缺页中断( kernel/kalloc.c:iscow() )。很简单,利用 kernel/vm.c:walk() 功能函数,去进程页表中搜索该虚拟地址对应的 PTE ,看其是否为 COW ,

/** 检查该页块是否为copy-on-write */
int 
iscow(pagetable_t pagetable, uint64 va)
{
  if(va > MAXVA)
    return 0;

  pte_t* pte = walk(pagetable, va, 0);
  if(pte==0 || (*pte&PTE_V)==0)
    return 0;

  return (*pte&PTE_COW);
}

如果为 COW ,则需要进行拷贝工作。在我的框架里,我交由 kernel/kalloc.c:cowcopy() 函数来完成,

/** copy-on-write拷贝工作(父->子) */
uint64 
cowcopy(pagetable_t pagetable, uint64 va)
{
  va = PGROUNDDOWN(va);
  pte_t* pte = walk(pagetable, va, 0);
  uint64 pa = PTE2PA(*pte);
  uint32 i = pa/PGSIZE;

  /** 检查是否只有一个进程正在使用该页块 */
  acquire(&(memrefs[i].lock));
  if(memrefs[i].count == 1) {
    /** 恢复该页块的写入权限 */
    *pte |= PTE_W;
    *pte &= (~PTE_COW);
    release(&(memrefs[i].lock));
    return pa;
  }

  release(&(memrefs[i].lock));
  /** 尝试分配空间(缺页中断handler) */
  char* mem = kalloc();
  if(mem == 0)
    return 0;

  /** 拷贝工作 */
  memmove(mem, (char*)pa, PGSIZE);
  *pte &= (~PTE_V);
  uint64 flag = PTE_FLAGS(*pte);
  flag |= PTE_W;
  flag &= (~PTE_COW);

  if(mappages(pagetable, va, PGSIZE, (uint64)mem, flag) != 0) 
    goto freeing;
  
  /** 顺利结束拷贝工作 */
  goto rest;

freeing:
  kfree(mem);
  return 0;
  
rest:
  kfree((char*)PGROUNDDOWN(pa));
  return (uint64)mem;
}

首先,我会检查是否只有一个进程正在使用该页块,这表明了已经没有子进程再在乎虚拟地址对应的 PTE 了,此时就可以酌情处理了,具体表现为,恢复该页块的一系列权限

接着就开始处理拷贝工作了,第一步,分配新内存;然后,调用 memmove() 将内容抄过去,并设置新的 PTE 权限。注意,需要将其标记为可写(不太理解,为什么要将 PTE 置 ~PTE_V,如此一来,岂不是新旧两个 PTE 都不可用了?);最后,调用 kernel/vm.c:mappages() 建立映射关系

还需留意,cowcopy() 如果拷贝失败,是向上层返回 0 ;如果成功,则返回对应的物理地址

#S4 - 让 copyout() 支持 cow

最后一个环节,也是最经常用到的 kernel/vm.c:copyout() ,可以说此函数无时无刻不在被调用,只要内核要与用户态交互,该函数就会工作。在交互的过程中,copy-on-write 事件也时有发生。所以,在 kernel/vm.c:copyout() 中也需要添加针对 COW 页块的业务逻辑,代码如下,

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);

    /** 当待拷贝的页块为copy-on-write时 */
    if(iscow(pagetable, va0))
      pa0 = cowcopy(pagetable, va0);

    if(pa0 == 0)
      return -1;
    ...
  }
  return 0;
}

就此,完成了 xv6 新的 fork 拷贝机制

#Result

手动进入 qemu

make qemu
$cowtest
...
$usertests

其中,在 usertests 中有三个测试,可能是因为我腾讯云服务器 2G 小内存的缘故,卡住了,分别是 kernmem、sbrkfail 和 stacktest。但是,无伤大雅,只要确保大体流程和具体业务逻辑是正确的就可以

#Reference

  1. CSDN -【操作系统】MIT 6.s081 LAB6
  2. 知乎 - MIT 6.s081 xv6-lab6-cow