LAB
传送门:Lab: Copy-on-Write Fork for xv6
INTRO
COW fork,原文来自 xv6-handout-fall2021, section 4.6,大概意思是 fork() 非常浪费,不仅体现于有些场景子进程拷贝完内存映像后,一点也没使用便随着紧接着的 exec() 丢弃了;也体现在当父进程非常大时可能会耗尽内存
为了解决 fork() 这种拷贝的困境,大多数操作系统采用 COW fork 技术,在 fork() 时期并不真正分配内存给子进程,而是让父进程和子进程共享同一份内存映像。之后,无论是父进程还是子进程往共享内存写入并抛出 page-fault 异常时,内核才真正分配内存
随之而来的还有一个问题要考虑,那就是释放物理页,由于 COW fork 会使多个映射都指向同一个物理页,如果不采取手段会导致重复释放同一个地址的内存。通常的办法是为物理页引入 "引用计数器"(reference counter),多个映射每次释放时都只减少 counter,直至 counter 为零才真正释放物理页
Implement Copy-on-Write (HARD)
要求
Your task is to implement copy-on-write fork in the xv6 kernel. You are done if your modified kernel executes both the cowtest and usertests programs successfully.
"你的任务是在 xv6 内核上实现 Copy-on-Write fork。只有当你修改过的内核都通过 cowtest 和 usertests 两个测试时才算通过整个 lab"
可采取的策略
Modify uvmcopy() to map the parent's physical pages into the child, instead of allocating new pages. Clear PTE_W in the PTEs of both child and parent.
"将 uvmcopy() 的子进程物理页映射修改为父进程的映射,而不是分配新页。父进程和子进程所映射的所有 PTE 的 PTE_W 清位"
Modify usertrap() to recognize page faults. When a page-fault occurs on a COW page, allocate a new page with kalloc(), copy the old page to the new page, and install the new page in the PTE with PTE_W set.
"修改 usertrap() 让它识别 page-fault 异常。当 COW 页上出现 page-fault 时,用 kalloc() 分配一页,然后将旧页拷贝到新页,最后将新页 PTE_W 置位"
Ensure that each physical page is freed when the last PTE reference to it goes away -- but not before. A good way to do this is to keep, for each physical page, a "reference count" of the number of user page tables that refer to that page. Set a page's reference count to one when kalloc() allocates it. Increment a page's reference count when fork causes a child to share the page, and decrement a page's count each time any process drops the page from its page table. kfree() should only place a page back on the free list if its reference count is zero. It's OK to to keep these counts in a fixed-size array of integers. You'll have to work out a scheme for how to index the array and how to choose its size. For example, you could index the array with the page's physical address divided by 4096, and give the array a number of elements equal to highest physical address of any page placed on the free list by kinit() in kalloc.c.
"确保释放每个物理页只发生在失去最后一个 PTE
引用计数器时,而不是之前。一个可以采纳的策略是为每个物理页,维持一个指向这个页的映射数量的
'引用计数器'。当 kalloc() 分配一个新页时就将引用计数器设为 1。当 fork
使子进程共享一页时就为对应的引用计数器增加
1。当任意进程不使用页时就相应地将引用计数器减 1。kfree()
只有在引用计数器为 0
时才将释放的页链接回空闲页链表。可以将引用计数器用一个固定大小的整型数组来维持。你要考虑怎样索引这个数组,以及数组的大小怎么表示。比如,你可以用以
4096 划分的页的物理地址,作为数组的索引;然后将数组的大小设置为
kalloc.c 中,kinit()
分配的在最高地址范围内的,物理页的数量"
———— 首先很明确的,引用计数器的变化是这样的:
- 初始化:在
kernel/kalloc.c/kalloc()刚分配的时候就设为 1 - 自增:在
kernel/proc.c/fork()每次 fork() 时引用加 1 - 自减:在
kernel/kalloc.c/kfree()中计数为 0 时才释放,其他情况减 1
然后是如何设置数组大小以及如果访问数组元素(索引)的问题,我是这么理解的:
- 数组大小:参考
kernel/kalloc.c/kinit()发现 xv6 内核使用的 RAM 是有范围的,进而得出可用 RAM 是宏KERNELBASE和PHYSTOP之间 - 数组索引:最直接的方法是用当前的物理地址模除页大小(4096)
Modify copyout() to use the same scheme as page faults when it encounters a COW page.
"修改 copyout(),为(内核)抛出一个 COW 页时使用相同的策略处理 page-fault"
———— 这一点提示我们解决 COW 页的这个 "scheme" 会在两个地方使用,所以为 "scheme" 封装成一个函数会比较好
提示
It may be useful to have a way to record, for each PTE, whether it is a COW mapping. You can use the RSW (reserved for software) bits in the RISC-V PTE for this.
"为每个 PTE 设置一种方式来记录它是否 COW 映射,可以使用 risc-v PTE 的 RSW(reserved for software, 软件保留)位"
If a COW page fault occurs and there's no free memory, the process should be killed.
"如果 COW 页异常出现,但此时没有可用内存,那就杀死这个进程"
Summary
最后整理一下思路,按照 lab 提示推荐的策略,COW fork() 是分两步进行的:第一步是 "分配",第二步是回收
分配其实很好处理,就是子进程共享父进程的映射
困难的是回收,由于每 fork() 一次就会多一个计数,也即是计数器是所有映射至同一页的,所有的进程所共用的,这就涉及同步的问题,需要考虑引入锁
现在,程序流大概是这样的:
- 共享映射:由于 fork() 是调用 uvmcopy() 的,所以修改 uvmcopy(),其实直接去掉 kalloc() 分配有关的语句就行。值得一提的是,只完成这一步仍会报 "remap" 的 panic,所以需要将 mappage() 对应的检查删除
- 缺页分配:这里会封装成一个函数,因为 uvmcopy() 和 copyout() 都需要处理 cow 页。我的思路是,对于引用等于 1 的 cow 页,比如子进程已经执行完成,现在只有父进程需要写入,那么对应的物理页就完全属于父进程了,所以直接修改属性为可写可读就行;而对于引用大于 1 的,就需要分配内存、拷贝整个页、安装页表映射、最后将引用计数减一(因为子进程已经有自己新分配的页了,已经不使用父进程的映射了)。值得一提的是,cow 的处理逻辑一定要有一些判断,比如分配大小,页表项属性之类,否则会卡 usertests 某些测试点
- 异常处理:riscv 硬件会在遭遇 page-fault 的时候抛出异常,并在 scause 寄存器记录错误码,在 stval 记录无法翻译的虚拟地址。利用 stval 记录的虚拟地址我们可以查看这一页是否 cow 页,如果是就丢给 cow 处理函数
- 释放:每次调用 kfree() 都先减少一页,然后才判断是否等于 0,如果是才真正释放;否则直接返回
- 引用计数器的逻辑:这应该是最难最头痛的一件事,因为每个物理页都对应一个计数器,而一个物理页可能挂靠了多个虚拟地址,这就使得不同的子进程会访问同一个计数器。所以核心思想是,读和写计数器一定一定要加锁(读也要加,因为有可能两个子进程同时访问同一个计数器,如果稍快访问那个执行写操作,那么稍慢访问那个读到的数据就不对了),否则,以我的经验来看,十有八九会在 usertests 报错 "FAILED -- lost some free pages xxxxx (out of xxxxx)"。把握这个思想加锁就行
Solution
1 | // path: kernel/vm.c |
写在最后
共耗时 12h13m