MIT 6.S081 Lab Six
- 引言
- Copy-on-Write Fork for xv6
- 问题
- 解决方案
- Implement copy-on write (hard)
- 代码解析
- 可选的挑战练习
引言
本文为 MIT 6.S081 2020 操作系统 实验六解析。
MIT 6.S081课程前置基础参考: 基于RISC-V搭建操作系统系列
Copy-on-Write Fork for xv6
虚拟内存提供了一定程度的间接寻址:
- 内核可以通过将PTE标记为无效或只读来拦截内存引用,从而导致页面错误,还可以通过修改PTE来更改地址的含义。
- 在计算机系统中有一种说法,任何系统问题都可以用某种程度的抽象方法来解决。
- Lazy allocation实验中提供了一个例子。
- 这个实验探索了另一个例子:写时复制分支(copy-on write fork)。
在开始本实验前,将仓库切换到cow分支
$ git fetch
$ git checkout cow
$ make clean
问题
xv6中的fork()
系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程较大,则复制可能需要很长时间。更糟糕的是,这项工作经常造成大量浪费;
- 例如,子进程中的
fork()
后跟exec()
将导致子进程丢弃复制的内存,而其中的大部分可能都从未使用过。 - 另一方面,如果父子进程都使用一个页面,并且其中一个或两个对该页面有写操作,则确实需要复制。
解决方案
copy-on-write (COW) fork()的目标是推迟到子进程实际需要物理内存拷贝时再进行分配和复制物理内存页面。
- COW fork()只为子进程创建一个页表,用户内存的PTE指向父进程的物理页。
- COW fork()将父进程和子进程中的所有用户PTE标记为不可写。
- 当任一进程试图写入其中一个COW页时,CPU将强制产生页面错误。
- 内核页面错误处理程序检测到这种情况将为出错进程分配一页物理内存,将原始页复制到新页中,并修改出错进程中的相关PTE指向新的页面,将PTE标记为可写。
- 当页面错误处理程序返回时,用户进程将能够写入其页面副本。
COW fork()将使得释放用户内存的物理页面变得更加棘手。给定的物理页可能会被多个进程的页表引用,并且只有在最后一个引用消失时才应该被释放。
Implement copy-on write (hard)
YOUR JOB
- 您的任务是在xv6内核中实现copy-on-write fork。如果修改后的内核同时成功执行
cowtest
和usertests
程序就完成了。 - 为了帮助测试你的实现方案,我们提供了一个名为
cowtest
的xv6程序(源代码位于user/cowtest.c)。 cowtest
运行各种测试,但在未修改的xv6上,即使是第一个测试也会失败。因此,最初您将看到:
$ cowtest
simple: fork() failed
$
“simple”测试分配超过一半的可用物理内存,然后执行一系列的fork()
。fork
失败的原因是没有足够的可用物理内存来为子进程提供父进程内存的完整副本。
完成本实验后,内核应该通过cowtest
和usertests
中的所有测试。即:
$ cowtest
simple: ok
simple: ok
three: zombie!
ok
three: zombie!
ok
three: zombie!
ok
file: ok
ALL COW TESTS PASSED
$ usertests
...
ALL TESTS PASSED
$
这是一个合理的攻克计划:
- 修改
uvmcopy()
将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W
标志。 - 修改
usertrap()
以识别页面错误。当COW页面出现页面错误时,使用kalloc()
分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中并设置PTE_W
。 - 确保每个物理页在最后一个PTE对它的引用撤销时被释放——而不是在此之前。这样做的一个好方法是为每个物理页保留引用该页面的用户页表数的“引用计数”。当
kalloc()
分配页时,将页的引用计数设置为1。当fork
导致子进程共享页面时,增加页的引用计数;每当任何进程从其页表中删除页面时,减少页的引用计数。kfree()
只应在引用计数为零时将页面放回空闲列表。可以将这些计数保存在一个固定大小的整型数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案。例如,您可以用页的物理地址除以4096对数组进行索引,并为数组提供等同于kalloc.c中kinit()
在空闲列表中放置的所有页面的最高物理地址的元素数。 - 修改
copyout()
在遇到COW页面时使用与页面错误相同的方案。
提示:
- lazy page allocation实验可能已经让您熟悉了许多与copy-on-write相关的xv6内核代码。但是,您不应该将这个实验室建立在您的lazy allocation解决方案的基础上;相反,请按照上面的说明从一个新的xv6开始。
- 有一种可能很有用的方法来记录每个PTE是否是COW映射。您可以使用RISC-V PTE中的RSW(reserved for software,即为软件保留的)位来实现此目的。
usertests
检查cowtest
不测试的场景,所以别忘两个测试都需要完全通过。- kernel/riscv.h的末尾有一些有用的宏和页表标志位的定义。
- 如果出现COW页面错误并且没有可用内存,则应终止进程。
代码解析
跟着提示一步一步来
(1). 在kernel/riscv.h中选取PTE中的保留位定义标记一个页面是否为COW Fork页面的标志位
// 记录应用了COW策略后fork的页面
#define PTE_F (1L << 8)
(2). 在kalloc.c中进行如下修改
- 定义引用计数的全局变量
ref
,其中包含了一个自旋锁和一个引用计数数组,由于ref
是全局变量,会被自动初始化为全0。
这里使用自旋锁是考虑到这种情况:
- 进程P1和P2共用内存M,M引用计数为2,此时CPU1要执行
fork
产生P1的子进程,CPU2要终止P2,那么假设两个CPU同时读取引用计数为2,执行完成后CPU1中保存的引用计数为3,CPU2保存的计数为1,那么后赋值的语句会覆盖掉先赋值的语句,从而产生错误
struct ref_stru {
struct spinlock lock;
int cnt[PHYSTOP / PGSIZE]; // 引用计数
} ref;
- 在
kinit
中初始化ref
的自旋锁
void
kinit()
{
initlock(&kmem.lock, "kmem");
initlock(&ref.lock, "ref");
freerange(end, (void*)PHYSTOP);
}
- 修改
kalloc
和kfree
函数,在kalloc
中初始化内存引用计数为1,在kfree
函数中对内存引用计数减1,如果引用计数为0时才真正删除
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r) {
kmem.freelist = r->next;
acquire(&ref.lock);
ref.cnt[(uint64)r / PGSIZE] = 1; // 将引用计数初始化为1
release(&ref.lock);
}
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// 只有当引用计数为0了才回收空间
// 否则只是将引用计数减1
acquire(&ref.lock);
if(--ref.cnt[(uint64)pa / PGSIZE] == 0) {
release(&ref.lock);
r = (struct run*)pa;
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);
acquire(&kmem.lock);
r->next = kmem.freelist;
kmem.freelist = r;
release(&kmem.lock);
} else {
release(&ref.lock);
}
}
- 添加如下四个函数,详细说明已在注释中,这些函数中用到了
walk
,记得在defs.h中添加声明,最后也需要将这些函数的声明添加到defs.h - 在cowalloc中,读取内存引用计数,如果为1,说明只有当前进程引用了该物理内存(其他进程此前已经被分配到了其他物理页面),就只需要改变PTE使能
PTE_W
;否则就分配物理页面,并将原来的内存引用计数减1。该函数需要返回物理地址,这将在copyout
中使用到。
/**
* @brief cowpage 判断一个页面是否为COW页面
* @param pagetable 指定查询的页表
* @param va 虚拟地址
* @return 0 是 -1 不是
*/
int cowpage(pagetable_t pagetable, uint64 va) {
if(va >= MAXVA)
return -1;
pte_t* pte = walk(pagetable, va, 0);
if(pte == 0)
return -1;
if((*pte & PTE_V) == 0)
return -1;
return (*pte & PTE_F ? 0 : -1);
}
/**
* @brief cowalloc copy-on-write分配器
* @param pagetable 指定页表
* @param va 指定的虚拟地址,必须页面对齐
* @return 分配后va对应的物理地址,如果返回0则分配失败
*/
void* cowalloc(pagetable_t pagetable, uint64 va) {
if(va % PGSIZE != 0)
return 0;
uint64 pa = walkaddr(pagetable, va); // 获取对应的物理地址
if(pa == 0)
return 0;
pte_t* pte = walk(pagetable, va, 0); // 获取对应的PTE
if(krefcnt((char*)pa) == 1) {
// 只剩一个进程对此物理地址存在引用
// 则直接修改对应的PTE即可
*pte |= PTE_W;
*pte &= ~PTE_F;
return (void*)pa;
} else {
// 多个进程对物理内存存在引用
// 需要分配新的页面,并拷贝旧页面的内容
char* mem = kalloc();
if(mem == 0)
return 0;
// 复制旧页面内容到新页
memmove(mem, (char*)pa, PGSIZE);
// 清除PTE_V,否则在mappagges中会判定为remap
*pte &= ~PTE_V;
// 为新页面添加映射
if(mappages(pagetable, va, PGSIZE, (uint64)mem, (PTE_FLAGS(*pte) | PTE_W) & ~PTE_F) != 0) {
kfree(mem);
*pte |= PTE_V;
return 0;
}
// 将原来的物理内存引用计数减1
kfree((char*)PGROUNDDOWN(pa));
return mem;
}
}
/**
* @brief krefcnt 获取内存的引用计数
* @param pa 指定的内存地址
* @return 引用计数
*/
int krefcnt(void* pa) {
return ref.cnt[(uint64)pa / PGSIZE];
}
/**
* @brief kaddrefcnt 增加内存的引用计数
* @param pa 指定的内存地址
* @return 0:成功 -1:失败
*/
int kaddrefcnt(void* pa) {
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
return -1;
acquire(&ref.lock);
++ref.cnt[(uint64)pa / PGSIZE];
release(&ref.lock);
return 0;
}
- 修改
freerange
void
freerange(void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) {
// 在kfree中将会对cnt[]减1,这里要先设为1,否则就会减成负数
ref.cnt[(uint64)p / PGSIZE] = 1;
kfree(p);
}
}
(3). 修改uvmcopy
,不为子进程分配内存,而是使父子进程共享内存,但禁用PTE_W
,同时标记PTE_F
,记得调用kaddrefcnt
增加引用计数
//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);
flags = PTE_FLAGS(*pte);
// 仅对可写页面设置COW标记
if(flags & PTE_W) {
// 禁用写并设置COW Fork标记
flags = (flags | PTE_F) & ~PTE_W;
*pte = PA2PTE(pa) | flags;
}
// 子进程页表中的pte的权限也是flags --> COW
if(mappages(new, i, PGSIZE, pa, flags) != 0) {
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
// 增加内存的引用计数
kaddrefcnt((char*)pa);
}
return 0;
}
本lab不依赖于之前的lab,此时使用的不是lazy allocation,而是预先分配策略。
(4). 修改usertrap
,处理页面错误
//trap.c
uint64 cause = r_scause();
if(cause == 8) {
...
} else if((which_dev = devintr()) != 0){
// ok
} else if(cause == 13 || cause == 15) {
uint64 fault_va = r_stval(); // 获取出错的虚拟地址
if(fault_va >= p->sz
// 如果第二个条件不满足,说明当前发生的是COW写入错误
|| cowpage(p->pagetable, fault_va) != 0
// 当前发生COW写入错误时,继续进入到第三个判断
// 该判断负责处理COW写入错误,即分配一个新页面
|| cowalloc(p->pagetable, PGROUNDDOWN(fault_va)) == 0)
p->killed = 1;
} else {
...
}
本lab不依赖于之前的lab,此时使用的不是lazy allocation,而是预先分配策略。
由于采用的是预先分配策略,所以产生page fault的原因只能有两个:
- 访问了p->sz之上的虚拟地址,即非法空间
- 权限问题导致的错误 --> 用户态下访问没有设置pte_U的虚拟地址 ;向只读的虚地址范围尝试写入,并且对应的虚拟式非COW页面;
- 尝试向COW页面写入数据 --> 这是我们唯一能够挽救的错误,其他错误发生时,直接Kill当前进程即可。
(5). 在copyout
中处理相同的情况,如果是COW页面,需要更换pa0
指向的物理地址
//vm.c
// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
uint64 n, va0, pa0;
while(len > 0){
// 将用户态虚拟地址转换为物理地址
va0 = PGROUNDDOWN(dstva);
pa0 = walkaddr(pagetable, va0);
// 处理COW页面的情况
if(cowpage(pagetable, va0) == 0) {
// 更换目标物理地址
pa0 = (uint64)cowalloc(pagetable, va0);
}
if(pa0 == 0)
return -1;
n = PGSIZE - (dstva - va0);
if(n > len)
n = len;
memmove((void *)(pa0 + (dstva - va0)), src, n);
len -= n;
src += n;
dstva = va0 + PGSIZE;
}
return 0;
}
copyout负责从内核态空间拷贝数据到用户态虚地址空间某处位置,因为涉及对用户态虚地址空间的写入操作,所以需要处理COW。
可选的挑战练习
- 修改xv6以同时支持lazy allocation和COW。
- 测量您的COW实现减少了多少xv6拷贝的字节数以及分配的物理页数。寻找并利用机会进一步减少这些数字。