Skip to content

线程同步

为什么要同步

线程之间的关系是合作关系。既然是合作,那就得有某种约定的规则,否则合作就会出问题。
例如,有两个线程同时运行,第一个线程在执行了一些操作后想检查当前的错误状态 errno,但在其做出检查之前,线程 2 却修改了 errno。
这样,当第一个线程再次获得控制权后,检查结果将是线程 2 改写过的 errno,而这是不正确的。

进程不同步造成错误

之所以出现上述问题,是基于两个原因:

  • errno 是线程之间共享的全局变量
  • 线程之间的相对执行顺序是不确定的

因此,要解决上述问题有两个办法,即分别消除上述两个原因。
消除第一个原因的办法就是限制全局变量,如果可以将所有的资源都私有化,让线程之间不共享,那么这种问题就不复存在。

但问题是,如果所有资源都不共享,那么还有必要发明线程吗?设置野咩有必要发明进程了。
因为这样就违背了进程和线程设计的初衷:共享资源、提高资源利用率。因此,这种解决方法是不切实际的。

那剩下的办法就是消除第 2 个原因,即让线程之间的相对执行顺序在需要的时候可以确定。
在讲述这个办法之前,我们再看一个更加戏剧化的例子。

假定有两个线程 A 和 B,分别执行指令 x=1 和 x=2,即

线程 A: x=1;
线程 B: x=2;

请问在上述两个线程结束后,x 的值是什么?

由于 x 是共享变量,且线程之间的相对执行顺序是不确定的,因此线程 A 既可以在线程 B 之前执行,有可能在线程 B 之后出现。
前者将使 x=2,后者则使 x=1。

还有别的结果吗?x 有可能等于 3 吗?

一般情况下,在任何计算机体系结构中,x=1 对应的不是一条微指令,即一条高级指令对应的是多条微指令,因此一条高级指令需要多个时钟周期(通常为 6 个时钟周期以上)才能完成。
例如,x=1 的赋值语句就是由多个步骤构成。这些步骤可能包括:先把总线清零,然后把 1 加到上面去。
这样的话,线程 A 和线程 B 的执行可能形成如下穿插:线程 A 把总线清零,线程 B 把总线清零,线程 A 将数值 1 加到总线上,线程 B 将 2 加到总线上。
这样就有可能出现结果 3。当然了,出现 x=3 的结果依赖于特定的指令集结构。
如果指令集结构在执行赋值语句时不是先将总线清零,然后将要赋值的常数加到总线上,就不会出现结果 3,而只能有结果 1 或者 2.

就算赋值语句可以完整(不分割)地执行,编译器的优化也有可能造成不可预料的结果。
例如,设定 x、y 两个变量的初始值为 0,进程中的两个线程执行的指令如下:

线程 A: r2=x; y=1;
线程 B: r1=y; x=2;

该程序运行的结果是什么呢?r1=0,r2=0 显然是第一种结果;r1=0, r2=2是第 2 钟可能的结果;r1=1,r2=0是第 3 种可能的结果。
r1=1, r2=2是否可能呢?看上去似乎不可能,因为线程 A 和线程 B 的指令无论如何穿插,此种结构也不可能出现。
遗憾的是,这种推论没有考虑到编译器优化的影响。
由于编译器在进行优化时可以(其实是通常)改变语句的顺序,有可能导致两个线程执行的实际指令顺序如下:

线程 A: y=1; r2=x;
线程 B: x=2; r1=y;

而此时,如果两个线程的指令执行进行穿插的话,结果就有可能是r1=1, r2=2了!

我们再看一个例子:

线程 A:

1
2
3
4
5
i = 0
while (i < 10) {
    i++
}
printf "A finished"

线程 B:

1
2
3
4
5
i = 0
while (i > -10) {
    i--
}
printf "B finished"

请问,哪个线程会赢呢?即哪个线程会先运行结束?或者说,有没有哪个线程肯定会赢?答案是不确定的。
如果两个线程恰好是你一步、我一步地执行的话,则两个线程都将无法结束。
事实上,如果不采取特殊措施,就没有办法确保谁会赢,也没办法确保是否会结束

由上述例子可见,引入线程后,也引入了一个巨大的问题,即多线程程序的执行结果有可能是不确定的。而不确定则是我们人类非常反感的东西。
那么如何在保持线程这个概念的同时,消除其执行结果的不确定性呢?答案是线程的同步。

