[MIT 6.S081] Lab 6: Copy-on-Write Fork for xv6

Lab 6: Copy-on-Write Fork for xv6

Implement copy-on write(hard)

要点

  • 修改 uvmcopy(), usertrap(), copyout() 等函数
  • 对物理页记录引用数(reference count), 当引用数为 0 时才实际进行释放.
  • 标记 COW 的 PTE, 利用保留的 RSW 比特位.
  • 无可分配内存时杀死进程

步骤

1. 构造 COW 物理页的引用计数结构.
  1. 引用计数结构体数组.
    根据指导书提示容易考虑使用数组来记录每个物理页的对应的引用数. 而数组的容量容易想到是使用最大物理地址(PHYSTOP>>12), 其中右移 12 位相当于除以 4096, 也就是一个物理页的大小.
    对于数组大小可以进一步优化, 由于 COW 考虑的是用户进程的空间, 而根据 xv6 物理地址空间分布, 内存 KERNBASE 以下的地址映射的是外设, 无关 COW 机制, 因此可以将数组容量缩小至 (PHYSTOP-KERNBASE)>>12.
    如果进一步考虑, 实际上内核代码段和数据段同样无关 COW 机制, 可以进一步缩减数组大小, 但由于内核地址大小不为常量, 最终是由 kernel/kernel.ld 中的 end 变量记录(在 kernel/kalloc.c 中有该变量引用), 因此不为常量不能用于数组大小的定义.
    而通过 kernel/kalloc.ckinit() 函数可以得知, 实际可用于 kalloc() 分配的物理内存即从 end 开始, 也就是说对于内核代码段和数据段部分的引用计数将一直为 0.
  2. 引用计数字段.
    引用计数字段此处直接使用了 uint8 类型, 即单字节无符号整数, 因为考虑到 xv6 的最多可分配进程数 NPROC 为 64, 所以 1 个字节存储引用计数是足够的.
  3. 引用计数的锁结构.
    容易知道, 引用计数数组是一个全局数组, 即多进程都有可能对同一父进程进行 fork() 等操作, 从而引起引用计数的变化, 因此需要锁结构进行数据一致性的保护. 由于此处引用计数的变化比较简单, 因此考虑使用自旋锁 struct spinlock.
    此处考虑自旋锁的选择, 在 kernel/kalloc.c 中有 kmem.lock 专门用于维护 kmem.freelist 即空物理页链表. 这里可以直接借用该自旋锁. 当然也可以使用新的自旋锁, 这样可以保证 kmem.lock 功能的专一. 而考虑此处是一个引用计数的数组, 而数组中的不同元素即不同物理页的引用计数之间实际上是不存在并发问题的, 因此此处笔者对每一个物理页的引用计数对应一个自旋锁. 这样的好处在于不同物理页之间的并发性提高了, 当然也相对带来了一定的内存开销.
    至于锁的初始化, 理论上需要 initlock() 函数, 但由于初始 locked 字段为 0 即可, 因此可以省去初始化每个物理页的自旋锁的步骤.
// COW reference count
struct {
  uint8 ref_cnt;
  struct spinlock lock;
} cows[(PHYSTOP - KERNBASE) >> 12];
2. 引用计数相关函数
  1. 为了方便, 此处引用计数结构体实际上是一个匿名结构体, 且定义的 cows 数组仅在其文件内可访问. 引用计数的场景其实比较简单, 只有加 1 和减 1 两种操作, 因此此处定义了 increfcnt()decrefcnt() 两个函数. 函数的输入均为物理地址 pa, 通过减基地址 KERNBASE 然后右移 12 位便得到了该物理地址所在物理页的引用计数元素, 通过内置的自旋锁加锁后对计数 ref_cnt 进行加 1 或减 1 操作. 而两函数另一个区别在于 decrefcnt() 会将引用计数进行返回, 用于判断在计数降至 0 时将物理页内存进行释放.
// increase the reference count
void increfcnt(uint64 pa) {
  if (pa < KERNBASE) {
    return;
  }
  pa = (pa - KERNBASE) >> 12;
  acquire(&cows[pa].lock);
  ++cows[pa].ref_cnt;
  release(&cows[pa].lock);
}

// decrease the reference count
uint8 decrefcnt(uint64 pa) {
  uint8 ret;
  if (pa < KERNBASE) {
    return 0;
  }
  pa = (pa - KERNBASE) >> 12;
  acquire(&cows[pa].lock);
  ret = --cows[pa].ref_cnt;
  release(&cows[pa].lock);
  return ret;
}
  1. 需要在 kernel/def.h 中声明对引用计数加 1 和减 1 的函数原型.
