Copy-on-Write Fork
Contents
虚拟内存在物理内存的基础上提供了一层抽象,因此内核可以通过 PTE 的标志位引发缺页中断的方式来控制内存的访问。计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,上个实验使用懒加载内存是一个例子,这个实验写时复制 fork 又是一个例子。
问题
fork 系统调用会拷贝父进程的所有内存,如果父进程的内存占用很大的话,拷贝将花费很长的时间,更糟糕的是,大量的拷贝都是不必要的,例如当调用 fork 系统调用后再在子进程中调用 exec 会导致子进程丢弃大部分拷贝的内存,只会使用其中一小部分,当然,如果父子进程需要写相同的页,那么这一页的拷贝是有必要的。
解决方案
COW (写时复制) 的目标就是将 fork 系统调用分配和拷贝内存的时机延迟到子进程真正需要使用这块内存的时候。
实现方式是 fork 时仅仅为子进程创建一个页表,这个页表的 PTE 指向父进程的物理内存,这些页称为 COW 页,这些页父子进程都不能写,当父子进程中有任何一个需要写 COW 页时,就会触发缺页中断,内核中断处理程序就会为触发缺页中断的进程分配一个新的物理页,将原来 COW 页的数据拷贝到新的物理页中,然后修改 PTE 的标志位,使得这一页可正常读写。
COW fork 使得释放物理页变的有一点点麻烦,一个物理页会被多个进程的页表引用,所以需要在所有进程对其引用都解除后,物理内存才能释放。
实现
-
修改 uvmcopy() 将父进程的物理内存映射到子进程而不是直接分配新的物理页,再将父进程的内存拷贝到新的物理页。这里需要把分别和拷贝的部分去掉,并且加上 COW 标志位,映射完成后需要增加物理页的引用计数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
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"); *pte = (*pte & ~PTE_W) | PTE_COW; pa = PTE2PA(*pte); flags = PTE_FLAGS(*pte); // if((mem = kalloc()) == 0) // goto err; // memmove(mem, (char*)pa, PGSIZE); if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){ goto err; } else { // 映射成功,增加引用计数 kincrpgref(pa); } } return 0; err: uvmunmap(new, 0, i / PGSIZE, 1); return -1; }
-
修改 usertrap 处理缺页中断,处理具有 COW 标志位的页,为其分配内存,并将父进程的内存拷贝到新分配的物理页,同时去掉 COW 标志位。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
#define PTE_COW (1L << 8) if((r_scause() == 13 || r_scause() == 15) && uvmiscowpage(r_stval())) { uint64 va = r_stval(); if(!(uvmcowcopy(va) == 0)) { p->killed = 1; } } int uvmcowcopy(uint64 va) { va = PGROUNDDOWN(va); struct proc* p = myproc(); pte_t* pte; // 查找中断页的 PTE if((pte = walk(p->pagetable, va, 0)) == 0) { panic("uvmcowcopy: pte should exist"); } // 转换成物理地址 uint64 pa = PTE2PA(*pte); uint64 npa; // 分配新的物理地址 if((npa = (uint64)kcowalloc((void*)pa)) == 0) { return -1; } // 去掉 COW 位 uint64 flags = (PTE_FLAGS(*pte) | PTE_W) & ~PTE_COW; // 去掉对父进程物理页的映射 uvmunmap(p->pagetable, va, 1, 0); // 映射到新的物理地址 if(mappages(p->pagetable, va, 1, npa, flags) == -1) { panic("uvmcowcopy: mappages"); } return 0; } // 判断是不是 COW 页 int uvmiscowpage(uint64 va) { pte_t *pte; struct proc *p = myproc(); return va <p->sz && ((pte = walk(p->pagetable, va, 0))!=0) && (*pte & PTE_V) && (*pte & PTE_COW); } // 分配物理内存并拷贝父进程的数据 void* kcowalloc(void* pa) { acquire(&pgrefslock); // 如果当前物理页只用 2 个以下的进程对其引用,直接返回,此时不需要拷贝。 if(pgrefs[((uint64)pa-KERNBASE)/PGSIZE] < 2) { release(&pgrefslock); return pa; } // 分配新的物理内存 uint64 npa = (uint64)kalloc(); if(npa == 0) { release(&pgrefslock); return 0; } // 拷贝数据 memmove((void*)npa, (void*)pa, PGSIZE); // 解除引用关系 pgrefs[((uint64)pa-KERNBASE)/PGSIZE]--; release(&pgrefslock); return (void*)npa; }
-
需要定义上面用的 pgrefs 数组用来标识物理页被引用的次数,同时因为这个数组需要多进程访问,需要用全局锁来保护。需要在 kinit 里初始化锁,在 kalloc 是设置引用计数,在 kfree 判断引用次数是否为零。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
struct spinlock pgrefslock; int pgrefs[(PHYSTOP-KERNBASE)/PGSIZE]; void kinit() { initlock(&kmem.lock, "kmem"); // 初始化引用计数锁 initlock(&pgrefslock, "pgrefs"); freerange(end, (void*)PHYSTOP); } void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP) panic("kfree"); // 修改引用计数 acquire(&pgrefslock); pgrefs[((uint64)pa-KERNBASE)/PGSIZE]--; if(pgrefs[((uint64)pa-KERNBASE)/PGSIZE] <= 0) { // 没有引用,可以是否物理内存 // 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); } release(&pgrefslock); } void * kalloc(void) { struct run *r; acquire(&kmem.lock); r = kmem.freelist; if(r) kmem.freelist = r->next; release(&kmem.lock); if(r) { // 设置引用计数 pgrefs[((uint64)r-KERNBASE)/PGSIZE] = 1; memset((char*)r, 5, PGSIZE); // fill with junk } return (void*)r; } // 增加引用计数 void kincrpgref(uint64 pa) { acquire(&pgrefslock); pgrefs[(pa-KERNBASE)/PGSIZE]++; release(&pgrefslock); }
-
修改 copyout 使用 COW。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; // 使用 COW if(uvmiscowpage(dstva)) uvmcowcopy(dstva); while(len> 0){ va0 = PGROUNDDOWN(dstva); pa0 = walkaddr(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; }
总结
这个实验和上个实验十分类似,做起来也比较轻松,主要是加深对虚拟内存的理解。