线程同步的目的

线程同步的目的就是不管线程之间的执行如何穿插,其运行结果都是正确的。或者说,要保证多线程执行下结果的确定性。
而在达到这个目标的同时,要保持对线程执行的限制越少越好。

除此之外,线程同步的另外一个目的涉及执行效率。
除了前面说过的多线程执行的结果是不确定的之外,其执行效率也是不确定的。
比如,在某段时间内,线程 A 执行了 5 条指令,而线程 B 只执行了 3 条指令。线程 A 比线程 B 多执行了两条指令。
但这并不是问题的关键。问题的关键是到底线程 A 是否比线程 B 执行的多,或者是多多少等,皆是不确定的。
如果我们想使其变得确定,就需要进行线程同步。

那么到底什么是“同步”呢?同步就是让所有线程按照一定的规则执行,使得其正确性和效率都有迹可寻。
线程同步的手段就是对线程之间的穿插进行控制。下面以“金鱼问题”来演示线程同步的各种控制手段

锁的进化:金鱼生存

养过金鱼的人都知道,金鱼有一个很大的特点,就是没有饱的感觉。因此,金鱼吃东西不会因为吃饱了就停止。
它们会不停地吃,一直到鱼缸里面的食物都被吃完为止。所以,如果你一直喂,它就一直吃,直到胀死。
因此,金鱼吃多少得由养金鱼的人来确定,其死活也由人所控制。

现在假定左怡和尤尔两个人合住一套公寓,共同养了一条金鱼。该金鱼每天进食一次。
两个人想把金鱼养活,一天只喂一次,也只能喂一次。如果一天内两人都喂了鱼,鱼就胀死。如果一天内两人都没有喂鱼,鱼就饿死。

他们二人为把鱼养好,既不让鱼胀死,也不让鱼饿死,做出如下约定:

  • 每天喂鱼一次,且仅一次
  • 如果今天左怡喂了鱼,尤尔今天就不能再喂;反之亦然。
  • 如果今天左怡没有喂鱼,尤尔今天就必须喂;反之亦然。

显然,要想保持鱼活着,左怡和尤尔得进行某种合作。
当然,最简单的情况是不进行任何沟通,每个人觉得需要喂鱼时,查看一下鱼的状态:如果感觉到鱼像是没进过食,则喂鱼;否则不喂。

下图给出的是在没有同步情况下左怡和尤尔所执行的程序。

没有同步情况下喂鱼程序

那上述程序里是如何判断 noFeed 的值呢?
程序里没有给出,因此只能依靠左怡和尤尔的高超养鱼技术,即通过查看鱼的外形来判断金鱼当天是否进食了。
当然,只有高手才能达到这个水平,一般的人看不出来的。
万一左怡活着尤尔没有看出对方已经喂过鱼,再喂一次,鱼就胀死了。活着,没有看出对方没有喂过鱼,而没有喂,鱼就饿死了。

即使假设左怡和尤尔都是养鱼高手,通过查看鱼的外形就可以判断鱼是否喂过,上述程序也能正确执行吗?答案是否定的。
由于线程的执行可以任意穿插,左怡可以先检查鱼,发现没有喂,就准备喂鱼。
但就在左怡准备喂但尚未喂的时候,程序切换,轮到尤尔执行。尤尔一看,鱼还没有喂(确实如此),就喂鱼。
在喂完鱼后,线程再次切换到左怡。此时左怡从检查完鱼状态后的指令开始执行,就是喂鱼。这样鱼被喂了两次,鱼就胀死了。

没有同步情况下可能的喂鱼结果

为什么这个程序会出现鱼胀死的情况呢?因为左怡和尤尔两个人同时执行了同一段代码(if (noFeed) feed fish)。
两个或多个线程争相执行同一段代码或访问同一资源的现象称为竞争(race)。
这个可能造成竞争代码段或资源称为临界区(critical section)。

当然,我们知道两个线程不可能真的在同一时刻执行(单核情况)。但有可能在同一个时候两个线程都在同一段代码上。
这个例子里竞争的是代码,是代码竞争。如果是两个线程同时访问一个数据就叫数据竞争。
上面程序造成鱼胀死就是因为两个线程同时进入了临界区。

以人类进化来比喻,图中的程序只相当于氨基酸阶段,胡乱竞争,并不具备任何协调能力。

