lab6:Copy-on-write fork
作业地址:Lab: Copy-on-Write Fork for xv6 (mit.edu)
实现 fork 懒复制机制,在进程 fork 后,不立刻复制内存页,而是将虚拟地址指向与父进程相同的物理地址。在父子任意一方尝试对内存页进行修改时,才对内存页进行复制。 物理内存页必须保证在所有引用都消失后才能被释放,这里需要有引用计数机制。
一开始做的时候没有上锁,一直没通过全部测试。后来参考了下面这位大佬的博客后意识到了竞争问题,加入自旋锁后顺利通过测试。
[mit6.s081] 笔记 Lab6: Copy-on-write fork | fork 懒拷贝 | Miigon’s blog
fork时不立刻复制内存
uvmcopy(),在复制父进程的内存到子进程的时候,不立刻复制数据,而是建立指向原物理页的映射,并将父子两端的页表项都设置为不可写。
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");
panic("uvmcopy: page not present");
pa = PTE2PA(*pte);
// 如果原来是可写的,那就要改成不可写,并且加上COW标志
if(*pte & PTE_W){
*pte &= ~PTE_W; // 去掉写flag
*pte |= PTE_C; //标记为写时复制状态
}
flags = PTE_FLAGS(*pte);
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
// kfree(mem);
goto err;
}
// 添加这个物理地址的引用计数
acquire(&pgreflock);
modify(GET_IDX(pa), 1);
release(&pgreflock);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
在riscv.h中加入对标志位PTE_C的定义:
#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_C (1L << 8) // copy on write
这样,fork 时就不会立刻复制内存,只会创建一个映射了。这时候如果尝试修改懒复制的页,会出现 page fault 被 usertrap() 捕获。接下来需要在 usertrap() 中捕捉这个 page fault,并在尝试修改页的时候,执行真正的复制操作。
捕获写操作并执行复制
在 usertrap() 中添加对 page fault 的检测,检测接收的页是否是一个懒复制页(有PTE_C标志,并且虚拟地址合法),如果此时物理页的引用次数大于1,那么需要进行复制,将旧物理页的引用次数减一,分配的新物理页的引用次数加一;若引用次数为1,直接返回该页。
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((which_dev = devintr()) != 0){
// ok
} else {
}
else if(r_scause() == 15){ // page fault
// 12 : instruction page fault
// 13 :load page fault
// 15 :store/AMO page Fault
uint64 va = r_stval(); // get the virtual address that caused the page fault.
pte_t *pte = walk(p->pagetable, va, 0);
uint flags = PTE_FLAGS(*pte);
uint64 old_pa = PTE2PA(*pte);
if(flags & PTE_C && uvmcheckcowpage(va)) // 是写时复制场景
{
acquire(&pgreflock);
int count = modify(GET_IDX((old_pa)),0);
release(&pgreflock);
if(count > 1) { // 引用次数超过1
char * pa = kalloc(); // alloc physial memory ,分配一页物理内存
if(pa == 0){ // 申请失败
p->killed = 1; // 杀死进程
exit(-1);
}
else{
memset(pa, 0, PGSIZE); // 记录之前指向的内存地址的值,然后拷贝过去
memmove(pa, (void * )old_pa, PGSIZE);
kfree((void*)old_pa); //对于旧的地址,直接尝试删除
// 增加新地址的引用次数,在kalloc中完成
uint flags_new = (flags | (PTE_W)) & (~PTE_C); // 把原来的flag,清空掉PTE_C标志,加上PTE_W标志
*pte = PA2PTE(pa) | flags_new | PTE_V; //修改pte
}
}
else{
uint flags_new = (flags | (PTE_W)) & (~PTE_C);
*pte = PA2PTE(old_pa) | flags_new | PTE_V;
}
}
else{
p->killed = 1;
exit(-1);
}
}
else {
printf("r_scause() == %d\n", r_scause());
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();
}
同时 copyout() 由于是软件访问页表,不会触发缺页异常,所以需要手动添加同样的监测代码,检测接收的页是否是一个懒复制页(有PTE_C标志,并且虚拟地址合法),如果此时物理页的引用次数大于1,那么需要进行复制,将旧物理页的引用次数减一,分配的新物理页的引用次数加一;若引用次数为1,直接返回该页。
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);
if(pa0 == 0)
return -1;
// 这里可能会往一个cow page的页进行写操作,这时需要及时复制,和trap类似的操作
pte_t *pte = walk(pagetable, va0, 0);
if((*pte & PTE_U) == 0) return -1;
uint flags = PTE_FLAGS(*pte);
if((flags & PTE_C) && (flags & PTE_V) && uvmcheckcowpage(dstva)) // 是写时复制场景
{
acquire(&pgreflock);
int count = modify(GET_IDX((pa0)),0);
release(&pgreflock);
if(count > 1) { // 引用次数超过1
char * pa = kalloc(); // alloc physial memory ,分配一页物理内存
if(pa == 0){ // 申请失败
return -1;
}
else{
memset(pa, 0, PGSIZE);
// 记录之前指向的内存地址的值,然后拷贝过去
uint64 old_pa = pa0;
memmove(pa, (void * )old_pa, PGSIZE);
kfree((void*)old_pa); //对于旧的地址,直接尝试删除
uint flags_new = (flags | (PTE_W)) & (~PTE_C); // 把原来的flag,清空掉PTE_C标志,加上PTE_W标志
*pte = PA2PTE(pa) | flags_new | PTE_V;
pa0 = (uint64)pa; // 更新读写的位置
}
}
else{ //此时引用次数为1
uint flags_new = (flags | (PTE_W)) & (~PTE_C);
*pte = PA2PTE(pa0) | flags_new | PTE_V;
}
}
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;
}
提供工具方法:uvmcheckcowpage
int uvmcheckcowpage(uint64 va) {
struct proc *p = myproc();
return va < p->sz;
}
到这里,就已经确定了大体的逻辑了:在 fork 的时候不复制数据只建立映射+标记,在进程尝试写入的时候进行实复制并重新映射为可写。
接下来,还需要做页的生命周期管理,确保在所有进程都不使用一个页时才将其释放
在原本的 xv6 实现中,一个物理页的生命周期内,可以支持以下操作:
- kalloc(): 分配物理页
- kfree(): 释放回收物理页
而在支持了懒分配后,由于一个物理页可能被多个进程(多个虚拟地址)引用,并且必须在最后一个引用消失后才可以释放回收该物理页,所以一个物理页的生命周期内,现在需要支持以下操作:
- kalloc,分配物理页时,引用次数加一
- fork,将旧的物理页的引用次数加一
- 写时复制场景usertrap\copyout:把旧的物理页的引用次数减一,新分配的物理页次数加一
- kfree:对物理页的引用次数减一,当计数为0,才真正释放物理页
记录物理页的引用次数
在kalloc.c中添加一个全局数组,用于记录物理页的引用次数,并提供Modify方法,用于修改引用计数数组
int rc_array[(PHYSTOP - KERNBASE) / PGSIZE];
struct spinlock pgreflock; // 用于 rc_array 数组的锁,防止竞态条件引起内存泄漏
int modify(int x,int c)
{
if(x < 1){
return -1;
}
rc_array[x] += c;
if(rc_array[x] < 0) rc_array[x] = 0;
return rc_array[x];
}
修改kfree函数:引用次数为0才能删去
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
{
panic("kfree");
}
// acquire(&pgreflock);
// 添加对引用次数的判断,如果引用次数为1,才能删去
acquire(&pgreflock);
int count = modify(GET_IDX(pa), -1); // 把自己的引用计数减一,如果引用计数还是大于0,说明不能删除,则返回。
release(&pgreflock);
if(count <= 0){
// Fill with junk to catch dangling refs.
// 如果引用计数为-1,说明还未分配空间,那么往下执行,分配空间
// 如果引用计数为0,说明空间已经不被使用了,释放空间
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
}
修改Kalloc函数,分配物理页时将引用次数加一
void * // 添加对引用次数+1的功能
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r)
kmem.freelist = r->next;
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
// lab6 add
if(r){
acquire(&pgreflock);
modify(GET_IDX(r), 1);
release(&pgreflock);
}
return (void*)r;
}
在usertrap和copyout函数中,加入判断,当物理页的引用次数为1时,说明此时只有一个进程使用这个物理页,那么就不用进行复制,直接返回这个物理页即可
加锁防止竞争
在rc_array添加了自旋锁pgreflock,在使用modify方法时,都使用acquire("&pgreflock")
和release(&pgreflock)
来对操作进行保护,防止竞争导致的内存泄露。
如果出现了内存泄露问题,在运行Make grade后,系统会提示usertests调用前后的空闲页数量不同。
FAILED -- lost some free pages 32447 (out of 32448)
在以下四种场景调用Modify操作中加入锁进行保护。
- kalloc
- fork
- 写时复制场景usertrap\copyout
- kfree
提交
$ make qemu-gdb
(7.1s)
== Test simple ==
simple: OK
== Test three ==
three: OK
== Test file ==
file: OK
== Test usertests ==
$ make qemu-gdb
(78.3s)
== Test usertests: copyin ==
usertests: copyin: OK
== Test usertests: copyout ==
usertests: copyout: OK
== Test usertests: all tests ==
usertests: all tests: OK
== Test time ==
time: OK
Score: 110/110