进程同步
进程同步存在问题,原因就是一个CPU要为两个以上的进程服务,而这其实是现在的操作系统也没有完美解决的
临界区问题
如果不加处理的话,就会出现问题:假设两个进程要访问同一个资源,由于CPU调度具有一定的随机性,而先访问的进程会对资源进行修改,这就使得进程对资源的访问结果具有一定的随机性,这显然是不可接受的
这里以生产者-消费者问题为例:
如果这两个操作在不被打断的情况下执行,那么就不会有同步问题
也就是说,涉及到共享资源的写时,操作需要是独立不受干扰地执行的
但是要实现原子操作不是直观看上去那么简单的,因为它在实际执行时可能根本不是一条语句,而是多条(例如像例子中的这种情况,不是所有的CPU都有直接对内存变量进行修改的指令,许多CPU指令集,特别是精简指令集,就像下面展示的那样,还需要借助寄存器)
注意,单条机器指令在执行的过程中是不会被中断的,这是因为此时是关中断了的
除了指令集本身的限制外,还可能有编译程序的限制。由于编译器要在不同平台上提供服务,虽然每个平台都是单独设计的,但是不一定能针对该平台充分优化。例如上图这种情况,如果编译器优化不到位,在所有平台遇到++都翻译为上述三条指令,那当然在各个平台都可以运行,虽然不是最优的。这也使得同步问题更加地突出
这虽然不是通常的情况,但是是的确可能发生的
上面的问题出在修改的值没有及时从寄存器写入内存
共享数据的最终稳定值取决于最后完成操作的那个进程
注意临界区这个词是形容进程代码的,并且不包括所有进程只读不写的情况
临界区是和进程相关
什么是临界区问题呢?
显然,实现这一点是不容易的
对于这个问题,我们可以提出一些条件来检验解决方案是否有效
什么是remainder section呢?
entry section和exit section是为了解决临界区问题而加入的、原本代码中没有的代码块,而临界区问题的解决方案也就是确定这两个区域的代码内容的问题
注意,当我们讨论一个资源的时候,另外的资源代码都算成remainder section。也就是说,选举必须是直接相关的进程才能做的
不在意这些进程是否都在持续运行,也不考虑运行速度:注意,进程同步和进程调度是在同步执行的,也就是说各干各的,至今为止也没办法做到这两个模块之间的协调,否则性能损失非常大
我们先讨论两个进程的情况
双进程算法1
这个算法就是为了解决临界区问题的一个算法
turn不是原来的程序中的变量,是为了解决问题另外定义的
注意do中的while语句后面有个分号,表示的就是空操作
那像这种本来CPU已经分配给了进程时间片,结果它却在while死循环是不是一种资源的浪费呢?是的,但是在并发下,保证共享资源的同步正确性显然更重要
做完操作之后执行turn=j,表示主动还掉turn
为什么它不符合progress呢?
例如,如果进程0正在remainder section中IO中断,并且将永久中断(例如让用户输入而用户不输入),此时进程0不更新turn,如果此时turn已经被进程1之前设置为了指自己,进程1就永远没有机会执行,反复循环,turn也不会变。这实际上就是,进程0在remainder section,却依然影响了选举
为什么满足mutual exclusion是很容易证明的,这里就不说了
双进程算法2
while(flag[j]);表示,如果对方进程想要访问,就优先对方访问,等对方访问完了、flag撤销了,自己再访问
同样不满足progress
这种算法很明显,有死锁的问题,就是两个进程都设置了flag,结果都卡在while死循环,这就违背了在有限时间内完成选举的要求
双进程算法3:Peterson算法
也就是说,只有j既被允许,同时又申请的情况下,i才会做空操作等待
这个算法是符合这三个原则的,有兴趣的可以自己证明下
两个进程的算法就讨论到这里了
下面开始讨论N个进程的算法:
N进程面包房算法
这个n是有限的,也就是说需要提前确定的,这是因为这个算法中使用到了数组,数组需要确定的大小
其实思路就是排号,为啥叫面包房算法,可能就是因为作者那里的面包房排号吧
number的分配通过max()+1以及number一致时候的判断逻辑来实现在并发下的正确性
因为设置number的过程不是一个原子操作,所以需要使用choosing和第一个while,相当于加一个锁。在第一个while循环的逻辑下,所有进程的choosing过程都不会被打断
第二个while循环就是实现了分配顺序。
注意在进程同步中,我们关心的是资源的正确性,而不是谁先操作谁后操作,也就是进程调度在这里暂时不是重点
-
互斥是否满足?
满足,因为根据number和进程号一定是可比较的
-
bounded waiting是否满足?
选举的有限次数也可以证明:每次while等待一个进程,这个进程只能加入临界区一次。这是因为它下一次进入的时候需要重新获取number.而获取的number一定是比其他所有都大的,这样现在正在等待的进程就不需要给它让步了
-
process是否满足?
这个从判断的条件中就可看出。如果在remainder section的话,它的Number就是0,这在前面的分配时就会被直接略去。由于bounded waaiting符合,所以while语句的执行是有限时间的,所以等待时间是有限时间
第一个问题很好理解,就是并行的结果,因为number的赋值操作不是原子的
第二个问题:
choosing数组的存在是为了保证while循环中比较时候所用到的number都是稳定的,防止出现一个进程计算number的过程还没有完成时间片就被抢走时,其他进程和该进程比较时用的还是老的number(也就是0),从而认为该进程没有在临界区,最后导致多于一个进程同时访问临界区的结果
硬件指令解决方案
除此之外,增加硬件指令也会导致CPU结构变得更加复杂,是一个需要很慎重的事情
特别是在多CPU中,关中断难道要关闭所有CPU的中断信号吗?所有这种方法是不行的,还需要找其他方法
追求的是原子的效果
- 测试并赋值
不是说硬件指令吗,为什么是一个函数?其实在计算机组成中我们学习了微指令,CPU的一些复杂的硬件指令也都是通过组合微指令而成的,写成函数的形式其实很自然,这里只不过是用C语言的格式描述了而已
由于这是一个硬件指令,所以在它执行完之前,是不可能有中断发生的,这一点就避免了许多许多问题
有了硬件指令后,就可以很简单地加一个锁就完事了
同样,这种方式没有对进程的数量进行限制,是无限的
但是这个方法不是没有问题的,它不满足bounded waiting:例如,一个进程设置了锁为true,但是时间片到期,CPU调度给了其他进程,但是由于已经上锁,其他进程无法访问共享资源,只有等待原进程被重新调度。但是如果原进程被调度后执行完了一次后又循环,又加上了锁,这就可能导致其他进程永远不能被执行,违背了bounded waiting的有限次数要求
这个指令的效果和用TestAndSet没有区别,所以它也不符合bounded waiting
另外,上面的办法都有一个问题没有解决,就是对应用程序编写者要求太高,上面的代码都是要加在应用程序开发代码中的,这对于应用程序开发者是一个很大的负担。而且,一个程序中可能有多个共享资源,也就有了多个临界区,要求开发者对每一个地方都插入上述的代码并进行调试显然是强人所难
这就是下面的解决方案要解决的问题
信号量
信号量试图将用户要做的额外步骤都放在一起,并且保证其是原子操作
注意这里说的是等待队列
如果进程的value小于0,就将自己挂到信号量里面的等待队列中并重新进行调度
如何应用呢?
这种方法对用户的要求是比较低的,如果有同步问题的话,只要定义一个信号量然后在上下分别加上wait和signal就好了
对于第一个进程,它把value从1减到0,正常执行;第二个、第三个等等之后的进程因为value值运算后都小于0,所以被wait挂起到等待队列。value值如果小于0的话,它的绝对值就是等待的进程个数。等到第一个进程执行完后,value+1,此时value依然是小于0的,就从等待队列拿一个进程唤醒,它访问完共享资源之后继续调用signal,使得value加一,直到value大于0,也就是恢复到1
bounded waiting条件能满足的条件是signal里从等待队列中选进程时使用先来先服务或者轮转法,不能按优先级排列,否则如果一直有高优先级的进程进来的话,低优先级的进程也会出现永远得不到服务的情况
那么,又回到了原本的问题:如何保证wait和signal操作是原子的?最简单的,可以使用关中断的方式,但是这种方法还是一样,代价太高。现在Linux实现了不需要关中断就能保证原子性的算法。不仅如此,它还给应用程序开发人员提供了信号量组的操作,可以一次性对多个信号量进行操作
信号量还可以解决其他的进程同步问题:
挺简单的,不解释了
经典同步问题
将信号量作为基本方法
这里给出一个使用信号量实现的解
利用信号量,使用wait和signal保护counter
但是这不是最好的解,因为还留下了两个while语句,本质上还是使用了忙等待,在效率上会有损失,我们可以想办法消除这些忙等待:
full可以表示当前缓冲区被占用的格子的数量,empty表示当前缓冲器空闲格子数量
每次生产empty减一,如果减到0了就阻塞了
每次消费full减一,等减到0就阻塞
信号量的使用次序也是很讲究的,如果使用不当就可能会造成死锁:
consumer通过wait(mutex),但发现full是0,此时会被挂起,但是还没有执行signal(mutex),所以producer仍无法执行,程序陷入死锁
饥饿是理论上不是死锁,但是实际上由于其他情况却发生了的进程无法被调度的情况
读进程之间不需要互斥,但是写进程和读写都互斥
问题是,读写进程同时排队时,先调度读还是先调度写
第一类是读者优先,第二类是写者优先
有算法可以避免饥饿的情况发生,这里就不细说了
这里只以第一类问题为例来使用信号量求解
mutex在读者之间协调
readcount是为读的进程计数
wrt解决所有的读者和不同的写者之间的互斥
mutex是为了给readcount加锁,使得readcount保持正确
if(readcount1) wait(wrt)是检测现在是否有写者,如果有的话就阻塞自己,如果没有的话,wait(wrt)也能避免之后读者在读数据的时候有写者想进入。而正因为有了这样的操作,所以只要读者不是第一个,也就是readcount1的话,就不需要管写者了,所以才会有这个if语句
之后的if(readcount==0)signal(wrt)是解开写者的锁,这也只有最后一个读者才会去解
如果能写代码更方便的话:
理想目标就是只使用一个信号量,然后提供四个系统调用,程序员只把调用写好,剩下的都是由操作系统去做
哲学家只有两种状态:思考和吃饭
只有五根筷子
避免死锁和饥饿
--若干进程之间分配若干资源
死锁是显然的:因为每个进程需要两个资源才能正常运行,很容易死锁
可能的解决方法:
- 留一个空闲资源,不要一次性所有人上座:加一个信号量,初始化为4
- 一次性获得资源,不要先后拿取两根筷子,要么拿两根要不不拿
- 不对称解:五个哲学家中一部分先拿左手边,一部分先拿右手。这种可以解决死锁(因为只要这些人中有不一致的,就一定有至少一个人能从左右手边拿到一对筷子),但是不能解决饥饿
这些经典的同步问题可以用来检验新的同步算法