变形虫阶段

要防止鱼胀死,就需要防止竞争。要想避免竞争,就需要防止两个或多个线程同时进入临界区。要达到这一点,就需要某种协调手段。

协调的目的就是在任何时刻都只能有一个人在临界区里,这称为互斥(mutual exclusion)。
互斥就是说一次只有一个人使用共享资源,其他人皆排除在外,并且互斥不能违反前面给出的进程模型。
因此,正确互斥需要满足 4 个条件:

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

如果任何一个条件不满足,那么设计的互斥就是不正确的

那么有没有办法确保一次只有一个人在临界区呢?有,让两个线程协调。当然,最简单的协调方法是交谈。 问题是左怡和尤尔不一定有时间碰面。那么剩下的办法是留字条。
由此,获得第一种同步方式:左怡和尤尔商定,每个人在喂鱼之前先留下字条,告诉对方自己将检查鱼缸并在需要时喂鱼。

留字条

上述机制能否避免鱼胀死呢?不能。
如果左怡和尤尔交叉执行上述程序,还是会造成胀死的结局。

那上面的程序为什么没有效果呢?这是因为使用的是互斥手段,即留字条,并没有达到互斥的目的。
因为字条并没有防止左怡和尤尔两个人同时进入临界区。
当然,与第一个解决方案比起来,本方案还是有所改善,即鱼胀死的概率降低了。

只有在左怡和尤尔严格交叉执行的情况下,才可能发生鱼胀死的现象。因此,我们并没有完全白费力气。

第1种同步机制的一种可能结果

此程序虽然加入了一点同步机制,但这个机制太原始,达不到真正的同步目的。
以人类进化来比喻,此程序相当于变形虫阶段。

鱼阶段

仔细分析可以发现,上述程序不解决问题的原因是我们先检查有没有字条,后留字条。
这样造成一个空当,使得检查字条和留字条之间的空隙。那我们就修改一下顺序,先留字条,再检查有没有对方的字条。
如果没有对方的字条,那么就喂鱼,喂完把字条拿掉。不够这种方法需要区分字条是谁的。

第2种同步机制改进的留字条方法

上述程序怎么样?鱼还会不会胀死呢?答案是不会了。
因为无论按照什么顺序穿插,总有一个人的留字条指令在另一个人的检查字条指令前执行,从而将防止两个人同时进入临界区。
因而鱼不会因为两个人都喂而胀死。

那这个程序是成功了。不过,不要过早下结论。我们来看一下,如果两个人穿插执行会出现什么结果。
这个时候问题出现了。由于穿插执行,因此在两者检查字条时,如果发现对方已经留有字条,从而都不会喂鱼,这个时候鱼饿死。

第2种同步机制的一种可能结果

由于存在饿死这种可能性,这个程序似乎并没有任何改善。难道我们这个力气白费了吗?没有。
因为比起胀死来说,饿死是一种改善。对于一个计算机系统来说,饿死也是好于胀死。
如果胀死,则程序的运行很可能出错:几个线程同时获得同一个资源,出现不一致性及结果不确定性几乎是难以避免的。
但如果是饿死,即大家都拿不到某个资源,线程处于饥饿状态,至多是停止推进,而这不一定产生错误结果,或许只是推迟结果的出现。

虽然饿死比胀死好受一点,但毕竟还是存在死的可能,还是在很原始的阶段。以人类进化来比,相当于鱼阶段。
因此,我们需要继续进化,或者说努力。

猴阶段

那么为什么鱼会饿死呢?是因为没有人进入临界区。虽然互斥确保了没有两个人同时进入临界区,但这种没有人进入临界区的情况则有点互斥过了头。
要想鱼不饿死,除了互斥之外,还要保证有一个人进入临界区来喂鱼。那用什么办法来保证呢?

办法就是让某个人等着,直到确认有人喂了鱼才离去,不要一见到对方的字条就开溜走人。
也就说,在两个人同时留下字条的情况下,必须选择某个人来喂鱼。这样就得出第 3 种同步方式

第3种同步机制循环等待

鱼显然不会胀死,因为使用的办法包括了第 2 种同步方式。那么鱼会不会饿死呢?也不会。
因为前面说过,鱼饿死的唯一情况是两个人同时留字条,并且又都走人。
而上述程序在两个人都留字条的情况下,左怡不会走人,而是一直循环等待直到对方删除字条后,再检查鱼有没有喂,并在没有喂的情况下喂鱼。
因此,该同步方式既防止了胀死,又防止了饿死。

