MIT-6.S081-2020实验(xv6-riscv64)八:lock

实验文档

概述

这次实验主要涉及锁在内核的应用,没有用到什么特别的理论知识,但是编程的时候陷阱重重,要么资源竞争,要么死锁,和实验三差不多,非常考验耐心和细心。

内容

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太常用了。目前觉得系统编程最难的就是四个问题:缺页错误(这里指的是编程逻辑的错误导致的错误)、内存泄漏、资源竞争、死锁,都是极为难以检查难以调试的。

posted @ 2021-01-08 14:19  YuanZiming  阅读(1830)  评论(0编辑  收藏  举报