在我们的计算机系统里有多个进程存在,进程之间会进行各种各样的交互,交互会涉及到对共享资源的访问,如果对这些交互处理不当,会产生各种各样的问题,比如饥饿、死锁等。
发生这些问题其实还是跟调度相关。独立的线程不会出现这些问题,合作线程会。
独立的线程有以下特征:
不和其他线程共享资源或状态 确定性:输入状态决定结果 可重现性:能够重现起始条件、I/0 调度顺序不重要合作的线程是线程之间协同操作,一会调用这个进程,另一回调另一个进程,有以下特征:
多个线程有共享资源 不确定性 不可重现不确定性和不可重现意味着BUG可能是间歇性发生的。
计算机和设备之间需要合作,涉及到进程/线程的交互,即使存在风险,但是有以下好处:
优点1:资源需要共享资源。日常生活中也是,很多人在一个银行里存钱取钱,比较方便效率也高。
优点2:实现更有效的资源利用。把大的任务拆成多个小的任务实现更有效的资源利用,比如I/O操作和计算可以重叠,多个处理器也可将程序分成多个部分并行执行。
优点3:设计的时候可以把大的工作分解成小的工作,是软件设计模块化,系统易于复用和扩展。
是否真的有很多因为并发执行带来的执行结果不确定的现象吗?看看下面示例
图中一个new_pid = next_pid ++ 指令其实在机器代码层面执行了四条汇编指令(把next_pid赋给寄存器Reg1;把寄存器Reg1的内容存到new_pid;增加Reg1;把寄存器Reg1的内容存到next_pid)。但如果两个进程都完成这个工作,即使简单的一个++指令也可能产生不同的执行结果。
如图,假如调度算法选择先进程A执行两条汇编代码后,转而进程B开始执行,然后继续执行A,会发生以下错误。原因是进程A寄存器里面的值是100,还没来得及增加到101赋给next_pid,进程B开始执行并且执行的操作是寄存器里面存储100,把增加后的101赋给next_pid,转回A执行时会仍把增加后的101赋给next_pid,next_pid不是我们想要的结果,而且两个新创建的进程拥有了同样的ID。
我们希望:
无论多少个线程的指令序列怎样交替执行,程序都必须正常工作 不确定性要求并行程序的正确性同步和互斥存在很重要的原因,就是要解决这种不确定问题,先看看一些概念。
竞态条件这个词就是说系统执行会有不确定性和不可重现性,结果依赖于并发执行或者事件的顺序/事件,这是系统缺陷。
避免竞态条件的方法其实就是让指令不被打断,比如刚才例子中进程A的++指令在执行四条汇编语句的时候不被打断。这就是原子操作,即不可被打断的操作。
原子操作是指一次不存在任何中断或者失败的执行,该执行要么成功结束,或者根本没有执行,并且不应该发现任何部分执行的状态。
实际计算机的操作往往不是原子的。有些操作看上去是原子操作,实际上不是,连x++这样的简单语句,实际上也是由3条指令构成的,有时候甚至连单条机器指令都不是原子的(Pipeline,super-scalar,out-of-order,page fault)。操作系统需要利用同步机制在并发执行的同时,保证一些操作是原子操作。
临界区是指进程的一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会被执行的代码区域。
当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共享资源。
两个或以上的进程,在相互等待完成特定任务,而最终没法将自身任务进行下去。
一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行。
现在讲一个例子,看看有什么办法可以解决竞态条件,得到确定的结果。例子比较常见,寝室里有一个冰箱,冰箱里放面包,如果没有就去买这样一个过程。
可以看到由于时间的差异性,导致不同的人在不同的时刻做了重复的事情,最后的结果是买了两份面包,而我们只希望买到一份就够,这其实可以看做两个进程的逻辑操作。
什么是“面包太多”问题的正确性质?
最多有一个人去买面包 当需要买面包的时候才去买面包怎么解决呢?生活中我们可以在冰箱上设置一个锁和钥匙(lock & key)
去买面包之前锁住冰箱并且拿走钥匙 修复了“太多”的问题,但是加锁产生的新问题是,冰箱里别的物品其他人拿不到。锁相关的概念:
Lock(锁):在门、抽屉等物体上加上保护性装置,使得外人无法访问物体内的东西,只能等待解锁后才能访问。 Unlock(解锁):打开保护性装置,使得可以访问之前被锁保护的物品类东西。 Deadlock(死锁):A拿到锁1,B拿到锁2,A想继续拿到锁2后再继续执行,B想继续拿到锁1后再继续执行,导致A和B谁也无法继续执行。听起来好像挺不错,但是转化成计算机程序还是没有解决问题,假如按照某种调度顺序执行进程A、B:
比如进程A检查面包和标签后在贴标签前,假如切换到进程B执行,就会购买太多面包,仍然有不确定性。
而且出现这种问题后我们很难去重现这种现象,因为下一次进程调度切换上下文的时机可能就不是这样,导致这样的BUG难以调试,我们必须要搞清楚调度器所做的事情,不然竞态条件随时会出现。
针对方案一问题一种快速修复的方法是先留便签,这样就不会有其他人在留便签前做同样的事情,但出现了新的问题,那就是按上图的执行顺序没有人会去买面包,这种现象也不是我们想要的。
有人提出,也许是便签没有标识,我们可以给每个进程分配一个特殊的便签,这样可以分辨是谁留下便签要去买面包
可以看到进程A留下的是note1,进程B留下的是note2,解决问题了吗?仍然没有。
可能导致没有进程去买面包,这是因为错误事件的上下文切换可能会导致每个进程都认为另一个进程会去买面包,这种锁定的状态叫做“饥饿(starvation)”。这是最难处理的,因为极其不可能发生的事情可能会在糟糕的时间安排里出现,而往往是难以找到原因的。
我们可以设计一种更复杂的解决办法,给两个进程设计不同的处理逻辑。
这种方法确实能解决竞态条件,但真的不够好。首先,我们现在只有两个进程,如果我们有更多进程,就很难去继续做扩展;其次,A和B的代码不同,我们做程序设计希望进程A和B有同等的概率去做购买面包的工作,而方案四的设计使得进程A有更大的概率去买面包,我们希望每个进程有同等概率执行后续工作;最后,当A在等待的时候,其实一遍等待一遍在做检查便签的操作,其实在消耗CPU的时间,这种情况叫做“忙等待(busy-waiting)”。
前面提到了临界区的概念,我们希望我们一个进程进入临界区访问共享资源的时候可以组织另一个进程也进入。怎么实现?
临界区是指进程的一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会被执行的代码区域
利用两个原子操作实现一个锁:
Lock.Acquire():在锁被释放前一直等待,然后获得锁;如果两个线程都在等待同一个锁,并且同时发现锁被释放了,那么只有一个能够获得锁。 Lock.Release():解锁并唤醒任何等待中的进程。这是现代计算机系统采用的比较好的处理竞态条件的方法。
首先,我们需要了解临界区的一些属性,基于这些属性,我们才能更好地设计进入临界区与离开临界区的实现。
互斥:任何时候只有一个进程或线程能够访问临界区。 Progress:如果一个线程想要进入临界区,那么它最终会成功,不会一直死等。 有限等待:如果一个线程i处于入口区,等待临界区里面的线程执行结束,那么i等待的时间是有限制的,在设置最久时间前一定会进入。 无忙等待(可选):尽量不要忙等,如果一个进程在等待进入临界区,那么在它可以进入之前会被挂起。以上属性其实也是访问临界区的规则,教科书里会归纳为空闲则入(临界区里没进程可直接进入)、忙则等待(临界区里有进程就在外面等着)、有限等待(等待的时间是有限制的)、让权等待(等待的时候应释放CPU)
了解这些属性后,我们下面会介绍三种方法保护临界区。三种方法各有特点,我们会比较它们在性能以及复杂性上各有什么特点。
可以思考一下,出现了竞态条件是因为一个进程执行的过程中调度算法切换到另一个进程执行,那我们在临界区内执行时禁止这种切换是不是就可以了,这种调度的切换其实是靠硬件中断实现的。
我们可以在进程进入临界区之前,禁止所有中断并保存标志,离开临界区之后,恢复硬件中断。没有中断就没有上下文切换,也就不会有不确定的执行结果。
但这个方法还是有一些缺点:
中断是用来响应外部事件的,比如网络包、时钟信号、文件读写等,如果中断被禁用,这些硬件事件无法得到响应,对效率的影响是很大的,可能导致其他进程处于饥饿状态。 临界区执行的时间长短是不确定的,如果时间很长,可能会导致整个系统都停下来。所以这种方法只对临界区很小的方法有效。 对于多处理器计算机,只屏蔽一个CPU中断机制是无法解决问题的。因为这些局限性,要小心使用禁用条件中断。
基于硬件中断的解决方案对系统性能影响大,且不适用于多CPU的计算机,第二种是很经典的基于软件的解决方案,除了操作系统,也用到了分布式系统中,这种方法依靠软件就能完成有效互斥。
下面例子是两个线程执行进入临界区再退出临界区,能否设计一个算法使得任何时刻只有一个线程在临界区内执行,能满足临界区的属性。
先来看看几种试图解决的方法。
这种while循环确实能保证互斥,即任何时刻都只有一个进程进入临界区执行,Progress(前进)不一定满足,因为Ti不在临界区,Tj进入临界区执行完毕后把turn赋成Ti,Tj若再想进入临界区不能实现,因为轮到Ti进入了,但是Ti根本不想进临界区,使得进程无法继续前进。
那我们挑选一下哪些进程是想进临界区的,用一个小数组flag来表示进程是否准备好了进临界区。
这个确实能满足前进属性,但是满足不了互斥属性,因为刚开始i和j的flag都是0,假如同时开始,都会跳过while循环进入临界区执行。
把flag赋值放到第一步执行,把while循环放到第二步。
因为第一步设成了1,所以两个不能同时进入,虽然满足了互斥,但是存在死锁。比如i和j都同时赋值了1,这样两个进程都想等待对方赋值0,都卡在了while的死循环中。
是由peterson这名科学家发明的,把提到过的turn 和 flag两个变量都用了起来。
可以满足互斥,意味着不会同时进入临界区,可用反证法证明,turn要么是0要么是1,所以只会有一个进程跳出while循环,也可验证满足其他性质。
dekkers算法也是用turn和flag两个变量实现,只是比peterson算法复杂,感兴趣可以课后研究。
在两线程的基础上,我们可以扩展到假如有N个线程,如何实现互斥。
N个线程的临界区,有点像银行取钱,在前台取号,如果不同前台同时发了同样的号,就比较身份证大小,谁小谁先取。
进入临界区之前,进程接收一个数字 得到的数字最小的进入临界区 如果进程Pi和Pj收到相同的数字,那么如果i<j,Pi先进入临界区,否则Pj先进入临界区 编号方案总是按照枚举的增加顺序生成数字复杂:需要两个进程间的共享数据项
需要忙等待:浪费CPU时间
没有硬件保证的情况下无真正的软件解决方案:peterson算法需要原子的LOAD和STORE指令
第三种方法基于硬件原子性操作的高级抽象方法。硬件提供了一些原语,像中断禁用、原子操作指令等,操作系统在此基础上提供更高级的编程抽象来简化并行编程,例如锁、信号量,这些方式是从硬件原语中构建的。
关键在于,怎样实现这样的抽象操作。
第一种叫做Test-and-Set(测试和置位),这是一条机器指令,这条机器指令完成了通常操作的读写两条机器指令的工作,完成了三件事情:
从内存中读取值 测试该值是否为1(然后返回真或假) 内存值设置为1第二种叫交换,做的事情就是交换内存中的两个值。
虽然这两个指令看起来由几条小指令组成,但是它已经被封装成了机器指令,这就意味着它在执行的时候不会被打断,不允许出现中断或切换,这就是机器指令的语义保证。在此基础上完成互斥。
还有可以做优化的地方在于,这个while执行的是一个忙等,等待锁的进程需要不断检查内存中变量的值,在等待的时候会消耗CPU资源。
就像某个进程做等待的时候可以被挂起,这样的思想同样可以拓展,我们发现某些进程在等待进入临界区的时候可以让它先进入睡眠。把当前进程挂入等待队列,可以把CPU解放出来给其他进程,在临界区的进程执行完以后将value赋值为0,同时完成一个唤醒操作。
那无忙等和忙等两种实现方式有什么区别呢?如果临界区很短的话,我们宁愿选择忙等的方式,因为无忙等的睡眠会涉及到上下文切换带来比较大的额外开销;如果临界区很长,占用CPU的开销远远大于换入换出进程的开销,那么我们更愿意选择非忙等的方式实现。具体应用具体实现。
也是忙等待的方式,也可以实现非忙等,同上。
锁是更高等级的编程抽象:
互斥可以使用锁来实现 通常需要一定等级的硬件支持常用的三种实现方法:
禁用中断(仅限于单处理器) 软件方法(复杂) 原子操作指令(单处理器或多处理器均可)可选的实现内容:
有忙等待 无忙等待