虚拟内存提供了一定程度的间接寻址:内核可以通过将PTE标记为无效或只读来拦截内存引用,从而导致页面错误,还可以通过修改PTE来更改地址的含义。在计算机系统中有一种说法,任何系统问题都可以用某种程度的抽象方法来解决。Lazy allocation实验中提供了一个例子。这个实验探索了另一个例子:写时复制分支(copy-on write fork)。
问题
xv6中的fork()
系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程较大,则复制可能需要很长时间。更糟糕的是,这项工作经常造成大量浪费;例如,子进程中的fork()
后跟exec()
将导致子进程丢弃复制的内存,而其中的大部分可能都从未使用过。另一方面,如果父子进程都使用一个页面,并且其中一个或两个对该页面有写操作,则确实需要复制。
解决方案
copy-on-write (COW) fork()的目标是推迟到子进程实际需要物理内存拷贝时再进行分配和复制物理内存页面。
COW fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页。COW fork()将父进程和子进程中的所有用户PTE标记为不可写。当任一进程试图写入其中一个COW页时,CPU将强制产生页面错误。内核页面错误处理程序检测到这种情况将为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE指向新的页面,将PTE标记为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。
COW fork()将使得释放用户内存的物理页面变得更加棘手。给定的物理页可能会被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
copy-on-write(COW)fork()的目的是:推迟对child的分配和拷贝物理内存页,直到拷贝确实需要。
COW fork()仅仅给child创建一个pagetable,其用户内存的PTES指向parent的物理页。
COW fork()标记parent和child的所有用户内存PTES是不可写的。
当某个进程尝试写其中一个COW页时,cpu将强制一个page fault。
kernel page-fault handler检测这种情形,为faulting进程分配一页物理内存,复制原始页到新页,
更改faulting进程相关PTE来指向新页,这次让PTE标记为可写。当page fault handler返回时,用户进程将能够向拷贝页写入。
COW fork()让物理页(实现用户内存)的释放更有技巧性。
一个给定物理页可能被多个进程的page table指向,仅应该在最后的指向消失时,才释放物理页。
一、Implement copy-on write
1.实验要求
任务是在xv6内核中实现copy-on-write fork。如果修改后的内核同时成功执行
cowtest
和usertests
程序就完成了。为了帮助测试你的实现方案,我们提供了一个名为
cowtest
的xv6程序(源代码位于user/cowtest.c)。cowtest
运行各种测试,但在未修改的xv6上,即使是第一个测试也会失败。因此,最初您将看到:$ cowtest simple: fork() failed $
“simple”测试分配超过一半的可用物理内存,然后执行一系列的
fork()
。fork
失败的原因是没有足够的可用物理内存来为子进程提供父进程内存的完整副本。完成本实验后,内核应该通过
cowtest
和usertests
中的所有测试。即:$ cowtest simple: ok simple: ok three: zombie! ok three: zombie! ok three: zombie! ok file: ok ALL COW TESTS PASSED $ usertests ... ALL TESTS PASSED $
2.提示
- 修改
uvmcopy()
将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W
标志。- 修改
usertrap()
以识别页面错误。当COW页面出现页面错误时,使用kalloc()
分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W
。- 确保每个物理页在最后一个PTE对它的引用撤销时被释放——而不是在此之前。这样做的一个好方法是为每个物理页保留引用该页面的用户页表数的“引用计数”。当
kalloc()
分配页时,将页的引用计数设置为1。当fork
导致子进程共享页面时,增加页的引用计数;每当任何进程从其页表中删除页面时,减少页的引用计数。kfree()
只应在引用计数为零时将页面放回空闲列表。可以将这些计数保存在一个固定大小的整型数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案。例如,您可以用页的物理地址除以4096对数组进行索引,并为数组提供等同于kalloc.c中kinit()
在空闲列表中放置的所有页面的最高物理地址的元素数。- 修改
copyout()
在遇到COW页面时使用与页面错误相同的方案。提示:
- lazy page allocation实验可能已经让您熟悉了许多与copy-on-write相关的xv6内核代码。但是,您不应该将这个实验室建立在您的lazy allocation解决方案的基础上;相反,请按照上面的说明从一个新的xv6开始。
- 有一种可能很有用的方法来记录每个PTE是否是COW映射。您可以使用RISC-V PTE中的RSW(reserved for software,即为软件保留的)位来实现此目的。
usertests
检查cowtest
不测试的场景,所以别忘两个测试都需要完全通过。- kernel/riscv.h的末尾有一些有用的宏和页表标志位的定义。
- 如果出现COW页面错误并且没有可用内存,则应终止进程。
3.具体实现
(1)创建 page 的计数数组
首先对每个物理页面创建一个计数变量,保存在一个数组中,页面的数目就是数组的长度。这里有一个知识点:不是所有的物理内存都可以被用户进程映射到的,这里有一个范围,即 KERNBASE 到 PHYSTOP。具体映射可以从 xv6 手册中看到:
由于一个页表的大小(PGSIZE)是 4096,因此数组的长度可以定义为:
(PHYSTOP - KERNBASE) / PGSIZE
- 我们可以先在 kernel/kalloc.c 中定义一个用于计数的数组:
由于是全局变量,C 语言会自动初始化为 0。
- 分配内存时增加数值:
在
kernel/riscv.h
中定义 COW 标记位和计算物理内存页下标的宏函数:
在 kalloc 时,设置值为 1:
- 在使用 kfree 释放内存页时,首先需要判断计数值是否大于 1:
(2)第二步,uvmcopy
在创建好计数数组后,在 fork 时,直接使用原来的物理页进行映射:
在 kernel/vm.c 中修改 uvmcopy 函数:
需要在代码前面添加 extern 声明,用于引入外部的变量:
extern uint page_ref[]; // kalloc.c
(3)第三步,处理中断 usertrap
和上一个实验相同,在 usertrap 中添加中断处理逻辑:
else if(r_scause() == 15) { uint64 va = r_stval(); if(va >= p->sz) p->killed = 1; else if(cow_alloc(p->pagetable, va) != 0) p->killed = 1; }
其中的 cow_alloc 函数,在 kalloc.c 中实现,并在 defs.h 中进行声明:
int cow_alloc(pagetable_t pagetable,uint64 va) { va = PGROUNDDOWN(va); if(va>=MAXVA) return -1; pte_t *pte = walk(pagetable, va, 0); if(pte==0) return -1; uint64 pa = PTE2PA(*pte); if(pa==0) return -1; uint64 flags = PTE_FLAGS(*pte); if(flags&PTE_COW) { uint64 mem = (uint64)kalloc(); if(mem==0) return -1; memmove((char *)mem, (char *)pa, PGSIZE); uvmunmap(pagetable, va, 1, 1); flags = (flags | PTE_W) & ~PTE_COW; if(mappages(pagetable,va,PGSIZE,mem,flags)!=0) { kfree((void *)mem); return -1; } } return 0; }
第四步,内核写内存 copyout
这里直接调用上面写的 cow_alloc 函数即可
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; pte_t *pte; while(len > 0){ va0 = PGROUNDDOWN(dstva); if (cow_alloc(pagetable, va0) != 0) return -1; pa0 = walkaddr(pagetable, va0); if(pa0 == 0) return -1; n = PGSIZE - (dstva - va0); if(n > len) n = len; pte = walk(pagetable, va0, 0); if(pte == 0) return -1; memmove((void *)(pa0 + (dstva - va0)), src, n); len -= n; src += n; dstva = va0 + PGSIZE; } return 0; }
最后注意在 defs.h 中添加 walk 声明。
4.测试结果