MIT-6.S081 XV6 Chapter6 Locking

265 阅读30分钟

Chapter 6 Locking

​ 阅读知乎和xv6手册的笔记。

​ 多核CPU同时对某个共享的数据结构进行读写操作可能会发生冲突,因此需要concurrency control,即。锁提供了一种互斥机制,一段时间内只有一个CPU才能拥有这个锁,如果一个锁和一个被共享的数据结构联系起来,那么这个数据结构一次只能被一个CPU使用。

临界区Cirtical Section:访问共享资源的代码段。

竞争条件Race Condition:多个执行线程同时进入临界区并尝试更新共享数据结构的情况。

常用的同步工具有:锁Lock条件变量Conditional Variable信号量Semaphore等。

struct element *list = 0;
struct lock listlock;

void push (int data) {
    struct element *l;
    l = malloc(sizeof *l);
    l->data = data;
    acquire(&listlock);
    // critical section
    l->next = list;
    list = l;
    release(&listlock);
}

acquirerelease之间的部分叫Cirtical Section。

锁的作用

  • 使用锁有助于避免更新的丢失。
  • 使用锁使多步操作变为原子性的操作。
  • 使用锁有助于维持一些规则或特性的不变性。

Race Conditions and Critical Section

​ 上面以及简单给出了Race Conditions and Critical Section的概念。要解决临界区问题,其中一种方法就是使用同步工具里的互斥锁Mutex Lock,或者简称为锁。使用锁的解决方案应该要满足以下的要求。

  • 互斥:这是最基本的要求,一次只能有一个进程进入临界区中,并在其中执行。
  • 进步:如果当前没有进程在临界区中执行,且有多个进程同时想要进入临界区,应该有某种方式来决定谁先进入,而且这种决定不能被无限推迟死锁Deadlock活锁Livelock)。
  • 有限等待/无饥饿:进程从请求进入临界区开始,直到被允许进入临界区,这段时间应该是有限的。换句话说,每个竞争进程都应该有公平的机会抢到锁,不能有竞争锁的进程处于饥饿Starvation,一直无法获得锁。

有两种用于处理操作系统临界区问题的常用方法:

  • 抢占式内核:允许在内核空间下运行的进程被抢占,不断通过时钟中断一个线程,运行其它线程;抢占式内核则需要仔细设计,以防止竞争条件出现,尤其是对称多处理器体系结构。这种内核响应更快,进程不会在内核中运行任意长的时间,还允许实时进程抢占在内核空间下运行的其它进程,所以使用更多。
  • 非抢占式内核:而非抢占式内核则不允许,进程会一直运行直到退出内核空间、阻塞或自愿放弃CPU;非抢占式内核基本不会导致竞争条件,因为任一时刻只有一个进程在内核空间下运行;

Peterson's Solution

基于软件的临界区问题的解决方案,Peterson算法。该方法可以解决两个线程想要同时进入临界区的问题。主要思想是谦让,一个线程总是试图把进入临界区的权利让给另一方。假设我方表示自己想要进入临界区,但是我们会把轮次让给另一个线程,谦让地希望让对方先进入。如果对方刚好也举起了flag,那么对方就能顺利地进入临界区,而我方就在原地自旋等待;如果对方并没有举起flag,表示它并不想进入临界区,或者对方先谦让我们,把轮次让给了我方,那么这时候就轮到我们顺利地进入临界区。

int flag[2];
int turn;

void init(){
  flag[0] = flag[1] = 0;  // flag为1时表示该线程想要抓到锁
  turn = 0;               // 一开始决定是0号进程的轮次
}

void lock(){
  flag[self] = 1;   // self是线程的编号,flag设为1表示自己想抓到锁
  turn = 1 - self;  // 把轮次让给另一个线程
  // 如果对方想要抓到锁,而且轮次也是对方的,那么自己就在临界区外等待
  while((flag[1-self] == 1) && (turn == 1 - self))
    ; // 自旋等待
}

void unlock(){
  flag[self] = 0;  // 表示自己现在释放了锁
}

void main()
{
  init();

  do{
    lock();

    // 临界区

    unlock();
  }while(true)

  return;
}

​ 这个解决方案满足我们临界区问题的三个要求,但是由于现代计算机执行基本机器语言指令(如load和store)的不同方式,还由于松散内存一致性模型等原因,这个解决方案是不能正确运行在现代硬件上的,指令顺序会被打乱。

Hardware Support

​ 只需要很少的硬件支持,实现锁就会容易很多。

Test and Set

int TestAndSet(int *old_ptr, int new){
  int old = *old_ptr;  // 获取旧的值
  *old_ptr = new;      // 存储新的值
  return old;          // 返回旧的值
}

