xv6-lab-COW 实验记录与分析
写在前面: 本次实验花费了超出我预期很多的时间来完成。分析原因是在测试的时候出现了大大小小很多问题,其中最关键的问题就是 Lost Pages, 刚开始我一直以为是我在哪里忘记kfree()
回收了,但是后来看了对应的Lecture才发现是缺少自旋锁, 发生了 竞争条件(race condition) 。此外这次记录文字比较密集请见谅。
Copy-On-Write
当时一开始在做这个部分的时候还是比较清晰,因为COW的策略其实比较清晰,官网上也给出了比较详细的提示。所以把握好COW的核心思想就能正常完成绝大部分的内容。
COW的策略现在在我看来相对清晰了,主要就是为了减少由于fork()
造成了页面复制开销,因为有一半以上的父进程在fork
出子进程之后,子进程立即exec()
. 所以在fork()
期间复制的所以页面就会被无意义的丢弃掉。此外,对于某些只读的进程复制页面也属于一种时间和空间的浪费。COW策略是节省了类似上述两种情况的开销,加入子进程并没有exec()
而是不断的读写fork()
的页面,那么COW不仅没有节省开销反而在这个基础上增加了n次trapin
和trapout
的上下文切换带来的开销。
要想实现COW,有几个很关键的地方需要注意:
- 为了不破坏父子进程之间的隔离性,任何对共享内存的更改都应该只对自身可见,所以利用页表机制的
store page fault
可以动态地更新映射关系,使原来指向的共享物理页面改为自己的私有物理页面。 - 对于每一个页表的每一个PTE都应该仔细考虑。在
fork()
过程中,应该清除PTE_W
权限才能导致page fault
的发生。而在处理page fault
的程序中,应该将PTE_W
添加上,同时 由于需要对一个新的物理页面进行映射,所以应该将原有PTE的权限位完全复制到新的PTE中。如果使用了PTE的保留位,也应该对该位进行配套的处理。 - 由于现在的COW机制允许将不同PTE映射至同一个物理页面,所以在进行
kfree()
的时候就应该对释放空间的机制进行调整。当且仅当最后一个PTE指向物理页面的时候才能真正地释放物理空间。我首先想到的是对于kalloc()
和kfree()
操作的每一个数据都包装成为一个结构体,里面包含了物理地址信息和引用次数,但是提示中说道可以用一个大数组来对应表示每一个物理页面的引用次数,觉得这样更直接,就用了提示中的方法,没有想到会真的存在 竞争条件的问题. - 正如在
lazy allocation
实验中遇到的问题一样,应该处理当用户进程向内核传递一个使用COW策略的地址的情况,首先肯定不能直接写,这样就同时更改了所用使用这个物理页面的进程信息。其次在内核中的copyout()
是用软件walk
出物理地址的,所以没办法直接产生page fault
。需要用软件判断一些标志来决定是直接写还是得重新分配页面之后再写。
当实现上述说的特性之后,简单的COW就可以被实现了。只需要注意一些特殊情况的有效性,比如判断地址的有效范围,判断标志位等等。由于这个时候的代码太过于难看,我自己都讨厌,而且有下面说的问题没解决,所以就不贴了,要是想看看反面教材的话可以在github中的提交历史中找到。重构后代码跳转
由于刚才使用全局变量数组来存放一个页面的引用次数,而且没有使用锁来处理 竞争条件, 所以会导致发生奇怪的问题。最直观的体现就是Lost Page
.
我能想到的一种最简单的可能性就是:两个进程同时
kfree()
,同时读取引用次数(假设2),然后都进入了不真正释放只减少引用的代码分支。然而已经没有其他进程引用这个物理页面了,仅有的两次kfree()
机会都已经用完了。这个物理页面被丢失了, 无论那个物理页面的引用次数被减少了几次都无所谓了。
Lecture12
当时发现对于这次的COW实验还单独开设了一节答疑课还挺高兴的,听完教授做这次实验的全部过程,我不禁感叹: 优雅,是在是太优雅了。
教授完全是和我们一样从0开始一步一步的开始整个实验,他所用的调试内核的策略是: take a baby step -> get wrong -> debugging and finding why -> take another baby step。而我反思了一下我做实验的这个步骤: think a lot -> try to do all i can do -> get wrong -> find something i was missing -> stuck… 。当然我觉得想清楚再做肯定是没问题的,但是一步做的事情不要太多,否则出现问题的时候再回来找曾经疏漏的地方时就异常困难,甚至花费好几倍的时间。
我还注意到了一点我和教授的差距:每当教授想要写一个新函数或者实现一个新功能, 首先他是在考虑有哪些非法情况我们需要排除,停下来好好想想之后再开始写主要功能。并且在很多不应该出现的情况后面加上printf()
或者panic()
输出错误信息。 而我却是一开始就写上了主要功能,有时候也懒得写调试语句,所以发生错误就得花费很久的时间去gdb一步一步调试。
发生错误或者panic()
的时候,我本能地有些慌张,错误信息只是匆匆看一眼,对于调试这个不知道因为好几个蝴蝶效应才产生的错误有些恐惧。但是教授却显得很从容和兴奋,仔细观察错误信息然后推理和猜测,最终很快解决这个问题。
看了教授写的代码,我再来看我自己写的就惨不忍睹了,代码复用很少,也不利于调试和重构。于是我在我自己的思路上仿照教授的风格重新写了一份作为接下来的参考。
Race-Condition
在真正面临这个问题之前我是了解过一点 race condition的,只不过真正要解决的时候又有一点迷茫了。作为下一个实验的衔接,简单来说,当使用全局变量的时候就应该要考虑 race condition,这个条件可能根据实际情况稍微有所变化。
当时知道是由于没有加锁之后,我尝试在原来的代码上更改,但是原来的版本我并没有选择在kfree()
中减少引用,而是直接在相应的usertrap
和copyout
函数中直接对那个全局数组进行操作,所以相应的就得在很多地方acquire and release
.一不小心就deadlock
。增加引用的操作也没有模块化,得一个一个acquire and release
。现在回想真是一个糟糕的经历。
然后就是关于锁的选择,目前浅显的理解是锁是用来将原来并行的代码强制串行化的。在代码没有问题的基础上,串行执行是保证没有问题的,但是我们为了让执行速度更快,让好几个CPU同时执行不同的进程,这才导致了race condition
。所以总结一下,既然串行执行没问题,那么极端一点,整个内核用一把锁也对执行的正确性没有问题。实际上有这么多的锁完全是为了提高代码的执行速度。在这个情况下,对于要不要初始化一把新的锁每个人都有自己的理解。
速度和简单总得选一个,由于我在这里对速度没有过多的要求,同时对锁的概念也不够了解,于是简单地将全局数组和freelist
设置为共用一把锁,所以没有初始化一把新的锁。
下面是重构后的代码, 值得注意的是在代码中对一些异常的判断,这在调试中非常关键:
code
首先需要在uvmcopy()
中放弃整个页面的复制,从而只复制PTEs和清除PTE_W
标志位。
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *oldpte, *newpte;
uint64 pa, i;
for(i = 0; i < sz; i += PGSIZE){
if((oldpte = walk(old, i, 0)) == 0)
panic("uvmcopy: pte should exist");
if((*oldpte & PTE_V) == 0)
panic("uvmcopy: page not present");
pa = PTE2PA(*oldpte);
*oldpte &= ~PTE_W;
*oldpte |= PTE_COW;
newpte = walk(new, i, 1);
*newpte = *oldpte;
pagerfcnt[(pa - FREESTART)/PGSIZE] += 1;
}
return 0;
}
维护一个存放引用次数的全局数组,同时对kfree()
和kalloc()
进行调整。全局数组的大小至少要对应所有的freepage
,这个区间是end
到PHYSTOP
的页数,当然大一点也可以,教授就是申请了一些多余的空间。
// after print the address of end[]
#define FREESTART PGROUNDUP(0x80046000L)
#define PA2REF(pa) (pagerfcnt[(PGROUNDDOWN(pa) - FREESTART)/PGSIZE])
int pagerfcnt[(PHYSTOP-FREESTART)/PGSIZE] = {0};//reference count for each page.
// when allocate new page, set the reference to 1
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r){
kmem.freelist = r->next;
if(PA2REF((uint64)r) != 0) panic("kalloc ref\n");
PA2REF((uint64)r) = 1;
}
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
//when calling kfree two method to deal with it
//one is decrease reference and return
//one is decrease reference and really free
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
acquire(&kmem.lock); //to avoid race condition, acquire the lock
if(PA2REF((uint64)pa) < 0)
panic("kfree ref\n");
PA2REF((uint64)pa) -= 1;
int tmp = PA2REF((uint64)pa);
release(&kmem.lock);
if(tmp != 0)
return;
// 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);
}
为了避免race condition
, 对于这种全局变量的读取和更改都需要先acquire lock
,然后再释放。所以新增了两个这方面的函数。
//increase the reference count to single page
void
incref(uint64 pa)
{
acquire(&kmem.lock);
if(PA2REF(pa) <= 0)
panic("increase ref\n");
PA2REF(pa) += 1;
release(&kmem.lock);
}
int
getref(uint64 pa)
{
int ref = 0;
acquire(&kmem.lock);
if(PA2REF(pa) <= 0)
panic("get reference \n");
ref = PA2REF(pa);
release(&kmem.lock);
return ref;
}
然后处理如何应对一个cow fault
,为了代码的复用率和模块化,我仿照教授的方式将这个措施写在了一个函数里面,和教授不同的是:我觉得当仅有一个进程引用一个物理页面的时候,可以直接将赋予写权限,就不需要再申请一次页面然后释放了,节省了一次复制的开销。这也是为什么我需要一个getref()
函数来获取引用次数的原因。其次,我还在判断page fault
是否是一个COW fault
的时候用了一个PTE中的保留位,因为经过上次的lazy lab
,我觉得page fault
产生的实际上应该会有很多是由于类似COW之类的页表操作技巧,所以为了扩展性用一个位是值得的。
#define PTE_COW (1L << 8) // used for COW
//handle the case of Copy On Write fault.
//return -1 if error happened
//return 0 if going on right or nothing happened.
int
cowfault(pagetable_t pagetable, uint64 va)
{
pte_t * pte;
if(va > MAXVA)// avoid tricky virtual address.
return -1;
if((pte = walk(pagetable, va, 0)) == 0)//avoid non-mapped va.
return -1;
if((*pte & PTE_COW) == 0)// not the COW case normal return
return 0;
if(((*pte) & PTE_V || (*pte) & PTE_U) == 0)//avoid pages like guard page or trampoline or trapframe...
return -1;
uint64 pa = PTE2PA(*pte);
if(getref(pa) == 1){
*pte |= PTE_W;
*pte &= ~PTE_COW;
}else{
int flag = PTE_FLAGS(*pte);
void *mem = kalloc();
if(mem == 0) return -1;
memmove(mem, (void*)pa, PGSIZE);
*pte = PA2PTE((uint64)mem) | flag | PTE_W;
*pte &= ~PTE_COW;
kfree((void*)pa);
}
return 0;
}
前面说到,关于cow page fault
的处理有两处,一处是usertrap
,用户直接写入共享地址;第二处是copyout
,用户先将地址传递给内核,由内核执行写入,由于这个时候不能产生store page fault
,需要软件判断。为了可扩展性,这两处都预留了做其他事情的空间,因为当cowfault
返回0的时候相当于什么也不做。
void
usertrap(void)
{
// .........................................
} else if((which_dev = devintr()) != 0){
// ok
} else if(r_scause() == 15){
if(cowfault(p->pagetable, r_stval()) < 0)
p->killed = 1;
//do other situation here.
} else {
printf("process : %s\n",p->name);
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
// ..........................................
}
// 由于copyout的cowfault部分可能不会执行,所以应该在前面再判断一次数据的有效性。
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
pte_t *pte;
while(len > 0){
va0 = PGROUNDDOWN(dstva);
if(va0 > MAXVA)
return -1;
pte = walk(pagetable, va0, 0);
if(pte == 0)
return -1;
if(((*pte) & PTE_V || (*pte) & PTE_U) == 0)// check for validation
return -1;
if((*pte & PTE_W) == 0){
if(cowfault(pagetable, va0) < 0)
return -1;
else{
// do other situation here.
}
}
// .........................................
}
最后贴上成功通过测试的截图纪念,这次实验确实比想象中的要复杂,也收获了很多。
写在最后:感谢能读到这里,希望没有浪费您宝贵的时间,如果觉得还不错,不要吝啬手中的赞呦~。
加以修改后的源代码放在了sycamoremoon’s github,需要的同学可以自取。
之后还会有其他的内容上传,希望我的小小努力能够给大家带来帮助
这是我的网站,内容会随着时间逐渐丰富的…