Lab Copy on Write Fork
前置知识
一、原理
- 在xv6原始的eager fork中,执行fork会立刻将parent的所有页拷贝一份给child,但有时child会执行exec,然后丢到所有的拷贝,加载exec中的内容。这种重复的拷贝会浪费时空,因此可以进行copy on write fork。
- 在cow中,执行fork只会拷贝parent的页表,这些页表不分配空间,而是指向parent的页。parent的页此时不能有PTE_W位(只读),当出现修改请求时,会出现一个page fault(13, 15),此时再copy一个新页并分配给请求修改的地址就可以了(此时分配的page可读可写)
- 因为每一个parent可能会有多个child,因此每一个页在这些child都分配出去之前是不能被释放的,也就是说,一个页只有引用次数为1的时候才可以被释放。关于引用的计数,可以通过开一个大数组来记录所有页的引用次数(但总感觉10^6的int数组有点大)
task: Implement copy-on write
首先,我们需要拿出一位flag来记录当前page是否是一个cow page,根据录播课,可拿出第八位
1
2
3
4
5
6
7//riscv.h
...
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4) // 1 -> user can access
#define PTE_F (1L << 8) // copy-on-write fork
...然后,我们需要一个数组来记录所有page的引用次数。这里有几个特殊地方需要注意:
因为整个cow fork与IO模拟设备无关,因此完全可以不用记录KERNBASE(0x80000000L)之下的page,这样可以节省一些空间消耗。
这个数组的每一个元素都要加自旋锁。如果不加,则可能会出现两个core同时申请一个parent的free 和 fork,两者同时拿到了parent的引用次数2,此时假设先执行fork,则引用加一为3,后执行free,引用减一为1,而后执行的结果会覆盖先执行的结果,从而导致race condition。对于到底是给每个元素加lock还是给整个数组加lock,我觉得应该是前者。因为如果给整个数组加lock,则这个数组只能在同一时间被一个core所使用,这显然会在并发中导致阻塞。
1 | // kalloc.c |
初始化自旋锁和元素值。关于为何要把每一个cnt设置为1,因为每次启动kernel的时候,都会kfree一遍所有的page,在kfree中,会把cnt-1,因此要先+1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// kalloc.c
void
kinit()
{
for (int i = 0; i < (PHYSTOP - KERNBASE) / PGSIZE; i++)
initlock(&ref[i].lock, "cow");
initlock(&kmem.lock, "kmem");
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)
{
ref[((uint64)p - KERNBASE) / PGSIZE].cnt = 1; //因为kfree会-1
kfree(p);
}
}正如原理所说,在kfree中,只有引用次数为1的page才可以被释放,否则就只是给它的引用次数减一。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
uint64 rpa = ((uint64)pa - KERNBASE) / PGSIZE;
acquire(&ref[rpa].lock);
if ((--ref[rpa].cnt) <= 0){
// 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);
}
release(&ref[rpa].lock);
}同时,在kalloc中,也需要把每一个初始分配的page引用计数设为1
1
2
3
4
5
6
7
8
9if(r) {
memset((char*)r, 5, PGSIZE); // fill with junk
uint64 rpa = ((uint64)r - KERNBASE) / PGSIZE;
acquire(&ref[rpa].lock);
if (ref[rpa].cnt)
panic("kalloc: not a free page");
ref[rpa].cnt = 1;
release(&ref[rpa].lock);
}把判断一个page是否是cow page,改变一个page的引用计数封装成函数(其实也可以直接写)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26// kalloc.c
// 修改cow计数
void changeref(uint64 pa, uint64 num)
{
if (pa >= PHYSTOP)
panic("chgref: pa too big");
if (pa % PGSIZE != 0)
panic("chgref: not a complete page");
uint64 rpa = (pa - KERNBASE) / PGSIZE;
acquire(&ref[rpa].lock);
ref[rpa].cnt += num;
release(&ref[rpa].lock);
}
// 该page是否是cow page
int iscow(pagetable_t pagetable, uint64 va)
{
if (va >= MAXVA)
return -1;
pte_t* pte = walk(pagetable, va, 0);
if (!pte)
return -1;
if((*pte & PTE_V) == 0)
return -1;
return ((*pte) & PTE_F ? 0 : -1);
}此时,如果申请修改,那么会有page fault,此时如果该page只有一个引用,那么把flag还原一下就好,如果有多个引用,那么就分配一个可修改的page,然后把引用数减一。因为它冗杂且多用,因此封装为函数。注意,所有新添加的函数都需要在defs.h中声明一下。同时在这个函数中我们使用了walkaddr来取物理地址,因此也需要把它声明到defs.h中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46// kalloc.c
uint64 cowalloc(pagetable_t pagetable, uint64 va)
{
if (va % PGSIZE != 0) {
printf("cowalloc: va exceeds MAXVA\n");
return -1;
}
uint64 pa = walkaddr(pagetable, va);
if (pa == 0)
return -1;
//panic("cowalloc: pte not exists"); // 这里不能panic,而是返回错误并kill process,否则你的stacktest就会panic
pte_t* pte = walk(pagetable, va, 0);
if (pte == 0)
panic("cowalloc: pte not exists");
if ((*pte & PTE_V) == 0 || (*pte & PTE_U) == 0) {
panic("cowalloc: pte permission err");
}
if (refcnt((void*)pa) == 1) { // 只有一个引用
*pte |= PTE_W;
*pte &= ~PTE_F; // 还原标记
return (uint64)pa;
} else { // 多个引用
uint64 mem = (uint64)kalloc();
if (mem == 0) {
printf("cowalloc: kalloc fails\n");
return -1;
}
memmove((void*)mem, (void*)pa, PGSIZE);
*pte &= ~PTE_V; // 防止被mappage判定为remap
if (mappages(pagetable, va, PGSIZE, mem, (PTE_FLAGS(*pte) | PTE_W) & ~PTE_F) != 0) {
kfree((void*)mem);
*pte |= PTE_V;
return -1;
}
kfree((void*)PGROUNDDOWN(pa)); // 引用减一
return mem;
}
}我们知道,fork会跳转到uvmcopy来拷贝page,因此在里面我们需要作出修改。每次fork的时候,不需要分配物理page,只需要把parent的flag设好,引用数加一,并添加child的映射就好。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32//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);
*pte &= ~PTE_W;
*pte |= PTE_F;
flags = PTE_FLAGS(*pte);
//if((mem = kalloc()) == 0)
// goto err;
//memmove(mem, (char*)pa, PGSIZE);
if(mappages(new, i, PGSIZE, pa, flags) != 0){
//kfree(mem);
goto err;
}
changeref(pa, 1);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}因为page fault会跳转到usertrap,因此我们也需要修改usertrap,方法和lazy如出一辙。注意,在实现cowalloc时,映射错误,请求越界,内存耗尽等错误都会返回-1,这些情况需要立刻kill进程。(但后来仔细想了一下,请求越界是否也可以直接panic呢?)
1
2
3
4
5} else if (r_scause() == 15) {
uint64 fault_va = r_stval();
if (fault_va >= p->sz || iscow(p->pagetable, fault_va) != 0 || cowalloc(p->pagetable, PGROUNDDOWN(fault_va)) == -1)
p->killed = 1;
} else {还有一个小问题,当执行copyout时是在kernel中,并不能触发usertrap,因此需要另外处理。这里同样也需要拷贝页面,并需要拿到新分配页面的地址,这也就是为什么我们的cowalloc返回的是一个地址了。而且如果cowalloc出错,返回的是-1,需要与下面的出错判断统一(当然你也可以让它出错返回0,但我懒得改了)
1
2
3
4
5
6
7
8
9
10while(len > 0){
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
if (iscow(pagetable, va0) == 0){
pa0 = (uint64)cowalloc(pagetable, va0);
if (pa0 == -1)
pa0 = 0;
}
if(pa0 == 0)
return -1;
后记
在观摩前人代码时,发现了一个有意思的问题。请回看uvmcopy函数和cowalloc函数,在uvmcopy中,我们并没有判断该page在fork之前是否已经是只读的。如果它是只读的,那经过fork后,它和其他可写page一起被设为了只读的cow page,而在cowalloc中,引用数为1的page会直接标记可写,那么会不会出现不可写的page经过这种操作错误转化成可写page呢?如果要解决的话,我觉得需要用上第二个RSW位特殊记录,但在lab中并没有进行实现,但可以通过所有test,有兴趣者可自行尝试。
review:
在复习中,我在xv6 cow中查找了所有关于PTE_V的修改,发现只有在实现cow相关功能时,会出现只读不写的flag,因此在这个单独的修改中直接判断是可行的。基于这个特性,我们在这个lab中甚至可以优化掉PTE_F位,但是一旦加入其他的优化,比如lazy allocation,就需要额外判断。课程中morris老师讲到,在现代操作系统中,不是实现的简单page table,而是模仿page table的数据结构,或许这样就可以避免冲突了。