#Head BLog
本人掘金的专栏文章链接,欢迎阅读!
这是本人的 CSDN 博客之分类专栏链接,欢迎点击阅读!
#Source
- MIT-6.S081 2020 课程官网
- Lab6: copy-on-write fork 实验主页
- MIT-6.S081 2020 xv6 book
- B站 - MIT-6.S081 Lec10 Multiprocessors and locking
#My Code
#Motivation
Lab6: copy-on-write fork 主要是想解决父子进程在 fork 时的冗余拷贝问题。具体表现为,旧版本的 fork 会将父进程中的所有用户空间内存复制到子进程中,此时,如果父进程非常大,那复制可能很耗时。更糟的是,子进程或许会在 fork 之后的 exec 运行时,丢弃之前辛辛苦苦拷贝的内容。如此一来,更是得不偿失
在开始实验之前,一定要阅读 xv6-6.S081 的第五章节 Locking 及
kernel/spinlock.h和kernel/spinlock.c
#Solution
为此,xv6 设计了一种新的 fork 拷贝机制(类似于 Lab5: lazy page allocation 的延迟分配策略)
在 fork 阶段,子进程不用一股脑拷贝父进程页表的所有信息,而是记录下父进程的第一张页表(常识:通过第一张页表,可以揪出所有页表),并将第一张页表(包含的所有 PTE )设为不可写且标记COW位(用于之后缺页中断的判断环节)。仅此而已,巧妙地省去了巨大的拷贝工作
在之后的进程运行中,若子进程需要父进程的某个页块,再进行拷贝工作(也不迟!)
#Implement copy-on write
#Motivation
实现新的 fork 拷贝机制
#Solution
首先,从 kernel/proc.c:fork() 出发,在 fork() 中,子进程会调用 kernel/vm.c:uvmcopy() 函数拷贝父进程的内存空间。这是问题的所在,也是千头万绪的第一根线。其实,为了实现新的 fork 拷贝机制,我们就要从这个函数( kernel/vm.c:uvmcopy() )入手,作为突破点才行
#S1 - uvmcopy() 限制 PTE 为只读
好,明白了其重要性后,开始动手。根据 Lab6: copy-on-write fork 实验主页 的第一个提示(关于如何修改 kernel/vm.c:uvmcopy() ),停止原先分配新页表的工作,取而代之的是,仅仅建立页表与物理地址的映射关系即可。同时,将旧的页表(所有 PTE) 设为不可写,这非常关键。这就相当于保护现场,留个存档,使任何人都不能修改它,只有当没人再会用的它的时候,方才恢复其可写的状态。具体代码如下,
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz)
{
pte_t *pte;
uint64 pa, i;
uint flags;
// char *mem;
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);
// if((mem = kalloc()) == 0)
// goto err;
// memmove(mem, (char*)pa, PGSIZE);
// if(mappages(new, i, PGSIZE, (uint64)mem, flags) != 0){
// kfree(mem);
// goto err;
// }
pa = PTE2PA(*pte);
/** 标记子进程和父进程不可写,这也会导致父进程失去对该PTE写入的权限 */
*pte &= (~PTE_W);
*pte |= PTE_COW;
flags = PTE_FLAGS(*pte);
if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0)
goto err;
/** 告诉引用计数向量,在第(uint64)pa/PGSIZE页块处,有进程正在引用 */
incr_ref((char*)pa);
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
其中的 PTE_COW 标记位是我自己添加的,在 kernel/risc.h 中,
/** 在PTE中添加COW标记位,其中第8位和第9位是预留位,可用! */
#define PTE_COW (1L << 8)
以及 incr_ref() ,皆在告诉 xv6 ,目前还有多少双眼睛在盯着该 PTE ,提醒它暂时不要释放此物理页块。根据 Lab6: copy-on-write fork 实验主页 的第三个提示可知,我们需要引入引用计数机制(类似于 C++ 的共享指针)来管理物理页块,我简单翻译一下,
当
kalloc()时,初始化引用,计数为1,表明该页块有进程在用;当有进程 fork 时,引用计数加1,表明又多了一位;当kfree()时,如果还有进程在用,则自走自的,不释放该物理页块;反之则释放该页块
#S2 - 使 kmem 支持多进程
可以自定义引用计数结构体,需要注意的是,要用锁来实现互斥功能,以期达到多进程之间有序竞争的效果。我选择在 kernel/kalloc.c 中定义引用计数等一系列结构体和函数,
/** 引用计数块 */
typedef struct {
struct spinlock lock;
int count;
} memref;
#define MEMREFS PHYSTOP/PGSIZE
/** 引用计数向量,监测每个页块 */
memref memrefs[MEMREFS];
之后,修改添加 kernel/kalloc.c:kinit() 、kernel/kalloc.c:kfree() 和 kernel/kalloc.c:kalloc() 等函数。针对 kernel/kalloc.c:kinit() ,无非就是简单地初始化引用计数向量,给向量的每个引用块都配上互斥锁,
void
kinit()
{
/** 初始化引用计数向量 */
for(int i=0; i<MEMREFS; i++)
initlock(&(memrefs[i].lock), "memrefs");
initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
在 kernel/kalloc.c:kalloc() 中,将分配的页块打上标记,表明有进程正在使用,
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r) {
/** 初始化引用计数,刚alloc完,引用必然为1 */
uint32 i = (uint64)r/PGSIZE;
acquire(&(memrefs[i].lock));
memrefs[i].count = 1;
release(&(memrefs[i].lock));
kmem.freelist = r->next;
}
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
同理,在 kernel/kalloc.c:kfree() 中,适时释放物理页块(提示3已经说的很清楚了)。唯一需要注意的是,记得放锁!放锁有很多技巧,我选择的是 goto ,自认为很巧妙,代码不会显得很臃肿,
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
/** 释放页块时需要解引用 */
uint32 i = (uint64)pa/PGSIZE;
acquire(&(memrefs[i].lock));
memrefs[i].count--;
if(memrefs[i].count > 0)
goto notzero;
// 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);
notzero:
release(&(memrefs[i].lock));
}
最后,补充一下 incr_ref() 计数累加函数的具体实现,
/** 新分配的页块,也要增加引用 */
int
incr_ref(void* pa)
{
if(((uint64)pa%PGSIZE)!=0 || (char*)pa<end || (uint64)pa>=PHYSTOP)
return -1;
uint32 i = (uint64)pa/PGSIZE;
acquire(&(memrefs[i].lock));
memrefs[i].count++;
release(&(memrefs[i].lock));
return 1;
}
当然,别忘了在 kernel/defs.h 中添加新增函数的声明,以便于其他 .c 文件能看到
#S3 - usertrap() 深拷贝
接下来,就到了重要环节了。上面所做的修改 kernel/vm.c:uvmcopy() 等事情并没有直接分配内存,只是做了简单的预载工作(类似于用指针指向了一块内存,但并没有实际的深拷贝行为)
当子进程用到父进程页表中的内容时,会发生缺页中断(因为子进程的页表中并没有该内容),需要在 kernel/trap.c:usertrap() 中顺利捕捉到缺页中断,并且能够 handle 拷贝工作。我建立的框架如下,
void
usertrap(void)
{
...
uint64 scause = r_scause();
if(scause == 8){
...
} else if(scause==13 || scause==15) {
/** 缺页中断 */
uint64 va = r_stval();
/** 是否为copy-on-write page fault */
if(!iscow(p->pagetable, va))
goto rest;
if(!cowcopy(p->pagetable, va))
goto killing;
/** 顺利结束,handle缺页中断 */
goto rest;
killing:
p->killed = 1;
rest:
;
} else if((which_dev = devintr()) != 0){
// ok
} else {
...
}
...
}
发生缺页中断后,会首先判断此次中断是否为 copy-on-write 类型的缺页中断。如果是,则进行父子进程对应内容的拷贝工作;反之则直接忽略
来看一下,如何判断是否为 copy-on-write 缺页中断( kernel/kalloc.c:iscow() )。很简单,利用 kernel/vm.c:walk() 功能函数,去进程页表中搜索该虚拟地址对应的 PTE ,看其是否为 COW ,
/** 检查该页块是否为copy-on-write */
int
iscow(pagetable_t pagetable, uint64 va)
{
if(va > MAXVA)
return 0;
pte_t* pte = walk(pagetable, va, 0);
if(pte==0 || (*pte&PTE_V)==0)
return 0;
return (*pte&PTE_COW);
}
如果为 COW ,则需要进行拷贝工作。在我的框架里,我交由 kernel/kalloc.c:cowcopy() 函数来完成,
/** copy-on-write拷贝工作(父->子) */
uint64
cowcopy(pagetable_t pagetable, uint64 va)
{
va = PGROUNDDOWN(va);
pte_t* pte = walk(pagetable, va, 0);
uint64 pa = PTE2PA(*pte);
uint32 i = pa/PGSIZE;
/** 检查是否只有一个进程正在使用该页块 */
acquire(&(memrefs[i].lock));
if(memrefs[i].count == 1) {
/** 恢复该页块的写入权限 */
*pte |= PTE_W;
*pte &= (~PTE_COW);
release(&(memrefs[i].lock));
return pa;
}
release(&(memrefs[i].lock));
/** 尝试分配空间(缺页中断handler) */
char* mem = kalloc();
if(mem == 0)
return 0;
/** 拷贝工作 */
memmove(mem, (char*)pa, PGSIZE);
*pte &= (~PTE_V);
uint64 flag = PTE_FLAGS(*pte);
flag |= PTE_W;
flag &= (~PTE_COW);
if(mappages(pagetable, va, PGSIZE, (uint64)mem, flag) != 0)
goto freeing;
/** 顺利结束拷贝工作 */
goto rest;
freeing:
kfree(mem);
return 0;
rest:
kfree((char*)PGROUNDDOWN(pa));
return (uint64)mem;
}
首先,我会检查是否只有一个进程正在使用该页块,这表明了已经没有子进程再在乎虚拟地址对应的 PTE 了,此时就可以酌情处理了,具体表现为,恢复该页块的一系列权限
接着就开始处理拷贝工作了,第一步,分配新内存;然后,调用 memmove() 将内容抄过去,并设置新的 PTE 权限。注意,需要将其标记为可写(不太理解,为什么要将 PTE 置 ~PTE_V,如此一来,岂不是新旧两个 PTE 都不可用了?);最后,调用 kernel/vm.c:mappages() 建立映射关系
还需留意,cowcopy() 如果拷贝失败,是向上层返回 0 ;如果成功,则返回对应的物理地址
#S4 - 让 copyout() 支持 cow
最后一个环节,也是最经常用到的 kernel/vm.c:copyout() ,可以说此函数无时无刻不在被调用,只要内核要与用户态交互,该函数就会工作。在交互的过程中,copy-on-write 事件也时有发生。所以,在 kernel/vm.c:copyout() 中也需要添加针对 COW 页块的业务逻辑,代码如下,
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);
/** 当待拷贝的页块为copy-on-write时 */
if(iscow(pagetable, va0))
pa0 = cowcopy(pagetable, va0);
if(pa0 == 0)
return -1;
...
}
return 0;
}
就此,完成了 xv6 新的 fork 拷贝机制
#Result
手动进入 qemu
make qemu
$cowtest
...
$usertests
其中,在 usertests 中有三个测试,可能是因为我腾讯云服务器 2G 小内存的缘故,卡住了,分别是 kernmem、sbrkfail 和 stacktest。但是,无伤大雅,只要确保大体流程和具体业务逻辑是正确的就可以