// cow.c - lab6
void            increfcnt(uint64 pa);
uint8           decrefcnt(uint64 pa);
  1. 此处笔者将 COW 机制的引用计数及其相关函数单独置于新文件 kernel/cow.c 中, 因此需要在 Makefile 文件中添加对该文件编译链接.
    在这里插入图片描述
3. COW 标记位

对于 COW 机制下的物理页, 需要其对应的虚拟页的 PTE 的标记位进行区分, 用于在引发 page fault 时识别出是 COW 机制, 并进行新物理页的分配.
根据指导书提示, 可以使用 PTE 中保留的两个 RSW 比特位中的一位.
在这里插入图片描述
kernel/riscv.h 中定义 COW 标志位.
在这里插入图片描述

4. 修改 uvmcopy() 函数
  1. uvmcopy() 函数用于在 fork() 时子进程拷贝父进程的用户页表. 而 COW 实际上影响的就是该部分, 并非实际拷贝, 而是将子进程虚拟页同样映射在与父进程相同的物理页上. 因此对于该函数主要修改之处就是将原本的 kalloc() 分配去掉.
  2. 此外, 由于是写时复制, 因此需要对父进程和子进程该物理页对应的虚拟页 PTE 的标志位进行处理, 移除原本的写标志位 PTE_W, 并添加 COW 标志位 PTE_COW.
  3. 在最后需要调用 increfcnt() 对当前物理页的引用计数加 1.
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;
//  char *mem;  // lab6

  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);
    // clear PTE_W adn add COW flags - lab6
    flags = (PTE_FLAGS(*pte) & (~PTE_W)) | PTE_COW;
    *pte = PA2PTE(pa) | flags;  // update old pte - lab6
// not allocate new page - lab6
//    if((mem = kalloc()) == 0)
//      goto err;
//    memmove(mem, (char*)pa, PGSIZE);
    if(mappages(new, i, PGSIZE, pa, flags) != 0){   // use the same pa as the old - lab6
//      kfree(mem);     // lab6
      goto err;
    }
    increfcnt(pa);   // increase reference count - lab6
  }
  return 0;

 err:
  uvmunmap(new, 0, i / PGSIZE, 1);
  return -1;
}
  • 注: 对于错误处理 err: 标签后的 uvmunmap() 函数的第四个参数 do_free 也可能会考虑将 do_free 参数置为 0. 但此处不能修改. 这需要结合后续对 free() 函数的改动, free() 只有计数为 0 时才会真正将物理页进行释放, 若此处将 do_free 参数置为 0, 则会导致 uvmunmap() 出错前映射的物理页的引用计数不会还原, 影响物理页的正确释放.
5. 构造 COW 机制函数
  • 此处考虑的是修改 usertrap()copyout() 两个函数, 来对 COW 的页进行处理. 原本笔者是进行的分别实现, 但实际上需要的操作处理是一致的, 因此构造了函数 walkcowaddr() 进行了统一处理. 此外, 若没有该函数, 则就需要将原 walk() 函数添加原型到 kernel/defs.h 中用于获取虚拟地址对应的 PTE, 笔者认为这样增大了该函数的作用域, 并不是很好的解决方案.
  • 通过 walkcowaddr() 函数名也可以看出, 其和 walkaddr() 函数是类似的, 主要就增加了对 COW 页面的处理. 之所以未直接修改 walkaddr() 是考虑到调用函数的场景不同.
  • 对于 walkcowaddr(), 当前面对 vapte 的判断保留, 然后添加对 PTE_W 标志位的判断, 若无该标记, 则进一步判断是否有 PTE_COW 标志位. 因为无论是引发 page fault 还是 copyout(), 都是在写操作时才会考虑进行 COW 操作, 读操作可以正常进行, 而写操作时当前页面不可读, 若无 PTE_COW 标记位则该物理页本身就不可写, 直接返回 0 表示失败; 反之有 PTE_COW 标记位则表明需要进行 COW 操作, 接着分配新的物理页并重新映射的用户页表中, 并返回新的物理地址. 需要注意新的物理页的 PTE_COW 标志位需要移除, 而 PTE_W 标志位需要添加, 正好与 uvmcopy() 复制时是相反的.
  • 这里取消原映射 uvmunmap() 函数的第四个参数 do_free 是置 1 的, 即将原映射的物理内存进行释放, 同样是结合 free() 函数的修改, 会对物理页引用计数减 1, 只有到 0 后才实际释放.
