说明:该系类文章更多的是从从哲学视角看 操作系统 这门学科。同时也是 操作系统的学习笔记总结。因为博主 这些年主要是以研究安卓系统和 嵌入式Linux为主,因此这个系类文章也是这两个领域不可或缺的基石之一,尤其是对操作系统感兴趣的伙伴可特别关注。
8 线程同步
8.1 为什么要同步
- 线程的同步类似于人与人之间的协调,因为有些工作需要合作才能顺利完成。
- 线程的关系是合作关系,既然是合作,就需要某种约定的规则,否则合作就会出现问题。
- 线程之间不同步的话,就会引入了一个很大的问题,即多线程程序的执行结果可能是不确定的,而“不确定”是我们所反感的东西。要想保持线程的同时,消除线程执行结果的不确定性,那么只有线程同步这种方式了。
8.2 线程同步的目的
- 线程同步的目的就是不管线程之间怎样穿插执行,其运行结果都是确定的,即保证多线程执行下结果的确定性;而同时要保证对线程执行的限制越少越好。
- 同步:让所有的线程按照一定的规则执行,使其正确性和效率都有迹可寻。线程同步的手段就是对线程间的穿插进行控制。
8.3 锁的进化:金鱼生存
金鱼生存问题是一个演示线程同步手段的好例子。金鱼的特点:没有饱的感觉,喂多少就吃多少。
假设佐伊和尤尔共同养了一条金鱼,为把金鱼养好,即不让鱼胀死,也不饿死,做出如下约定:
- 每天喂鱼一次,且只有一次。
- 如果佐伊喂了鱼,则尤尔今天就不能喂鱼,反之亦然。
- 如果佐伊没有喂鱼,则尤尔今天必须喂鱼,反之亦然。
在没有同步的情况下,佐伊和尤尔的执行顺序如下;
但是由于线程可以任意穿插,则执行结果可能如图所示:
很明显,这样的话鱼会胀死的。这里就涉及 新概念:竞争和临界区。
- 竞争:多个线程争相执行同一段代码/同一资源的现象(数据竞争:两个线程同时访问一个数据;代码竞争:两个线程同时访问一段代码)。
- 临界区:可能造成竞争的共享代码段/资源。
8.3.1 变形虫阶段
要防止鱼胀死,就要防止竞争;即防止两个/多个线程同时进入临界区。因此要协调。协调的目的就是任何时候只有一个人在临界区内,这称为互斥;即一次只有一个人使用共享资源。
正确互斥需要4个条件(只要有一个条件不满足,互斥的设计就是不正确的):
- 不能有2个进程同时在临界区里面。
- 进程要能够在任何数量和速度的CPU上正确执行。
- 在互斥区外不能阻止另一个进程的运行。
- 进程不能无限制的等待进入临界区。
通过交谈,佐伊和尤尔商定在喂鱼之前留字条,这是第一种同步机制,如图所示:
此方案有所改善,即降低了鱼胀死的概率,但没有完全解决问题(佐伊和尤尔交叉执行上述程序,还会造成鱼胀死的结局)。如下图所示:
8.3.2 鱼阶段
查看上一阶段解决不了问题的原因:没有先检查有没有字条后留字条,因此造成了空当。即检查字条和留字条之间有空隙。解决方法:先留字条后再检查又没有字条。改进的留字条的方法如图所示:
这样两个人就不会同时进入临界区了。因此鱼不会因为两个人都喂而胀死。但是如果程序穿插执行,效果如图所示:
那么鱼有被饿死的可能,这是一种进步,但是并没有完全解决问题。
8.3.3 猴阶段
查看上一阶段解决不了问题的原因:除了互斥之外,还要确保有一个人进入临界区来喂鱼。解决方法:让某个人等着,知道确认有人喂了鱼才离开,不要一见到字条就离开。改进的循环等待模式如下:
这个方案鱼既不会饿死、也不会胀死,但是程序本身不对称。
8.3.4 锁
查看上一阶段解决不了问题的原因:程序不对称(程序的编写会很困难,同时增加了证明的难度);时间、资源的浪费(循环等待,这可能会造成CPU调度的优先级倒挂)。解决方法的分析:循环等待不能去掉(如果这样那么就回到第二种同步机制上了);两者都对称、美观(使鱼饿死成为可能)。
解决方法:这个解决问题的思考方向有问题,我们需要换一种思路来思考这个问题。对之前的每个方案进行修改:将检查字条和留字条合并成一个原子操作,即提高抽象的层次,将控制层面上升到对一组指令的控制。于是锁的概念出现了(锁的原始模型:只能有一个人在教室里,只要进去就上锁,出来就闭锁)。加锁后的程序如图所示:
这样,问题就可以解决了。锁的基本操作:闭锁和开锁
闭锁操作的步骤(2个步骤是一个原子操作):
- 等待锁达到打开状态
- 获得锁并且锁上
开锁操作的步骤:
- 打开锁
锁的特性规则:
- 锁的初始化状态是打开。
- 进入临界区前必须获得锁。
- 出临界区时必须释放锁。
- 如果别人持有锁则等待。
正确使用锁以后程序就可以正常运行,同时变得容易了。问题是解决了,但是能不能更好地解决呢,即缩短别人持有锁时自己等待的时间。仔细分析发现,喂鱼并不需要在持有锁的时候进行。只要在检查字条和留字条的地方加锁就可以。执行过程如图所示:
等待时间因此而大幅度缩短了,但是等待终究是需要时间的,下面需要考虑的就是有没有不需要等待的方法。
8.4 睡觉与叫醒:生产者与消费者问题
睡觉与叫醒:如果锁被对方持有,则不需要等待锁变为打开状态,而是睡觉去;锁打开以后再把你叫醒。消费者和生产者的问题是一个演示这种机制的一个较好的例子。
模型静态说明:
- 生产者:生产东西;
- 消费者:消费别人的东西;
- 商店:一个中间机构,生产者生产东西给商店;消费者从商店拿东西。
模型动态说明:
- 生产者如果发现商店货架已满,则回去睡觉,等有人买了后再送货,当然,这需要消费者来叫醒。
- 消费者如果发现商店货架已空,则回去睡觉,等货架有货后再来买,当然,这需要生产者来叫醒。
- 商店的存在能够让消费者和生产者独立运行(否则就要采取一步一趋的方式)。
用计算机模拟生产者和消费者:一个进程代表生产者;一个进程代表消费者;一片内存缓冲区就代表我们的商店。生产者生产物品从一端放入缓冲区;消费者从另一端获取物品,如图所示:
sleep和wakeup是操作系统里睡觉和叫醒操作的原语。
- 一个程序调用sleep后将进入休眠状态,其所占CPU将被释放。
- 一个执行wakeup的程序将发送一个信号给指定的接收进程。
消费者/生产者的同步程序如图所示:
程序的逻辑没有问题。但是这个count有问题,因为变量没有被保护,可能存在数据竞争的问题,即生产者和消费者同时对该数据进行修改。这个问题可以通过锁的方案来解决,因为时间很短,可以接受。问题的关键是有可能造成死锁,即消费者和生产者进程均无法推进(存在信号丢失问题:即消费者正准备睡觉,但是生产者已经发出信号,则此信号无效,因为消费者没有处于睡觉的状态)。解决的方法就是不能让两者同时睡觉。而这本质的原因就是信号丢失,只要用某种方式方信号累积起来而不是丢掉,那么问题就解决了。于是新的机制出现了:能够将信号累积起来的操作在操作系统里叫做信号量。
8.5 信号量
semaphore(信号量)不只是同步的原语,还是通信原语。同时还可以作为锁来使用。
@1 同步原语:信号量实际上就是一个计数器,取值为当前累积的信号数量,支持两个操作,up和down(也称为p、v操作)
down操作:
- 判断信号量的取值是否>=1。
- 如果是,则将信号值-1,继续往下执行。
- 否则在该信号上等待。
up操作:
- 将信号量的值加+1。
- 线程继续向下执行。
注意:虽然down和up是多个步骤,但是是一组原子操作。
@2 锁原语:如果将信号量的取值限制为0和1两种情况,则我们获得的就是一把锁,也即二元信号量,操作如下:
二元信号量down操作:
- 等待信号量取值变为1;
- 将信号量的值设为0;
- 继续执行。
二元信号量up操作:
- 将信号量的值设为1;
- 叫醒该信号上面等待的第1个线程;
- 线程继续执行;
由于二元信号量的取值只有0和1,因此可以防止任何两个程序同时进入临界区。具备锁的功能,与锁很相似(down是获得锁、up是释放锁),却比锁灵活(在信号量上的线程不是等待,而是睡觉等待另一个线程执行up操作将其叫醒);因此,二元信号量是从某种意义上说就是锁与睡觉、叫醒两种原语操作的合成。有了信号量,解决生产者和消费者的问题就可以这样:
- 首先,对于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 生产者与消费者的管程内部部分如下:
生产者和消费者对缓冲区的访问都是在管程里面;因此,对线程的访问,对count计数器的修改都是互斥的。
@@2.2 生产者与消费者的管程外部部分如下:
生产者生产出商品,并调用insert函数将商品放入缓冲区中;消费者消费商品,调用remove函数将商品从缓冲区中取走。
@3 整个管程中没有加锁(编译器自动检测并加锁)。其中
wait以原子操作实现3个步骤:
- 释放锁;将本线程挂在条件变量x的等待队列上;
- 睡觉;
- 等待被叫醒;
signal实现的操作: 将等在条件变量上的第一个线程叫醒。(在叫醒方面还提供了一种机制,广播broadcast,在调用wait、signal、broadcast时该线程必须持有与管程相连的锁)整个过程与之前的sleep、wakeup操作类似,但是不同的是:管程不会发生死锁(sleep与wakeup方案中将要睡觉和睡觉这2个操作中存在空档)
注意:如果一个线程释放等待信号线程的signal,则此时有两个线程活跃在管程内部(即signal不是线程最后的操作,那么后面的操作就和新的线程一起在管程里面了),这违反了管程的约定。为了防止这种问题发生,管程机制特别规定:signal语句是一个线程在管程里面的最后一个操作(因为这样即使理论上有两个线程活跃于管程内,但是实际上只有一个线程活跃,因为一个线程的下一步操作已经在管程之外,从而维持我们关于管程的约定)。
@4 解决管程问题的方法:
- HORSE管程:发送signal时同时释放锁,让被叫醒者获得锁(在signal之后运行的线程将是被叫醒的线程),叫醒者本身只能在被叫醒者运行完毕/其他原因释放锁后才能运行。
- MESA管程:不能在管程设计时就确定了,因为这样做十分不灵活,对操作系统而言下一步执行哪个操作应该由它决定(这样就可以利用操作系统的机制来竞争这把锁)。
8.8 消息传递
管程机制的问题:
- 对编译器的依赖,而实际上多道胡编译器也没有实现管程机制。
- 只能在单个计算机上面运行,严重限制了其使用。
于是想在多计算机环境下进行同步,那就需要其他的机制了。这种机制就是消息传递。消息传递是通过同步双方经过相互接收、发送消息来实现(send与receive操作,均为系统调用,既可以阻塞也可以非阻塞)。用消息传递机制实现生产者与消费者之间的同步问题:
对于该问题,需要send和receive均为阻塞操作,即执行receive操作需要收到消息后返回,否则将挂起。这种机制对于生产者与消费者之间的同步问题:既不会死锁,也不会繁忙等待,而且没有区域限制(可以跨计算机同步)。因此当前使用较为普遍。
消息传递的问题:
- 消息丢失:在一台计算机上基本不会,但是在网络上则很有可能,因为网络的不可靠性(可以通过网络协议如TCP/IP,可以将数据传输的可靠性提高,但不是100%)。
- 身份认证:怎样知道消息是从哪里发出来的(可以通过网络协议以及数字签名和加密认证等方式来解决)。
- 效率低下:往返发送系统消息存在系统消耗,同时数据传输也有延迟(尤其是在网络比较慢的时候)。
8.9 栅栏(barrier)
通信原语barrier:到达栅栏的线程必须停下来等待,直到障碍解除才能往前推进(主要用来对一组线程进行同步,有些时候,需要几个线程汇合在一起,协同完成任务)。
栅栏的参考模型如下: