说明:该系类文章更多的是从从哲学视角看 操作系统 这门学科。同时也是 操作系统的学习笔记总结。因为博主 这些年主要是以研究安卓系统和 嵌入式Linux为主,因此这个系类文章也是这两个领域不可或缺的基石之一,尤其是对操作系统感兴趣的伙伴可特别关注。


9 锁的实现

同步原语都是原子操作,原子操作是很多步骤的,它是如何实现的?

硬件本身已经提供了一些原子操作:

  • 中断禁止和启用
  • 内存加载和存入
  • 测试和设置

在这些硬件操作的基础上我们实现了锁、信号量等同步机制。(其中,内存加载和存入与测试和设置类似)

9.1 以中断启用和禁止来实现锁

发生线程切换的两种方式:

  • 线程自愿放弃CPU而将控制权交给操作系统。(通过类似yield的操作)
  • 被强制放弃CPU而失去控制权。(通过中断来实现)

操作系统是通过周期性的时钟中断来获得CPU的控制权。由于原语执行过程中,不会放弃CPU控制权;要想防止线程间切换就要在原语执行过程中不能发生中断。由此,通过禁止中断且线程不主动放弃CPU控制权就可以防止线程切换,也就能够将一组操作变成原子操作。但是禁止中断的时候还要注意:程序进入临界区前要禁止中断,离开的时候需要再开启中断(接下来就会有一个问题,既然禁止中断就可以将一组操作变成原子操作,那么我们要锁干什么,因为这种做法是很危险的,因为将操作系统赖以生工作的机制直接交给用户来控制是十分不明确的,使用锁的时候忘记开锁也就是程序会报错,但是中断一旦禁止,可能会造成整个程序崩溃)。使用中断禁止实现锁的过程如下:

lock的实现:

操作系统哲学原理(09)线程原理-锁_原子操作

闭锁的第一个操作就是禁止中断,接下来就是检查value的值;

  • 如果是FREE,就表明资源没有被其他线程占用;我们将其设置为BUSY,然后启用中断。
  • 如果是BUSY,就表明资源被其他线程占用;我们需要循环等待(这个过程中需要不断开启中断、启用中断来判断value的值,如果不开启中断,那么别的程序可能无法推进)

unlock的实现:

操作系统哲学原理(09)线程原理-锁_临界区_02

这里需要注意:value的值设置为FREE也需要中断禁止来保护,否则在赋值的过程中有可能被冲掉(因为value=FREE不是一个原子操作)。同时这里也有问题:

  • 频繁禁止中断有可能对重要事件的处理不及时。
  • 在锁的实现中,其他线程获得CPU的机会不大,会使其他任务的效率变低,但那只是表面现象,实际上启用和禁止中断之间发生线程切换并不是小概率事件(因为中断禁止所禁止的并不是中断的发出,而是对中断的响应。因为中断一旦发出,就会一直处于发出状态(因为会存在中断表里面),此时只要中断被启用就会马上得到响应;因此,使用中断启用和禁止所留下的空档绰绰有余)

9.2 以测试和设置指令来实现锁

现代处理器基本上都会提供一条“读-修改-写入”的原子指令。该操作以不可分割的方式执行以下两个操作:

  1. 将内存指定位置的存储单元的内容读到一个寄存器。
  2. 将新的值写入刚才的内存单元。

测试与设置就是类似的指令,但是略有不同:

  • 设置操作:将1写入指定内存单元。
  • 读取操作:返回指定内存单元里原来的值。

测试与设置指令如图所示:

操作系统哲学原理(09)线程原理-锁_临界区_03

利用其实现lock,如图所示:

操作系统哲学原理(09)线程原理-锁_死锁_04

lock的逻辑是这样的:

  1. test_and_set的操作时将value的值设置为1,并在此之前将其保存;
  2. 如果value的值是0,将value设置为1,获得锁并退出循环。
  3. 如果value的值时1,将返回,循环继续(该指令将value的值设置为1并不改变value的状态),循环一直持续到成功获得锁为止。

利用其实现unlock,如图所示:

操作系统哲学原理(09)线程原理-锁_禁止中断_05

unlock的操作很简单,将value的值设置为0,而这并不需要保护,因为本身就是一个原子操作(当然有一个限制,那就是unlock之前必须先lock),而且调用unlock的线程一定是获得锁的线程。将lock和unlock的指令用汇编来实现,如图所示:

操作系统哲学原理(09)线程原理-锁_死锁_06

测试与设置和中断禁止都使用了繁忙等待,这里比较一下两者的代码:

操作系统哲学原理(09)线程原理-锁_禁止中断_07

在左边的程序中,每3个语句出现一次切换机会,而在右边的语句中,每个语句后面都是一次切换的机会。因此,右边的程序执行效率将高于左边的执行效率。同时需要指出的是,这种区别是很小的,因为中断禁止所禁止的并不是中断的发出,而是对中断的响应。因为中断一旦发出,就会一直处于发出状态,此时只要中断被启用就会马上得到响应;因此,使用中断启用和禁止所留下的空档绰绰有余。

9.3 以非繁忙等待、中断启用与禁止来实现锁

前面两种方式看上去简单,也容易实现,但是都需要繁忙等待,因此我们要对前面两种方式进行改善;即在拿不到锁的时候去睡觉,等待别人叫醒。这样,锁的实现思路如下:

  1. 使用中断禁止,但是不进行繁忙等待。
  2. 如果拿不到锁,则等待其他线程放弃CPU并进入睡眠状态,以便持有锁的线程可以更好地运行。
  3. 释放锁的时候将睡眠线程叫醒。

这样锁的实现方式如下:

lock的实现方式如图所示:

