操作系统引入进程后,虽然改善了资源的利用率,提高了系统的吞吐量,但是系统中的多个进程由于竞争使用系统资源,导致它们之间存在一定的相互依赖、相互制约的关系。为了有效地协调各个并发进程间的关系,系统必须采用同步机制,确保进程之间能正确地竞争资源,并相互协调、相互合作。
3.1基本概念
3.1.1进程的制约关系
多道程序环境下,系统中存在着多个并发进程。这些并发进程之间可能相互独立,即一个进程的执行不影响其他进程的执行,此时系统无须对这些并发进程进行特别控制;并发进程之间也可能彼此相关、相互影响,即一个进程的执行可能影响其他进程的运行结果,此时,系统就需要合理地控制和协调这些进程的执行。根据共享资源性质的不同,并发进程之间的关系可以分为间接制约关系和直接制约关系。
(1)间接制约关系:也称”竞争关系“,指系统中多个进程访问相同的资源,其中一个进程访问资源时,其他需要访问此资源的进程必须等待,只有当该进程释放该资源后,其他进程才能访问。进程的竞争关系可通过进程互斥方式拉解决。
(2)直接制约关系:也称”合作关系“,指系统中多个进程需要相互合作才能完成同一任务。例如,假设输入进程和计算进程共同使用一个单缓冲区,那么当输入进程将数据写入缓冲区后,计算进程才能开始计算;当计算进程将缓冲区中的数据取走后,输入进程才可以再次向缓冲区中写入数据。进程的合作关系可通过进程同步机制来实现。
3.1.2进程互斥与同步
1.临界资源及临界区
为了便于控制和管理竞争资源,系统引入了临界资源和临界区的概念:
(1)临界资源:指一次只允许一个进程访问的资源。临界资源在任何时刻都不允许两个及两个以上并发进程同时访问。系统中有许多独占性硬件资源(如卡片输入机和打印机等)和软件资源(如变量、表格、队列、栈和文件等)均属于临界资源。
(2)临界区:指进程访问临界资源的那段程序代码。
系统若能保证进程互斥地进入各自的临界区,便可实现临界资源的互斥访问。
2.进程互斥
进程互斥是指当一个进程进入临界区使用临界资源时,其他进程必须等待。当占用临界资源的进程退出临界区后,另一个进程才被允许使用临界资源。
若要实现各进程对临界资源的互斥访问,则需要保证各进程互斥地进入自己的临界区。进程在进入临界区之前,应先对临界资源进行检查,确认该资源是否正在被访问。若临界资源正被其他进程访问,则该进程不能进入临界区;若临界资源空闲,该进程便可以进入临界区对临界资源进行访问,并将该资源的标志设置为正在被访问。因此,进程访问临界资源前,应增加一段用于进行上述检查的代码,这段代码称为进入临界区;临界资源访问结束后,也要增加一段用于将临界资源标志恢复为未被访问的代码,这段代码称为退出临界区。临界区的框架如下:
do{
进入临界区(检测资源是否正在被访问)
访问临界资源
退出临界区(将访问资源恢复为未被访问)
其余代码
}while(1);
3.进程同步
进程同步是指多个进程为了合作完成同一个任务,在执行次序上相互协调、相互合作,在一些关键点上还需要相互等待或相互通信。
进程同步的例子在现实生活中随处可见,例如司机与售票员的关系。公共汽车的司机负责开车和到站停车,售票员负责售票和开关车门,他们之间是相互合作、相互配合的。例如车门关闭后才能启动,到站停车后才能打开车门,即”启动汽车“在”关闭车门“之后,而”打开车门“在”到站停车“之后。司机和售票员之间的活动关系如图3-1所示。
图3-1 司机与售票员的关系
若进程P1和P2分别表示司机和售票员,当它们并发向前推进时,则需要满足以下要求:
(1)若P1推进到①,但P2未到达②时,则P1应等待,直到P2到达②为止。
(2)若P2推进到④,但P1未达到③时,则P2应等待,直到P1到达③为止。
(3)若P1在①处等待,则当P2到达②处时,应通知(唤醒)P1。
(4)若P2在④处等待,则当P1到达③处时,应通知(唤醒)P2。
由此可知,为了协调进程推进次序,相互合作的并发进程有时需要互相等待与互相唤醒。
4.同步与互斥的关系
同步与互斥是并发进程之间两种重要关系,其中互斥反映了进程间的竞争关系,而同步则反映了进程间的合作关系。
进程互斥是进程同步的一种特殊情况。例如,某个进程进入临界区时,其他进程不允许进入临界区。当进程完成任务离开临界区,并归还临界资源后,唤醒其等待进入临界区的进程。这说明互斥的进程也存在特殊的合作关系。因此,互斥是一种特殊的同步关系。
互斥所涉及的并发进程之间只是竞争获得共享资源的使用权,这种竞争没有固定的、必然的关系,谁竞争到资源,谁就拥有资源的使用权,直到不需要时才归还;而同步所涉及的并发进程之间有一种必然的联系,在进程同步过程中,即使没有进程使用共享资源,尚未得到同步消息的进程也不能去使用共享资源。
5.临界区的管理准则
为了实现进程的同步与互斥,可以利用软件方法或在系统中设置专门的同步机制,协调各个并发进程。同步机制必须遵循以下4条准则:
(1)闲则让进:当临界资源处于空闲状态时,系统应允许一个请求访问该临界资源的进程进入自己的临界区,访问该临界资源。
(2)忙则等待:当临界资源正在被访问时,其他试图进入临界区访问该临界资源的进程必须等待,以保证临界资源的互斥访问。
(3)有限等待:对于等待访问临界资源的进程,系统应保证这些等待进程在有限时间内能进入临界区,访问临界资源,以避免陷入“死等”状态。
(4)让权等待:当进程不能进入临界区访问临界资源时,应立即释放CPU,以免进该进入“忙等”(即等待时占有CPU)状态。
3.2同步机制
进程同步机制的基本目标是在功能上保证进程能够正确地互斥执行各自的临界区,其具体的实现方法包括软件方法、硬件方法、信号量方法和管程这四大类。
3.2.1软件方法
1.算法1
该算法的基本思想:若一个进程申请使用临界资源,应先查看该资源当前是否被一个进程访问。若资源正在被访问,则该进程只能等待,否则进入自己的临界区执行。下面是进程P1和P2的伪代码,其中inside1和inside2为布尔型变量,且初值均为false,表示P1和P2均不在其临界区内。
boolean inside1,inside2;
inside1 = false;//P1不在其临界区内
inside2 = false;//P2不在其临界区内
cobegin//并发执行开始
process P1(){
while(inside2);//等待P2访问完资源
inside1 = true;
访问临界资源;
inside1 = false;//P1访问资源结束
}
process P2(){
while(inside1);//等待P1访问完资源
inside2 = true;
访问临界资源;
inside1 = false;//P2访问资源结束
}
coend//并发执行结束
该算法虽然实现了进程互斥管理的”闲则让进“准则,保证了每次只允许一个进程进入临界区,但违背了”忙则等待“准则。例如P1和P2先后执行” while(inside2);“和” while(inside1);“,发现对方均不在临界区内,则它们执行”inside1 = true;“和”inside2 = true;“,并进入了各自临界区内,同时访问该临界资源。
2.算法2
算法1违背了"忙则等待"准则,没有实现对临界区的互斥访问。算法2对其进行了改进,即进程若想进入临界区,必须抢先将自己的标志为true,以防止对方再进入临界区。
算法2的程序伪代码如下:
boolean inside1,inside2;
inside1 = false;//P1不在其临界区内
inside2 = false;//P2不在其临界区内
cobegin//并发执行开始
process P1(){
inside1 = true;//抢先将自己的标志设置为true,以防止对方再进入临界区
while(inside2);//等待P2访问完资源
访问临界资源;
inside1 = false;//P1访问资源结束
}
process P2(){
inside2 = true;
while(inside1);//等待P1访问完资源
访问临界资源;
inside1 = false;//P2访问资源结束
}
coend//并发执行结束
算法2虽然解决了”忙则等待“问题,但存在着”有限等待“问题。例如,当P1和P2都判断对方不在临界区时,P1执行“inside1=true”,同时P2同样也执行“inside2=true”,然后P1和P2分别执行“while(inside2);”和“while(inside1);”时,均因为条件不满足,而无法往下执行,导致P2和P1将陷入无限等待状态。
3.Peterson算法
Peterson采用原语形式,提出了一种表述简单的算法,很好地解决了临界区互斥的问题,能满足临界区访问的四个条件。Peterson算法的基本思想:当一个进程需要进入临界区,需要先调用enter_section()函数,判断是否可以安全进入临界区,若不能则等待,当从临界区退出后,调用leave_section()函数,允许其他进程进入临界区。Peterson算法流程如下:
#define FALSE 0
#define TRUE 1
#define N 2 //竞争资源的进程数目
int observer; //轮到哪个进程观察要进入临界区的情况
int wanted_in(N); //各进程希望进入临界区的标志
enter_section(process);//进入临界区的互斥控制函数
int process; //进入编号,0或1
{
int other; //对方进程号
other=1-process;
wanted_in[process]=TRUE;//本进程要进入临界区
observer=process; //观察进入临界区的情况,设置标志位
while(observer==process&&wanted_in[other]);//等待什么都不做
}
leaver_section(process);//退出临界区函数
int process;
{
wanted_in[process]=FALSE;//离开临界区
}
3.2.2硬件方法
软件方法相对复杂且容易出错,因而现在系统较少采用。目前常用的是通过硬件方法实现同步互斥操作。
1.开关中断法
开关中断法采用中断方式,借助硬件中断机构实现临界区的管理。当进程进入临界区后,关闭系统中断;离开临界区后,重新开启系统中断。由于进程切换是由时钟或者其他中断导致,因而当中断被屏蔽后,其他进程无法获得CPU调度,导致无法运行,从而实现了临界区的互斥访问。进程进入临界区后,只要不自行挂起,就会连续地执行,直至退出临界区,并在执行开中断指令后,才可能重新调度,允许其他进程进入临界区。
do{
开中断
访问临界资源
关中断
其余代码
}while(1);
开关中断方法具有效率高、简单易行,且系统不会出现忙等现象;但其缺点也较明显,如只适用于单CPU系统和系统效率较低,进而影响系统处理紧迫事件的能力。多CPU系统中,禁止中断只会影响当前CPU,而其他CPU上并行执行的进程仍然能不受阻碍地进入临界区。
2.测试与设置方法
测试和设置(Test and Set,TS)方法利用指令读取内存中某个变量的值后,重新给它赋一个新值。TS指令定义如下:
int TS(int *target){
int temp;
temp=*target;
*target=1;
return(temp);
}
TS指令首先读取当前变量的值,作为参数返回,同时将其值置为1。由于该指令是原子操作,因此,它在执行期间不允许被打断,即所有语句要么全执行,要么都不执行。
TS指令可用来实现进程互斥操作。具体地,设置一个共享变量(如lock),置其初值为0,表示临界区内没有进程。每个进程在进入临界区之前,先使用TS指令测试该共享变量。若其值为0,并将其值置为1;若其值为1,则表明其他进程已进入临界区,此时该进程需等待。进程离开临界区时,需将共享变量的值置为0。使用TS指令的互斥算法如下:
while(1){
while(TS(lock));
访问临界资源;
lock=0;
}
尽管上面算法可以实现进程互斥操作,但仍然存在“忙碌等待”,浪费了CPU宝贵的资源,因而实际情况中较少使用。
3.swap指令
swap指令也称交换指令,其功能是交换两个变量的值,具体实现如下:
void swap(int *a,*b){
int temp;
temp=*a;*a=*b;*b=temp;
}
swap指令是原子操作,执行期间是不可分割的。使用swap指令实现进程互斥时,需对临界区(可表示为一组共享变量)定义一个全局变量(如lock),并对每个进程定义一个局部变量(如key)。利用swap指令实现的进程互斥算法具体实现如下:
key=1;
do{
swap(&lock,&key);//若其他进程没有退出临界区,则lock与key交换为11两个轮换交换
while(key==1);
访问临界资源;
lock=0;
其余代码;
}while(1);
进程在进入临界区前,利用swap指令交换lock和key的值,检查key的状态,判断是否有进程已进入临界区。若其他进程已进入,则该进程不断重复交换和检查过程,直到其他进程退出临界区。
3.3信号量方法
由于硬件方法采用原语或指令形式,将修改和检查作为一个不可分割的整体,因而比软件方法具有明显的优势。然而,进入临界区的进程是随机选择的,使得部分进程可能一直未被选择,从而导致“饥饿”现象。为此,实际系统中常采用信号量机制和PV操作进程互斥。
信号量机制是指两个或多个进程利用彼此之间收发的简单信号来实现并发执行,其中进程若未受到指定的信号,则停留在特定的地方,直至收到了信号后才能继续往下执行。信号量机制目前是一种卓有成效的进程同步机制,已被广泛应用于各种系统。
3.3.1信号量机制
1.信号量的概念
信号量(Semaphore)是一种特殊变量,它用来表示系统中资源的使用情况,其值与临界区内所使用的临界资源的状态有关。如果信号量S是一个整型变量,则其值表示系统中某类资源的数目。S必须且只能设置一次初值,并大于或等于0。当其值大于0时,表示系统中对应可用资源的数目;当其值小于0时,其绝对值表示等待该类资源的进程的数目;当其值等于0时,表示系统中对应资源已用完,且没有进程等待该类资源。
2.信号量的操作
信号量机制中,信号量的值仅能通过两个标准的原语操作来改变,它们分别是P操作和V操作。信号量S的P、V操作表示为:P(S)和V(S),也称为wait和signal。由于P、V操作是原语,因此,它们在执行的过程中不可中断。
利用信号量和P、V操作既可以解决并发进程对资源的竞争问题,又可以解决并发进程的合作问题。进程在互斥访问临界资源、进入临界区前,先执行P操作,退出临界区后应执行V操作。
3.3.2信号量的分类
信号量机制自提出以来得到了很大发展,已从最初的单信号量机制发展到多信号量机制。
1.单信号量机制
单信号量机制是指信号量所涉及的变量只有一个。根据变量的类型,单信号量机制包括互斥型信号量、整型信号量和记录型信号量等,其中互斥型信号量最简单,而记录型信号量表达能力最强。
(1)互斥型信号量
互斥型信号量也称0/1信号量所涉及的变量只有一个,它的值为0、1或FALSE、TRUE,表示当前信号量所代表的临界资源是否可用,其中1或TRUE表示临界资源可用,而0或FALSE表示临界资源当前已被占用。
互斥型信号量定义为:
boolean S; //互斥信号量的定义
互斥型信号量的P、V操作描述如下:
void P(boolean S){
while(!S); //若信号量为FALSE,表示资源不可用,继续测试
S=FALSE; //表示可用进入临界区,同时不允许其他进程进入
}
void V(boolean S){
S=TRUE; //允许其他进程进入临界区
}
(2)整型信号量
互斥型信号量虽然能保证进程互斥地访问临界资源,但不能反映临界资源的数目。针对这个问题,提出了整型信号量,即信号量的类型为整型。整型信号量S的初始值应大于等于0,其值不仅能表示临界资源是否空闲,还具有如下物理意义:
①S>0:表示当前有S个资源可用;
②S=0:表示当前没有资源可用,且没有等待该资源的进程;
③S<0:表示当前有|S|个进程正在等待此资源。
整型信号量定义为:
int S; //整型信号量定义
整型信号量的P、V操作描述如下:
void P(int S){
S--; //表示申请一个资源
while(S<0); //若信号量为0表示无资源可用,反复测试
}
void V(int S){
S++; //表示释放一个资源
}
(3)记录型信号量
整型信号量虽然能描述当前可用的资源数量,但当进程检测到无资源可用时,只能反复检测,导致“忙等”现象,不满足“让权等待”准则。为了解决这个问题,提出了记录型信号量机制。记录型信号量是一个记录型的数据结构,它包含两个数据项:一个是表示可用资源数目的整型变量,另一个是与该信号量对应的进程阻塞队列的首指针域。与整型信号量不同的是,若当前无临界资源可用,则申请访问临界资源的进程将被插入阻塞队列中;进程在退出临界区、释放临界资源时,需唤醒阻塞队列中的其他进程。
记录型信号量定义为:
struct semaphore //信号量数据结构定义
{
int value;
PCB *p; //进程队列指针
}
记录型信号量的P、V操作描述如下:
void P(struct semaphore S){
S.value--; //表示申请一个资源
if(S.value<0) block(S.p);
//若信号量为0,表示无资源可用,加入阻塞队列,否则进入临界区
}
void V(struct semaphore S){
S.value++; //表示释放一个资源
if(S.value<=0) wakeup(S.p);
//若信号量<=0,表示有进程等待该资源,唤醒阻塞队列中的进程
}
2.多信号量机制
单信号量机制适用于多个并发进程仅共享一个临界资源的情况,然而一个进程在某些场合同时需要访问两个或更多的共享资源。例如,进程A和进程B都要求访问数据D和E,可设置互斥信号量Sd和Se,且初始值均为1,A和B都包含两个对Sd和Se的操作,即:
Pa(){ Pb(){
P(Sd); P(Se);
P(Se); P(Sd);
} }
若A和B按如下述次序交替执行P操作
Pa():P(Sd); //于是Sd=0
Pb():P(Se); //于是Se=0
Pa():P(Se); //于是Se=-1,A阻塞
Pb():P(Sd); //于是Sd=-1,B阻塞
最后,A和B都将处于僵持状态,无法继续往前推进。当同时要求访问更多的临界资源时,发生僵持的可能性更大。
为了解决这个问题,提出了多信号量机制。多信号量机制主要有两种,即AND型信号量和信号量集。
(1)AND型信号量
AND同步机制的基本思想是:将进程整个运行过程中需要的所有资源,一次性全部分配给进程,待进程使用完再一起释放。只要其中有一个资源未能分配给进程,其他所有可能为它分配的资源也不分配给它;即对若干个临界资源的分配采用原子操作方式,要么它请求的资源一次性全部分配,要么一个资源也不分配给它。
AND型信号量集的P、V操纵描述如下:
void P(S1,S2,...,Sn){
while(TRUE){
if(S1>=1 && .. && Sn>=1){
for(i=1;i<=n;i++) Si--; //表示申请n个不同的资源
break;
}
else block(); //无资源可用,加入阻塞队列
}
}
void V(S1,S2,...,Sn){
while(TRUE){
for(i=1;i<=n;i++) Si++; //表示释放n个不同的资源
wakeup(); //唤醒阻塞队列中的进程
}
}
(2)信号量集
AND型信号机制每次只能对某类临界资源进行一个单位的申请或释放。若进程需要n个资源时,则需要重复n次P操作,导致效率低下。此外,它也未考虑每种资源具有不同的数量。
信号量集是在AND型信号量的基础上进行扩充的,它在一次P、V原语操作中完成所有的资源申请或释放。令ti为信号量Si对应资源的分配下限值,即分配时要求Si>ti,否则表明资源数量低于ti,此时便不予分配。di为资源Si的申请量,即Si=Si-di,而不是简单的Si=Si-1。这种情况下,对应的P、V操作格式分别为:
P(S1,t1,d1,...,Sn,tn,dn);
V(S1,d1,...,Sn,dn);
信号量机制有以下几种特殊情况:
①P(S,1,1):退化为一般的记录型信号量(S>1)或互斥信号量(S=1);
②P(S,1,0):一种特殊的信号量(可控开关)。S>=1表示允许多个进程进入特定区;S=0将阻止任何进程进入特定区。
3.3.3互斥与同步
1.进程互斥的实现
通过P、V操作可实现进程的互斥访问。具体地,首先为临界资源设置一个互斥信号量S,并设置其初始值,然后各个进程将访问临界资源的临界区代码至于P(S)操作和V(S)操作之间,使得进程进入临界区之前,需先执行P操作。若该资源未被占用,P操作成功,进程便可以进入自己的临界区,访问临界资源,此时其他进程若也需访问该资源时,在执行P操作时将会受到阻塞,从而保证了临界资源的互斥地访问。当访问临界资源的进程退出临界区时,需对信号量S执行V操作,以便释放信号量。
P、V原语实现进程间互斥的具体步骤如下:
(1)为互斥访问的临界资源设置信号量,置其初始值为1,表示该临界资源未被占用,即临界资源的可用数量为1
(2)执行P操作,申请进入临界区。
(3)进入临界区,访问临界资源。
(4)执行V操作,释放临界资源,允许其他进程访问。
下面代码表示n进程互斥访问临界资源S:
semaphore S=1;
P1(){ P2(){ Pn(){
while(1){ while(1){ while(1)
P(S); P(S); P(S);
临界区; 临界区; ... 临界区;
V(S); V(S); V(S);
剩余区; 剩余区; 剩余区;
} } }
} } }
使用P、V操作时,P、V操作必须成对出现。缺少P操作将会导致系统混乱,不能保证对临界资源的互斥访问,缺少V操作将会使临界资源永远不被释放,从而使得因等待该资源阻塞的进程无法得到唤醒。
2.进程同步的实现
除了进程异步,P、V操作还能实现进程的同步关系。P、V操作实现进程同步问题的具体实现如下:
(1)分析所涉及进程之间的制约关系。
(2)设置私用信号量,包括信号量数量、物理含义及其初值,其中信号量的数量应与进程间制约关系的数量一致,且信号量的初值为0。
(3)给出进程相应程序的算法描述,并将P、V操作加到程序适当的位置。
下面给出一个实例,说明如何使用P、V操作实现进程同步关系。假设P1和P2为两个同步进程,它们之间的制约关系是只有当P1完成后,P2才可以开始。令S为P1和P2的同步信号,其初始值为0,表示P1执行未结束。它们使用P、V操作的具体实现算法如下:
semaphore S=0;
P1(){ P2(){
... P(S);
计算完成; 计算开始;
V(S); ...
} }
若P1先执行,当其计算完成后,执行V(S)将S的值修改为1,这相当于给P2发送了信号,通知P2可以开始执行。此时,P2执行P(S),检测通过后,开始计算。反之,若P2先执行,当执行到P(S)时,由于S的值为-1,进而进入阻塞队列。随后,P1开始执行,直至P1顺利完成后,唤醒阻塞队列中的P2,这种情况下,P2又可以继续往下执行。注意到,P1和P2实现同步操作时,只有当P2等到信号到达后,才能往下继续执行,否则P2将一直等待,直到被唤醒。
P、V操作实现进程同步时,尽管P、V操作也是成对出现的,但是它们是分别出现在需要同步的进程中,而非出现在同一进程的程序段里。
通过以上分析可知,信号量可分为两种:
(1)公有信号量:进程通过对公有信号量进行P、V操作,可实现资源的互斥访问。
(2)私有信号量:仅允许拥有此信号量的进程执行P操作,而其他进程可对该信号量实施V操作,进而可实现进程之间的同步关系。
3.4经典的同步问题
3.4.1生产者-消费者问题
生产者-消费者问题是一个著名的进程同步问题。该问题描述如下:生产者先制造产品,再存放到公用仓库,随后由消费者从仓库中取出这些产品进行消费。尽管生产者和消费者是以异步的方式运行的,但它们之间必须保持同步,即消费者不能到空的仓库去取产品消费,生产者也不能将产品存放到已满的仓库中。此外,仓库每次也只允许一个人进出。
操作系统中很多并发进程之间的同步关系都可以抽象成生产者消费者模型。例如,输入进程、计算进程和打印进程就是一种典型的生产者-消费者问题,其中输入进程可看成生产者,计算进程就是消费者;若把计算进程看成生产者,则打印进程就是消费者。
根据生产者、消费者和公用缓冲区的数目,生产者-消费者问题可进一步划分为生产者和消费者-公用单缓冲区和生产者消费者-公用多缓冲区。
1.生产者-消费者公用单缓冲区问题
单缓冲区是指缓冲区中只能存放一个数据或产品。生产者每次只能向缓冲区中放一个数据,而消费者每次只能从缓冲区取一个数据。当缓冲区为空时,消费者无法从缓冲区中取到数据;当缓冲区为满时,生产者无法再向缓冲区中写数据,如图3-2所示。
图3-2 生产者和消费者公用单缓冲区
公用单缓冲区问题的同步算法如下:
semaphore empty = 1;//empty表示缓冲区是否为空,初值为1
semaphore full = 0;//full表示缓冲区是否为满,初值为0
void producer(){
while(1){
生产一个产品;
P(empty);
产品送往Buffer;
V(full);
}
}
void comsumer(){
while(1){
P(full);
从Buffer取出一个产品;
V(empty);
消费该产品;
}
}
2.生产者-消费者公用多缓冲区问题
多缓冲区是指缓冲区中可以存放若干个数据或产品,每次只允许一个进程访问。图3-3给出了生产者-消费者公用多缓冲区示意图,其中缓冲区组织成环状,并可存放n个数据。
图3-3 生产者-消费者公用多缓冲区
生产者-消费者公用多缓冲区模型既涉及进程同步,又涉及进程互斥。互斥主要表现在缓冲区是临界资源,生产者和消费者不能同时对此缓冲区进行操作。同步主要表现在:当缓冲区已满时,生产者不能再存放数据;当缓冲区为空时,消费者不能获取数据。因此,可设一个互斥一个互斥信号量mutex和两个同步信号量empty和full,其中:
(1)公有信号量mutex:公用缓冲区的互斥信号量,初值为1;
(2)私有信号量empty:用于同步控制,表示缓冲区中空单元数,初值为n;
(3)私有信号量full:用于同步控制,表示缓冲区中产品的数量,初值为0。
生产者-消费者公用多缓冲区问题的算法描述如下:
int n=0,out=0;
item Buffer[n];
semaphore mutex=1; //mutex表示互斥访问缓冲区信号量,初值为1
semaphore empty=n; //empty表示空白缓冲区的个数,初值为n
semaphore full=0; //full表示有数据的缓冲区个数,初值为0
void producer(){
while(1){
生产一个产品;
P(empty); //检查是否有空白缓冲区
P(mutex); //检查是否占用公用缓冲区
Buffer[in]=product; //将数据放入缓冲区
in=(in+1)mod n; //指针推进
V(mutex); //释放公用缓冲区
V(full); //有数据的缓冲区个数加1
}
}
void comsumer(){
while(1){
P(full); //检查缓冲区中是否有数据
P(mutex); //检查能否占用公用缓冲区
product=Buffer[out]; //取走缓冲区中的一个数据
out=(out+1)mod n; //指针推进
V(mutex); //释放公用缓冲区
V(empty); //将空白缓冲区的个数加1
消费该产品;
}
}
生产者-消费者问题中应该注意以下几点:
(1)P(mutex)和V(mutex)必须成对出现,每个进程使用公有资源前,先执行P(mutex)申请该资源,使用结束后,应执行V(mutex)释放资源。
(2)私有信号量empty和full的P操作和V操作也必须成对出现,但出现在不同类型的进程中,这与互斥信号量mutex的P、V操作不同。
(3)生产者和消费者进程中,多个P操作的顺序不能颠倒。生产者进程应先执行P(empty),再执行P(mutex),即检查仓库中有空位后,再去试图占用仓库。若这两个P操作顺序颠倒,即先后执行P(mutex)和P(empty),那么当仓库已满时,生产者进程会因为执行P(empty)而受到阻塞,因此只有等待消费者进程将产品取走之后唤醒它,但此时由于生产者先执行了P(mutex)占用了公有资源,导致消费者无法进入而陷入了僵局,产生死锁状态。同理,消费者的P操作顺序也不能颠倒。若P操作顺序颠倒,当仓库为空时,当消费者先执行P(mutex)时占用了仓库,然后执行P(full)时发现没有产品,此时,生产者也进不来,同样陷入死锁状态。
3.4.2读者-写者问题
读者-写者问题是另一个典型的进程同步问题。它描述了多个读者与多个写者之间共享访问数据区的情况,其中读者每次从数据区中读写一个数据,而写者每次向数据区中写入一个数据。读者和写着之间应满足以下三个条件:
(1)允许多个读者同时执行读操作。
(2)不允许读者和写者同时操作。
(3)不允许多个写者同时执行写操作。
该问题若考虑读者优先,则可进一步细化为:
(1)如果读者进程申请读数据,此时若没有读者正在读、写者正在写,则该进程可以读;若有读者正在读,且有写者等待,则该写者也可以读;若有写者正在写,则该读者需等待。
(2)如果写者进程申请写数据,此时若没有读者正在读或写者正在写,则该写者可以写;若有读者正在读,则该写者等待;若有其他写者正在写,则该写者等待。
根据以上分析,读者-写者问题可采用记录型信号量机制来实现。设:
(1)整型变量readcount,用于记录读者的个数,初值为0。
(2)互斥信号量mutex,用于读者互斥访问readcount,初值为1。
(3)互斥信号量mutexsection,用于写者和其他写者或者读者之间互斥访问,初值为1。
读者-写者问题的解决算法描述如下:
struct semaphore mutex,mutexsection=1,1;
int readcount=0;
void readr_i(void){ //i=1,2,...,k;
while(true){
P(mutex); //开始对readcount共享变量进行互斥访问
//如果是第一个读者,判断是否有写者进程在共享数据区
if(readcount==0) P(mutexsection);
readcount=readcount+1; //将读者个数加1
V(mutex);//允许多个读者访问数据区
执行读操作;
P(mutex);//开始对readcount共享变量进行互斥访问
readcount=readcount-1; //使读者个数减1
//如果有写者等待进入共享数据区,就唤醒一个写者
if(readcount==0) V(mutexsection);
V(mutex); //开始对readcount共享变量的互斥访问
}
}
void writer_j(void){ //j=1,2,...,m;
while(true){
P(mutexsection); //判断是否可以进入共享数据区
执行写操作;
V(mutexsection); //释放共享数据区
}
}
该算法考虑读者优先,即只要有其他读者正在读数据,不管是否有写者等待,新来的读者可以直接读数据。这可能会导致写者无限期等待的情况,即“饥饿”现象。
3.4.3哲学家进餐问题
哲学家进餐问题描述五位哲学家坐在一张圆桌周围的五张椅子上,圆桌上有五个碗和五只筷子,如图3-4所示。哲学家交替吃饭和思考。当一位哲学家饿了时,就去取最靠近其左边的筷子和右边的筷子。如果成功得到了两只筷子,就开始吃饭。吃完后放下筷子继续思考。
假设这五位哲学家用五个进程表示,五只筷子为共享资源,并使用信号量Stick[i](i=0,...,4)表示它们的使用情况,初始值设为1,表示每只筷子每次只允许一个人使用。令进程Pi(i=0,...,4)左边的筷子为Stick[i],右边的筷子为Stick[(i+1)%5]。例如P4左边的筷子为Stick[4],右边的筷子为Stick[0]。
哲学家进餐问题的算法描述如下:
struct semaphore Stick[5]={1,1,1,1,1};
philosopher_i(){
do{
思考:
饥饿:
P(Stick[i]);
P(Stick[(i+1)%5]);
进餐;
V(Stick[i]);
V(Stick[(i+1)%5]);
}while(true);
}
根据算法可知,哲学家饥饿时总是先执行P(Stick[i])试图拿左边的筷子,再执行P(Stick[(i+1)%5])去拿右边的筷子,成功后便可进餐。进餐完毕,又先放下左边的筷子,再放下右边的筷子。
该算法虽然保证了不会有两个相邻的哲学家同时进餐,但却可能发生死锁。例如,当五位哲学家同时拿起左边的筷子,再试图拿右边的筷子时,都将因无筷子可拿而陷入无限等待状态。这个问题可以采用以下三种方法来解决:
(1)方法1:最多允许有四位哲学家同时拿左边的筷子,最终能保证至少有一位哲学家能够进餐,并在用餐完毕能释放出他所占用的两只筷子,以保证其他哲学家能够进餐。
(2)方法2:规定奇数号哲学家先拿左边的筷子,再拿右边的筷子,而偶数号哲学家则相反,这样总会有一位哲学家能获得两只筷子而进食。
(3)方法3:仅当哲学家的左、右两只筷子同时可用时,才允许他拿起筷子进餐。
第一种方法可采用信号量机制来解决,如增加一个用于互斥的信号量count(初值为4),这样就能限制同时申请拿左手边筷子的人数,从而保证了任何情况下至少有一个哲学家能同时拿到两边的筷子,使得每个哲学家均有进餐的可能。改进的算法如下:
struct semaphore Stick[5]={1,1,1,1,1};
struct semaphore count=4;
Philosopher_i(){
do{
思考:
饥饿:
P(count);
P(Stick[i]);
P(Stick[(i+1)%5]);
进餐;
V(Stick[i]);
V(Stick[(i+1)%5]);
V(count);
}while(TRUE);
}
对于第二种方法。其改进的程序如下:
struct semaphore Stick[5]={1,1,1,1,1};
Philosopher_i(){ //i=0,1,2,3,4
do{
思考:
饥饿:
if(i%2==0){ //偶数号哲学家,先右后左
P(Stick[(i+1)%5]);
P(Stick[i]);
进餐;
V(Stick[(i+1)%5]);
V(Stick[i]);
}else{ //奇数号哲学家,先左后右
P(Stick[i]);
P(Stick[(i+1)%5]);
进餐;
V(Stick[i]);
V(Stick[(i+1)%5]);
}
}while(TRUE);
}
对于第三种方法,可采用AND型信号量机制予以解决,其改进的程序如下:
struct semaphore Stick[5]={1,1,1,1,1};
Philosopher_i(){ //i=0,1,2,3,4
do{
思考:
饥饿:
P(Stick[i],Stick[(i+1)%5]); //同时拿起才能进餐
进餐;
V(Stick[i],Stick[(i+1)%5]);
}while(TRUE);
}
3.5管程
信号量机制虽然方便、有效,但每个进程必须自卑P、V操作,因而在实现进程同步时存在以下缺点:
(1)管理的不便:P、V操作分散在各个进程,不利于临界资源的管理,且P、V操作顺序非常重要,使用不当时可能导致进程死锁。
(2)易读性差及正确性难以保证:信号量的操作正确与否取决于整个系统的并发过程的分析,且进程代码的修改与维护可能影响全局,导致正确性难以保证。
为了克服信号量机制的缺点,20世纪70年代人们提出了一种被称为管程的进程同步工具。
3.5.1管程的概念
管程的基本思想是采用一种数据结构抽象地表示系统中的共享资源,忽略内部结构和实现细节,共享资源的申请、释放和其他操作都是在数据结构上的操作过程。
管程通常由以下四个部分组成:
- 管程的名称
- 局部于管程内部的共享数据结构说明
- 对该数据结构进行操作的一组过程
- 对局部管程内部的共享数据设置初始值的语句。
一般而言,管程可描述如下:
monitor monitor_name //管程名
variable declarations; //共享变量说明
cond declarations; //条件变量说明
public: //能被进程调用的过程
void P1(...)
{...}
void P2(...)
{...}
...
void Pn(...)
{...}
{ // 管程主体
initialization code; // 初始化代码
}
管程借鉴面向对象设计思想,将描述共享资源的数据结构及相关操作的一组过程封装在一个对象内部。任何管程外的过程或函数都不能访问管程内部,而管程内部的过程仅能访问管程内部的数据结构。所有外部过程要访问内部的数据结构时,都必须通过管程间接访问。管程每次只准许一个进程进入管程,执行管程内的过程,从而实现进程互斥。
管程是实现进程同步的一种重要手段,它具有四个特征:
(1)互斥性:任何时刻最多只能有一个进程进入管程活动,其他进程必须等待。
(2)安全性:管程的数据结构及过程只能访问内部数据或被内部访问,管程之外的过程都不能访问它们,所有需要访问管程内部的进程都必须经过管程才能进入。
(3)共享性:进程只有通过管程、调用管程内的函数才能访问共享数据。
(4)结构性:管程以模块的方式封装共享资源及其访问方法,隐藏了实现细节,使得结构清晰,提高了可读性和易维护性,也保证了资源访问的正确性。
3.5.2条件变量
在使用管程实现进程同步前,必须先设置两个同步操作原语wait和signal。当进程通过管程请求临界资源而没能得到满足时,管程就调用wait原语将该进程阻塞并插入阻塞队列中;当进程访问完并释放资源后,管程就调用signal原语唤醒阻塞队列中的队首进程。尽管wait和signal原语能实现进程同步,但可能会导致“忙等”现象。例如,如果进程在访问管程期间被阻塞,且在阻塞期间不释放管程,那么将导致其他进程无法进入管程。为了解决这个问题,引入了条件变量。
条件变量指进程在访问管程时的阻塞条件。针对不同的阻塞条件,可设置多个不同的条件变量。给定一个条件变量x,其wait和signal操作说明如下:
(1)x.wait:正在调用管程的进程因条件x而阻塞,则调用x.wait将自己插入到x的阻塞队列上,并释放管程,使得其他进程可以访问该管程。
(2)x.signal:正在调用管程的进程发现x条件发生了变化,则调用x.signal唤醒x阻塞队列上的进程。若不存在这样的进程,则继续执行原进程;否则选择其中一个进程(要么原进程,要么是刚唤醒的进程)执行。
3.5.3管程的应用
生产者-消费者问题也可使用管程来解决。首先,为生产者和消费者建立一个管程,并命名为PC。该管程包括两个过程:放产品put(x)和取产品get(x),以及两个条件变量notfull和notempty,其中:
(1)put(x):生产者将产品放入缓冲池,其中count>=n表示缓冲区满,生产者需等待;count<=0表示缓冲区空,消费者需等待
(2)get(x):消费者从缓冲池取产品,其中count<=0表示缓冲区没有产品,消费者需等待。
管程的具体实现如下:
Monitor Producer-Consumer{
item buffer[N];
int in,out,count;
condition notfull,notempty;
public:
void put(item x){
if(count>=N) cwait(notfull);//如果缓冲区满,则需要等待notful为真
buffer[in]=x;
in=(in+1)%N;
count++;
csignal(notempty);//唤醒notempty
}
void get(item x){
if(count<=0) cwait(notempty);//等待缓冲区非空
x=buffer[out];
out=(out-1)%N;
count++;
csignal(notfull);//唤醒notfull;
void init(){
count=0;
in=0;
out=0;
}
}PC;
生产者和消费者的进程代码分别如下:
void producer_i(){ //i=1,2,...,m
item x;
while(TRUE){
...
生产x;
PC.put(x):
}
}
void comsumer_i(){
item x;
while(TRUE){
PC.get(x):
消费x;
...
}
}
3.6进程通信
进程通信是进程在运行期间的信息交换,它是实现多进程间协作和同步的常用工具,也是操作系统内核层极为重要的部分。
根据通信的机制不同,进程通信可分为低级通信和高级通信:
(1)低级通信:指进程的互斥与同步中的信息交换。
(2)高级通信:指用户直接利用系统提供的通信命令,传送大量数据的通信方式。
这里重点介绍进程的高级通信,进程的高级通信主要有三种方式:共享存储器系统、消息传递系统和管道通信系统。
3.6.1共享存储器系统
共享存储器系统是指相互通信的多个进程通过共享某些数据结构或存储区方式,实现进程之间的信息交换。共享存储器系统有可以分为共享数据结构和共享存储区两种方式:
(1)共享数据结构方式:指相互通信的进程通过共同使用某些数据结构,实现信息交换的目的。该方式由于交换信息量较少、效率较低且实现复杂,
(2)共享存储区方式:指在存储器中划出一块共享存储区,相互通信的进程通过对共享存储区中的数据进行读或写,实现数据通信的目的,其中一个进程向共享空间中写数据,而另一个进程则从共享空间读数据。该方式的特点是进程之间可将共享的内存页面通过链接方式映射到各个进程自己的虚拟地址空间中,使得进程访问共享内存页面如同访问自己的私有空间,因而效率高,适用于传送较多的数据。
采用共享存储区系统的进程通信过程大致包括以下三个步骤:
(1)申请共享存储区块:进程通信之前先向系统申请共享存储区中的一个区块,并为它指定一个区块关键字。若该区块已分配给了其他进程,则将该区块的关键字返回给该进程。
(2)合并分配的存储区:申请进程把获得的共享存储区的区块链接到本进程上。
(3)读写公用存储区:进程可以像读写普通存储器一样,读、写这一共享的存储区块,实现信息的传递。
由于共享存储区的通信方式是通过将共享的存储区块直接附加到进程的虚拟地址空间中来实现的,因此通信进程之间读写操作的同步问题必须由各进程利用同步工具解决。此外,该方式只适用于同一个计算机系统中的进程通信,不适合网络通信。
3.6.2消息传递系统
消息传递系统利用系统提供的消息发送与接收命令(原语),实现进程间的数据交换,即两个进程在通信时,发送进程直接或间接地将信息传送给接收进程。这种方式大大提高了工作效率,方便用户使用。因此,消息传递系统成为最常用的高级通信。根据实现方式的不同,可分为直接通信方式和间接通信方式两种。
1.直接通信方式
直接通信方式是指发送进程和接收进程都显示地提供给对方自己的地址或标识符,发送进程利用系统提供的发送原语,直接将消息挂在接收进程的消息缓冲队列上;接收进程使用接收原语从消息缓冲队列中取出消息。发送原语和接收原语分别为:
Send(P,Message); //将消息发送给进程P
Receive(Q,Message); //接收来自进程Q的消息
假设进程P要发送消息给进程Q,其消息发送过程如下:P利用Send(P,Message); 将消息Message发送至Q的缓冲队列,随后P阻塞;Q利用Receive(Q,Message); 从消息队列中接收Message,随后唤醒P。若Q先执行Receive原语,则其先阻塞,直到P执行Send原语。
直接消息传递系统由于没有缓冲,发送进程和接收进程必须交替执行,因而可实现实时通信,但缺乏灵活性。
2.间接通信方式
间接通信方式是指发送进程使用发送原语将消息发送到某种中间实体(俗称信箱),接收进程使用接收原语从该中间实体中取出消息。当多个进程共享一个信箱时,它们就能进行通信。间接通信方式灵活较大,既可以实现实时通信,也可以实现非实时通信。计算机网络中的电子邮件系统就是典型的间接通信方式。间接通信方式如图3-5所示。
图3-5 间接通信方式
发送原语和接收原语分别为:
Send(MailBox,Message); //把消息送至信箱MailBox
Receive(MailBox,Message); //从信箱MailBox接收消息
间接通信方式的具体工作可描述如下:消息发送时,发送进程首先检查指定的信箱MailBox,如果信箱已满,则该进程被阻塞;若信箱没有满,则将信件存入信箱,如果有进程正在等待信箱中的信件,则唤醒该等待进程;接收消息时,接收进程首先检查MailBox,如果信箱中有信件,取出信件,,随后若有进程在等待信件存入信箱,则唤醒该等待进程;若信箱中没有信件,则接收进程被阻塞。
用来暂存消息的信箱是一种数据结构,由信箱头和信箱体两个部分组成。信箱的创建和撤销操作可由操作系统或用户完成。创建者是信箱的拥有者,信箱通常可分为三类:
(1)私用信箱:用户进程为自己建立的信箱,信箱的拥有者可从信箱中取出消息,而其他用户只能向该信箱发送消息。
(2)公用信箱:操作系统创建的信箱,并提供给用户使用,用户既可以给信箱发送消息,也可以从信箱中取走发送给自己的消息。
(3)共享信箱:用户进程创建的信箱,并指明哪些用户可共享该信箱,信息的拥有者和共享者可从信箱中读取发送给自己的消息。
利用信箱通信时,发送进程与接收进程存在下列关系:
(1)一对一关系:一个发送进程与一个接收进程之间建立的专用通信通道,它们之间的通信不受其他进程影响。
(2)多对一关系:允许一个接收进程与多个发送进程之间进行通信,也称为客户/服务器方式。
(3)一对多关系:允许一个发送进程与多个接收进程之间通信,使发送进程可使用广播方式向一组或全部接收进程发送消息。
(4)多对多关系:允许建立一个公用邮箱,使得多个进程既可以把消息发送到该邮箱,也可以从邮箱中取走发送给自己的消息。
3.6.3管道通信系统
管道通信系统是指借助于管道文件的一种通信方式,其中发送(写)进程以字符流的形式向管道文件写入大量的数据,而接收(读)进程则从管道文件中接收数据。管道通信方式最早是在UNIX操作系统实现的。由于它能有效传送大量数据,因而又被引入到许多其他操作系统中。该通信方式中,管道文件是两个进程交换信息的桥梁,它实际上是一种专门用于通信交换的共享文件。因此,管道通信机制具有以下三个特点:
(1)互斥:管道文件必须互斥地访问,当一个进程对管道文件进行读/写时,另外一个进程必须等待。
(2)同步:发送进程将数据写入管道文件后就睡眠等待,直到接收进程将管道文件中的数据取走后再唤醒;接收进程读到一个空的管道文件时应睡眠等待,直到发送进程将数据写入管道后再唤醒。
(3)实时:通信的进程必须确定对方同时存在时才能进行通信。此外,管道以先进先出(FIFO)方式组织数据传输。
管道通信的发送和接收函数分别为:
//从管道handle中写入长度为len的消息message
write(handle,message,len);
//从管道handle中读取长度为len的消息message
read(handle,message,len);
管道一般有两种形式:
(1)匿名管道:指管道没有名字,它只适用于父子进程之间的通信。
(2)命名管道:进程通过使用管道的名字获得管道,它可用于任何进程之间的通信,但不能同一时间被若干进程不加限制地访问。
管道通信方式的特点是简单方便,且一次性可以传送大量数据,但只适合于实时通信,因而效率较低。此外,管道文件是一个单向通信信道,若进程之间要进行双向通信,则需要定义两个管道。