6.S081 lab 6: Copy-on-Write Fork for xv6
最近看完了xv6里有关页的内容,并且将这个部分的实验做完,第6个lab明显难度起来了,为了巩固一下学习到的知识,决定写一篇博客。
xv6课程安排为:https://pdos.csail.mit.edu/6.828/2021/schedule.html
下面为这个实验的要求:
Lab: copy-on-write fork for xv6
配套的视频课程为:https://youtu.be/KSYO-gTZo0A(注意:在LEC 7: page fault这个里讲了copy-on-write fork,LEC 9里讲的是中断机制)
本次实验问题
xv6 中的 fork() 系统调用将所有父进程的用户空间内存复制到子进程中。 如果父级进程很大,则复制可能需要很长时间。 更糟糕的是,复制后的空间经常被大量浪费。 例如,子进程中的 fork() 后跟 exec() 可能不会使用其中的大部分,将导致子进程丢弃复制的内存。 另一方面,如果父母和孩子都使用一个页面,并且都写这个页面,那么确实需要一个副本来避免可能出现的问题。
解决方案
写时复制 (COW) fork() 的目标是推迟为子进程分配和复制物理内存页面,直到实际需要副本(如果有的话)。
COW fork() 只为子级创建一个页表,用户内存的 PTE 指向父级的物理页面。 COW fork() 将 parent 和 child 中的所有用户 PTE 标记为不可写。当任一进程尝试写入这些 COW 页之一时,CPU 将强制发生页错误。内核页面错误处理程序检测到这种情况,为出错进程分配物理内存页面,将原始页面复制到新页面中,并修改出错进程中的相关 PTE 以引用新页面,这次使用PTE 标记为可写。当页面错误处理程序返回时,用户进程将能够写入它的页面副本。
COW fork() 使实现用户内存的物理页面的释放变得有点棘手。一个给定的物理页可以被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
Copy-on-write(标记难度为困难guidance)
你的任务是在 xv6 内核中实现 copy-on-write fork。 如果你修改的内核成功地执行了cowtest 和usertests,你就完成了这次实验。
为了帮助测试这次实验的实现,提供了一个名为cowtest 的xv6程序(源代码在user/cowtest.c 中)。 cowtest运行各种测试,当不对xv6进行修改直接运行如下所示:
$ cowtest
simple: fork() failed
“simple”测试分配了一半以上的可用物理内存,然后进行fork()s。 fork失败是因为没有足够的可用物理内存给子进程一个完整的父内存副本。
实验指导里提供了一些思路:
- 修改 uvmcopy() 以将父级的物理页面映射到子级,而不是分配新页面。在子级和父级的 PTE 中清除 PTE_W。
- 修改 usertrap() 以识别页面错误。当 COW 页面发生缺页时,使用 kalloc() 分配新页面,将旧页面复制到新页面,并将新页面安装到 PTE 中并设置 PTE_W。
- 确保每个物理页面在对它的最后一个PTE引用消失时被释放,而不是在释放进程的时候直接释放它的物理页面。做到这一点的一个好方法是为每个物理页保留一个“引用计数”(reference count),该“引用计数”是指引用该页的用户页表的数量。当 kalloc() 分配页面时,将页面的引用计数设置为 1。当 fork 导致子共享页面时增加页面的引用计数,并在每次任何进程从其页表中删除页面时减少页面的计数。 kfree() 只应在其引用计数为零时将页面放回空闲列表。可以将这些计数保存在固定大小的整数数组中。你必须制定一个方案来确定如何索引数组以及如何选择其大小。例如,您可以使用页的物理地址除以 4096(单页的大小) 来索引数组,并为数组提供等于 kalloc.c 中 kinit() 放置在空闲列表中的任何页的最高物理地址的元素数(意思是这个引用计数数组可以覆盖全部的进程物理地址)。
- 修改 copyout() 以在遇到 COW 页面时使用与页面错误相同的方案。
同时,提供了一些提示:
- lazy page allocation lab可能使你熟悉与写时复制相关的大部分xv6内核代码。 但是,你不应该将此实验基于你的lazy page allocation解决方案;相反,请按照上面的说明从 xv6 的新副本开始。(在LEC 7课上实现了lazy page allocation,比较简单)
- 对于每个 PTE,有一种方法来记录它是否是 COW 映射可能很有用。 为此,您可以使用 RISC-V PTE 中的 RSW(为软件保留)位。
- usertests 探索了cowtest 没有测试的场景,所以不要忘记检查所有测试是否都通过了。
- 一些有用的宏和页表标志定义在 kernel/riscv.h 的末尾。
- 如果发生 COW 页面错误并且没有可用内存,则应终止该进程。
根据实验的思路来一步步完成,首先先在riscv.h增加宏PTE_COW,在kalloc.c里声明一个“引用计数”的结构体,再到uvmcopy()里修改对子进程空间的分配。
kernel/riscv.h
#define PTE_COW (1L << 8) // 记录是否是要进行COW fork的页
kernel/kalloc.c
// 引用计数
struct {
// 注意:这里需要加spinlock,以免有两个进程同时对这个页进行操作而造成一些问题
// 比如,假设一个页的引用计数为2,若一个进程的pte映射到这个页,它会将其计数加一变成3,而与此同时另一个进程释放这个页,
// 它会将其计数减一变成1,而实际这个页的计数依旧是2
struct spinlock lock;
int cnt[PHYSTOP/PGSIZE]; // 通过PHYSTOP得到进程最大空间再除每个页的大小就可以对每个页有一个索引
} ref;
kernel/vm.c
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);
// 当此时的页是可写的,需要进行修改
if(flags & PTE_W){
flags = (flags | PTE_COW) & (~PTE_W); // 标记为要进行COW fork,并且只读
*pte = PA2PTE(pa) | flags;
}
// 只进行映射,并不实际的分配物理空间
if(mappages(new, i, PGSIZE, pa, flags) != 0){
goto err;
}
// 增加此物理页的引用计数
if(addref((char*)pa) != 0) return -1;
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
kernel/kalloc.c
// 增加物理页的引用计数
int
addref(void* pa)
{
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;
}
下面来修改usertrap()
kernel/trap.c
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
}else if(r_scause() == 13 || r_scause() == 15){ // 这里为检测页错误
uint64 va = r_stval(); // 得到当前引起页错误的虚拟地址
// 检测是否为进行cow fork的页
// 为其分配物理页面
if(va > p->sz || cowpage(p->pagetable,va) != 0 || cow_alloc(p->pagetable, PGROUNDDOWN(va)) == 0)
p->killed = 1;
}else if((which_dev = devintr()) != 0){
// ok
} 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;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
下面在kalloc.c中增加cowpage和cow_alloc函数以及获得页的引用计数的函数getcnt
int
getcnt(void* pa)
{
return ref.cnt[(uint64)pa / PGSIZE];
}
// 检查是否是要进行cow fork的页
// 是的话返回0,否则返回-1
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_COW) ? 0 : -1;
}
// 给cow page分配物理页
void*
cow_alloc(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);
if(getcnt((char*)pa) == 1){ // 获取当前物理页的计数引用,如果只是1的话,标记为可写和非cow fork
*pte |= PTE_W;
*pte &= ~PTE_COW;
return (void*)pa;
}else{
// 有多个进程指向这个物理页面,所以需要分配新的物理页面以供虚拟地址去指向
char* mem;
if((mem=kalloc()) == 0) return 0;
// 因为刚分配新的空间,需要将孩子进程的pte的有效为先设为0
*pte &= ~PTE_V;
// 将旧页的内容复制到新的页里
memmove(mem, (char*)pa, PGSIZE);
// 进行映射,将虚拟地址映射到新分配的物理页面,并且标记为可写和非cow fork
if(mappages(pagetable, va, PGSIZE, (uint64)mem, (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW) != 0){
kfree(mem);
*pte &= ~PTE_V;
return 0;
}
// 将旧物理页的引用计数减一
kfree((char*)PGROUNDDOWN(pa));
return mem;
}
}
对原来的kfree函数进行更改,同时也要对kinit进行更改以初始化引用计数的锁
kernel/kalloc.c
void
kinit()
{
initlock(&kmem.lock, "kmem");
initlock(&ref.lock,"kref");
freerange(end, (void*)PHYSTOP); // 此函数会对页进行释放,由于ref里cnt数组初始化都为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){
ref.cnt[(uint64)p / PGSIZE] = 1; // 先设为1,以免释放后变成负数
kfree(p);
}
}
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// 当引用计数为0时释放这个页
acquire(&ref.lock);
if(--ref.cnt[(uint64)pa / PGSIZE] == 0){
release(&ref.lock);
// 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);
}else release(&ref.lock);
}
更改copyout函数,当往物理页写时,若是cow fork的页需要分配新的物理页来往里写数据
kernel/vm.c
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 page,则分配新的物理页
if(cowpage(pagetable, va0) == 0){
pa0 = (uint64)cow_alloc(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;
}
不要忘记将新添加的函数声明在defs.h中,并且由于cowpage写在kalloc.c里,所以defs.h里需要把walk(pagetable_t, uint64, int)也声明了。
运行测试结果如下:
$ cowtest
simple: ok
simple: ok
three: ok
three: ok
three: ok
file: ok
ALL COW TESTS PASSED
$ usertests
usertests starting
test MAXVAplus: OK
test manywrites: OK
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=3267
sepc=0x00000000000058d0 stval=0x00000000000058d0
usertrap(): unexpected scause 0x000000000000000c pid=3268
sepc=0x00000000000058d0 stval=0x00000000000058d0
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 sbrklast: OK
test sbrk8000: 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 killstatus: 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
最后对COW fork,lazy allocation,demand page,paging to disk进行简单的归纳:
COW fork
COW fork 的基本思想是让父子进程在最初时共享所有物理页,但每个物理页都将它们映射为只读(清除 PTE_W 标志)。 父母和孩子均可以从共享的物理内存中读数据。
如果写入页面的话,RISC-V CPU将引发页面错误异常。内核的陷阱处理程序通过分配新的物理内存页面并且复制到故障地址映射的地方来响应。 内核将故障进程的页表中的相关PTE更改为指向副本并允许写入和读取,然后在导致故障的指令处恢复故障进程。
Lazy allocation
首先,当应用程序通过调用 sbrk 请求更多内存时,内核会注意到大小的增加,但不会分配物理内存,也不会为新的虚拟地址范围创建 PTE。其次,在其中一个新地址发生页面错误时,内核分配一页物理内存并将其映射到页表中。 像 COW fork 一样,内核可以对应用程序透明地实现延迟分配。
如果应用程序需要 1 GB 的内存,内核必须分配 262,144 个 (4096 字节的)页面并将其归零。 lazy allocation允许此成本随时间分摊。不过这样会增加额外的页错误的开销,包含了内核空间/用户空间的转换。
操作系统可以通过为每个页面错误分配一批连续的页面而不是一个页面以及通过编写专门针对此类页面错误的内核进入/退出代码来降低此成本。
Demand page
在exec中,xv6 将应用程序的所有文本和数据急切地加载到内存中。 由于应用程序可能很大并且从磁盘读取成本很高,因此用户可能会注意到这种启动成本:当用户从 shell 启动大型应用程序时,用户可能需要很长时间才能看到响应。 为了提高响应时间,现代内核为用户地址空间创建页表,但将页的 PTE 标记为无效。 在页面错误时,内核从磁盘读取页面内容并将其映射到用户地址空间。 与 COW fork和lazy allocation一样,内核可以对应用程序透明地实现此功能。
Paging to disk
在计算机上运行的程序可能需要比计算机的 RAM 更多的空间。 为了优雅地应对,操作系统可以实现paging to disk,这个想法是在 RAM 中仅存储一小部分用户页面,并将其余部分存储在磁盘上的分页区域(paging area)中。内核将与存储在分页区域(不在 RAM 中)中的内存相对应的 PTE 标记为无效。如果应用程序尝试使用在磁盘的页面之一,应用程序将引起页面错误,并且必须调入页面:内核陷阱处理程序将分配物理 RAM 页面,从磁盘插入RAM,并修改相关的PTE来指向RAM。
总结
其实总的写下来代码量并不多,但是刚入手时对如何映射页以及如何在页错误时分配新的物理页感到十分困惑,又因为这个实验要设置的标志一会是考虑reference count一会又要考虑PTE_COW,在分配和释放的时候总是想不清楚,不过最后做下来发现也没有特别绕的地方,最重要的思想就是子进程一开始没有自己的物理页,原来的被分享的物理页是只读的,一旦出现页错误就要为这个子进程分配新的物理空间,并且将它的虚拟地址映射过去,然后将这些物理页标记成为可写的。