关于并发控制的简单整理

来源:互联网 发布:客服系统组成部分知乎 编辑:程序博客网 时间:2024/06/03 22:09

在开始之前,先引入一个情景:

环境是一个餐厅的厨房,人物当然是一群厨师。

每当饭点,厨房的厨师需要做菜。厨师中又分有主厨,副厨,一些打下手的,还有学徒。显然厨师之间需要配合协助,同时对于多个打下手的人之间又会出现一些竞争(比如:刀具的使用,食材的选择,餐盘的使用等等),还有一些与做菜无关的人员,对于学徒,也许只是让他们在旁边拖拖地,洗洗盘,并没有严格的融入到做菜这个行为中去。

如果我们将这个情景抽象为一个操作系统中的情景,那么对应应该是这样的,各种刀具,餐具,食材等等厨房的用具都是硬件资源,厨师(除了主厨)的行为可以代表为一个进程(或线程),厨师之间的配合规则是一个同步的机制,厨师之间对于某些资源的争用,则是该片主要内容互斥问题。

引入厨房和厨师的介绍,希望可以让大家对并发控制所涉及的处理对象有一个直观的印象,对于并发控制的介绍,先从基础开始(对于基础已知,可跳过此处)。

和并发相关的关键术语   术语                                                                                说明原子操作一个或多个指令的序列,对外不可分;即没有其他进程可以看到其中间状态或中断临界区是一段代码,在这段代码中进程将访问共享资源,当另外一个进程在这段代码中运行时,这个进程
不能在这段代码中执行死锁两个或两个以上的进程因其中的每个进程都在等待其他进程昨晚某些事情而不能继续执行的情形互斥当一个进程在临界区访问共享资源时,其他进程不能进入该临界区访问任何共享资源的情形竞争条件多个线程或者进程在读写一个共享数据是,结果依赖于他们执行的相对时间的情形饥饿指一个可运行的进程尽管能继续执行,但被调度器无限期的忽视,而不能被调度执行的情况










进程的交互交互程度关系进程之间的影响潜在控制问题进程之间不感知竞争*一个进程的结果与其他进程的活动无关
*进程的执行时间可能会受影响*互斥
*死锁(可复用的资源)
*饥饿进程之间间接感知
(如共享对象)共享合作*一个进程的结构可能依赖于从其他进程获得的消息
*进程的执行可能会受到影响*互斥
*死锁(可复用的资源)
*饥饿
*数据一致性进程直接感知
(存在通信原语)通信合作*一个进程的结果可能依赖于从其他进程获得的信息
*进程的即使可能会受到影响*死锁(可消费的资源)
*饥饿









(注:上表,均参考操作系统精髓与设计原理第6版中文译本,陈向群、陈渝,机械工业出版社)


由于上述进程交互之间的关系,同时多道程序设计系统中交替和重叠的执行模式下,进程的相对执行速度不可预测,所以会带来一下困难。

1,全局资源的共享并不安全

2、很难做好资源的分配

3、程序设计错误很难调试。

并发控制就需要处理好上述的困难,也就是确保每个活跃的进程与执行速度无关时,为每个活跃进程分配好资源,同时保护好数据和物理资源不被无意干涉。

那么并发控制通过什么样的方式处理调解?

1、基于硬件,比如有中断禁用,专用机器指令等

2、软件方式,有Dekker‘s Algorithm,Peterson's Algorithm,Lamport's Algorithm等等

3、基于高级抽象的数据结构,有信号量,管程等等

按照上述的解决方式,分别介绍一两种方法。

1、基于硬件

1)可以利用硬件的方式,来实施禁用中断,保证程序在执行过程中不被打断。

具体可以通过CPU发送电信号,使用寄存器中特定的中断标识位来标识当前程序不可被中断,其他程序等待当前进程结束并释放共享资源后,交替进入共享资源。

这种方法可以解决单处理机上互斥的问题,同时带来的一个缺陷是执行效率会很低;对于多处理机而言,这个方法完全不能达到互斥。

2)由于存在上述的问题,有人尝试使用机器指令,实现在一个指令周期内的原子操作。

在操作系统概念(第七版)中,介绍的是TestAndSet和Swap原子指令,而在操作系统精髓与设计原理中介绍的是比较和交换,Exchange指令,两书所述基本思想相同。在操作系统概念中给出了一个相对可行的基于机器指令的算法,此处引用。

do {    waiting[i] = true;//此处的waiting数组代表进程是否等待,等待为true,反之为false    key = true;//表示临界区是否可用    while(waiting[i] && key)         key = TestAndSet(&lock);//其中的全局变量lock表示临界区是否被当前进程锁定    waiting[i] = false;//当前进程设为忙碌    //critical section    j = (i + 1) % n;    while((j != i) && !wait[j])         j = (j + 1) % n;    if(j == i)//如果遍历所有进程,发现没有进程申请试用临界区,则将临界区标记为未锁定;如果发现其中有进程等待临界区,则将等待进程设为忙碌        lock = false;    else        waiting[j] = false;    //remainder section}while(true);
以下是TestAndSet原子操作

bool TestAndSet(bool *target){    bool rv = *target;    *target = true;    return rv;}
以上算法,通过lock和waiting数组保证了互斥操作和进程可执行,通过对进程的遍历保证了进程的有限等待。

基于硬件的方式,虽然简单易证明其正确性,在单处理机上易于实现多个进程,改进后的机器指令适用于共享内存的多处理机上的多个进程和多个临界区,但是忙等待的出现以及可能出现饥饿和死锁现象,不得不寻求更好的解决方法。

2、软件方法

此处看看使用软件的方法能否解决硬件方式的缺陷。

关于Dekker's Algorithm由于之前学习过,则直接提供一个链接。

此处讨论Peterson's Algorithm和Lamport's bakery Algorithm 
了解过Dekker's Algorithm之后,发现代码实在难懂,这里给出Peterson's Algorithm的代码。

bool flag[2];//表示两个进程是否进入临界区int turn;//表示临界区哪个进程占用void p0() {    while(true) {        flag[0] = true;//表示进程0申请进入临界区        turn = 1;//允许进程1进入临界区        while(flag[1] && turn == 1)//进程1申请进入的同时,临界区允许进入,则进程0等待            ;/*do nothing*/        /*critical section*/        flag[0] = false;//进程0退出临界区        /*remainder section*/}void p1() {//思想相同,对象不同    while(true) {        flag[1] = true;        turn = 0;        while(flag[0] && turn == 0)            ;/*do nothing*/        /*critical section*/        flag[1] = false;        /*remainder section*/}
这种方法与Dekker' s Algorithm类似,通过进程申请进入临界区,之后由临界区判断,哪个进程占用临界区,从而达到互斥的要求,进程正常执行并且能有限等待。

当进程0申请进入临界区,由于中断的不确定性,turn的值将不确定,但是可以确定turn值是0或1中的某一个。

当turn的值是1时,同样的原因,flag[1]不确定,

当其值为true时,进程0因为while循环而等待,直到进程1释放临界区资源;当其值为false时,则进程0访问临界区,之后必定会使的turn为0(不论是中断还是顺序执行),然后进程1等待,直到进程0释放临界区。

当turn的值是0时,同上述分析,是可行的。

同样的Peterson's Algorithm也有忙等待的缺陷,从代码看,只能处理两个进程,并不能使用多个(当然也可推广为多个,此处)。

接下来介绍能处理多个进程的Lampost's bakery Algorithm。

int ticket[n];//表示进程所在队列中的编号,如果为零,则表示未进入队列bool entering[n];//表示进程准备进入队列void lock(int pid) {    entering[pid] = true;//进程pid准备进入队列    int Max = 0;//队列中的最大编号    for(int i = 0; i < n; ++i) {//通过for循环,寻找最大编号        int current = ticket[pid];        Max = current > Max? current: Max;//更新最大值    }    ticket[pid] = Max + 1;//添加到队列最后    entering[pid] = false;//表示进程pid已经进入队列    for(int i = 0; i < n; ++i) {//查看除了当前的进程,是否还有其他进程使用临界区        if(i != pid) {            while(entering[i] == 1)//保证进程拥有编号                ;            while(ticket[i] != 0 && ( ticket[pid] > ticket[i] || ( ticket[pid] == ticket[i] && pid > i) ) )                ;//保证编号较小的进程优先,或者在相同编号下次序较小的优先        }    }    /* critical section */}void unlock(int pid) {    ticket[pid] = 0;//退出临界区,即退出队列}
该算法的思想来自于面包店买面包的情况,先来先服务。当然如果已经买了面包,发现漏买某种面包的时候,需要重新排队购买。

在模拟一下该情景,对于每一个要买面包的人,需要先领取服务的编号,也就是在队列中的编号,如果发现领取了相同的编号,则在队列中先来的先服务。

如果是先来先服务,那么是不是可以舍弃entering数组呢?此处不可舍弃。考虑这种情况,如果舍弃entering数组,那么有两个进程i1和进程i2,i1 小于i2,当进程i2先进入lock函数时,获得编号,由于中断的存在进程i1未分配编号但是其Max的值为0,之后又是进程i2执行由于进程i1未获得编号,从而进入临界区;之后进程i1由于同进程i2拥有相同的编号,但是优先级小于进程i2,也顺利进入临界区。也就是通过entering数组保证了该算法的原子性,其中while(entering[i] == 1)就是为了进程i获得一个编号,避免上述情况的出现。

同样的该算法软件实现的算法相似,都需要忙等待(维基百科中如果对象是线程则,可使用yield交出线程)
从上述软件的实现方式看,都不得不使用忙等待,这种占用宝贵CPU资源的做法,实在无法容忍。于是采用高级抽象的数据结构的方式孕育而生。
3、基于高级抽象数据结构
首先我们先来看看信号量。

信号量三个基本操作

A 一个信号量初始化未非负整数

B semWait操作是信号量减1,如果为负,则执行semWait的进程阻塞;否则进程执行。

C semSignal操作是信号量加1,如果信号量为非正,则被semWait操作阻塞的进程解除阻塞;否则什么也不做。

对于A操作,

如果是二元信号量(只有1和0两种取值),如果取值为1表示临界区允许访问;反之,临界区拒绝访问。

如果是计数信号量,那么信号量的初值代表共享资源的数目;当信号量的值为零时,表示共享资源分配无剩余;当信号量为负时,其绝对值表示正在等待的进程数目。

对于B,C操作,

关于二元信号量代码如下

typedef struct {    enum {zero, one} value;//二元信号量的取值    queue<process> process;//阻塞队列}binary_semaphore;//二元信号量类型void semWaitB(binary_semaphore s) {//判断是否阻塞当前进程。如果临界区允许访问,则执行当前进程,并将临界区设为不可访问;否则添加到阻塞队列中挂起    if(s.value == one) s.value = zero;    else {        /*add this process to qprocess*/        block();    }}void semSignalB(semaphore s) {//判断是否唤醒阻塞队列中的进程。如果阻塞队列中为空,则将临界区设为可访问;否则将阻塞队列中的进程移除并唤醒    if(!s.qprocess.empty()) s.value = zero;    else {        /*remove a process P from qprocess*/        wakeup(P);    }}

对于计数信号量代码如下

typedef struct {    int count;//资源数量    queue<process> process;//阻塞队列}semaphore;//计数信号量类型void semWait(semaphore s) {//对计数器减1操作后,判断其是否为负数。为负则已无共享资源,将进程挂起到阻塞队列中;为非负,则是资源分配出一份给进程    s.count--;    if(s.count < 0) {        /*add this process to qprocess*/        block();    }}void semSignal(semaphore s) {//对计数器加1操作后,判断其值是否为非正。为非正则阻塞队列中有进程阻塞,唤醒并执行;为正,则是资源有剩余,不做任何操作    s.count++;    if(s.count <= 0) {        /*remove a process P form qprocess*/        wakeup(P);    }}
尝试分析其是否可行是发现,其原子性是如何保证的?

其原子性是基于禁用中断或使用软件方式获得的。没错这样的方式会引入这两种方式的缺陷,忙等待等等。对于这个问题操作系统概念(第7中文版)和操作系统精髓与设计原理(第6中文版)都给出了相同的解释。

使用软件方法是会引起忙等待这种消耗CPU处理能力的,但是由于对整个进程的临界区的处理内容的多少是不确定的,处理的时间也是不确定的,如果很长时间的占用临界区,势必会极大的浪费CPU资源,如果这种浪费是在我们可以接受的范围以内,自然也就可以接受。由于对信号量的处理函数规模较小,忙等待的现象也就显得不是那么明显。如果是单处理机,则使用禁用中断的方式;如果是多处理机,则使用软件方式。单处理中直接禁用中断,即可确保其原子操作,而在多处理机上未必可行。基于多处理机的优点,使用软件方式更合适,因为在忙等待的时候占用某个处理机的时候,可以使用其他的处理机进行其它希望进入临界区的进程。

基于原子性操作的信号量处理函数,如果使用硬件方式处理,则参考上述的使用机器指令的代码,至于禁用中断,在函数内第一行和函数内末尾分别使用禁用中断和允许中断即可,如果使用软件的方式,则参看软件方式处理双进程的的Dekker's Algorithm和Peterson's Algorithm。

接下来,看一下如何使用信号量来处理进程之间访问临界区的方法

semaphore mutex;void p() {    semWait(mutex);    /*critical section*/    semSignal(mutex);    /*remainder section*/}
由于semWait()和semSignal()上面已经论证了具有原子性,同时根据也可以保证这两个原子操作是可靠的,也就是能达到互斥并做到有限等待的。所以使用信号量可以达到并发控制的要求。

当然信号量也是有缺陷的,由于信号量作为一个进程之外的数据单独处理,在处理复杂进程的时候,很难发现其是否遗漏,是否顺序是否正确,信号量操作是否写错等等。信号量过于灵活,就像goto语句一样,会在编程时就出现未知的错误。

由于上述问题的出现,于是又出现了新的数据结构——管程。

管程是更高层次的抽象,该类型提供了一组由程序员定义的、在管程内互斥的操作(类似于在面向对象的程序设计中称之为方法),一组变量类型的申明,对其内部变量操作的子程序和函数的实现。它能够确保其内部的变量只能被其内部的操作、函数等访问,同样的其内部的操作、函数等也只能访问内部变量,同时确保只有一个进程在管程中执行。

引用操作系统概念(第7中文版)中的管程的定义

monitor monitorName {    //shared variable declarations    process P1(...) {//各个进程        ...    }    process P2(...) {        ...    }    ...    process Pn(...) {        ...    }    initialization code(...) {//初始化代码        ...    }}
这只是一个数据结构,类似于信号量是否有其他的操作?

管程通过一个条件变量,来控制互斥与同步的机制,但是由于管程之间并没有保证互斥,于是又借用了上面所说的信号量来实现管程中条件变量的控制。用信号量处理,还是那句话,有缺陷但是系统是可以接受的。
此处不给出相关使用管程实现互斥的方法,如果需要可以参考操作系统概念(第7中文版)183页至185页上关于管程实现的例子与方法或参考该链接。

管程通过monitorName.cWait(condition c)和monitorName.cSignal(condition c)两个原子操作改变条件变量。

其中的操作

cWait(condition c):调用进程的执行在条件c上挂起,管程可被另一进程占用。

cSignal(condition c):再挂起进程中选择一个恢复执行,如果没有这样的进程则什么也不做。

也就是说进程如果为通过与条件变量产生关联,则该进程的请求不能在管程中执行,此处于信号量有区别。

此外在执行cSignal(condition c)时,有两种方式

A 唤醒等待,进程Pi等待新来的进程Pj离开管程或者等待另一个条件

B 唤醒继续,Pj唤醒并等待Pi离开管程或等待另一个条件

两种方式均可行,A方式多用于教材,B方式多用于工业,前者更安全,后者效率则更高。

上面只是简单介绍了并发控制的一些方法,还有许多没有涉及。最后引用Tsinghua University关于操作系统课程PPT讲义上的图,结束全文。


从上图我们可以了解到操作系统与底层硬件之间的关系极为密切,操作系统必须通过底层来完成并发控制。以上之所以从硬件一直到抽象数据类型,不仅仅是因为历史原因,而是前辈的从当时的水平不断的改进当时的历程。

如有不当之处,请各位批评指正,谢谢。


0 0