我们终于有了个能正确养鱼的程序了,一切似乎大功告成。但真的吗?

我们终于进化到了猴子阶段,能够有一定的组织,不会饿死,也不会胀死。但这就足够了吗?

第 3 种同步机制虽然正确,但存在很多问题。

首先是程序不对称。左怡执行的程序和尤尔执行的程序并不一样,。那不对称有什么问题吗?当然有。
做科学研究的人说完全对称的人最好看。自然,完全对称的程序也最好看。上述程序因为不对称而不美观。
这样就没有达到 Donald Knuth 所谓的“程序就像蓝色的诗歌”的境界。其次,不对称造成编写困难。
为了追求程序的正确性,即使是做同样操作的线程也得编写得不同,这自然就增加编程的难度。
最后,不对称还造成程序证明的困难。要想从理论上证明图中程序的正确性是一件十分复杂的事情。这一点研究程序证明的人是很清楚的。

上述程序的另一个大问题是浪费。上述程序种左怡执行的循环等待是一种很大的浪费。但浪费还不是循环等待的唯一问题,它还可能造成 CPU 调度的优先级倒挂。
优先级倒挂就是高优先级的线程等待低优先级的线程。
例如,如果尤尔先于左怡启动,留下字条后正准备检查是否有左怡的字条时,左怡启动。
由于左怡的优先级高于尤尔,因此左怡获得 CPU,留下字条,进入循环等待。
由于左怡的优先级高,因此尤尔将无法获得 CPU 而完成剩下的工作,进而造成左怡始终处于循环等待阶段无法推进。
这样高优先级的左怡就被低优先级的尤尔所阻塞。
由于优先级倒挂完全违反了设立优先级的初衷,就像总统得听平民指挥似的,因此令人无法容忍(至少让总统无法容忍)。

那我们有没有更好的办法来解决喂养金鱼的问题呢?有,就是继续对同步方案进行改进。
那么在哪一个方案的基础上改进呢?我们自然会想到最后一个方案,因为它已经满足了既不饿死也不胀死的条件,无非就是不好看和循环等待。
关键是这两点可以改进吗?答案是否定的。循环等待不能去掉,一去掉变成第 2 个方案;
如果想使其对称、美观,就需要将尤尔改为和左怡同样,而这样同样会造成鱼饿死的困难。
因此对最后一个方案进行修改似乎不是明智之举。
看来,我们这种零敲碎打的推进模式已经走到了尽头,需要新的思路了。

新的思路就是直接对最开始的两个方案进行修改。
由于最开始的两个方案均达不到既不饿死又不胀死的条件,因此我们自然选择一个较为美观、简单的方案来修改。
在两个方案之间,第一个方案完全对称,而第二个方案不完全对称,因为每个人的字条不同。
因此,我们选择第 1 个方案作为修改的基础。但如何修改呢?

要想直到如何修改,就得知道第一个方案为什么不满足条件。

那么第 1 个方案为什么不满足条件呢?
我们说过,是因为检查字条和留字条是两个步骤,中间留有被别的线程穿插的空当,从而造成字条作用的丧失。
我们就想,能否将这两个步骤并为一个步骤,或者变成一个原子操作,使其中间不留空当,不就解决问题了吗?

换句话说,我们之所以到现在还没把金鱼问题处理掉,是因为我们一直在非常低的层次上打转。
因为我们试图工作的层面是鱼和鱼缸这个层面,即留字条是为了防止两个人同时查看鱼和鱼缸。
我们仅仅在指令层上进行努力。由于控制的单元是一条条指令,因此对指令之间的空当无能为力。
而解决这种问题的办法就是提高抽象的层次,将控制的层面上升到对一组指令的控制。

例如,在金鱼问题里,如果我们将抽象层次从保护鱼和鱼缸的层次提高到保护放置鱼缸的房间的层次,这个问题就可以解决。
即设计一种同步措施,使得在任何时候只能有一个人进入放置鱼缸的房间。
这样,检查字条和留字条的两步操作就变成将房间锁上的一步操作。

