1.CH-6文档学习笔记

第六章 锁

本章解释为什么xv6需要锁,xv6如何实现和使用锁。

6.1 竞态条件

可以将锁视为串行化并发的临界区域,使得任意时刻只有一个进程在运行这部分代码,从而保证正确。

尽管正确使用锁可以改正不正确的代码,但锁会限制性能。

锁的位置对性能也很重要。例如,在push中把acquire的位置提前也是正确的:将acquire移动到第13行之前完全没问题。但这样对malloc的调用也会被串行化,从而降低了性能。

6.2 代码:Locks

Xv6有两种类型的锁:自旋锁(spinlocks)睡眠锁(sleep-locks)

先讲解自旋锁(自旋,即循环等待)开始。自旋锁的结构体为struct spinlock(kernel/spinlock.h:2)。结构体中的重要字段是locked,当锁可用时为零,当被持有时为非零。从逻辑上讲,xv6应该通过执行以下代码来获取锁

void
acquire(struct spinlock* lk) // does not work!
{
  for(;;) {
    if(lk->locked == 0) {
      lk->locked = 1;
      break;
    }
  }
}

但这种实现不能保证多处理器上的互斥。两个CPU可能会同时到达第5行,看到lk->locked为零,然后都执行第6行占有锁。此时两个CPU都持有锁,从而违反了互斥属性。需要一种方法,使第5和第6行作为原子(即不可分割)步骤执行。

现代处理器都提供了处理这种情况的原子指令,在RISC-V上,这条指令是amoswap r, a。该指令的作用是读取内存地址a的值,将寄存器r的内容写入a,再将a的值放入r中。

void acquire(struct spinlock *lk){
  push_off(); // disable interrupts to avoid deadlock.
  if(holding(lk))
    panic("acquire");

#ifdef LAB_LOCK
    __sync_fetch_and_add(&(lk->n), 1);
#endif      

  while(__sync_lock_test_and_set(&lk->locked, 1) != 0) {
#ifdef LAB_LOCK
    __sync_fetch_and_add(&(lk->nts), 1);
#else
   ;
#endif
  }

  __sync_synchronize();

  // Record info about lock acquisition for holding() and debugging.
  lk->cpu = mycpu();
}

Xv6的acquire (kernel/spinlock.c:22)使用__sync_lock_test_and_set,可以当做是amoswap指令;返回值是lk->locked的旧的内容。

acquire()将获取锁的操作包装在循环中,不停重试(spin)直到获得锁。每次尝试将1lk->locked进行交换,并检查返回的前一个值;

  • 如果前一个值为0,那么意味着我们获得了该锁。
  • 如果前一个值是1,那么其他CPU此时持有该锁,我们将1和lk->locked交换不会改变其值。

获取锁后,为便于调试,acquire将记录下来获取锁的CPU。lk->cpu字段受锁保护,只能在持有锁时更改。

函数release(kernel/spinlock.c:47) 与acquire相反:它清除lk->cpu字段,然后释放锁。从概念上讲,release只需要将0赋值给lk->locked。xv6使用执行原子赋值的C库函数__sync_lock_release。该函数也可以视为RISC-V的amoswap指令。

void release(struct spinlock *lk){
  if(!holding(lk))
    panic("release");

  lk->cpu = 0;

  __sync_synchronize();

  __sync_lock_release(&lk->locked);

  pop_off();
}

6.3 代码:使用锁

Xv6在许多地方使用锁。kalloc(kernel/kalloc.c:69)kfree(kernel/kalloc.c:47)就是很好的例子。

作为粗粒度锁的一个例子,xv6的kalloc.c由单个锁保护空闲列表。不同CPU上的多个进程试图同时分配页面,每个进程在获得锁之前将必须在acquire中自旋等待。如果对锁的争夺浪费了大部分时间,可通过改变分配器的设计来提高性能,使其拥有多个空闲列表,每个列表都有自己的锁,以允许真正的并行分配。

作为细粒度锁定的一个例子,xv6对每个文件都有一个单独的锁,这样操作不同文件的进程可以无需等待彼此的锁。文件锁的粒度可以进一步细化,以允许进程同时写入同一个文件的不同区域。

表6.3列出了xv6中的所有锁。

描述
bcache.lock 保护块缓冲区缓存项(block buffer cache entries)的分配
cons.lock 串行化对控制台硬件的访问,避免混合输出
ftable.lock 串行化文件表中文件结构体的分配
icache.lock 保护索引结点缓存项(inode cache entries)的分配
vdisk_lock 串行化对磁盘硬件和DMA描述符队列的访问
kmem.lock 串行化内存分配
log.lock 串行化事务日志操作
管道的pi->lock 串行化每个管道的操作
pid_lock 串行化next_pid的增量
进程的p->lock 串行化进程状态的改变
tickslock 串行化时钟计数操作
索引结点的 ip->lock 串行化索引结点及其内容的操作
缓冲区的b->lock 串行化每个块缓冲区的操作
image-20221025175805990

