Lab4:Copy-on-write Fork
本 lab 的任务是实现写时复制 fork。
参考文章:Lab6: Copy-on-Write Fork for xv6 详解
阅读指路:
kernel/vm.c:【核心数据结构是 pagetable_t,它实际上是一个指向 RISC-V 根页表页的指针;pagetable_t可以是内核页表,也可以是进程的页表。核心函数是 walk 和 mappages,前者通过虚拟地址得到 PTE,后者将虚拟地址映射到物理地址】
kernel/sysproc.c
There is a saying in computer systems that any systems problem can be solved with a level of indirection.
- fork机制存在的问题:
The fork() system call in xv6 copies all of the parent process’s user-space memory into the child. If the parent is large, copying can take a long time. Worse, the work is often largely wasted; for example, a fork() followed by exec() in the child will cause the child to discard the copied memory, probably without ever using most of it.
众所周知,有关存储的存取和复制等操作是计算机性能的一大瓶颈。
【只有当父进程和子进程共享同一个物理页并且有一方要向共享的物理页写入内容时,才真正需要分配新页并进行复制,这就是Copy-on-Write机制的思想】
- 解决方案——COW机制详细介绍:
Copy-on-Write fork() 机制对每一个子进程创建一个页表,其中的页表项PTE指向父进程的物理页,对于所有父进程和子进程的页表项PTE都标记为不可写。
当任何其中一个进程尝试写入这些物理页,触发缺页异常。内核的缺页异常处理函数检测到这种情况,对于造成异常的进程,分配新的一页物理内存,复制原来物理页的内容到新的物理页,修改对应的页表项PTE使得该进程引用新的物理页,标记为可写。当缺页异常程序返回,用户态进程可以正常向新的物理页中写入内容(内核维护COW机制,应用程序感知不到这种异常的发生)。
Copy-on-Write fork()机制对于释放物理页的方式有所变化,给定的物理页可能同时被很多线程的页表所引用,当且仅当没有任何页表引用该物理页(即最后一个引用取消时),释放物理页内存。
合理的实现方法:
- 修改 uvmcopy() 将父进程的物理页映射到子进程,而不是分配新页。在子进程和父进程的PTE中清除PTE_W标志(设为不可写,并且需要设为COW机制页)。
- 修改 usertrap() 识别页错误。当COW页面出现页错误时,使用 kalloc() 分配一个新页面,并将旧页面复制到新页面,然后将新页面添加到PTE中,设置PTE_W。
- 确保每个物理页在最后一个PTE对它的引用撤销时被释放。一个好方法是为每个物理页保留引用该页面的用户页表数的 “引用计数”(当kalloc()分配页时,将页的引用计数设置为1;当fork导致子进程共享页面时,增加页的引用计数;当任何进程从其页表中删除页面时,减少页的引用计数)
kfree() 只在引用计数为零时将页面放回空闲列表。可以将这些计数保存在一个固定大小的整型数组中。你必须制定一个如何索引数组以及如何选择数组大小的方案(例如,您可以用页的物理地址除以4096对数组进行索引,并为数组提供等同于kalloc.c中kinit()在空闲列表中放置的所有页面的最高物理地址的元素数) - 修改 copyout() 在遇到COW页面时使用与页错误相同的方案。
Hints:(细节处理的提示)
- 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机制映射的物理页】 - Some helpful macros and definitions for page table flags are at the end of kernel/riscv.h.
【kernel/riscv.h最后部分的定义有帮助】 - If a COW page fault occurs and there’s no free memory, the process should be killed.
【当COW写入的页错误触发时,如果没有空闲页可以分配,进程应该被 killl】
主要工作:
首先根据Hints,在 kernel/riscv.h 中设置新的PTE标记位,标记是否为COW机制的页面:
// [kernel/riscv.h]
#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4) // 1 -> user can access
#define PTE_COW (1L << 8) // 标志位:标记一个页面是否为COW Fork页面
根据实现方法的第一条,修改 kernel/vm.c 中的 uvmcopy() 函数():
// Given a parent process's page table, copy
// its memory into a child's page table.
// Copies both the page table and the physical memory.
// returns 0 on success, -1 on failure.
// frees any allocated pages on failure.
// 【Modify: 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.】
int
uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) //typedef uint64 *pagetable_t; (512 PTEs)
{
pte_t *pte; //uint64
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) // invalid
panic("uvmcopy: page not present");
pa = PTE2PA(*pte); // 地址翻译:页表项到物理地址
// COW机制:
// 不再为子进程重新分配内存,只进行标志位操作
// 将父进程和子进程的PTE权限改为不可写,并设置该页为COW Fork物理页
*pte = (*pte & ~PTE_W) | PTE_COW;
flags = PTE_FLAGS(*pte);
// if((mem = kalloc()) == 0) // 分配内存
// goto err;
// memmove(mem, (char*)pa, PGSIZE); // 复制页pa到新分配的页mem
if(mappages(new, i, PGSIZE, pa, flags) != 0){ // Create PTEs
goto err;
}
if(add_ref_count((void*)pa) != 0){
// 成功添加页表项后, 调用kalloc.c中的函数, 令该物理页的引用数++
goto err;
}
}
return 0;
err:
uvmunmap(new, 0, i / PGSIZE, 1);
return -1;
}
根据实现方法的第三条,在 kenel/kalloc.c 中添加物理页的引用计数以及相关机制:
- 加入数据结构维护每个物理页的引用数(注意需要定义新的锁进行互斥性保护)
// [COW fork]
struct ref_mem{
struct spinlock lock;
uint64 count[PHYSTOP / PGSIZE];
// count:计数器数组, 每个物理页需要一个计数器 (数组长度 = 地址空间大小/页大小 = 最大页数)
} ref;
- 在 kinit() 中加入初始化锁
void
kinit()
{
initlock(&kmem.lock, "kmem");
initlock(&ref.lock, "ref"); //[initlock]
freerange(end, (void*)PHYSTOP);
}
- kalloc() 函数修改,新分配的物理页引用计数=1
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist;
if(r)
kmem.freelist = r->next;
release(&kmem.lock);
if(r){ //成功分配, 指向该页的进程数设为初值1
acquire(&ref.lock);
ref.count[(uint64)r / PGSIZE] = 1;
release(&ref.lock);
}
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
- kfree() 函数修改,当且仅当物理页没有被引用时才真正释放
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
//
acquire(&ref.lock);
ref.count[(uint64)pa / PGSIZE]--; // 指向该物理页的进程数--
if(ref.count[(uint64)pa / PGSIZE] != 0){
// 【只有当没有进程引用该物理页才继续执行kfree真正释放内存】
release(&ref.lock);
return;
}
release(&ref.lock);
//
// 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);
}
- 添加函数接口,分别实现 引用数++ 和 获取当前引用数:
int
add_ref_count(void* pa){
if(((uint64)pa % PGSIZE)){
printf("Invalid address: Cannot divided by page size.\n");
return -1;
}
if((char*)pa < end || (uint64)pa >= PHYSTOP){
printf("Invalid address: out of range.\n");
return -1;
}
acquire(&ref.lock);
ref.count[(uint64)pa / PGSIZE]++;
release(&ref.lock);
return 0;
}
int get_ref_count(void* pa) { // 获取内存的引用计数
return ref.count[(uint64)pa / PGSIZE];
}
- freerange() 函数中隐藏了难以发现的可能的错误:
发现 kinit()—freerange()—kfree() 的调用关系,需要做特殊处理:在freerange()中设所有物理页引用数=1,在kfree()中减为0(否则若一开始引用数为0,在kfree中0–会变为很大的正数,造成难以发现的错误,无法释放物理页)
void
freerange(void *pa_start, void *pa_end) // 以页为单位, 释放某个区域的内存
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start);
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE){
// 注意:初始化kinit()调用freerange(),
// 对于所有页的count值设为1; 后续在kfree中减为0, 可以正常进行释放
ref.count[(uint64)p / PGSIZE] = 1;
kfree(p);
}
}
以上我们已经实现了 uvmcopy()复制父进程物理页方法的修改 和 kalloc.c中的COW支持,
下面根据实现方法第二条,修改 usertrap() 对于页错误的情况给出处理步骤:
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
//【Modify: 当COW出现缺页异常时,使用kalloc()分配一个新页面,
// 将旧页面复制到新页面,然后将新页面设置到PTE中并设置PTE_W位】
void
usertrap(void)
{
......
else if(r_scause() == 13 || r_scause() == 15){
uint64 va = r_stval(); // 【获取出现页错误的虚拟地址】
if(va >= p->sz
|| cow_judge(p->pagetable, va) != 1
|| cow_alloc(p->pagetable, va) == 0)
p->killed = 1;
}
......
usertrapret();
}
kernel/trap.c 中定义 cow_judge() 函数判断一个页面是否为COW页面:
// cow_judge 判断一个页面是否为COW页面(是,返回1;不是,返回0;错误,返回-1)
int cow_judge(pagetable_t pagetable, uint64 va) {
if(va > MAXVA) // 虚拟地址大于进程可占有的最大虚拟地址,错误。
return -1;
pte_t* pte;
if((pte = walk(pagetable, va, 0)) == 0){ // 不存在此页表,错误
/*
pte_t* walk(pagetable_t pagetable, uint64 va, int alloc):
Return the address of the PTE in page table pagetable
that corresponds to virtual address va. If alloc != 0,
create any required page-table pages.*/
return -1;
}
if((*pte & PTE_V) == 0) // invalid page
return -1;
if(*pte & PTE_COW) // 是COW Fork页面返回1,否则返回0
return 1;
return 0;
}
kernel/trap.c 中定义 cow_alloc() 函数分配新的物理页:
// cow_alloc copy-on-write分配器
void* cow_alloc(pagetable_t pagetable, uint64 va) {
va = PGROUNDDOWN(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
int count = get_ref_count((void*)pa);
if(count == 1) { // 唯一进程引用当前页,无需重新分配,直接设置为可写并取消COW Fork标志位
*pte = (*pte & ~PTE_COW) | PTE_W;
return (void*)pa;
}
else { // 需要重新分配物理页
char* new_pa;
if((new_pa = kalloc()) == 0){ // 分配新的一个物理页
return 0;
}
memmove(new_pa, (char*)pa, PGSIZE);
// 【注意清除有效位PTE_V,否则在mappagges中会判定为remap】:
*pte = (*pte) & ~PTE_V;
if(mappages(pagetable, va, PGSIZE, (uint64)new_pa, (PTE_FLAGS(*pte) & ~PTE_COW) | PTE_W) != 0) {
// 如果添加映射失败,恢复以前状态
kfree(new_pa);
*pte = (*pte) | PTE_V;
return 0;
}
kfree((void*)PGROUNDDOWN(pa)); // 最后一步:减小pa物理页的引用数(或直接释放物理页)
return (void*)new_pa;
}
}
最后不要忘了,根据实现方法第四步,需要修改 kernel/vm.c 中 copyout() 函数的复制机制:
// 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 Fork页面没有可写权限,分配新的物理页进行复制】
if(cow_judge(pagetable, va0) == 1) {
pa0 = (uint64)cow_alloc(pagetable, va0);
}
if(pa0 == 0)
return -1;
n = PGSIZE - (dstva - va0); // n = 页面大小-偏移量
if(n > len)
n = len; // 一次复制搞定
memmove((void *)(pa0 + (dstva - va0)), src, n); // 内存复制(注意第一个参数已转换为物理地址)
len -= n; // 每次复制n字节
src += n; // 新的复制起始点
dstva = va0 + PGSIZE; // 虚拟地址偏移更新
}
return 0;
}
PS:【以上新定义的函数都需要在 kernel/def.h 中声明,这样其他用户程序只需要 #include"def.h" 就可以调用这些函数】