那么如何保证这个房间一次只进入一个人呢?我们先看看生活当中我们是如何确保一个房间只能进入一个人的。
例如,两个教师都想使用同一个教室来为学生补课,怎么协调呢?
进到教室后将门锁上,另外一个教师就无法进来使用教室了。即教室是用锁来保证互斥的。
那么在操作系统里,这种可以保证互斥的同步机制称为锁。

有了锁,金鱼问题就可以解决了。当一个人进来想喂鱼时,就把放有鱼缸的房间锁住。这样另外一个人进不来,自然无法喂鱼。

锁

从上面程序我们可以看到,基于锁的互斥性,左怡和尤尔只能有一个人进入房间来喂鱼,因此鱼不会胀死。
并且,如果两人都同时执行上述程序时,由于先拿到锁的人会进入房间来喂鱼,因此鱼也不会饿死。
更为重要的是,两个人执行完全同样的代码。既对称,也容易写,证明起来也不困难。这样,金鱼问题从而得到解决。

以人类进化来比喻,上述程序相当于人阶段了。

锁的基本操作

锁有两个基本操作:闭锁和开锁。闭锁就是将锁锁上,其他人进不来;开锁就是你做的事情做完了,将锁打开,别的人可以进去了。

闭锁操作有两个步骤,分别如下:

  • 等待锁达到打开状态
  • 获得锁并锁上

开锁操作很简单,就是一步:打开锁

显然,闭锁的两个操作应该是原子操作,即不能分开。不然,就会留下穿插空当,从而造成锁的功效的丧失。

我们给出的第一种解决方案里的字条,从某种意义上来说,就是一把锁,只是这把锁没有将资源锁住。
那为什么没有锁住呢?这是因为这把锁不具备一把正常锁所应该具备的特性:

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

而我们的字条解决方案违反了第 4 个条件,即在别人持有锁(留下字条)的情况下,也照样进入临界区(因为检查是否有别人持有锁在别人留锁之前进行)。
因此,这个字条无法起到锁的作用,即是一把破锁。

在使用了正常的锁之后,整个程序就可以正确运行了。

那么这个程序有什么问题没有?如果左怡正在喂鱼的话,尤尔能干什么事情吗?只能等待(等待锁变为打开状态)。
如果左怡喂鱼的动作很慢,尤尔等待的时间就会很长。而这种繁忙等待不仅将造成浪费,而且将降低系统效率。
那有没有办法消除锁的繁忙等待呢?答案是否定的,因为锁的特性就是在别人持有锁的情况下需要等待。
不过还是可以减少繁忙等待的时间长度。怎么缩短等待的时间呢?

仔细分析发现,左怡喂鱼并不需要在持有锁的状态下进行。
我们就希望喂鱼的这段时间不要放在锁里面,而是获得锁后留下字条说他喂鱼去了,然后释放锁,再喂鱼。
而尤尔在拿到锁后先检查有没有字条,有字条就释放锁,干别的去。没有就留字条,然后释放锁,再喂鱼。
这样,由于持锁的时间只限于设置字条的时间,因此,对方循环等待的时间会很短,而真正的操作(在这里是喂鱼)则随便多慢也没有问题了。

减少锁的繁忙等待时间

现在在锁上的繁忙等待时间已经很少了。但不管怎样,终究还是需要等待的。
那有没有办法不用进行任何繁忙等待呢?有,答案就是睡觉与叫醒,即 sleep 和 wake up

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

什么是睡觉与叫醒呢?就是如果对方持有锁,你就不需要等待锁变为打开状态,而是去睡觉,锁打开后对方再来把你叫醒。
我们下面用生产者与消费者的问题来演示这个机制。

有人说这个世界上只存在两种人:生产者和消费者。要么你生产某种东西,要么你消费某种别人生产的东西。
当然,你也可能既是生产者,又是消费者。即在一个产品上你是生产者,但在另一个产品上你却是消费者。
但对于某个特定的产品,一个人只可能是消费者或者生产者,而不能同时身兼两职。
当然,对于某个具体的产品,你可能既不是消费者,也不是生产者。
但对于任何特定的产品,它一定存在一个消费者和一个生产者(我们这里不探讨废品或没有人要的商品)。