6.5 可重入锁

可重入锁容易导致bug难以被观察,xv6中没有使用

6.6 锁和中断

一些xv6自旋锁保护线程中断处理程序共用的数据。例如,clockintr定时器中断处理程序在增加ticks(kernel/trap.c:163)的同时,内核线程可能在sys_sleep (kernel/sysproc.c:64)中读取tickstickslock串行化了这两个访问。

假设sys_sleep持有tickslock,并且它所在的CPU发生了计时器中断。clockintr会尝试获取tickslock,但此时该锁被sys_sleep持有。此时就是死锁,sys_sleep持有锁,但是无法被执行,clockintr需要锁,却一直占有CPU,导致sys_sleep无法被执行。

// 获取中断是否被启用
static inline int intr_get(){
  uint64 x = r_sstatus();
  return (x & SSTATUS_SIE) != 0;
}

// 启用中断
static inline void intr_on(){
  w_sstatus(r_sstatus() | SSTATUS_SIE);
}

// 关闭中断
static inline void intr_off(){
  w_sstatus(r_sstatus() & ~SSTATUS_SIE);
}


void push_off(void){
  int old = intr_get();

  intr_off();
  if(mycpu()->noff == 0)
    mycpu()->intena = old;
  mycpu()->noff += 1;
}

void pop_off(void){
  struct cpu *c = mycpu();
  if(intr_get())
    panic("pop_off - interruptible");
  if(c->noff < 1)
    panic("pop_off");
  c->noff -= 1;
  if(c->noff == 0 && c->intena)
    intr_on();
}

为避免该情况,Xv6使用以下策略:当某个CPU获得锁时,xv6将禁用该CPU上的中断。其他CPU可正常中断。

当该CPU上没有自旋锁时,重新启用中断,xv6做一些记录以处理嵌套的临界区。acquire调用push_off(kernel/spinlock.c:89)release调用pop_off(kernel/spinlock.c:100)来跟踪当前CPU上锁的嵌套级别。当计数为0时,pop_off恢复中断启用。intr_offintr_on函数分别执行RISC-V指令来禁用和启用中断。

在设置lk->locked (kernel/spinlock.c:28)之前,严格执行acquire调用push_off是很重要的。如果两者颠倒,会存在一个既持有锁又启用了中断的短暂窗口期,此时如果发生定时中断将导致系统死锁。类似地,只有在释放锁之后release才可调用pop_off

6.7 指令和内存访问排序

xv6在acquirerelease中使用__sync_synchronize()告诉编译器和CPU不要重排loadstore指令。xv6的acquirerelease在几乎所有情况下都会强制顺序执行。第9章讨论了一些例外。

6.8 睡眠锁

睡眠锁详见第7章文档

6.9 真实世界

尽管对并发原语和并行性进行了多年的研究,但使用锁进行编程仍然具有挑战性。通常最好将锁隐藏在更高级别的结构中,如同步队列,尽管xv6没有这样做。如果您使用锁进行编程,明智的做法是使用可识别竞争条件(race conditions)的工具,因为很容易错过需要锁的不变量。

大多数操作系统都支持POSIX线程(Pthreads),它允许一个用户进程在不同的cpu上同时运行多个线程。Pthreads支持用户级锁、屏障等。Pthreads还允许程序员有选择地指定锁应该是可重入的。

支持用户级的Pthreads需要操作系统的支持。例如,如果一个pthread在系统调用中阻塞,那么同一进程的另一个Pthread应该能够在该CPU上运行。另一个例子是,如果一个pthread改变了其进程的地址空间(例如,映射或解除映射内存),内核必须安排运行同一进程线程的其他cpu更新其硬件页表,以反映地址空间中的变化。

不使用原子指令也可以实现锁,但是代价很高,而且大多数操作系统都使用原子指令。

如果许多CPU试图同时获取相同的锁,可能会付出昂贵的开销。如果一个CPU在其本地cache中缓存了一个锁,而另一个CPU必须获取该锁,那么更新保存该锁的cache行的原子指令必须将该行从一个CPU的cache移动到另一个CPU的cache中,并且可能会使cache行的任何其他cache失效。从另一个CPU的cache中获取cache行比从本地cache中获取一行的代价要高几个数量级。

为了避免与锁相关的开销,许多操作系统使用无锁的数据结构和算法。例如,可以实现一个像本章开头那样的链表,在列表搜索期间不需要锁,并且使用一个原子指令在一个列表中插入一个条目。然而,无锁编程比有锁编程更复杂-;例如,人们必须担心指令和内存重新排序。有锁编程已经很难了,所以xv6避免了无锁编程的额外复杂性。

posted @ 2024-04-21 00:14  INnoVation-V2  阅读(3)  评论(0编辑  收藏  举报