概述
这次实验主要涉及锁在内核的应用,没有用到什么特别的理论知识,但是编程的时候陷阱重重,要么资源竞争,要么死锁,和实验三差不多,非常考验耐心和细心。
内容
Memory allocator
这个任务要求给物理内存分配程序重新设计锁,使得等待锁时的阻塞尽量少。可以按CPU的数量将空闲内存分组,分配内存的时候优先从当前所用CPU所管理的空闲内存中分配,如果没有则从其他CPU的空闲内存中获取,这样就可以把原来的锁拆开,每个CPU各自处理自己的空闲内存时只要锁上自己的锁就行了:
void
kinit()
{
for (int i = 0; i < NCPU; i++) initlock(&kmem[i].lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
void
kfree(void *pa)
{
struct run *r;
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
int cpu_id; push_off(); cpu_id = cpuid(); pop_off();
acquire(&kmem[cpu_id].lock);
r->next = kmem[cpu_id].freelist;
kmem[cpu_id].freelist = r;
release(&kmem[cpu_id].lock);
}
由于kinit这个函数不是并行的,所以一开始会将所有空闲内存都交给一个CPU管理。
void *
kalloc(void)
{
struct run *r;
int cpu_id; push_off(); cpu_id = cpuid(); pop_off();
acquire(&kmem[cpu_id].lock);
r = kmem[cpu_id].freelist;
if(r) {
kmem[cpu_id].freelist = r->next;
release(&kmem[cpu_id].lock);
} else {
release(&kmem[cpu_id].lock);
for (int i = 0; i < NCPU; i++) if (i != cpu_id) {
acquire(&kmem[i].lock);
r = kmem[i].freelist;
if (r) {
kmem[i].freelist = r->next;
release(&kmem[i].lock);
break;
} else release(&kmem[i].lock);
}
}
......
Buffer cache
这个任务要求给硬盘缓存分配程序重新设计锁,使得等待锁时的阻塞尽量少。但是,因为硬盘缓存包含遍历查找操作,即查找当前硬盘块是否已被缓存,显然这时就不能把缓存也按CPU进行分配,加上这个任务的操作也比较复杂,因此比上个任务多了很多问题。
实验文档给出的分配方式是对硬盘块号进行取模哈希,数据结构如下:
struct {
struct spinlock lock;
struct spinlock block[BNUM];
struct buf buf[NBUF];
// Linked list of all buffers, through prev/next.
// Sorted by how recently the buffer was used.
// head.next is most recent, head.prev is least.
struct buf head[BNUM];
} bcache;
这里保留了原来的锁是因为在不要求修改的bpin、bunpin函数中使用了,但要小心的是,既然用到了新定义的锁,那么在整个实验中所有相关的代码都必须用新定义的锁,不能用原来的锁。
void
binit(void) {
struct buf *b;
initlock(&bcache.lock, "bcache");
// Create linked list of buffers
for (int i = 0; i < BNUM; i++) {
initlock(bcache.block + i, "bcache");
bcache.head[i].prev = &bcache.head[i];
bcache.head[i].next = &bcache.head[i];
}
for(b = bcache.buf; b < bcache.buf+NBUF; b++){
b->next = bcache.head[0].next;
b->prev = &bcache.head[0];
initsleeplock(&b->lock, "buffer");
bcache.head[0].next->prev = b;
bcache.head[0].next = b;
}
}
因为一开始所有的缓存对应硬盘块号都是0,所以把它们都放到0号桶里。
void
brelse(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic("brelse");
releasesleep(&b->lock);
int entry = b->blockno % BNUM;
acquire(bcache.block + entry);
b->refcnt--;
if (b->refcnt == 0) b->ticks = ticks;
release(bcache.block + entry);
}
释放缓存时只要获取块对应的桶对应的锁即可,由于原来的代码在释放缓存时会将缓存插在head的下一个节点,按照原来程序的思路head的下一个节点是目前最近使用的缓存,所以把查找最久未使用缓存的方式改成了查找最前时间戳后,这里也应该更新时间戳。
最麻烦的是bget,这里拆成两部分,第一部分是待查找磁盘块已被缓存的情况:
int entry = blockno % BNUM;
acquire(bcache.block + entry);
// Is the block already cached?
for(b = bcache.head[entry].next; b != &bcache.head[entry]; b = b->next){
if(b->dev == dev && b->blockno == blockno){
b->refcnt++;
release(bcache.block + entry);
acquiresleep(&b->lock);
return b;
}
}
也是只要获取待查找块对应的桶对应的锁即可,但要注意一个问题,这个锁必须一直持有到整个函数运行结束,不能在中间释放了再重新获取,也就是说:
获得锁
查找缓存但没找到
释放锁
可以并行的其他操作
获得锁
更新一个可用的缓存
释放锁
不等价于
获得锁
查找缓存但没找到
可以并行的其他操作
更新一个可用的缓存
释放锁
而且前者是错的,后者是错的。理由是如果中间释放了锁,当前进程可能会让出控制权执行别的进程,那么就会出现一个问题,比如A进程查找1号缓存,查不到,释放了锁,程序转到B进程,它也查找1号缓存,查不到,释放了锁而刚好B立刻又获得了锁,继续往下更新了一个可用的缓存,释放锁,这是A获得锁,继续往下更新了一个可用的缓存,释放锁。现在缓存中就有两个编号完全相同且引用数都为1的缓存了,这个是不允许的行为,可能会出现各种问题,而且这些问题的发生全靠运气,有时不出错,有时这个panic,有时那个panic,非常难调。
第二部分是待查找磁盘块未被缓存的情况:
for (int i = (entry + 1) % BNUM; i != entry; i = (i + 1) % BNUM) {
uint minticks = 0x3fffffff; struct buf *minbuf = 0;
acquire(bcache.block + i);
for(b = bcache.head[i].prev; b != &bcache.head[i]; b = b->prev)
if (b->refcnt == 0 && b->ticks < minticks) {
minticks = b->ticks; minbuf = b;
}
if (minbuf != 0) {
minbuf->dev = dev;
minbuf->blockno = blockno;
minbuf->valid = 0;
minbuf->refcnt = 1;
minbuf->next->prev = minbuf->prev;
minbuf->prev->next = minbuf->next;
minbuf->next = bcache.head[entry].next;
minbuf->prev = &bcache.head[entry];
bcache.head[entry].next->prev = minbuf;
bcache.head[entry].next = minbuf;
release(bcache.block + i);
release(bcache.block + entry);
acquiresleep(&minbuf->lock);
return minbuf;
}
release(bcache.block + i);
}
panic("bget: no buffers");
}
一开始我寻找可更新缓存的办法是直接遍历整个数组,为了防止竞争,需要在遍历前把所有的桶锁起来,然而这样会发生死锁,即假设处理0号桶的进程运行到这里,把0号桶锁了,准备获取1号桶的锁,与此同时处理1号桶的进程运行到这里,把1号桶锁了,准备获取0号桶的锁,这样就死锁了。仔细分析原因,发现只要锁桶的顺序是乱序的,都可能发生死锁,这里的解决方法是使用“资源有序分配法”,就如上面的循环,从当前桶的下一个桶往上遍历到当前桶的前一个桶(循环遍历),保证了顺序,就不会死锁了。另外一个需要小心的是链表的操作,双向链表确实很容易写错,需要谨慎。
总结一下,这个实验和上一个实验相比,感觉更考验并行思维,主要体现在锁的应用,上个实验的后两个任务和这个实验比真的是小巫见大巫了,可能设计实验的老师主要还是想让学生熟悉一下pthread才弄那两个任务,毕竟pthread太常用了。目前觉得系统编程最难的就是四个问题:缺页错误(这里指的是编程逻辑的错误导致的错误)、内存泄漏、资源竞争、死锁,都是极为难以检查难以调试的。