那么生产者生产的产品由消费者来消费,但消费者一般不直接从生产者手里获取产品,而是通过一个中介机构,比如商店。
生产者把东西放在这里,消费者到这里来拿。为什么需要这个中介机构呢?这是因为商店的存在使得生产者和消费者能够相对独立地运行,而不必亦步亦趋地跟在另一方后面。
因为只要商店货架没满,生产者就可以一直生产。他不用等消费者订一件他才做一件。
如果没有商店,则生产者无法独立操作,必须拿到消费者订单后才能生产。
消费者在每次订货后需要等待生产者制造出产品后,才能进行新的订货。这种你走一步我才走一步的生产-消费模式效率十分低下。

用计算机来模拟生产者和消费者是件很简单的事:一个进程代表生产者,一个进程代表消费者,一片内存缓冲区代表商店。
生产者生产的物品从一端放入缓冲区,消费者从另外一端获取物品。

一个非常好的例子是校园中的售货机。售货机是缓冲区,负责装载售货机的送货员是生产者,而购买可乐、糖果的学生是消费者。
只要售货机不满也不空,送货员和学生就可以继续他们的送货和消费。问题是,如果学生来买可乐,却发现售货机空了,怎么办?
学生当然有两个选择:一是坐在售货机前面等待,直到送货员来装货为止;二是回宿舍睡觉,等售货员装货后再来买。
第一种方式显然效率很低,估计没有什么人愿意这么做。比较起来,第二种方式要好些。
只不过睡觉中的学生不可能知道售货员来了,因此我们需要送货员来了后将学生叫醒。

同样,如果送货员来送货发现售货机满时也有两种应对办法:一是等有人来买走一些东西,然后将售货机填满;
二是回家睡觉,等有人买了后再来补货。当然,这个时候购买者需要将送货员叫醒。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define N 100   // of slots in the buffer
int count = 0;  // of items in the buffer

void producer() {
    int item;
    while (true) {
        item = produce_item();
        if (count == N) { sleep(); }
        insert_item(item);
        count = count + 1;
        if (count == 1) { wakeup(consumer); }
    }
}

void consumer() {
    int item;
    while (true) {
        if (count == 0) { sleep(); }
        item = remove_item();
        count = count - 1;
        if (count == N - 1) { wakeup(producer); }
        consume_item(item);
    }
}

程序中的 sleep 和 wakeup 就是操作系统里的睡觉和叫醒操作原语。一个程序调用 sleep 后将进入休眠状态,将释放其所占用的 CPU。
一个执行 wakeup 的程序将发送一个信号给指定的接收进程,如 wakeup(producer) 就发送一个信号给生产者。

我们仔细来看上面的程序。最上面两行定义了缓冲区的大小(可容纳 100 件商品)和当前缓冲区里的商品个数,初始化为 0.
生产者程序运行如下:每生产一件商品,检查当前缓冲区的商品数,如果缓冲区已满,则该程序进入睡眠状态;否则将商品放入缓冲区,将计数加 1.
然后判断计数是否等于 1,如果是,说明在放这件商品前缓冲区中的商品个数为 0,有可能存在消费者见到空缓冲区而睡觉,因此需要发送叫醒信号给消费者。

消费者程序运行如下:先检查当前商品计数,如果是 0,没有商品,当然去睡觉。
否则,从缓冲区拿走一件商品,将计数减 1.然后判断计数是否等于 N - 1,如果是,则说明在拿这件商品前缓冲区中的商品计数为 N,有可能存在生产者见到满缓冲区而去睡觉,因此需要发送叫醒信号给生产者。然后尽情地享用商品。

这个程序看上去似乎正确无误。但真是这样吗?

可能有的读者会注意到一个问题:
如果生产者在判断 count == 1 时发送叫醒信号给消费者,但也有可能没有消费者在睡觉(售货机为空的时候,恰恰没有人来买东西,自然就不会有学生睡觉了),这个信号不是无旳放矢吗?
同理,消费者也存在无的放矢发送叫醒生产者信号的问题。那这是不是问题呢?

发过电报的人都知道,如果对方因故(如查无此人、此人暂时不在)无法接收电报,并不会造成什么危害,充其量就是大家浪费一点时间来收发这封电报而已。
这里也一样,如果发送的信号没有进程接收,则这个信号权当浪费了,并不会造成任何损害。
因此,这个可能无的放矢的信号不是什么问题。

那还有没有其他问题呢?

