xv6源码分析(三):锁

xv6支持多核心CPU,计算机上有多个同时运行代码的CPU,但是所有CPU共享同一个地址空间,为了保护数据结构的一致性,xv6需要某一种机制来防止它们互相干扰。其实即使在单处理器的抢占内核中,也同样需要处理内核数据结构的互斥现象。xv6以硬件提供的处理方式,采用了较为底层的锁机制来处理多核操作系统的互斥问题,xv6只是简单的使用了自旋锁,因为一般情况下内核对数据结构的占用时间一般比较短,使用自旋锁能够简单的实现和减少处理同步时容易出现的死锁问题。

自旋锁

xv6使用一个结构体来代表专门锁住某一数据结构的锁:

// Mutual exclusion lock.
struct spinlock {
  uint locked;       // Is the lock held?

  // For debugging:
  char *name;        // Name of lock.
  struct cpu *cpu;   // The cpu holding the lock.
  uint pcs[10];      // The call stack (an array of program counters)
                     // that locked the lock.
};

变量locked代表锁的状态,当locked为1表示当前访问的数据结构已经锁住,locked为0表示未锁住,能够访问当前数据结构,spinlock还附带有调试信息,比如锁的名字,当前占有锁的cpu和调用栈。
实际上按照普通方式访问并修改变量locked本身就存在竞争条件,可能有两个CPU同时读取locked的值为0,并认为都可以获得锁占用数据结构,然后将locked置1占用锁,实际上有两个CPU同时获得了锁,违反了访问该数据结构的互斥性,出现这种现象的根本原因是读取变量和修改变量不是一个原子操作,如果读取变量和修改变量这两个过程连续进行不可打断,并在访问时只允许一个CPU进行,便能实现访问locked的原子操作,xv6使用了x86架构下的xchg指令实现,xchg原子地交换一个寄存器和内存字的值,通过循环反复xchg,如果返回的内存字值为1,代表有CPU或者进程占用锁,继续循环等待不断xchg,如果xchg返回为0,代表目前没有人占用锁,通过将锁置1占用数据结构,然后跳出循环,通过这样的实现,每个CPU或进程在访问一个可能出现竞争的数据结构时,必须提前获得这个数据的锁,如果暂时无法获得锁则不断循环测试自旋,在处理好了数据后再解除锁的占用以便另一个CPU或者进程能重新占用锁。

xv6使用acquire和release操作来代表获得锁和解除锁的占用
acquire:

  pushcli(); // disable interrupts to avoid deadlock.
  if(holding(lk))
    panic("acquire");

  // The xchg is atomic.
  while(xchg(&lk->locked, 1) != 0)
    ;

  // Tell the C compiler and the processor to not move loads or stores
  // past this point, to ensure that the critical section's memory
  // references happen after the lock is acquired.
  __sync_synchronize();

  // Record info about lock acquisition for debugging.
  lk->cpu = cpu;
  getcallerpcs(&lk, lk->pcs);

release:

  if(!holding(lk))
    panic("release");

  lk->pcs[0] = 0;
  lk->cpu = 0;

  // Tell the C compiler and the processor to not move loads or stores
  // past this point, to ensure that all the stores in the critical
  // section are visible to other cores before the lock is released.
  // Both the C compiler and the hardware may re-order loads and
  // stores; __sync_synchronize() tells them both not to.
  __sync_synchronize();

  // Release the lock, equivalent to lk->locked = 0.
  // This code can't use a C assignment, since it might
  // not be atomic. A real OS would use C atomics here.
  asm volatile("movl $0, %0" : "+m" (lk->locked) : );

  popcli();

xv6在处理可能出现互斥的数据结构时,都会使用锁机制来防止互相干扰,这些操作基本遵循一个规则:

acquire();
.....
release();

但是在并发中,使用锁机制也很容易带来各种问题,这些问题往往难以发现,因为发生错误的概率低,大部分情况下都可以正常运行,一旦出现问题,便会造成非常严重的后果甚至宕机。

同一个CPU多次获得锁时容易出现的问题

  1. 抢占式内核中断处理程序

xv6在acquire中只是简单地关闭所有的中断,之所以需要关闭中断是为了防止当在抢占式内核中,当前过程占用锁然后进入中断处理再次想要获得锁时出现的死锁现象,使用自旋锁非常容易出现死锁现象,尤其是在抢占式内核中,所以xv6在中断处理和自旋锁的关系上做的非常决绝:占用锁时禁止所有中断
pushcli主要关闭外部中断并递增调用pushcli关闭中断的次数,这样做的原因是如果代码中获得了两个锁,那么只有当两个锁都被释放后中断才会被允许。同时acquire 一定要在可能获得锁的 xchg 之前调用 pushcli。如果两者颠倒了,就可能在几个时钟周期里,中断仍被允许,而锁也被获得了,如果此时不幸地发生了中断,系统就会死锁。类似的,release 也一定要在释放锁的 xchg 之后调用 popcli。
pushcli:

void
pushcli(void)
{
  int eflags;

  eflags = readeflags();
  cli();
  if(cpu->ncli == 0)
    cpu->intena = eflags & FL_IF;
  cpu->ncli += 1;
}

popcli:

void
popcli(void)
{
  if(readeflags()&FL_IF)
    panic("popcli - interruptible");
  if(--cpu->ncli < 0)
    panic("popcli");
  if(cpu->ncli == 0 && cpu->intena)
    sti();
}

2.模块化与递归锁

