Lab 6: Copy-on-write fork
COW fork() creates just a pagetable for the child, with PTEs for user memory pointing to the parent’s physical pages. COW fork() marks all the user PTEs in both parent and child as not writable. When either process tries to write one of these COW pages, the CPU will force a page fault. The kernel page-fault handler detects this case, allocates a page of physical memory for the faulting process, copies the original page into the new page, and modifies the relevant PTE in the faulting process to refer to the new page, this time with the PTE marked writeable. When the page fault handler returns, the user process will be able to write its copy of the page.
COW fork() makes freeing of the physical pages that implement user memory a little trickier. A given physical page may be referred to by multiple processes’ page tables, and should be freed only when the last reference disappears.
实现 fork 懒复制机制,在进程 fork 后,不立刻复制内存页,而是将虚拟地址指向与父进程相同的物理地址。在父子任意一方尝试对内存页进行修改时,才对内存页进行复制。 物理内存页必须保证在所有引用都消失后才能被释放,这里需要有引用计数机制。
- 需要增加记录应用次数的数组;
- 只有当引用计数为0时,才能真正free;
- 注意设置flag;
修改fork 时调用的uvmcopy()
首先修改 uvmcopy(),在复制父进程的内存到子进程的时候,不立刻复制数据,而是建立指向原物理页的映射,并将父子两端的页表项都设置为不可写。
// 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);
if(*pte & PTE_W) {
// 清除父进程的 PTE_W 标志位,设置 PTE_COW 标志位表示是一个懒复制页(多个进程引用同个物理页)
*pte = (*pte & ~PTE_W) | PTE_COW;
}
flags = PTE_FLAGS(*pte);
// 将父进程的物理页直接 map 到子进程 (懒复制)
// 权限设置和父进程一致
// (不可写+PTE_COW,或者如果父进程页本身单纯只读非 COW,则子进程页同样只读且无 COW 标识)
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){
goto err;
}
// 将物理页的引用次数增加 1
krefpage((void*)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
上面用到了 PTE_COW 标志位,用于标示一个映射对应的物理页是否是懒复制页。这里 PTE_COW 需要在 riscv.h 中定义:
// kernel/riscv.h
#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_COW (1L << 8) // 是否为懒复制页,使用页表项 flags 中保留的第 8 位表示
// (页表项 flags 中,第 8、9、10 位均为保留给操作系统使用的位,可以用作任意自定义用途)
usertrap()
与 lazy allocation lab 类似,在 usertrap() 中添加对 page fault 的检测,并在当前访问的地址符合懒复制页条件时,对懒复制页进行实复制操作:
// kernel/trap.c
void
usertrap(void)
{
// ......
} else if((which_dev = devintr()) != 0){
// ok
} else if((r_scause() == 13 || r_scause() == 15) && uvmcheckcowpage(r_stval())) { // copy-on-write
if(uvmcowcopy(r_stval()) == -1){ // 如果内存不足,则杀死进程
p->killed = 1;
}
} 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;
}
// ......
}
同时 copyout() 由于是软件访问页表,不会触发缺页异常,所以需要手动添加同样的监测代码(同 lab5),检测接收的页是否是一个懒复制页,若是,执行实复制操作:
// kernel/vm.c
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
if(uvmcheckcowpage(dstva)) // 检查每一个被写的页是否是 COW 页
uvmcowcopy(dstva);
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
// .......memmove from src to pa0
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
// ......
}
实现懒复制页的检测(uvmcheckcowpage())与实复制(uvmcowcopy())操作:
// kernel/vm.c
// 检查一个地址指向的页是否是懒复制页
int uvmcheckcowpage(uint64 va) {
pte_t *pte;
struct proc *p = myproc();
return va < p->sz // 在进程内存范围内
&& ((pte = walk(p->pagetable, va, 0))!=0)
&& (*pte & PTE_V) // 页表项存在
&& (*pte & PTE_COW); // 页是一个懒复制页
}
// 实复制一个懒复制页,并重新映射为可写
int uvmcowcopy(uint64 va) {
pte_t *pte;
struct proc *p = myproc();
if((pte = walk(p->pagetable, va, 0)) == 0)
panic("uvmcowcopy: walk");
// 调用 kalloc.c 中的 kcopy_n_deref 方法,复制页
// (如果懒复制页的引用已经为 1,则不需要重新分配和复制内存页,只需清除 PTE_COW 标记并标记 PTE_W 即可)
uint64 pa = PTE2PA(*pte);
uint64 new = (uint64)kcopy_n_deref((void*)pa); // 将一个懒复制的页引用变为一个实复制的页
if(new == 0)
return -1;
// 重新映射为可写,并清除 PTE_COW 标记
uint64 flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW;
uvmunmap(p->pagetable, PGROUNDDOWN(va), 1, 0);
if(mappages(p->pagetable, va, 1, new, flags) == -1) {
panic("uvmcowcopy: mappages");
}
return 0;
}
在 fork 的时候不复制数据只建立映射+标记,在进程尝试写入的时候进行实复制并重新映射为可写。
接下来,还需要做页的生命周期管理,确保在所有进程都不使用一个页时才将其释放
页的生命周期管理
物理页生命周期以及引用计数
在原本的 xv6 实现中,一个物理页的生命周期内,可以支持以下操作:
- kalloc(): 分配物理页
- kfree(): 释放回收物理页
懒分配后
- kalloc(): 分配物理页,将其引用计数置为 1
- krefpage(): 创建物理页的一个新引用,引用计数加 1
- kcopy_n_deref(): 将物理页的一个引用实复制到一个新物理页上(引用计数为 1),返回得到的副本页;并将本物理页的引用计数减
1 - kfree(): 释放物理页的一个引用,引用计数减 1;如果计数变为 0,则释放回收物理页
这里首先定义一个数组 pageref[] 以及对应的宏,用于记录与获取某个物理页的引用计数
// kernel/kalloc.c
// 用于访问物理页引用计数数组
#define PA2PGREF_ID(p) (((p)-KERNBASE)/PGSIZE)
#define PGREF_MAX_ENTRIES PA2PGREF_ID(PHYSTOP)
struct spinlock pgreflock; // 用于 pageref 数组的锁,防止竞态条件引起内存泄漏
int pageref[PGREF_MAX_ENTRIES]; // 从 KERNBASE 开始到 PHYSTOP 之间的每个物理页的引用计数
// note: reference counts are incremented on fork, not on mapping. this means that
// multiple mappings of the same physical page within a single process are only
// counted as one reference.
// this shouldn't be a problem, though. as there's no way for a user program to map
// a physical page twice within it's address space in xv6.
// 通过物理地址获得引用计数
#define PA2PGREF(p) pageref[PA2PGREF_ID((uint64)(p))]
void
kinit()
{
initlock(&kmem.lock, "kmem");
initlock(&pgreflock, "pgref"); // 初始化锁
freerange(end, (void*)PHYSTOP);
}
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
acquire(&pgreflock);
if(--PA2PGREF(pa) <= 0) {
// 当页面的引用计数小于等于 0 的时候,释放页面
// Fill with junk to catch dangling refs.
// pa will be memset multiple times if race-condition occurred.
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
}
release(&pgreflock);
}
void *
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
// 新分配的物理页的引用计数为 1
// (这里无需加锁)
PA2PGREF(r) = 1;
}
return (void*)r;
}
// Decrease reference to the page by one if it's more than one, then
// allocate a new physical page and copy the page into it.
// (Effectively turing one reference into one copy.)
//
// Do nothing and simply return pa when reference count is already
// less than or equal to 1.
//
// 当引用已经小于等于 1 时,不创建和复制到新的物理页,而是直接返回该页本身
void *kcopy_n_deref(void *pa) {
acquire(&pgreflock);
if(PA2PGREF(pa) <= 1) { // 只有 1 个引用,无需复制
release(&pgreflock);
return pa;
}
// 分配新的内存页,并复制旧页中的数据到新页
uint64 newpa = (uint64)kalloc();
if(newpa == 0) {
release(&pgreflock);
return 0; // out of memory
}
memmove((void*)newpa, (void*)pa, PGSIZE);
// 旧页的引用减 1
PA2PGREF(pa)--;
release(&pgreflock);
return (void*)newpa;
}
// 为 pa 的引用计数增加 1
void krefpage(void *pa) {
acquire(&pgreflock);
PA2PGREF(pa)++;
release(&pgreflock);
}
为 pageref[] 数组定义了自旋锁 pgreflock,并且在除了 kalloc 的其他操作中,都使用了 acquire(&pgreflock); 和 release(&pgreflock); 获取和释放锁来保护操作的代码。
相关复制的场景
父进程: 分配物理页 p(p 引用计数 = 1)
父进程: fork()(p 引用计数 = 2)
父进程: 尝试修改 p,触发页异常
父进程: 由于 p 引用计数大于 1,开始实复制 p(p 引用计数 = 2)
--- 调度器切换到子进程
子进程: exec() 替换进程影像,释放所有旧的页
子进程: 尝试释放 p(引用计数减 1),子进程丢弃对 p 的引用(p 引用计数 = 1)
--- 调度器切换到父进程
父进程: (继续实复制p)创建新页 q,将 p 复制到 q,将 q 标记为可写并建立映射,在这过程中父进程丢弃对旧 p 的引用
执行测试
./grade-lab-cow usertest
== Test running cowtest == (6.8s)
== Test usertests == Timeout! (304.4s)
...
test bigfile: OK
test dirfile: OK
test iref: OK
test forktest: OK
test bigdir: qemu-system-riscv64: terminating on signal 15 from pid 120203 (make)
MISSING '^ALL TESTS PASSED$'
QEMU output saved to xv6.out.usertests
== Test usertests: copyin ==
usertests: copyin: FAIL
Parent failed: test_usertests
== Test usertests: copyout ==
usertests: copyout: FAIL
Parent failed: test_usertests
== Test usertests: all tests ==
usertests: all tests: FAIL
Parent failed: test_usertests
看了好多博客,不知道这里到底为啥报错,先继续往后做吧,总共得分为81/110.