​ 返回old_ptr指向的旧值old,同时更新成新值new。如果当前线程不能获取该锁,它就在原地上等待,不断消耗CPU周期,这种锁叫做自旋锁Spinning Lock

typedef struct lock_t{
  int flag;
} lock_t;

void init(lock_t *lock){
  lock->flag = 0;  // 0表示锁可用,1表示锁已被占用
}

void lock(lock_t *lock){
  while(TestAndSet(&lock->flag, 1) == 1)  // 第一个执行TestAndSet的线程会得到旧值0而跳出循环
    ;  // 不做任何事情,只是自旋地等待
}

void unlock(lock_t *lock){
  lock->flag = 0;
}

void main()
{
  init(&lock_t);

  do{
    lock(&lock_t);

    // 临界区

    unlock(&lock_t);
  }while(true);

  return;
}

​ 自旋锁解决方案满足互斥和进步,但是不满足有限等待

typedef struct lock_t{
  int flag;
} lock_t;

int waiting[n];  // waiting[i] = 1 时表示i号线程想要进入临界区
 
void init(lock_t *lock){
  lock->flag = 0;  // 0表示锁可用,1表示锁已被占用
  for(int i=0;i<n;i++)
    waiting[i] = 0;
}

void lock(lock_t *lock){
  waiting[i] = 1;  // i是当前线程的编号,表示i号线程想要进入临界区,因此先等待
  int key = 1;
  while(waiting[i] && key)  // 只有当waiting[i] = 0 或 key = 0 时i号线程才能进入临界区
    key = TestAndSet(&lock->flag, 1);  // 第一个执行TestAndSet的线程会使key变为0
  waiting[i] = 0;  // 表示i号线程已经进入临界区,不用再自旋等待
}

void unlock(lock_t *lock){
  j = (i + 1) % n;  // 尝试把机会让给下一个线程
  while((j != i) && !waiting[j])
    j = (j + 1) % n;  // 轮询一圈,按编号顺序搜索,找到需要进入临界区的下一个线程
  if(j == i)
    lock->flag = 0;  // 当前没有其它线程需要进入临界区,直接释放锁
  else
    waiting[j] = 0;  // j号线程想要进入临界区,于是使其不再等待
                     // waiting[j] = 0 表示j号线程可以进入
                     // 而lock->flag = 1 仍成立,表示锁仍被持有,只是从i转移到j
}

void main()
{
  init(&lock_t);

  do{
    lock(&lock_t);

    // 临界区

    unlock(&lock_t);
  }while(true);

  return;
}

​ 改进后满足了临界区问题的三个要求,希望进入临界区的线程,现在至多只需要等待n-1次。

Compare and Swap

int CompareAndSwap(int *ptr, int expected, int new){
  int actual = *ptr;
  if(actual == expected)
    *ptr = new;
  return actual;
}

​ 思路就是检测ptr指向的值是否和expected相等。如果是,就更新ptr的值为new,否则什么也不更新。无论如何,最后都返回该内存地址的实际值actual。

Fetch and Add

int FetchAndAdd(int *ptr){
  int old = *ptr;
  *ptr = old + 1;
  return old;
}

Load-Linked and Store-Conditional

​ 用于实现临界区的一对指令。在MIPS架构中,提供了链接加载Load-Linked条件式存储Store-Conditional指令,可以配合使用,以实现很多并发结构。

int LoadLinked(int *ptr){
  return *ptr;
}

int StoreConditional(int *ptr, int value){
  if(在上一次加载ptr之后,期间没有对ptr的更新){
    *ptr = value;
    return 1;  // 成功
  }else{
    return 0;  // 失败
  }
}

​ Load-Linked和典型加载指令类似,从内存中取出值存入一个寄存器。而Store-Conditional比较特别,只有上一次加载的地址ptr,在这期间都没有被更新时,它才会返回1表示成功,同时更新经过Load-Linked得到的ptr中的值;如果失败,返回0,且不会更新ptr中的值。

void lock(lock_t *lock){
  while(LoadLinked(&lock->flag) || !StoreConditional(&lock->flag, 1))
    ;
}

​ 首先,一个线程自旋等待flag被置0,这表明锁不被任何人持有。一旦如此,线程就尝试通过Store-Conditional来获取锁。如果成功,flag也会被更改为1,然后线程可以进入临界区。

Memory Barriers

一般来说,内存模型可以分为两类:

  • 严格一致性/强一致性(Strongly Ordered):一个CPU对内存做了某些修改,其它CPU马上就能看到这项更新。
  • 松散一致性/弱一致性(Weakly Ordered):一个CPU对内存做了某些修改,但其它CPU不能马上,而可能在稍后才会看见这项更新。

