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


8 线程同步

8.1 为什么要同步

  • 线程的同步类似于人与人之间的协调,因为有些工作需要合作才能顺利完成。
  • 线程的关系是合作关系,既然是合作,就需要某种约定的规则,否则合作就会出现问题。    
  • 线程之间不同步的话,就会引入了一个很大的问题,即多线程程序的执行结果可能是不确定的,而“不确定”是我们所反感的东西。要想保持线程的同时,消除线程执行结果的不确定性,那么只有线程同步这种方式了。

8.2 线程同步的目的

  • 线程同步的目的就是不管线程之间怎样穿插执行,其运行结果都是确定的,即保证多线程执行下结果的确定性;而同时要保证对线程执行的限制越少越好。
  • 同步:让所有的线程按照一定的规则执行,使其正确性和效率都有迹可寻。线程同步的手段就是对线程间的穿插进行控制。

8.3 锁的进化:金鱼生存

金鱼生存问题是一个演示线程同步手段的好例子。金鱼的特点:没有饱的感觉,喂多少就吃多少。

假设佐伊和尤尔共同养了一条金鱼,为把金鱼养好,即不让鱼胀死,也不饿死,做出如下约定:

  • 每天喂鱼一次,且只有一次。
  • 如果佐伊喂了鱼,则尤尔今天就不能喂鱼,反之亦然。
  • 如果佐伊没有喂鱼,则尤尔今天必须喂鱼,反之亦然。

在没有同步的情况下,佐伊和尤尔的执行顺序如下;

操作系统哲学原理(08)线程原理-线程同步_互斥

但是由于线程可以任意穿插,则执行结果可能如图所示:

操作系统哲学原理(08)线程原理-线程同步_线程同步_02

很明显,这样的话鱼会胀死的。这里就涉及 新概念:竞争和临界区。

  • 竞争:多个线程争相执行同一段代码/同一资源的现象(数据竞争:两个线程同时访问一个数据;代码竞争:两个线程同时访问一段代码)
  • 临界区:可能造成竞争的共享代码段/资源。

8.3.1 变形虫阶段

要防止鱼胀死,就要防止竞争;即防止两个/多个线程同时进入临界区。因此要协调。协调的目的就是任何时候只有一个人在临界区内,这称为互斥;即一次只有一个人使用共享资源。

正确互斥需要4个条件(只要有一个条件不满足,互斥的设计就是不正确的)

  1. 不能有2个进程同时在临界区里面。
  2. 进程要能够在任何数量和速度的CPU上正确执行。
  3. 在互斥区外不能阻止另一个进程的运行。
  4. 进程不能无限制的等待进入临界区。

通过交谈,佐伊和尤尔商定在喂鱼之前留字条,这是第一种同步机制,如图所示:

操作系统哲学原理(08)线程原理-线程同步_信号量_03

此方案有所改善,即降低了鱼胀死的概率,但没有完全解决问题(佐伊和尤尔交叉执行上述程序,还会造成鱼胀死的结局)。如下图所示:

操作系统哲学原理(08)线程原理-线程同步_临界区_04

8.3.2 鱼阶段

查看上一阶段解决不了问题的原因:没有先检查有没有字条后留字条,因此造成了空当。即检查字条和留字条之间有空隙。解决方法:先留字条后再检查又没有字条。改进的留字条的方法如图所示:

操作系统哲学原理(08)线程原理-线程同步_临界区_05

这样两个人就不会同时进入临界区了。因此鱼不会因为两个人都喂而胀死。但是如果程序穿插执行,效果如图所示:

操作系统哲学原理(08)线程原理-线程同步_互斥_06

那么鱼有被饿死的可能,这是一种进步,但是并没有完全解决问题。

8.3.3 猴阶段

查看上一阶段解决不了问题的原因:除了互斥之外,还要确保有一个人进入临界区来喂鱼。解决方法:让某个人等着,知道确认有人喂了鱼才离开,不要一见到字条就离开。改进的循环等待模式如下:

操作系统哲学原理(08)线程原理-线程同步_原语_07

这个方案鱼既不会饿死、也不会胀死,但是程序本身不对称。

8.3.4 锁

查看上一阶段解决不了问题的原因:程序不对称(程序的编写会很困难,同时增加了证明的难度);时间、资源的浪费(循环等待,这可能会造成CPU调度的优先级倒挂)。解决方法的分析:循环等待不能去掉(如果这样那么就回到第二种同步机制上了);两者都对称、美观(使鱼饿死成为可能)

解决方法:这个解决问题的思考方向有问题,我们需要换一种思路来思考这个问题。对之前的每个方案进行修改:将检查字条和留字条合并成一个原子操作,即提高抽象的层次,将控制层面上升到对一组指令的控制。于是锁的概念出现了(锁的原始模型:只能有一个人在教室里,只要进去就上锁,出来就闭锁)。加锁后的程序如图所示:

操作系统哲学原理(08)线程原理-线程同步_线程同步_08

这样,问题就可以解决了。锁的基本操作:闭锁和开锁

闭锁操作的步骤(2个步骤是一个原子操作)

  1. 等待锁达到打开状态
  2. 获得锁并且锁上

开锁操作的步骤:

  1. 打开锁

锁的特性规则:

  • 锁的初始化状态是打开。
  • 进入临界区前必须获得锁。
  • 出临界区时必须释放锁。
  • 如果别人持有锁则等待。

正确使用锁以后程序就可以正常运行,同时变得容易了。问题是解决了,但是能不能更好地解决呢,即缩短别人持有锁时自己等待的时间。仔细分析发现,喂鱼并不需要在持有锁的时候进行。只要在检查字条和留字条的地方加锁就可以。执行过程如图所示:

操作系统哲学原理(08)线程原理-线程同步_信号量_09

等待时间因此而大幅度缩短了,但是等待终究是需要时间的,下面需要考虑的就是有没有不需要等待的方法。

8.4 睡觉与叫醒:生产者与消费者问题

睡觉与叫醒:如果锁被对方持有,则不需要等待锁变为打开状态,而是睡觉去;锁打开以后再把你叫醒。消费者和生产者的问题是一个演示这种机制的一个较好的例子。

模型静态说明:         

  • 生产者:生产东西;
  • 消费者:消费别人的东西;
  • 商店:一个中间机构,生产者生产东西给商店;消费者从商店拿东西。

模型动态说明:

  • 生产者如果发现商店货架已满,则回去睡觉,等有人买了后再送货,当然,这需要消费者来叫醒。
  • 消费者如果发现商店货架已空,则回去睡觉,等货架有货后再来买,当然,这需要生产者来叫醒。
  • 商店的存在能够让消费者和生产者独立运行(否则就要采取一步一趋的方式)

用计算机模拟生产者和消费者:一个进程代表生产者;一个进程代表消费者;一片内存缓冲区就代表我们的商店。生产者生产物品从一端放入缓冲区;消费者从另一端获取物品,如图所示:

操作系统哲学原理(08)线程原理-线程同步_信号量_10

sleep和wakeup是操作系统里睡觉和叫醒操作的原语。

  • 一个程序调用sleep后将进入休眠状态,其所占CPU将被释放。
  • 一个执行wakeup的程序将发送一个信号给指定的接收进程。

消费者/生产者的同步程序如图所示:

操作系统哲学原理(08)线程原理-线程同步_线程同步_11

程序的逻辑没有问题。但是这个count有问题,因为变量没有被保护,可能存在数据竞争的问题,即生产者和消费者同时对该数据进行修改。这个问题可以通过锁的方案来解决,因为时间很短,可以接受。问题的关键是有可能造成死锁,即消费者和生产者进程均无法推进(存在信号丢失问题:即消费者正准备睡觉,但是生产者已经发出信号,则此信号无效,因为消费者没有处于睡觉的状态)。解决的方法就是不能让两者同时睡觉。而这本质的原因就是信号丢失,只要用某种方式方信号累积起来而不是丢掉,那么问题就解决了。于是新的机制出现了:能够将信号累积起来的操作在操作系统里叫做信号量。

