Lab6: Copy-on-Write Fork for xv6
文章目录
- Lab6: Copy-on-Write Fork for xv6
-
- 问题
- Implement copy-on write (hard)
-
- 第一步 修改uvmcopy
- 第二步 增加引用计数(物理页)
-
- 坑
- 第三步 修改usertrap,处理页面错误
- 第四步 修改copyout() 内核进程不会触发usertrap!
问题
xv6中的fork()
系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程较大,则复制可能需要很长时间。更糟糕的是,这项工作经常造成大量浪费;例如,子进程中的fork()
后跟exec()
将导致子进程丢弃复制的内存,而其中的大部分可能都从未使用过。另一方面,如果父子进程都使用一个页面,并且其中一个或两个对该页面有写操作,则确实需要复制。
解决方案
copy-on-write (COW) fork()的目标是推迟到子进程实际需要物理内存拷贝时再进行分配和复制物理内存页面。
COW fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页。COW fork()将父进程和子进程中的所有用户PTE标记为不可写。当任一进程试图写入其中一个COW页时,CPU将强制产生页面错误。内核页面错误处理程序检测到这种情况将为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE指向新的页面,将PTE标记为可写。当页面错误处理程序返回时,用户进程将能够写入其页面副本。
COW fork()将使得释放用户内存的物理页面变得更加棘手。给定的物理页可能会被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
Copy On Write Fork (课堂内容摘要)
有一个非常有效的优化:当我们创建子进程时,与其创建,分配并拷贝内容到新的物理内存,其实我们可以直接共享父进程的物理内存page。所以这里,我们可以设置子进程的PTE指向父进程对应的物理内存page。
- 一旦子进程想要修改这些内存的内容,相应的更新应该对父进程不可见,因为我们希望在父进程和子进程之间有强隔离性。而为了确保进程间的隔离性,我们可以将这里的父进程和子进程的PTE的标志位都设置成只读的。
在某个时间点,当我们需要更改内存的内容时,我们会得到page fault。
- 在得到page fault之后,我们需要拷贝相应的物理page。假设现在是子进程在执行store指令,那么我们会分配一个新的物理内存page,然后将page fault相关的物理内存page拷贝到新分配的物理内存page中,并将新分配的物理内存page映射到子进程。这时,新分配的物理内存page只对子进程的地址空间可见,所以我们可以将相应的PTE设置成可读写,并且我们可以重新执行store指令。实际上,对于触发刚刚page fault的物理page,因为现在只对父进程可见,相应的PTE对于父进程也变成可读写的了。
- 但是对于这里的物理内存page,现在有多个用户进程或者说多个地址空间都指向了相同的物理内存page,举个例子,当父进程退出时我们需要更加的小心,因为我们要判断是否能立即释放相应的物理page。如果有子进程还在使用这些物理page,而内核又释放了这些物理page,我们将会出问题。那么现在释放内存page的依据是什么呢?
- 我们需要对于每一个物理内存page的引用进行计数,当我们释放虚拟page时,我们将物理内存page的引用数减1,如果引用数等于0,那么我们就能释放物理内存page。所以在copy-on-write lab中,你们需要引入一些额外的数据结构或者元数据信息来完成引用计数。
Implement copy-on write (hard)
YOUR JOB
您的任务是在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
$
这是一个合理的攻克计划:
- 修改
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页面错误并且没有可用内存,则应终止进程。
第一步 修改uvmcopy
修改uvmcopy()
将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W
标志。,但是得加一个标准位 (在***kernel/riscv.h***的末尾),使用RISC-V PTE中的RSW(reserved for software,即为软件保留的)位来实现此目的。
***kernel/riscv.h***的末尾有一些有用的宏和页表标志位的定义
选取PTE中的保留位定义标记一个页面是否为COW Fork页面的标志位
#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4) // 1 -> user can access#define PTE_F (1L << 8) // cow的fork
这里已经有5个了,再补一个就行 PTE中的RSW是8号,用8号比较好
修改uvmcopy
,不为子进程分配内存,而是使父子进程共享内存,但禁用PTE_W
,同时标记PTE_F
, (只mappages,不kalloc) (这里没加引用计数,后面补上)
// Given a parent process's page table, copy
// its memory into a child's page table.
// Copies both the page table and the
// physical memory.
// returns 0 on success, -1 on failure.
// frees any allocated pages on failure.
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;uint64 pa, i;uint flags;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);// 仅对可写页面设置COW标记if(flags & PTE_W) {
// 禁用写并设置COW Fork标记flags = (flags | PTE_F) & ~PTE_W;*pte = PA2PTE(pa) | flags;}if(mappages(new, i, PGSIZE, pa, flags) != 0) {
uvmunmap(new, 0, i / PGSIZE, 1);return -1;}}return 0;
}
第二步 增加引用计数(物理页)
涉及kfree(减少)、kalloc(初始化、增加)、uvmcopy(增加) 拷贝给子进程的函数
确保每个物理页在最后一个PTE对它的引用撤销时被释放——而不是在此之前
定义引用计数的全局变量ref
,其中包含了一个自旋锁和一个引用计数数组,由于ref
是全局变量,会被自动初始化为全0。
我们需要对于每一个物理内存page的引用进行计数,当我们释放虚拟page时,我们将物理内存page的引用数减1,如果引用数等于0,那么我们就能释放物理内存page。所以在copy-on-write lab中,你们需要引入一些额外的数据结构或者元数据信息来完成引用计数。
这里使用自旋锁是考虑到这种情况:进程P1和P2共用内存M,M引用计数为2,此时CPU1要执行
fork
产生P1的子进程,CPU2要终止P2,那么假设两个CPU同时读取引用计数为2,执行完成后CPU1中保存的引用计数为3,CPU2保存的计数为1,那么后赋值的语句会覆盖掉先赋值的语句,从而产生错误
在***kalloc.c***中进行如下修改
struct ref_stru {
struct spinlock lock;int cnt[PHYSTOP / PGSIZE]; // 引用计数 最大物理地址除以页面大小,为每一个物理地址建一个映射
} ref;int krefcnt(void* pa) {
// 获取内存的引用计数return ref.cnt[(uint64)pa / PGSIZE];
}
- 在
kinit
中初始化ref
的自旋锁
initlock(&ref.lock, "ref"); // 这里的锁需要个名字
- 修改
kalloc
(赋内存函数)和kfree
(销毁内存函数)函数,在kalloc
中初始化内存引用计数为1,在kfree
函数中对内存引用计数减1,如果引用计数为0时才真正删除
void
kfree(void *pa)
{
struct run *r;if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)panic("kfree");// 只有当引用计数为0了才回收空间// 否则只是将引用计数减1acquire(&ref.lock);if(--ref.cnt[(uint64)pa / PGSIZE] == 0) {
release(&ref.lock);r = (struct run*)pa;// Fill with junk to catch dangling refs.memset(pa, 1, PGSIZE);acquire(&kmem.lock);r->next = kmem.freelist;kmem.freelist = r;release(&kmem.lock);} else {
release(&ref.lock);}
}
void *
kalloc(void)
{
struct run *r;acquire(&kmem.lock);r = kmem.freelist; // 获取内存if(r) {
kmem.freelist = r->next; // 从空闲链表中删除获取的内存acquire(&ref.lock);ref.cnt[(uint64)r / PGSIZE] = 1; // 将引用计数初始化为1release(&ref.lock);}release(&kmem.lock);if(r)memset((char*)r, 5, PGSIZE); // fill with junkreturn (void*)r;
}
int kaddrefcnt(void* pa) {
// 放在uvmcopy,增加引用计数if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)return -1;acquire(&ref.lock);++ref.cnt[(uint64)pa / PGSIZE];release(&ref.lock);return 0;
}
坑
注意kalloc里还有一个函数调用了kfree,这个函数不管有没有kalloc的,在内存初始化的时候调用的
void
kinit()
{
initlock(&kmem.lock, "kmem");initlock(&ref.lock, "ref");freerange(end, (void*)PHYSTOP);
}void
freerange(void *pa_start, void *pa_end)
{
char *p;p = (char*)PGROUNDUP((uint64)pa_start);for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) {
// 在kfree中将会对cnt[]减1,这里要先设为1,否则就会减成负数ref.cnt[(uint64)p / PGSIZE] = 1;kfree(p);}
}
第三步 修改usertrap,处理页面错误
COW页面出现页面错误时,使用kalloc()
分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W
。
else if(cause == 13 || cause == 15) {
uint64 fault_va = r_stval(); // 获取出错的虚拟地址if(fault_va >= p->sz|| cowpage(p->pagetable, fault_va) != 0|| cowalloc(p->pagetable, PGROUNDDOWN(fault_va)) == 0)p->killed = 1;
} // cowpage 判断一个页面是否为COW页面
int cowpage(pagetable_t pagetable, uint64 va) {
if(va >= MAXVA)return -1;pte_t* pte = walk(pagetable, va, 0);if(pte == 0)return -1;if((*pte & PTE_V) == 0)return -1;return (*pte & PTE_F ? 0 : -1);
}//cowalloc copy-on-write分配器void* cowalloc(pagetable_t pagetable, uint64 va) {
if(va % PGSIZE != 0)return 0;uint64 pa = walkaddr(pagetable, va); // 获取对应的物理地址if(pa == 0)return 0;pte_t* pte = walk(pagetable, va, 0); // 获取对应的PTEif(krefcnt((char*)pa) == 1) {
// 只剩一个进程对此物理地址存在引用// 则直接修改对应的PTE即可*pte |= PTE_W;*pte &= ~PTE_F;return (void*)pa;} else {
// 多个进程对物理内存存在引用// 需要分配新的页面,并拷贝旧页面的内容char* mem = kalloc();if(mem == 0)return 0;// 复制旧页面内容到新页memmove(mem, (char*)pa, PGSIZE);// 清除PTE_V,否则在mappagges中会判定为remap*pte &= ~PTE_V;// 为新页面添加映射if(mappages(pagetable, va, PGSIZE, (uint64)mem, (PTE_FLAGS(*pte) | PTE_W) & ~PTE_F) != 0) {
kfree(mem);*pte |= PTE_V;return 0;}// 将原来的物理内存引用计数减1kfree((char*)PGROUNDDOWN(pa));return mem;}
}
在copyout
中处理相同的情况,如果是COW页面,需要更换pa0
指向的物理地址
while(len > 0){
va0 = PGROUNDDOWN(dstva);pa0 = walkaddr(pagetable, va0);// 处理COW页面的情况if(cowpage(pagetable, va0) == 0) {
// 更换目标物理地址pa0 = (uint64)cowalloc(pagetable, va0);}if(pa0 == 0)return -1;...
}
第四步 修改copyout() 内核进程不会触发usertrap!
改动
copyout
,每次都对其中的va0
尝试cow_alloc
,不用管是否成功,后面的逻辑会处理异常。这里只需要改动copyout
而不需要改copyin
是因为前者是内核拷贝到用户,是会对一个用户页产生写的操作,而后者是用户拷到内核,只是去读这个用户页的内容,COW页允许读。
修改copyout()
在遇到COW页面时使用与页面错误相同的方案。
// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
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); // 物理地址// 处理COW页面的情况if(cowpage(pagetable, va0) == 0) {
// 重新申请物理地址pa0 = (uint64)cowalloc(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 0;
}
$ cowtest
simple: ok
simple: ok
three: ok
three: ok
three: ok
file: ok
ALL COW TESTS PASSED
$ usertests
usertests starting
test execout: OK
test copyin: OK
test copyout: OK
test copyinstr1: OK
test copyinstr2: OK
test copyinstr3: OK
test rwsbrk: OK
test truncate1: OK
test truncate2: OK
test truncate3: OK
test reparent2: OK
test pgbug: OK
test sbrkbugs: usertrap(): unexpected scause 0x000000000000000c pid=3251sepc=0x000000000000555e stval=0x000000000000555e
usertrap(): unexpected scause 0x000000000000000c pid=3252sepc=0x000000000000555e stval=0x000000000000555e
OK
test badarg: OK
test reparent: OK
test twochildren: OK
test forkfork: OK
test forkforkfork: OK
test argptest: OK
test createdelete: OK
test linkunlink: OK
test linktest: OK
test unlinkread: OK
test concreate: OK
test subdir: OK
test fourfiles: OK
test sharedfd: OK
test dirtest: OK
test exectest: OK
test bigargtest: OK
test bigwrite: OK
test bsstest: OK
test sbrkbasic: OK
test sbrkmuch: OK
test kernmem: OK
test sbrkfail: OK
test sbrkarg: OK
test validatetest: OK
test stacktest: OK
test opentest: OK
test writetest: OK
test writebig: OK
test createtest: OK
test openiput: OK
test exitiput: OK
test iput: OK
test mem: OK
test pipe1: OK
test preempt: kill... wait... OK
test exitwait: OK
test rmdot: OK
test fourteen: OK
test bigfile: OK
test dirfile: OK
test iref: OK
test forktest: OK
test bigdir: OK
ALL TESTS PASSED