我们看出来了,这个 count 有问题。因此变量 count 没有被保护,所以可能发生数据竞争。即生产者和消费者可能同时对该数据进行修改。
例如,假定 count 现在等于 1,那么生产者先运行,对 count 加 1 操作后 count 变为 2,但在判断 count 是否等于 1 之前,CPU 被消费者获得,随后对 count 进行了减 1 的操作后切换回生产者,这个时候 count 等于 1,因此生产者将发出叫醒消费者的信号。显然,这个信号是不应该发出的。

对 count 没有保护并不是上述程序唯一问题。关键问题是上述程序可能造成生产者和消费者无法往前推进的情况,即死锁

例如,假定消费者先来,这个时候 count=0,于是去睡觉,但是在判断 count 等于 0 后并且在执行 sleep 语句前 CPU 发生切换,生产者开始运行,
它生产一件商品后,给 count 加 1,发现 count 结果为 1,因此发出叫醒消费者信号。
但这个时候消费者还没有睡觉(正准备要睡),所以该信号没有任何效果,浪费了。而生产者一直运行知道缓冲区满了后也去睡觉。
这个时候 CPU 切换到消费者,而消费者执行的第一个操作就是 sleep,即睡觉。
至此,生产者和消费者都进入睡觉状态,从而无法相互叫醒而继续往前推进。系统死锁发生。

那我们如何解决上述两个问题呢?对第一个问题,解决方案很简单:用我们刚刚讲完的锁!
在进行对 count 的操作前后分别加上开锁和闭锁即可防止生产者和消费者同时访问 count 情况的出现。
但是我们不就是因为锁存在繁忙等待才发明 sleep 和 wakeup 原语吗?怎么又把锁请回来了呢?

确实,我们不喜欢锁所采用的繁忙等待,因而发明了 sleep 和 wakeup 原语。这样在需要等待时,我们去睡觉。
但是,我们不喜欢等待,并不是一刻都不能等待。只要等待的时间很短,就是可以接受的。就是让一个人等 1 分钟,通常都不会觉得漫长。
而在 count 的访问前后加上锁所造成的繁忙等待是很短的。不就是将商品放入或拿出缓冲区吗?这要多长时间呢?

好,上述解释勉强算把第一个问题解决了。但第二个问题怎么解决呢?

显然,生产者和消费者都不会自己从睡觉中醒过来。所以如果二者同时去睡觉了,自然也无法叫醒对方。那解决的方案就是不让二者同时睡觉。
而造成二者同时睡觉的原因是生产者发出的叫醒信号丢失(因为消费者此时还没睡觉)。
那我们就想,如果用某种方法将发出的信号累积起来,而不是丢掉,问题不就解决了吗?
在消费者获得 CPU 并执行 sleep 语句后,生产者在这之前发送的叫醒信号还保留,因此消费者将马上获得这个信号而醒过来。
而能够将信号累积起来的操作系统原语就是信号量

信号量

信号量(semphore)可以说是所有原语里面功能最强大的。它不仅是一个同步原语,还是一个通信原语。而且,它还能作为锁来使用!

简单来说,信号量就是一个计数器,其取值为当前累积的信号数量。它支持两个操作:加法操作 up 和减法操作 down,分别描述如下:

down 减法操作:

(1) 判断信号量的取值是否大于等于 1
(2) 如果是,将信号量的值减去 1,继续往下执行。
(3) 否则在该信号量上等待(线程被挂起)

up 加法操作:

(1) 将信号量的值增加 1(此操作将叫醒一个在该信号量上面等待的线程)。
(2) 线程继续往下执行。

这里需要注意的是,down 和 up 两个操作虽然包含多个步骤,但这些步骤是一组原子操作,它们之间是不能分开的。

down 和 up 操作在历史上被称为 P 和 V 操作。P 和 V 指的是荷兰语里 prob-eren 和 verhogen 两个单词,分别表示减少和增加的意思。
由于美国人在操作系统领域取得了绝对控制权,自然不能用什么荷兰语来表示操作系统里面最重要的同步原语的两个基本操作,因此,将名字改为 up 和 down。

消息传递

消息传递是通过同步双方经过互相收发信息来实现。它有两个基本操作,发送 send 和接收 receive

生产者和消费者通过消息的传送进行同步,既不会死锁,也不会繁忙等待。而且,无须使用临界区等机制。
更为重要的是,它可以跨计算机进行同步,即可以对处于不同计算机上的线程实现同步。
由于这些优点,消息传递是当前使用非常普遍的线程同步机制。