xv6 Lab (cow)
Nowherechan 学徒

COW 章节仅有一个实验,要求在 xv6 操作系统中实现 fork 的写时复制操作。

概念

父进程 fork 出子进程时,不对内存进行复制,而当需要改写某内存页面上的内容时,再将该页面进行复制,称为写时复制;这样一来内存的利用率上升了,与此同时对于没有大量写入需求的子进程而言,其创建的开销降低,操作系统多方面的性能都得到提升。

原理

进程 fork 的时候,按照原有的规则是需要 allocproc,然后用 uvmcopy 将父进程的内存复制一份,将复制的内存 map 到子进程的页表上。随后再设置一些 traprame 的内容来保证 fork 系统调用的返回以及状态保存。

修改后,我们所需要达到的效果是:页表还是重新创建,但是父进程的内存暂时先不进行复制,而是子进程的页表项也同样指向父进程的物理页。然后将这些页表项上的标记位设置为只读。这样一来,写内存的时候,就会报错,因为没有写的权限呀。然后就会产生缺页中断。缺页中断的时候,我们再通过检查 PTE_COW 位等标记位来判断是 segment fault 还是应该去做“写时复制”。如果是后者,就需要复制一份内存,然后将页表项指向到新复制的内容,并且添加上可写位,这样一来,从缺页中断恢复时,写内存的指令再度执行,这个时候就不会再报错缺页中断了,而是正常写入。

事实上,不仅仅是写时复制,程序申请大量内存时的“延迟分配”,以及磁盘交换分区等,各种优化操作都可以通过缺页中断来实现。分页策略为系统提供了一种极其优越的抽象,程序执行过程中,缺页中断前后,程序自身完全不知道什么事情发生了!

问题

上面对写时复制的那堆描述看起来非常美好,但是实际上还需要做很多其他的事情。

进程结束或报错后,物理内存的 free

首先,页面的 free 成了问题:父子进程共用一个物理内存页,那要是子进程被 kill 了或者执行结束了,要是直接去 free 这个页面,可能导致父进程运行出错。

解决方法就是全局对每个物理页加一个引用计数。每有一个进程引用了该页面,那么就引用数 +1;如果有引用它的进程,执行了 cow,或者说执行结束了,那么就得引用数 -1。这个操作肯定是要加锁的,考虑到并发以及并行。

我这边引用计数的规则跟官网的 hint 给的略有不同,官网给出的是使用该页面的进程数,我则是“额外进程数”,即官网的设计 -1 即我的设计。观察到 uvmunmap 函数中参数加上 do_free,可以直接在解除绑定的时候 free 掉物理页,那么当子进程写时复制,需要与原物理页解绑的时候,可以直接调用这个函数。refcnt 减少的操作直接在 kfree 中进行。同理,refcnt 增加的操作直接在 uvmcopy 中进行,这是最底层的那个函数,至于更底层的 kalloc,它并不包含原进程物理页的任何信息。

其实我这样设计仅仅是因为 kalloc 完全不需要任何改变,而 kfree 内仅需增加一点点判断;其实这点好处聊胜于无,怎样设计都可以。

对于不可写的内存,cow 后可能变得可写

这是一个盲点,hint 里面一点都没有提到,而是后来的 usertests 检查到的。usertests 中有一个是往地址为 0 的地方写东西,按照一般逻辑当然是需要报错的,但是仅仅实现上述代码,这里是不会报错的:没有可写权限,然后缺页中断,然后写时复制,然后赋予可写权限,然后正常执行……等等,怎么就赋予可写权限了?

解决这个问题,我的策略是除了 PTE_COW 位以外,还另加了一个“原可写”标记位,进程要写原来不可写的内存,报 store 缺页中断的时候,就应该把这个进程 kill 掉了(我的代码中,PTE_COW 位是 PTE_C,“原可写”位是 PTE_CW)。

其他

其他就是不要自作聪明给指令缺页中断也加上奇怪 panic(我之前真就这么做了,usertests 错了一堆),应该报错的就报错,应该 kill 的就 kill,如果原来的系统能够通过 usertests,修改 cow 之后也需要通过 usertests 的话,就不要擅自加新功能。不然会被 usertests 中的一堆“ridiculous address”弄得焦头烂额。

代码实现

There is only one task in this lab, so I didn’t squash my commits.
The commit history showed what did I think about this lab and how did I improve my design to solve these problems. :)

代码

 Comments