8.5 信号量

semaphore(信号量)不只是同步的原语,还是通信原语。同时还可以作为锁来使用。

@1 同步原语:信号量实际上就是一个计数器,取值为当前累积的信号数量,支持两个操作,up和down(也称为p、v操作)

down操作:

  1.      判断信号量的取值是否>=1。
  2.      如果是,则将信号值-1,继续往下执行。
  3.      否则在该信号上等待。

up操作:

  1.      将信号量的值加+1。
  2.      线程继续向下执行。

注意:虽然down和up是多个步骤,但是是一组原子操作。

@2 锁原语:如果将信号量的取值限制为0和1两种情况,则我们获得的就是一把锁,也即二元信号量,操作如下:

二元信号量down操作:

  1. 等待信号量取值变为1;
  2. 将信号量的值设为0;
  3. 继续执行。

二元信号量up操作:

  1. 将信号量的值设为1;
  2. 叫醒该信号上面等待的第1个线程;
  3. 线程继续执行;

由于二元信号量的取值只有0和1,因此可以防止任何两个程序同时进入临界区。具备锁的功能,与锁很相似(down是获得锁、up是释放锁),却比锁灵活(在信号量上的线程不是等待,而是睡觉等待另一个线程执行up操作将其叫醒);因此,二元信号量是从某种意义上说就是锁与睡觉、叫醒两种原语操作的合成。有了信号量,解决生产者和消费者的问题就可以这样:

操作系统哲学原理(08)线程原理-线程同步_互斥_12

  • 首先,对于item的操作不会出现数据竞争。(item操作均加锁mutex)
  • 其次,不会同时让消费者和生产者睡觉。(empty和full不同时为0)

其中full和empty对应的是一个缓冲区,但是对于消费者和生产者,它们等待的信号是不同的,因此它们需要睡在不同的信号上(一个满,一个空)

8.6 锁、睡觉和叫醒、信号量

操作系统的原语并不是没有联系,而是一环扣一环的,具有严密的逻辑性。

使用信号量的缺陷:当少于3个信号量时,顺序很容易掌握,但是对于多个信号量,down与up的顺序就不那么容易掌握了,而此时写程序也就变得很复杂了。(如果一个程序的信号繁多,死锁或者效率低下几乎是肯定的)

要想改变这种情况,就需要操作系统自己管理这些东西,这个方法就是管程。

8.7 管程

@1 信号量存在程序编写困难和执行效率低下的问题,那么交给操作系统做这个就可以了,这个新的东西就是管程(monitor,也叫监视器,监视的就是同步的操作)

  • 管程是一个程序语言级别的构造,它的正确运行由编译器来保证。(这是计算机里面的一条原理:你不行的时候将事情交给别人)
  • 管程就是将要同步的代码通过构造框来框起来,在任何时候只有一个线程活跃在管程内部;即将要保护的代码置于begin monitor和end monitor之间。在编译器编译的时候,发现begin monitor和end monitor就会对其进行同步操作的处理之后再转换成低级语言。
  • 管程使用了两种同步机制:锁(互斥)和条件变量(控制线程执行的顺序,即一个线程可以再上面等待的东西,另一个线程可以通过发送信号将在条件变量上的线程叫醒;类似于信号量却又不是信号量,因为没有up和down的操作)
  • 管程的中心思想:运行一个在管程里面睡觉的线程,在进入管程前需要把进入管程的锁和条件变量释放(否则其他的线程将无法进入管程,因为这会造成死锁)。这里允许别的线程进入管程,因此线程可以在管程中睡觉。(这与其他机制不一样,一般来讲,线程在临界区呆的时间越长,别的线程等待的时间久越长,而这里正好相反)