// lab6
uint64 walkcowaddr(pagetable_t pagetable, uint64 va) {
  pte_t *pte;
  uint64 pa;
  char* mem;
  uint flags;

  if (va >= MAXVA)
    return 0;

  pte = walk(pagetable, va, 0);
  if (pte == 0)
      return 0;
  if ((*pte & PTE_V) == 0)
      return 0;
  if ((*pte & PTE_U) == 0)
    return 0;
  pa = PTE2PA(*pte);
  // 判断写标志位是否没有
  if ((*pte & PTE_W) == 0) {
    // pte without COW flag cannot allocate page 
    if ((*pte & PTE_COW) == 0) {
        return 0;
    }
    // 分配新物理页
    if ((mem = kalloc()) == 0) {
      return 0;
    }
    // 拷贝页表内容
    memmove(mem, (void*)pa, PGSIZE);
    // 更新标志位
    flags = (PTE_FLAGS(*pte) & (~PTE_COW)) | PTE_W;
    // 取消原映射
    uvmunmap(pagetable, PGROUNDDOWN(va), 1, 1);
    // 更新新映射
    if (mappages(pagetable, PGROUNDDOWN(va), PGSIZE, (uint64)mem, flags) != 0) {
      kfree(mem);
      return 0;
    }
    return (uint64)mem;    // COW情况下返回新物理地址
  }
  return pa;
}
  • 在编写好 walkcowaddr() 函数后, 便只需在 usertrap()copyout() 中调用即可. 对于前者, 和 Lazy Allocation 相同, 需要增加一个 trap 的判断条件, 但此处只考虑 r_scause()==15 的条件, 因为只有在 store 指令写操作时触发 page fault 才考虑 COW 机制, 而不是和 Lazy Allocation 一样需要读写均考虑. 对于 copyout() 函数则比较简单, 只需要将原本的 walkaddr() 更改为 walkcowaddr() 即可.
void
usertrap(void)
{
  // ...
  if(r_scause() == 8){
    // ...
  } else if(r_scause() == 15) { // COW - lab6
    if (walkcowaddr(p->pagetable, r_stval()) == 0) {
      goto bad;
    }
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
bad:    // lab6
    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;
  }
  // ...
}
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);
    pa0 = walkcowaddr(pagetable, va0);  // with COW - lab6
    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 0;
}
6. 修改引用计数相关函数

上文只在 uvmcopy() 中考虑了引用计数的操作, 因此最后很重要的是对引用计数的其他相关函数进行修改, 保证引用计数的整个流程中数组的正确性.

  1. 根据指导书, 首先考虑的就是 kernel/kalloc.ckalloc() 函数. 在调用该函数时, 则表明需要将一个物理页分配给一个进程, 并对应一虚拟页. 因此, 需要调用 increfcnt() 函数对引用计数加 1, 即从原本的 0 加至 1.
void *
kalloc(void)
{
  struct run *r;

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

  // init page's ref_cnt to 1 - lab6
  increfcnt((uint64)r);

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}
  1. 接下来就是 kalloc() 函数对应的 kernel/kalloc.c 中的 kfree() 函数, 用于物理页的释放. 在真正将物理页回收到 kmem.freelist 前, 需要对物理页的引用计数减 1, 并判断是否为 0, 若不为 0 则表明仍有其他进程引用该物理页, 则直接返回不回收; 反之才进行真正的回收.
    该改动也对照了上文中 do_free 参数为 1 的情况, 因为在 kfree() 中首先是对引用计数进行减 1, 只有减至 0 才会真正释放.
void
kfree(void *pa)
{
  struct run *r;

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

  // decrease the page's reference count
  // and not place the page back if its reference count isn't 0
  // - lab6
  if (decrefcnt((uint64) pa)) {
    return;
  }

  // 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);
}
  1. 需要同样进行修改的还有 kernel/kalloc.c 中的 freerange() 函数. 该函数被 kinit() 函数调用, 其主要作用就是对物理内存空间中未使用的部分以物理页划分调用 kfree() 将其添加至 kmem.freelist 中. 这里的问题在于对于 cows 数组中的 ref_cnt 字段初始值为 0, 在初始调用 freerange()free() 函数时会将引用计数减 1, 由于其类型为 uint8, 会产生下溢变为 255, 从而不能将物理页回收至 kmem.freelist 中, 引发错误. 因此, 需要在调用 free() 之前再调用 increfcnt() 来先将引用计数变为 1, 这样在 free() 时正好可以减至 0 进行回收.
void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start);
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) {
      increfcnt((uint64)p); // lab6
      kfree(p);
  }
}

遇到问题

  • xv6 启动时报 kalloc 的 panic, 如下图所示:
    在这里插入图片描述
    解决: 该问题很难直接调试得到, gdb 调试会发现在初始化内核地址空间调用 kalloc() 时会出现无页面可分配即 kalloc() 返回 0 的情况. 最后经过反复分析, 发现原因即在于 freerange() 未对引用计数先加 1, 导致 kmem.freelist 无物理页可分配. 上文已具体描述.
  • 在 xv6 中运行 cowtest 出现 remap 的 panic. 如下图所示:
    在这里插入图片描述
    解决: 该问题即出现了虚拟页重映射, 原因在于 walkcowaddr() 中未调用 uvmunmap() 先将移除原映射.

测试

  • 在 xv6 中执行 cowtest:
    在这里插入图片描述
  • 在 xv6 中执行 usertests:
    在这里插入图片描述
  • make grade 测试:
    在这里插入图片描述
  • 9
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 在xv6中,copy-on-write fork是一种优化技术,它可以在子进程创建时避免不必要的内存复制。具体来说,当父进程调用fork()创建子进程时,子进程会共享父进程的内存页表,而不是复制一份父进程的内存。只有当子进程尝试修改共享的内存时,才会发生实际的复制操作。这种技术可以减少内存使用和复制时间,提高系统性能。 ### 回答2: xv6是一个操作系统教学项目,这是一个现代化风格的UNIX第六版。copy-on-write forkxv6中实现的一种机制,它与fork系统调用有关。这种机制可减少在进行进程复制时所涉及的空间和时间开销,从而增加操作系统的效率。 在fork系统调用中,操作系统会复制原始进程,创建一个独立的进程。传统方法是,操作系统会将原有进程的内存空间全部复制一份给新进程,并在新进程中对地址进行修正。这样做会消耗大量的空间和时间,尤其是当进程较大时,复制整个内存空间会非常耗时。 copy-on-write fork的实现与传统方法不同。当原始进程需要创建新进程时,操作系统会将进程的内存空间标记为只读状态,并保留原内存页的映射关系。这样,当进程尝试写入内存时,操作系统将会产生一个缺页异常。在此时,操作系统会创建一个新页,将原内存页的内容复制到新页中,并在新页上进行写入操作。这样可以减少空间和时间开销,因为新页仅在需要写入时被复制,而不是在进程创建时。 copy-on-write fork有许多优点。首先,这种机制使系统更高效。使用copy-on-write fork可以显著降低进程复制的时间和空间开销。其次,这种机制还可以提高系统的可扩展性。当进程需要更多内存时,操作系统会重新映射新的内存页,而不是将整个进程复制一次。因此,系统可以更轻松地扩展。 总之,copy-on-write forkxv6中非常有用的一个机制。它可以减少进程复制所需的时间和空间开销,从而提高操作系统的效率和可扩展性。 ### 回答3: 在操作系统课程xv6中,实现了一种名为“copy-on-write fork”的操作,这种操作可以让父进程和子进程在初始时共享相同的物理内存。当父进程或子进程试图修改内存时,内存页会被复制并分配新的物理内存,以避免父进程和子进程之间的竞争条件。 这种“copy-on-write”技术可以减少系统中的内存浪费,并且在分配内存时减少了复制操作,从而提高了系统的性能。在实现中,当父进程调用fork()创建一个新的子进程时,子进程将直接引用父进程的地址空间。父进程和子进程都共享相同的物理内存,但是它们各自有自己的页目录和页表来管理地址空间和虚拟内存。 当父进程或子进程尝试读取数据时,它们可以访问共享的物理内存。然而,当父进程或子进程试图修改数据时,操作系统会将所涉及的内存页复制到另一个物理内存地址,并使涉及的进程引用新的物理内存地址。这样,父进程和子进程将各自拥有自己的数据副本,一个进程修改数据不会影响另一个进程。 这种技术在许多操作系统中都有广泛应用,因为它可以提供更高效的内存管理和更好的性能。实现“copy-on-writefork操作在操作系统课程中具有教育意义,因为它可以让学生更深入了解xv6的内部机制和操作系统的基本理论。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值