在同一个CPU中,使用自旋锁还会出现另一个问题。如果某一过程调用另一个过程中,调用者持有锁,调用者在释放锁之前,被调者再次获得锁,则出现死锁问题。

这个问题xv6文档中讲得非常详细了,文采有限这里做个搬运工吧,如果侵权,提醒我删除:

系统设计力求简单、模块化的抽象:最好是让调用者不需要了解被调者的具体实现。锁的机制则和这种模块化理念有所冲突。例如,当 CPU 持有锁时,它不能再调用另一个试图获得该锁的函数 f:因为调用者在 f 返回之前无法释放锁,如果 f 试图获得这个锁,就会造成死锁。

现在还没有一种透明方案可以让调用者和被调者可以互相隐藏所使用的锁。我们可以使用递归锁(recursive locks)使得被调者能够在此获得调用者已经持有的锁,这种方案虽然是透明通用的,但是十分繁复。还有一个问题就是这种方案不能用来保护不变量。在 insert 调用 acquire(&listlock)后,它就可以假设没有其他函数会持有这个锁,也没有其他函数可以操作链表,最重要的是,可以保持链表相关的所有不变量。 在使用递归锁的系统中,insert 可以假设在它之后 acquire 不会再被调用:acquire 之所以能成功,只可能是 insert 的调用者持有锁,并正在修改链表数据。这时的不变量有可能被破坏了,链表也就不再保护其不变量了。锁不仅要让不同的 CPU 不会互相干扰,还需要让调用者与被调者不会互相干扰;而递归锁就无法保证这一点。

由于没有理想、透明的解决方法,我们不得不在函数的使用规范中加入锁。编程者必须保证一个函数不会在持有锁时调用另一个需要获得该锁的函数 f。就这样,锁也成为了我们的抽象中的一员。

锁的顺序容易引发的问题

这里再次搬运:

如果一段代码要使用多个锁,那么必须要注意代码每次运行都要以相同的顺序获得锁,否则就有死锁的危险。假设某段代码的两条执行路径都需要锁 A 和 B,但路径1获得锁的顺序是 A、B,而路径2获得锁的顺序是 B、A。这样就有能路径1获得了锁 A,而在它继续获得锁 B 之前,路径2获得了锁 B,这样就死锁了。这时两个路径都无法继续执行下去了,因为这时路径1需要锁 B,但锁 B已经在路径2手中了,反之路径2也得不到锁 A。为了避免这种死锁,所有的代码路径获得锁的顺序必须相同。避免死锁也是我们把锁作为函数使用规范的一部分的原因:调用者必须以固定顺序调用函数,这样函数才能以相同顺序获得锁。

由于 xv6 本身比较简单,它使用的锁也很简单,所以 xv6 几乎没有锁的使用链。最长的锁链也就只有两个锁。例如,ideintr 在调用 wakeup 时持有 ide 锁,而 wakeup 又需要获得 ptable.lock。还有很多使用 sleep/wakeup 的例子,它们要考虑锁的顺序是因为 sleep 和 wakeup 中有比较复杂的不变量,我们会在第5章讨论。文件系统中有很多两个锁的例子,例如文件系统在删除一个文件时必须持有该文件及其所在文件夹的锁。xv6 总是首先获得文件夹的锁,然后再获得文件的锁。

使用锁的原则

再次搬运,侵删

xv6 非常谨慎地使用锁来避免竞争条件。一个简单的例子就是 IDE 驱动(3800)。就像本章开篇提到的一样,iderw(3954)有一个磁盘请求的队列,处理器可能会并发地向队列中加入新请求(3969)。为了保护链表以及驱动中的其他不变量,iderw 会请求获得锁 idelock(3965)并在函数末尾释放锁。练习1中研究了如何通过把 acquire 移动到队列操作之后来触发竞争条件。我们很有必要做一个这些练习,它们会让我们了解到想要触发竞争并不容易,也就是说很难找到竞争条件。并不是说 xv6 的代码中就没有竞争。

使用锁的一个难点在于要决定使用多少个锁,以及每个锁保护哪些数据、不变量。不过有几个基本原则。首先,当一个 CPU 正在写一个变量,而同时另一个 CPU 可能读/写该变量时,需要用锁防止两个操作重叠。第二,当用锁保护不变量时,如果不变量涉及到多个数据结构,通常每个数据结构都需要用一个单独的锁保护起来,这样才能维持不变量。

上面只说了需要锁的原则,那么什么时候不需要锁呢?由于锁会降低并发度,所以我们一定要避免过度使用锁。当效率不是很重要的时候,完全可以使用单处理器计算机,这样就完全不用考虑锁了。当我们要保护内核的数据结构时,使用一个内核锁还是值得的,当进入内核时必须持有该锁,而退出内核时就释放该锁。许多单处理器操作系统就用这种方法运行在了多处理器上,有时这种方法被称为“内核巨锁(giant kernel lock)”,但使用这种方法就牺牲了并发性:即一时间只有一个 CPU 可以运行在内核上。如果我们想要依靠内核做大量的计算,那么使用一组更为精细的锁来让内核可以在多个 CPU 上轮流运行会更有效率。

最后,对于锁的粒度选择是并行编程中的一个重要问题。xv6 只使用了几个简单的锁;例如,xv6 中使用了一个单独的锁来保护进程表及其不变量,我们将在第5章讨论这个问题。更精细的做法是给进程表中的每一个条目都上一个锁,这样在不同条目上运行的线程也能并行了。但是在进程表中维护那么多个不变量就必须使用多个锁,这就让情况变得很复杂了。不过 xv6 中的例子已经足够让我们了解如何使用锁了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值