实现锁的释放和睡觉这两件事情必须是一个原子操作(因为如果有空档,将会造成有两个线程活跃在管程内)

@2 利用管程实现生产者和消费者的同步:

@@2.1 生产者与消费者的管程内部部分如下:

操作系统哲学原理(08)线程原理-线程同步_互斥_13

生产者和消费者对缓冲区的访问都是在管程里面;因此,对线程的访问,对count计数器的修改都是互斥的。

@@2.2 生产者与消费者的管程外部部分如下:

操作系统哲学原理(08)线程原理-线程同步_信号量_14

生产者生产出商品,并调用insert函数将商品放入缓冲区中;消费者消费商品,调用remove函数将商品从缓冲区中取走。

@3 整个管程中没有加锁(编译器自动检测并加锁)。其中

wait以原子操作实现3个步骤:

  1. 释放锁;将本线程挂在条件变量x的等待队列上;
  2. 睡觉;
  3. 等待被叫醒;

signal实现的操作: 将等在条件变量上的第一个线程叫醒。(在叫醒方面还提供了一种机制,广播broadcast,在调用wait、signal、broadcast时该线程必须持有与管程相连的锁)整个过程与之前的sleep、wakeup操作类似,但是不同的是:管程不会发生死锁(sleep与wakeup方案中将要睡觉和睡觉这2个操作中存在空档)

注意:如果一个线程释放等待信号线程的signal,则此时有两个线程活跃在管程内部(即signal不是线程最后的操作,那么后面的操作就和新的线程一起在管程里面了),这违反了管程的约定。为了防止这种问题发生,管程机制特别规定:signal语句是一个线程在管程里面的最后一个操作(因为这样即使理论上有两个线程活跃于管程内,但是实际上只有一个线程活跃,因为一个线程的下一步操作已经在管程之外,从而维持我们关于管程的约定)。

@4 解决管程问题的方法:

  • HORSE管程:发送signal时同时释放锁,让被叫醒者获得锁(在signal之后运行的线程将是被叫醒的线程),叫醒者本身只能在被叫醒者运行完毕/其他原因释放锁后才能运行。
  • MESA管程:不能在管程设计时就确定了,因为这样做十分不灵活,对操作系统而言下一步执行哪个操作应该由它决定(这样就可以利用操作系统的机制来竞争这把锁)

8.8 消息传递

管程机制的问题:

  • 对编译器的依赖,而实际上多道胡编译器也没有实现管程机制。
  • 只能在单个计算机上面运行,严重限制了其使用。

于是想在多计算机环境下进行同步,那就需要其他的机制了。这种机制就是消息传递。消息传递是通过同步双方经过相互接收、发送消息来实现(send与receive操作,均为系统调用,既可以阻塞也可以非阻塞)。用消息传递机制实现生产者与消费者之间的同步问题:

操作系统哲学原理(08)线程原理-线程同步_互斥_15

对于该问题,需要send和receive均为阻塞操作,即执行receive操作需要收到消息后返回,否则将挂起。这种机制对于生产者与消费者之间的同步问题:既不会死锁,也不会繁忙等待,而且没有区域限制(可以跨计算机同步)。因此当前使用较为普遍。

消息传递的问题:

  • 消息丢失:在一台计算机上基本不会,但是在网络上则很有可能,因为网络的不可靠性(可以通过网络协议如TCP/IP,可以将数据传输的可靠性提高,但不是100%)
  • 身份认证:怎样知道消息是从哪里发出来的(可以通过网络协议以及数字签名和加密认证等方式来解决)
  • 效率低下:往返发送系统消息存在系统消耗,同时数据传输也有延迟(尤其是在网络比较慢的时候)。

8.9 栅栏(barrier)

通信原语barrier:到达栅栏的线程必须停下来等待,直到障碍解除才能往前推进(主要用来对一组线程进行同步,有些时候,需要几个线程汇合在一起,协同完成任务)

栅栏的参考模型如下:

操作系统哲学原理(08)线程原理-线程同步_临界区_16