操作系统哲学原理(09)线程原理-锁_临界区_08

  1. 先是禁止中断,然后检查value的值是否为FREE;
  2. 如果是FREE,就将value设置为繁忙,启用中断,获得锁,退出。
  3. 如果不是FREE,就将自己添加到等待队列中去,然后切换别的线程,最后启用中断。

unlock的实现如图所示:

操作系统哲学原理(09)线程原理-锁_禁止中断_09

  1. 先是禁止中断,将value的值设置为FREE,检查是否有线程等待该锁;
  2. 如果没有,直接启用中断。
  3. 如果检测到其他线程等待该锁,就将该等待线程从等待队列移动到就绪队列,并将value的值设置为繁忙(将等待队列移动到就绪队列相当于将锁给了该线程)

但是这里面有很多问题:

  • 切换到别的线程语句在启用中断指令前执行,但是切换到其他程序后,该线程就无法继续执行,后面的指令就不能执行了。
  • 由于我们是在中断禁止的情况下切换到别的线程的,如果别的线程没有执行中断启用或者自动放弃CPU给另一个线程,就会造成死锁。

解决该问题的方式:查看lock程序中,什么时候可以启用中断?

  • 将自己放到等待队列之前(将发生信号丢失而造成死锁,类似于sleep与wakeup原语;假设A线程调用lock获得锁,进入else部分;如果在启用中断的时候立即发生中断,则持有锁的线程获得机会运行并释放锁,但是由于此时没有线程处于锁等待状态,因此释放锁的线程不会释放任何线程;之后,线程A将自己放到等待锁的队列;而此时由于锁已经释放,线程A将无法从等待队列中被叫醒,死锁发生;这种方式不行)
  • 将自己放到等待队列之后,但在切换到另一线程之前(违反了操作系统的规则,即一个线程在一个时刻只能处于一种状态。可以将自己放在等到队列和切换到别的线程这两个操作变成一个原子操作,即不能在其中间中断;那么剩下一种可能就是闭锁操作不启用中断(切换到其他线程调用其他线程的lock前启用中断,即留给别的线程去启用中断)。但是所有操作系统调用都遵守一个约定:在进入系统调用前必须禁止中断,在返回用户代码前必须启用中断;因此这种方式不行)              
  • 将自己放到等待队列之后启用中断。(无意义,因为根本执行不到)

9.4 以最少繁忙等待、测试与设置来实现锁

上面的方式过于危险(因为有时会造成系统崩溃,毕竟涉及中断禁止),因此我们就会考虑一下测试与设置的非繁忙等待,但是使用测试与设置来实现锁并不能完全避免繁忙等待,因此我们的目的就是尽量降低等待的时间。利用测试与等待来实现锁的中心思想是:只用繁忙等待来执行闭锁的操作,如果不能获得锁,就放弃CPU。方式如下:

lock的实现方式如图所示:

操作系统哲学原理(09)线程原理-锁_死锁_10

这里的逻辑如下:

  1. 如果guard的值为1,则lock将一直循环,直到guard为0。
  2. 如果guard的值为0,检查锁的值value。
  3. 如果value的值是FREE,将其设置为BUSY。将guard设置为0;获得锁之后退出lock。
  4. 如果value的值为BUSY,先将guard设置为0,将自己添加到等待队列上,切换到别的线程(如果另一个线程持有这把锁,线程做完自己的事情后将调用unlock来释放锁,而unlock首先测试guard,如果没有人持有锁,则该线程将value设置为FREE,然后检测是否有线程在锁上等待,如果有,则将等待线程移动到就绪队列,然后将锁交给被叫醒的线程,最后将guard设置为0)

unlock的实现如图所示:

操作系统哲学原理(09)线程原理-锁_死锁_11

  1. 先在guard上进行测试与设置,通过后检查是否有线程等待在该锁上;
  2. 如果有,则直接将锁交给该线程(通过移动等待队列到就绪队列和将value设置为繁忙),然后设置value=BUSY。
  3. 最后设置guard=0。

注意:我们直接将锁交给队列里等待的线程,因此被唤醒的线程无需再竞争这把锁,而是直接进入临界区进行操作。这样做的效率显然高于再次竞争锁的方式。

那么上面的方案与之前的测试与设置相比为什么会大大缩短繁忙等待时间?         

  1. 因为我们将等待的对象从value变为guard,而guard与value相比需要保护的范围小了,因为guard要防止的只是不要同时拿锁而已,一旦拿锁的动作完成,guard都将被设置为0,这个范围很小;在拿到锁的情况下,guard需要保护的只有if的条件判断和value=BUSY;在没有拿到锁的情况下,guard保护的只有将自己加到等待队列的一段代码。
  2. 拿到锁的临界区操作并不由guard保护,不管临界区多大,guard维持繁忙的时间不会受到丝毫影响。因此,一个线程在guard上等待的时间几乎是恒定的,只有短短几句指令。
  3. 这种策略的根本思想就是循环等待不在lock,而是等在guard上。从而将临界区的工作时间从需要等待的时间中消除了,进而在实现锁的同时大大降低了繁忙等待的时间。

与中断启用和禁止相比,有同样的一个问题:如果将自己放在等到队列后突然发生线程切换,那么本线程也将处于等待和就绪两个队列。解决的方法是:将执行lock这个系统调用的线程优先级提高,以使得这种情况发生的概率降低。当然,完全避免是不可能的,这也是操作系统会死锁的缘故。

9.5 中断禁止、测试与设置

两种方式比较起来,测试与设置硬件原语的优势:

  • 更加简单,使用范围比较广泛。
  • 可以在多CPU的环境下工作(多CPU的中断禁止都是相互独立的,没有一条指令可以将多CPU中断禁止;但是测试与设置是针对内存单元的,多CPU有全局内存,所以能够工作;这里涉及旋锁)