​ 计算机体系结构提供了一种指令,它可以强制内存的更新对所有CPU可见,这种指令就是内存屏障Memory Barriers

Atomic Variables

Spinning Lock

对于锁我们一般提供两个接口,一个是acquire用于获取锁,一个是release用于释放锁。

acquire(){
  while(!available)
    ;  // busy wait (i.e. spinning)
  available = false;
}

release(){
  available = true;
}

​ xv6里面自旋锁的实现,利用了RISC-V的test_and_set指令,利用了RISC-V的内存屏障指令,还结合了中断的控制。

// 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.
};

// Acquire the lock.
// Loops (spins) until the lock is acquired.
void
acquire(struct spinlock *lk)
{
  push_off(); // disable interrupts to avoid deadlock.
  if(holding(lk))
    panic("acquire");

  // On RISC-V, sync_lock_test_and_set turns into an atomic swap:
  //   a5 = 1
  //   s1 = &lk->locked
  //   amoswap.w.aq a5, a5, (s1)
  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;

  // 即 memory barrior
  // it tells the compiler and CPU to not reorder loads or stores across the barrier
  __sync_synchronize();

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

// Release the lock.
void
release(struct spinlock *lk)
{
  if(!holding(lk))
    panic("release");

  lk->cpu = 0;

  // On RISC-V, this emits a fence instruction.
  __sync_synchronize();

  // Release the lock, equivalent to lk->locked = 0.
  // This code doesn't use a C assignment, since the C standard
  // implies that an assignment might be implemented with
  // multiple store instructions.
  // On RISC-V, sync_lock_release turns into an atomic swap:
  //   s1 = &lk->locked
  //   amoswap.w zero, zero, (s1)
  __sync_lock_release(&lk->locked);

  pop_off();
}
  • 自旋锁不满足有限等待的要求,可能会导致某些线程饥饿。
  • 性能方面,自旋锁的主要缺点是,它会忙等,这种忙等会持续消耗CPU资源,因为它要不停地旋转以等待锁可用。

​ 不过,在多CPU上,尤其是当线程数大于CPU数时,自旋锁的性能表现不错。假设线程A在CPU1上,线程B在CPU2上,都竞争同一个锁。A占有锁时,B会在CPU2上自旋,而临界区一般很短,所以B很快就获得锁。因此,自旋等待其它CPU上的锁时,不会浪费很多CPU周期。另外,线程在等待锁时,没有上下文切换(尤其是上下文切换的开销可能比较大),因此如果临界区较短,使用自旋锁也是一个不错的方案。

Locks and Interrupt

​ 最早的互斥解决方案之一,是为单CPU系统开发的方法,在临界区中关闭中断。优点,简单;缺点很多。

  • 开关中断是特权指令,这要求我们给予用户线程高特权级别,即要求我们信任这些线程。显然,如果我们必须信任所有的程序,问题就会接踵而至。例如,一个线程可以随意地关闭中断,然后占用CPU;或者线程关闭中断后,不幸陷入死循环,但控制器无法返回到内核中。
  • **这种方案不适用于多处理器。**如果多个线程运行在不同CPU上,每个线程都试图进入同一个临界区,那么关闭中断也是没有作用的,因为线程可以运行在其它CPU上,同样可以进入同一个临界区。再者多处理器系统已经很普遍,所以我们需要支持多处理器的解决方案。
  • **关闭中断可能将导致中断丢失,这可能会导致严重的系统问题。**例如磁盘设备完成了读取请求,但CPU错失了这一事实,那么之后内核如何知道区唤醒等待读取的进程?
  • 相比于其它常规指令,开关中断指令执行较慢。

​ 在xv6中,当线程和中断处理程序要获取同一把自旋锁的时候。xv6中的一个例子是,时钟中断处理程序clockintr需要增加ticks值,而一个内核线程也可能通过系统调用sys_sleep来访问ticks值,因此,我们为ticks维护了一把锁tickslock,clockintr和sys_sleep都需要获取该自旋锁来访问或修改ticks值。

​ 但是,自旋锁和中断处理程序出现在一起时,可能会导致一些问题。现在假设,一个内核线程调用sys_sleep,因此它持有tickslock。此时运行该内核线程的CPU收到时钟中断,因此被导向到中断处理程序clockintr中,clockintr第一件事就是acquire(&tickslock),但是因为这把锁已经被内核线程持有,所以clockintr会一直在原地自旋等待tickslock被释放。

​ tickslock无法被该内核线程释放!因为中断处理程序clockintr还没有返回!因此该CPU发生死锁Deadlock,其它试图获取tickslock的CPU也会接二连三地死锁。

​ 首先,对于单CPU系统,显然clockintr会一直原地自旋占用CPU,如果允许中断嵌套的话,我们只是运行了新的中断处理程序或者新的clockintr,最后无论如何都会死锁在clockintr上。然后,对于多CPU系统,其它CPU也无法选择该持有锁的内核线程调度运行。内核调度器选择那些状态为RUNNABLE的线程调度执行,而对于被中断的线程,由于yield在clockintr返回之后才被调用,因此该线程的状态还是RUNNING而不是RUNNABLE,自然就被其它的内核调度器无视了。

因此,为了避免在这种情况下发生死锁,

如果中断处理程序需要持有某一把自旋锁,那么每个CPU在持有这把自旋锁时,一定要保持中断关闭

​ xv6的解决方式更加保守一些:只要CPU试图获取任何自旋锁,那么该CPU总是会关闭中断。

​ 在xv6的实现中,一旦某个线程获取了自旋锁,那么在该CPU上将不会产生中断(因此不会发生线程调度),因为在acquire中已经关闭了该CPU的中断。

Sleep Lock

自旋锁会浪费大量的CPU周期 已经持有自旋锁的进程不应该主动放弃CPU,这可能会导致死锁

​ 为此,我们重新设计另一种类型的锁。这种类型的锁,会在acquire需要自旋等待时让出CPU;同时,在持有这种锁时,允许主动放弃CPU和开放中断。

void init(){
  flag = 0;
}

void lock(){
  while(TestAndSet(&flag, 1) == 1)
    yield();  // 主动放弃CPU
}

void unlock(){
  flag = 0;
}

​ 我们需要显式地增加某种机制,决定锁释放时,谁能抢到锁。为此需要操作系统提供更多的支持,使用队列来保存等待锁的进程是常用的解决方案。改进实现方法如下。

// 在这里,有两个lock,flag和guard
typedef struct lock_t{
  int flag;
  int guard;
  queue_t *q;
} lock_t;

void lock_init(lock_t *m){
  m->flag = 0;
  m->guard = 0;
  queue_init(m->q);
}

void lock(lock_t *m){
  while(TestAndSet(&m->guard, 1) == 1)
    ;  // 自旋地等待直到获取guard lock
  if(m->flag == 0){
    m->flag = 1;   // flag lock还没有被获取,因此成功获取
    m->guard = 0;  // 释放guard lock
  }else{
    queue_add(m->q, gettid());  // flag lock已经被获取,该线程将进入队列中睡眠
    m->guard = 0;  // 睡眠之前释放guard lock
    park();        // 睡眠
  }
}

void unlock(lock_t *m){
  while(TestAndSet(&m->guard, 1) == 1)
    ;  // 自旋地等待直到获取guard lock
  if(queue_empty(m->q))
    m->flag = 0;  //没有人要获取flag lock,直接释放它
  else
    unpark(queue_remove(m->q));  // 从队列里唤醒一个线程,把flag lock交给它
  m->guard = 0;  // 结束之前释放guard lock
}

​ 在这个解决方案里,我们引入了队列,保证了公平性,现在这个睡眠锁的设计满足有限等待条件。

  • 我们实际上使用了两把锁来实现这个睡眠锁,一个是自旋锁guard lock(小锁),另一个锁是真正的临界区锁flag lock(大锁)。因此,这个方法并没有完全避免自旋等待,但是,这个自旋等待的时间很有限,因为guard lock不是保护真正的临界区,只是保护在lock和unlock中的几条指令。
  • 我们在调用park睡眠之前,要先释放这把小的自旋锁guard lock,否则会出现问题。
  • 唤醒另一个线程的时候flag仍然保持为1,尽管它并非持有guard lock。从逻辑上看,没有持有guard lock时是不能将flag置1的,但我们直接保持flag为1,并唤醒这个线程,相当于直接把flag lock传递给它。

​ 这个解决方案还有一个潜在的竞争条件。例如,thread1已经持有了flag lock,后续thread2尝试获取flag lock时就会陷入lock( )的else分支中,将自己添加到队列之后,但在调用park( )之前被切换到thread1,thread1执行unlock时,由于队列不为空,将会执行unpark(thread1),但thread1的park( )并未执行,unpark( )可能会返回thread1并未睡眠的消息,然后thread1退出临界区,thread2再次执行并park( )自身,进入睡眠。由于thread1始终没有将flag lock重置为0,因此后续尝试获取该锁的thread3、thread4...都将与thread2一并调用park( )进入睡眠中,陷入一种死锁状态,这种情况有时也称为唤醒/等待竞争wakeup/waiting race

​ Solaris增加了第三个系统调用setpark来解决这种情况,一个线程通过调用setpark表明自己马上要park,此时如果刚好另一个线程被调度,并且调用了unpark,那么后续的park调用就会直接返回,而不是一直睡眠。或者,另外一种方案是把guard传入内核,内核可以采取一些预防措施,保证原子地释放锁,并把运行进程移出队列。

​ xv6的睡眠锁实现,也采用了自旋锁和真正的睡眠锁相结合的方式。xv6的睡眠锁实现,允许中断开放,因此中断处理程序不能使用睡眠锁(同样会导致死锁)。再者,因为睡眠锁会让出CPU,所以不能在自旋锁保护的临界区中使用睡眠锁,但是可以在睡眠锁保护的临界区中使用自旋锁。

// Long-term locks for processes
struct sleeplock {
  uint locked;       // Is the lock held?
  struct spinlock lk; // spinlock protecting this sleep lock
  
  // For debugging:
  char *name;        // Name of lock.
  int pid;           // Process holding lock
};

void
acquiresleep(struct sleeplock *lk)
{
  // 第一个进程先抓取小锁,将小锁的locked置1
  // 这时大锁还未被上锁(lk->locked=0),跳过while继续执行
  // 将大锁也抓取,将大锁的locked置1
  // 最后释放小锁,这时大锁在第一个进程手中
  // 后续的进程进来之后可以抓到小锁
  // 但是因为大锁被抓,lk->locked=1,则进到while中
  // 调用sleep(),释放小锁并且挂起进程在大锁上
  // 只有调用releasesleep(),才会释放大锁,同时唤醒挂起在大锁上的进程
  acquire(&lk->lk);
  while (lk->locked) {
    sleep(lk, &lk->lk);
  }
  lk->locked = 1;
  lk->pid = myproc()->pid;
  release(&lk->lk);
}

void
releasesleep(struct sleeplock *lk)
{
  // 要释放大锁的进程先获取小锁
  // 将lk->lk->locked置0,同时调用wakeup()唤醒一个挂起在大锁上的进程
  // 选中一个进程改变其状态为RUNNABLE之后,回来释放小锁
  // 被唤醒的进程会从sleep()中sched()后的对应位置继续,获取小锁
  // 然后,从上次acquiresleep()的while中继续,此时lk->locked=0,跳出while
  // 然后将lk->locked置1,最后释放小锁,这样上次挂起的进程就抓到了大锁
  // 这里注意,在sleep()中需要使用acquire()去抓取小锁
  // 因为要保证该进程总能抓到这把锁,即使该进程要空转,这是为了防止死锁
  // 这样唤醒进程发现小锁被抓走时,会在该处自旋,等到小锁被重新放出为止
  acquire(&lk->lk);
  lk->locked = 0;
  lk->pid = 0;
  wakeup(lk);
  release(&lk->lk);
}

睡眠锁的优点:

  • 避免使用自旋锁时,带来的大量CPU周期的浪费;

缺点:

  • 来回地切换进程会导致较高的上下文切换开销

​ 根据两种锁的特点,在临界区较短的时候,使用自旋锁比较合适;而在临界区比较长的时候,就应该使用睡眠锁。也可以将两种锁结合,称为两阶段锁,第一阶段先自旋一段时间,希望可以获取锁,但如果没有获得,那么在第二阶段调用者会睡眠,直到锁可用。

Contention on the Locks

​ 由于sleep在xv6中的机制,xv6中有很多长度为2的lock-order。比如consoleintr中要求先获得cons.lock,当整行输入完毕之后再唤醒等待输入的进程,这需要获得睡眠进程的锁。xv6的文件系统中有一个很长的lock chain,如果要创建一个文件需要同时拥有文件夹的锁、新文件的inode的锁、磁盘块缓冲区的锁、磁盘驱动器的vdisk_lock的锁以及调用进程的p->lock的锁

image-20230517135503960

​ 除了lock ordering之外,锁和中断的交互也可能造成死锁。比如当sys_sleep拥有tickslock时,发生定时器中断,定时器中断的handler也需要acquiretickslock,就会等待sys_sleep释放,但是因为在中断里面,只要不从中断返回sys_sleep就永远无法释放,因此造成了死锁。对这种死锁的解决方法是:如果一个中断中需要获取某个特定的spinlock,那么当CPU获得了这个spinlock之后,该中断必须被禁用。xv6的机制则更加保守:当CPU获取了任意一个lock之后,将disable掉这个CPU上的所有中断(其他CPU的中断保持原样)。当CPU不再拥有spinlock时,将通过pop_off重新使能中断。

​ 在实际设计并发代码时,我们可以先采用粗粒度Coarse-grained的上锁方式,对要保护的临界区上一把大锁。然后我们在保证并发正确性的情况下,逐步地拆解这些数据结构,并且使用细粒度Fine-grained的上锁方式。总之,采用何种粒度的上锁方式,取决于对性能和代码的复杂性的要求。

Deadlock and Lock Ordering

The Cause of Deadlocks

​ 一般来说,死锁的产生需要以下4个条件,只要它们同时成立,就会引起死锁。

  • 互斥:至少有一个资源处于非共享模式,即进程对于需要的资源进行互斥的访问。如果另一进程申请该资源,则申请进程应等到该资源释放为止。
  • 占有并等待:进程至少占有一个资源,并等待其它资源,而该资源被其它进程所占有。
  • 非抢占:进程获得的资源不能被抢占,亦即锁、信号量等不能被抢占。
  • 循环等待:进程之间存在一个环路,环路上每个进程都额外持有一个资源,而这个资源又是下一个进程要申请的。

​ 如果4个条件有任意一个不满足,那么死锁不会发生

可能引起死锁的情况:

  • 中断处理程序尝试获取线程已经持有的自旋锁。
  • 持有自旋锁的进程主动放弃CPU。
  • 上锁的顺序Lock Ordering有关,一个最简单的例子,进程1获取锁的顺序是先A后B,进程2则是先B后A。如果进程1获得A的同时,进程2也获得了B,那么死锁就发生了。

​ 避免第3种死锁的解决方案看上去也很简单,只需要规定一个全局的上锁顺序即可。规定全局的上锁顺序,意味着锁实际上是每个函数规范的一部分:调用者必须以一种规定的顺序调用函数,以遵循获取锁的顺序。

​ xv6里面有很多这种上锁顺序的实例,尤其是同时持有两个锁的上锁顺序,几乎处处可见。其中,xv6的文件系统代码包含最长的上锁序列。例如,在创建一个文件时,我们需要同时获取以下的锁才能继续执行:文件所在目录的锁,新文件的inode的锁,磁盘缓冲块的锁,磁盘驱动器的锁,以及调用进程的锁。按以上的顺序获取这些锁,死锁才不会发生。

Deadlocks Prevention

预防死锁最好的办法,就是破除4个条件的任意一个。

循环等待

最经常采用的死锁预防技术就是不让代码产生循环等待,最直接的方法就是提供一个全局的上锁顺序,即全序total ordering。更复杂的系统可能有很多个锁,锁的全序可能很难做到,因此偏序partial ordering也是一种有效的方法。

​ 但是,全序和偏序都依赖于细致的锁策略设计和实现,更重要的是,这十分依赖于程序员是否按顺序来编写程序,因此程序员编写程序的正确性也是重要因素。

​ 有一种方法是,程序员根据锁的地址作为获取锁的顺序,按从低到高或从高到低,这样无论传入参数是什么顺序,函数都会用固定的顺序进行加锁。

占有并等待

死锁的占有并等待条件,可以通过原子地抢锁来避免。因此可以规定一种协议:

每个进程在执行之前,应该先申请并成功获得所需的所有资源。

​ 然后,我们可以设置一把大锁,进程要申请资源时,先获取大锁,成功持有大锁之后,再去获取自己所需资源的锁,最后再释放大锁。

​ 这个方法和之前一样,不适合封装,因为在这个方案中我们需要明确地直到要获取哪些锁并且提前取得这些锁;而且因为要提前获得所有锁,而不是在需要的时候,这可能降低了并发。再者,资源利用率可能比较低,因为许多资源被分配(获取了所有这些资源的锁),但是可能很长时间不被使用。最后,可能发生饥饿,如果一个进程需要多个常用资源,那它可能会永久地等待,因为它所需要的资源总是至少有一个被分配给其它进程。

非抢占

为确保这一条件不成立,可以设计一种协议:

如果一个进程持有资源并申请另一个不能立即分配的资源,并且即将进入等待状态,那么它现在持有的资源都可被抢占。

​ 在实现这种协议时,我们可以引入一个新的接口——trylock。顾名思义,trylock会尝试获取某把互斥锁,如果发现它已经被占有,那么就返回,稍后再尝试去获取它。

​ 利用trylock,我们可以先获取一把互斥锁,在获取第二把的时候使用trylock接口。如果trylock返回的结果是失败,我们就释放持有的第一把互斥锁。

​ 但是,考虑如下情况:有两把互斥锁L1和L2。一个线程lock(L1),trylock(L2);而另一个线程lock(L2),trylock(L1)。如果两个线程的第一步都同时成功,那么它们各自会持有L1和L2。这时,两个线程都尝试trylock,但是都失败了,于是各自释放L1和L2。在下一次尝试中,它们又遇到同样的情况,如此反复。在这种情况下,系统一直在运行这两段代码,因此这不是死锁,但是整个工作进度又不会有进展。我们把这种现象称为活锁Livelocks,解决的方法是,在trylock失败,并且释放持有的第一把锁之后,随机地等待一段时间,然后再重复整个动作,这样可以降低线程之间重复互相干扰的情况。

​ 而且,使用trylock方法还是存在一些难题。例如,若其中某一个锁封装在函数内部,那么在你调用trylock失败之后,就很难跳回到开始的地方;而且,如果代码在中途获取了某些资源(如内存),也要确保这些资源被正确释放。

互斥

​ 最后的预防方法是完全避免互斥,即如果所有资源都是可以共享的,就不需要使用互斥锁死锁就不会发生。但通常代码都会存在临界区,因此很难完全避免互斥。

​ 一种思路是设计无等待wait-free的数据结构,可以利用硬件指令来构造。

​ 下面展示了两个例子,利用前一章中介绍的compare-and-swap来实现这种无等待数据结构:原子地给某个变量增加特定的值,原子地在链表头部插入结点。

void AtomicIncrement(int *value, int amount){
  do{
    int old = *value;
  }while(CompareAndSwap(value, old, old + amount) == 0);
}

void insert(int value){
  node_t *n = malloc(sizeof(node_t));
  assert(n != NULL);
  n->value = value;
  do{
    n->next = head;
  }while(CompareAndSwap(&head, n->next, n) == 0);
}

Pthreads API

Pthreads是POSIX标准定义的线程创建与同步API,它是线程行为的规范,而不是实现。但大多数操作系统(尤其是Unix类系统)都实现了这个线程规范,并为用户级别的线程同步提供了互斥锁、条件变量、读写锁等接口实现。

​ 以下是一个典型的pthreads编程实例。这是一个用户级多线程程序,对于Pthreads程序,独立线程是通过特定函数执行的(这里是runner函数),原来进程只有一个执行线程,在main中创建了第二个线程,该线程就从runner开始执行,两个线程都共享全局数据。

image-20230517142515097.png ​ pthread_t是线程的标识符类型,和进程标识符类型相似,每个进程都对应一个;pthread_attr_t是线程属性类型,通常我们使用缺省属性即可;pthead_create创建一个新的线程,从左往右传递的四个参数是,线程标识符、线程属性、执行函数名称、执行函数的参数(可以打包更多的参数,通过结构体传入)。

Conditional Variables

​ 在很多情况下,线程需要检查某一条件满足之后,才能继续运行。最简单的方法是,使用一个共享变量,让线程在循环中检查该变量,直到满足条件时继续执行。显然该线程会自旋地检查,浪费CPU资源,因此我们希望,在线程需要的某个条件不满足时,有某种方式能让该线程睡眠,直到等待的条件满足,再将其唤醒。

​ 线程可以使用条件变量,来等待一个条件变为真。条件变量是一个显式队列,当某些执行状态不满足时,线程可以将自己加入该队列并睡眠;当其它线程改变了该执行状态,使得条件变为满足,那么就可以唤醒一个或者多个在队列中等待的线程,从而让它们继续执行。

#include <pthread.h>
#include <stdio.h>
//条件变量应该配合互斥锁使用
int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;  
// 初始化,也可以用pthread_mutex_init(&m, NULL)
pthread_cond_c c = PTHREAD_COND_INITIALIZER;    
// 初始化,也可以用pthread_cond_init(&c, NULL)
//条件变量有两种操作,wait和signal。
//线程要睡眠的时候,调用条件变量的wait方法;另一个线程要唤醒等待在某个条件变量上的睡眠线程时,调用条件变量的signal方法。
void thr_exit(){
  pthread_mutex_lock(&m);
  done = 1;  //状态变量
  pthread_cond_signal(&c);
  pthread_mutex_unlock(&m);
}

void *child(){
  printf("child\n");
  thr_exit();
  return NULL;
}
//pthread_cond_wait需要传入互斥锁m和条件变量c,它假定在调用该函数时,锁m已经被该线程持有。那么wait做的事情就是释放锁m,然后让调用线程睡眠(这些工作是原子性的)。当线程被唤醒时,wait为该线程重新获取锁m,再从wait返回调用者。
void thr_join(){
  pthread_mutex_lock(&m);
  while(done == 0)
    pthread_cond_wait(&c, &m);
  pthread_mutex_unlock(&m);
}

int main(){
  printf("parent: begin\n");
  pthread_t p;
  pthread_create(&p, NULL, child, NULL);
  thr_join();
  printf("parent: end\n");
  return 0;
}

pthread_cond_signal传入条件变量c,调用线程给在条件变量c上等待/睡眠的线程发信号,唤醒等待线程。

  • 状态变量done是否需要:主线程不知道子线程已经完成了任务,这就是变量done的作用,它记录了线程有兴趣知道的值。
  • 互斥锁是否需要:使用条件变量,调用signal和wait时要持有锁
  • while是否需要?能不能改成if?或者都不使用?:不能
  • 唤醒线程中,cond_signal和unlock的顺序是否能互换:实际上两种方案都能够工作,各有利弊。推荐将cond_signal放在unlock之前

Semaphore

​ 可以用信号量来实现互斥锁和条件变量,或者用于完成更多复杂的工作。信号量是一个有整数值的对象,和条件变量类似,可以用两个函数wait和signal操作它,而我们使用POSIX标准的命名:sem_waitsem_post

创建一个信号量。

#include<semaphore.h>
sem_t s;
sem_init(&s, 0, 1);

​ 调用sem_init,传入信号量的指针,共享级别的标志和信号量的初始值即可。第二个参数我们设置为0,表示信号量在同一进程的多个线程之间是共享的;初始值我们设置为1,表明这是一个二值信号量binary semaphore(相对的有计数信号量counting semaphore)。二值信号量类似于互斥锁,在没有提供互斥锁的操作系统上,我们可以用二值信号量来提供互斥。

int sem_wait(sem_t *s){
  s->value--;
  if(s->value < 0){
    add this thread/process to S->list;
    block() or sleep()
  }
}

int sem_post(sem_t *s){
  s->value++;
  if(S->list)  // 有一个或多个进程/线程在等待
  {
    remove a thread/process P from S->list
    wakeup(P); 
  }
}

​ 可以发现,sem_wait要么立刻返回(调用时S值≥1),要么会让调用线程睡眠而被挂起(调用时S值<1),直到之后的一个sem_post操作。sem_post并不等待某些条件满足,而是直接增加信号量的值S,然后如果有线程在队列中等待,就唤醒其中一个。当S为负数时,可以验证S就是在队列中等待线程的个数。

用信号量构建互斥锁:

二值信号量非常适合于构建互斥锁,它的实现非常简单,下面展示的这个例子,在需要互斥的并发环境下可以正常地工作。

#include<semaphore.h>
sem_t m;
sem_init(&m, 0, 1);

sem_wait(&m);
// 临界区
sem_post(&m);

用信号量构建条件变量:

信号量可以用在一个线程暂停执行,等待某一条件成立,并由另一个线程修改条件,发送信号,从而唤醒等待线程的场景。

#include<semaphore.h>
#include<stdio.h>

sem_t c;

void *child(){
  printf("child\n");
  sem_post(&c);
  return NULL;
}

int main(){
  sem_init(&c, 0, 0);  // 信号量初始值为0
  printf("parent:begin\n");
  pthread_t p;
  pthread_create(&p, NULL, child, NULL);
  sem_wait(&c);
  printf("parent:end\n");
  return 0;
}
  • 创建子线程之后,子线程先运行,打印child使得条件满足,sem_post使S值为1,然后退出;主线程调用sem_wait时,因为S为1,所以可以直接继续执行。
  • 主线程先运行,调用sem_wait时,因为S为0,所以S自减1变为-1,主线程将睡眠并被挂起,等待条件满足;子线程运行,打印child使得条件满足,sem_post使S回到0,并且唤醒主线程;主线程被唤醒,继续执行。

用互斥锁和条件变量实现信号量

typedef struct sem_t{
  int value;
  pthread_cond_t cond;
  pthread_mutex_t lock;
}sem_t

// 只能由一个线程来初始化
void sem_init(sem_t *s, int value){
  s->value = value
  pthread_cond_init(&s->cond, NULL)
  pthread_mutex_init(&s->lock, NULL)
}

void sem_wait(sem_t *s){
  pthread_mutex_lock(&s->lock);
  while(s->value <= 0)
    pthread_cond_wait(&s->cond, &s->lock);
  s->value--;
  pthread_mutex_unlock(&s->lock);
}

void sem_post(sem_t *s){
  pthread_mutex_lock(&s->lock);
  s->value++;
  pthread_cond_signal(&s->cond);
  pthread_mutex_unlock(&s->lock);
}