目录
3.2.1.4 算法四:Peterson‘s Algorithm
3. 同步与互斥
本节问题?
1. 为什么要引入进程同步的概念?
2. 不同的进程之间会存在什么关系?3. 当单纯的用本节介绍的方法解决问题时会出现什么新的问题?
3.1 同步与互斥的基本概念
在多道程序环境下,进程是并发执行的,不同的进程之间存在着不同的相互制约关系。为了协调进程之间的相互制约关系,引入进程同步的概念。
举个简单的例子来看:
让系统计算 1 + 2 * 3,假设系统产生两个进程:一个是加法进程,一个是乘法进程。要让计算结果是正确的,一定要让加法进程发生在乘法进程之后,但实际上操作系统具有异步性,若不加以约束,加法进程发生在乘法进程之前是绝对有可能的,因此要制定一定的机制去约束加法进程,让它在乘法进程完成之后才发生,这种机制就是本节我们要学习的;
3.1.1 临界资源
虽然多个进程可以共享系统中的各种资源,但其中许多资源一次只能为一个进程所用,我们将一次仅允许一个进程使用的资源称为临界资源。许多物理设备都属于临界资源,如打印机等。此外,还有许多变量、数据等都可以被若干进程共享,也属于临界资源。
对临界资源的访问,必须互斥地进行,在每个进程中,访问临界资源的那段代码称为临界区。
为了保证临界资源的正确使用,可把临界资源的访问过程分为四个部分:
① 进入区。为了进入临界区使用临界资源,在进入区要检查会否可进入临界区,若能进入临界区,则应设置正在访问临界区的标志,以阻止其他进程同时进入临界区。② 临界区。进程中访问临界资源的那段代码,又称临界段。
③ 退出区。将正在访问临界区的标志清除。
④ 剩余区。代码中其余部分。
do
{
entry section; //进入区
critical section; //临界区
exit section; //退出区
remainder section; //剩余区
}
while(true)
3.1.2 同步
同步也称为直接制约关系,是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递信息所产生的制约关系。进程间的直接制约关系源于它们之间的相互合作。
举个例子:
输入进程 A 通过单缓冲向进程 B 提供数据。当该缓冲区空时,进程 B 不能获得所需数据而阻塞,一旦进程 A 将数据送入缓冲区,进程 B 就被唤醒。反之,当缓冲区满时,进程 A 被阻塞,仅当进程 B 取走缓冲数据时,才唤醒进程 A 。
3.1.3 互斥
互斥也称间接制约关系。当一个进程进入临界区使用临界资源时,另一个进程必须等待,当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。
举个例子:
在仅有一台打印机的系统中,有两个进程 A 和进程 B,若进程 A 需要打印时,系统已将打印机分配给进程 B,则进程 A 必须阻塞。一旦进程 B 将打印机释放,系统便将进程 A 唤醒,并将其由阻塞态变为就绪态。
为了禁止两个进程同时进入临界区,应遵循以下准则:
① 空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
② 忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待。
③ 有限等待。对请求访问的进程,应保证能在有限时间内进入临界区。
④ 让权等待。当进程不能进入临界区时,应立即释放处理器,防止进程忙等待。
3.2 实现临界区互斥的基本方法
3.2.1 软件实现方法
在进入临界区设置并检查一些标志来标明是否有进程在临界区中,若已有进程在临界区,则在进入区通过循环检查进行等待,进程离开临界区后则在退出区修改标志。
3.2.1.1 算法一:单标志法
该算法设置一个公用整型变量 turn,用于指示被允许进入临界区的进程编号,即若 turn = 0,则允许 进程进入临界区。
该算法可确保每次只允许一个进程进入临界区。但两个进程必须交替进入临界区,若某个进程不再进入临界区,则另一个进程也将无法进入临界区(违背 “空闲让进”)。这样很容易造成资源利用不充分。
P0 进程:
while(turn != 0); //进入区 while 循环,只要turn不是0,那么turn就是1,表示允许P0进程进入临界区
critical section; //临界区
turn = 1; //退出区
remainder section; //剩余区
P1进程:
while(turn != 1); //进入区
critical section; //临界区
turn = 0; //退出区
remainder section; //剩余区
这里首先介绍一下上述代码的意思:turn 的初值为 0 ,即刚开始只允许 P0 进程进入临界区。
若 P1 先上处理机运行,则 P1 进程的 while 循环条件满足,turn 始终不等于 1 ,则一直在 P1 进程的 while 循环中运行,直到 P1 进程的时间片用完,产生调用,切换 P0 上处理机运行。
P0 进程的 while 循环不会卡住 turn = 0 这个初始条件,P0 可以正常访问临界区,在 P0 访问临界区期间哪怕是发生了进程切换,这个时候 turn 还是等于 0 ,P1 进程的 while 循环还是无法跳过,只有 P0 进程在退出区将 turn 的值改为 1 之后,才可以调用 P1 进程。
因此,该算法可以实现 “同一时刻最多只允许一个进程访问临界区” 。
若
顺利进入临界区并从临界区离开,则此时临界区是空闲的,但是
并没有进入临界区的打算,turn = 1 一直成立,导致
进程的 while 循环永远不成立,那么
就无法再进入临界区(一直被 while 死循环困住)。
这种算法也存在一个致命的缺点:两个进程必须轮流的使用同一个资源,主打的就是一个谦让;就是说 A 使用完这个资源,然后就谦让的让 B 去使用;B 使用完这个资源,就谦让的让 A 去使用;但是如果 A 和 B 其中一个不再使用这个资源了,那么另外一个人永远也不能使用这个资源,因为对方永远没有谦让的告诉你可以使用这个资源了;违反了 空闲让进 的原则,哪怕是资源没有被使用,也不允许某些进程访问。
3.2.1.2 算法二:双标志法先检查
该算法的基本思想是在每个进程访问临界区资源之前,先查看临界资源是否被访问,若正在被访问,该进程需要被等待;否则,进程才进入自己的临界区。为此,设置一个数据 flag[i],如第 i 个元素值为 FALSE,表示 进程未进入临界区,值为 TRUE,表示
进程进入临界区。
bool flag[2]; //表示进入临界区意愿的数组
flag[0] = false;
flag[1] = false; //刚开始设置两个进程都不想进入临界区
P0 进程
while(flag[1]);
flag[0]=true;
critical section;
flag[0]=false;
remainder section;
P1 进程
while(flag[0]);
flag[1]=true;
critical section;
flag[1]=false;
remainder section;
上述算法这么理解:
每个进程在使用之前,先去搞明白对方的进程是否有想要使用资源的意愿;比如说:P0 进程,首先先通过 while 循环检查 P1 进程的意愿,看 P1 进程的标志位 flag 是否等于 true;如果 P1 不想使用资源,那么再设置自己的状态位 flag 为 ture,告诉对方,现在我想要访问资源,紧接着访问临界区,并在离开区将 flag 设置为 false;
这样一来,可以不用交替的访问资源,只需要查看对方进程的状态即可;
当然,这种算法也存在一定的问题,因为两个进程是并发执行的,初始时我设置 flag 都是 false,也就表示两个进程都不想进入临界区;此时,P0 进程会跳过 while 循环,将自己的状态设置为 true;因为并发执行,这个时候如果发生进程切换,P1 进程也会发现 P0 的状态 flag 也是 false,P1 进程也会跳过 while 循环,将自己的状态也设置为 ture;这个时候 P0 和 P1 进程都想要访问资源;
两个进程可能同时进入临界区; 违反了 “忙则等待” 的原则;
优点:不用交替进入,可连续使用;
缺点: 和
可能同时进入临界区。
按序列 ①②③④ 执行时,会同时进入临界区(违背 “忙则等待”)。在检查对方的 flag 后和切换自己的 flag 前有一段时间,结果都检查通过。问题出在检查和修改操作不能一次进行。
3.2.1.3 算法三:双标志法后检查
算法二先检测对方的进程标志状态,再置自己的标志,由于在检测和放置中可插入另一个进程到达时的检测操作,会造成两个进程在分别检测后同时进入临界区。为此,算法三先将自己的标志设置为 TRUE,再检测对方的标志,若对方的标志为 TRUE,则进程等待;否则进入临界区。
bool flag[2]; //表示进入临界区意愿的数组
flag[0] = false;
flag[1] = false; //刚开始设置两个进程都不想进入临界区
P0 进程
flag[0]=true;
while(flag[1]);
critical section;
flag[0]=false;
remainder section;
P1 进程
flag[1]=true;
while(flag[0]);
critical section;
flag[1]=false;
remainder section;
双标志法后检查只是相对于双标志法先检测来说:先上锁,然后再检查标志位;
上述算法的意思是:
首先还是设置状态位 flag 表示我两个进程都不想进入临界区;然后 P0 进程先将自己的状态位设置为 ture,表示我想要访问临界区;然后再检查对方状态的标志位,如果对方不想访问,那直接进入临界区,出来的时候将状态位设置为 false 即可;
P1 进程也是如此;
但是我们同样考虑并发的情况:P0 进程设置自己的状态位为 ture,然后检查对方的状态位,这个时候发生任务切换,P1 就会将自己的状态位也设置为 ture ,这个时候两个进程同时都想要进入临界区,但是检测对方的状态后,发现对方也想要进入临界区,于是互相谦让,从而导致 “饥饿” 现象的发生。
两个进程几乎同时都想进入临界区,它们分别将自己的标志值 flag 设置为 TRUE,并且同时检测对方的状态(执行 while 语句),发现对方也要进入临界区时,双方互相谦让,结果谁也进不了临界区,从而导致 “饥饿” 现象。
3.2.1.4 算法四:Peterson‘s Algorithm
为了防止两个进程为进入临界区而无限期等待,又设置了变量 turn ,每个进程在先设置自己的标志后再设置 turn 标志。这时,再同时检测另一个进程状态标志和允许进入标志,以便保证两个进程同时要求进入临界区时,只允许一个进程进入临界区。
bool flag[2]; //表示进入临界区意愿的数组
int turn = 0; // turn 表示优先让哪个进程进入临界区
P0 进程:
flag[0]=TRUE; turn=1; //进入区
while(flag[1]&&turn==1); //进入区
critical section; //临界区
flag[0]=FALSE; //退出区
remainder section; //剩余区
P1 进程:
flag[1]=TURE; turn=0; //进入区
while(flag[0]&&turn==0); //进入区
critical section; //临界区
flag[1]=FALSE; //退出区
remainder section; //剩余区
上述算法的意思是?
首先设置临界区意愿数组,P0 进程首先表达自己想要进入临界区的意愿,然后将 turn 设置为 1 ,表示谦让,我愿意让 P1 进程先进入临界区,然后通过 while 去判断 P1 进程是否也有进入临界区的意愿,如果 P1 进程有进入临界区的意愿,并且 P0 进程也愿意让 P1 优先进入临界区,那么 P0 进程的 while 循环成立,P0 进程等待,P1 进程进入临界区;反之也是这样;
考虑进程 ,一旦设置 flag[i]=true,就表示它想要进入临界区,同时 turn=j,此时若进程
已在临界区中,符合进程
的 while 循环条件,则
不能进入临界区。若
不想要进入临界区,即 flag[j]=false,循环条件不符合,则
可以顺利进入,反之亦然。
本算法的基本思想是算法一和算法三的结合。利用 flag 解决临界资源的互斥访问,而利用 turn 解决 “饥饿” 现象。
3.2.2 硬件实现方法
计算机提供了特殊的硬件指令,允许对一个字中的内容进行检测和修正,或对两个字的内容进行交换等。通过硬件支持实现临界段问题的方法称为低级方法,或称元方法。
3.2.2.1 中断屏蔽方法
当一个进程正在执行它的临界区代码时,防止其他进程进入其临界区的最简单方法就是关中断。因为 CPU 只在发生中断时引起进程切换,因此屏蔽中断能够保证当前运行的进程让临界区代码顺利的执行完,进而保证互斥的正确实现,然后执行开中断。
……
……
关中断;
临界区;
开中断;
……
……
这种方法限制了处理机交替执行程序的能力,因此执行的效率会明显降低。对内核来说,它执行更新变量或列表的几条指令期间,关中断是很方便的,但将关中断的权利交给用户则很不明智,若一个进程关中断后不再开中断,则系统可能因此终止。
3.2.2.2 硬件指令方法
3.2.2.2.1 TestAndSet 指令
这条指令是原子操作,即执行该代码时不允许被中断。其功能是读出指定标志后把该标志设置为真。
boolean TestAndSet(boolean *lock)
{
boolean old;
old=*lock;
*lock=ture;
return old;
}
可以为每个临界区资源设置一个共享布尔变量 lock,表示资源的两种状态:ture 表示正在被占用,初值为 false。进程在进入临界区之前,利用 TestAndSet 检查标志 lock,若无进程在临界区,则其值为 false,可以进入,关闭临界资源,把 lock 置为 true,使任何进程都不能进入临界区;若有进程在临界区,则循环检查,直到进程退出。
布尔变量是一种只能取两个值之一的变量,这两个值通常被定义为真(True)和假(False)。布尔变量是计算机编程中常用的数据类型,用于表示逻辑条件的真假或表示某种状态的开关。它们是基本的逻辑数据类型,由实现计算机逻辑运算的算法和语句使用。
while TestAndSet(&lock);
//进程的临界区代码段
lock=false;
//进程的其他代码
3.2.2.2.2 Swap 指令
该指令的功能是交换两个字(字节)的内容。
Swap(boolean *a,boolean *b)
{
boolean Temp;
Temp=*a;
*a=*b;
*b=Temp;
}
注意:以上对 TestAndSet 和 Swap 指令的描述仅是功能实现,而并非软件实现的定义。事实上,它们是由硬件逻辑直接实现的,不会被中断。
用 Swap 指令可以简单有效地实现互斥,为每个临界资源设置一个共享布尔变量 lock,初值为 false;在每个进程中再设置一个局部布尔变量 key,用于与 lock 交换信息。在进入临界区前,先利用 Swap 指令交换 lock 与 key 的内容,然后检测 key 的状态;有进程在临界区时,重复交换和检查过程,直到进程退出。
key=true;
while(key!=false)
Swap(&lock,&key);
//进程的临界区代码段
lock=false;
//进程的其他代码
硬件方法的优点:适用于任何数目的进程,而不管是单处理机还是多处理机;简单、容易验证其正确性。可以支持进程内有多个临界区,只需为每个临界区设立一个布尔变量;
硬件方法的缺点:进程等待进入临界区时需要耗费处理机时间,不能实现让权等待。从等待进程中随机选择一个进入临界区,有的进程可能一直选不上,从而导致 “饥饿” 现象。
3.2.3 互斥锁
解决临界区最简单的工具就是互斥锁(mutex lock)。一个进程在进入临界区时应获得锁;在退出临界区时释放锁。函数 acquire() 获得锁,而函数 release() 释放锁;
每个互斥锁有一个布尔变量 available,表示锁是否可用。如果锁是可用的,调用 acquire() 会成功,且锁不可再用。当一个进程试图获取不可用的锁时,会被阻塞,直到锁被释放。
acquire()
{
while(!available); //忙等待
available=false; //获得锁
}
release()
{
available=true; //释放锁
}
acquire() 或 release() 的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。
互斥锁的主要缺点是忙等待,当有一个进程在临界区中,任何其他进程在进入临界区时必须连续循环调用 acquire()。当多个进程共享同一 CPU 时,就浪费了 CPU 的周期。因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上等待,不影响其他线程的执行。
3.3 信号量
信号量机制是一种功能较强的机制,可用来解决互斥与同步问题,它只能被两个标准的原语 wait(S) 和 signal(S) 访问,也可记为 “P操作” 和 “V操作”。
信号量 其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为 1 的信号量。
一对原语:wait(S) 和 signal(S) 原语,可以把原语理解成我们自己写的函数,函数名分别为 wait 和 signal,括号里的 信号量 S 其实就是函数调用时传入的一个参数。这两个原语简称为 “P操作” 和 “V操作”(来自荷兰语中的 proberen 和 verhogen)。
说了这么多,想要表达的意思就是:信号量是一个可以表示系统中某种资源的数量的一个变量;可以通过 PV 这对原语实现对信号量的操作。
原语是指完成某种功能且不被分割、不被中断执行的操作序列,通常可由硬件来实现。例如,前述的 Test - and - Set 和 Swap 指令就是由硬件实现的原子操作。原语功能的不被中断执行特性在单处理机上可由软件通过屏蔽中断方法实现。原语之所以不能被中断执行,是因为原语对变量的操作过程若被打断,可能会去运行另一个对同一变量的操作过程,从而出现临界段问题。
3.3.1 整型信号量
整型信号量被定义为一个用于表示资源数目的整型量 S,wait 和 signal 操作可描述为
wait(S)
{
while(S<=0);
S=S-1;
}
signal(S)
{
S=S+1;
}
在整型信号量机制中的 wait 操作,只要信号量 S<=0,就会不断的测试。因此,该机制并未遵循 “让权等待” 的准则,而是使进程处于 “忙等” 的状态。
举个例子:
某计算机系统有一台打印机 int S=1; //初始化整型信号量 S,表示当前系统中可用的打印机资源数 void wait(int S) //wait 原语,相当于进入区 { while(S<=0); //如果资源数不够,就一直 while 循环等待 S=S-1; //如果资源数够,就占用一个资源 } void signal(int S) //signal 原语,相当于退出区 { S=S+1; //使用完资源后,在退出区释放资源 }
进程 P0: …… wait(S); //进入区,申请资源 使用打印机资源…… //临界区,访问资源 signal(S); //退出区,释放资源 …… 此时若有其他进程也想要使用打印机资源 进程 P1 …… wait(S); //进入区,申请资源 使用打印机资源…… //临界区,访问资源 signal(S); //退出区,释放资源 …… 这个时候调用 wait 原语,因为原本定义整型信号量等于 1,已经被进程 P0 使用了,当 进程 P1 也想要使用这个资源时,while 循环成立,会一直在 while 循环中循环等待; 直到 P0 这个进程把打印机资源释放;
3.3.2 记录型信号量
记录型信号量机制是一种不存在 “忙等” 现象的进程同步机制。除了需要一个用于代表资源数目的整型变量 value 外,再增加一个进程链表 L,用于链接所有等待该资源的进程。记录型信号量得名于采用了记录型的数据结构。
记录型信号量可描述为
typedef struct
{
int value; //代表资源的数目
struct process *L; //表示所有等待该资源的进程
}semaphore;
wait 操作:
void wait(semaphore S) //相当于申请资源
{
S.value--;//剩余资源数--
if(S.value<0) //如果剩余资源数减减以后小于0,那么就意味着原本就没有资源
{
add this process to S.L;
block(S.L); //使用 block 原语使进程从运行态进入阻塞态,并把它挂到信号量 S 的等待队列中
}
}
wait 操作,S.value-- 表示进程请求一个该类资源(也就是每申请一个资源,代表资源数目的变量就减减),当 S.value < 0 时,表示该类资源已分配完毕,因此进程应调用 block 原语,进行自我阻塞,放弃处理机,并插入该类资源的等待队列 S.L,可见该机制遵循了 “让权等待” 的准则。
void signal(semaphore S) //相当于释放资源
{
S.value++; //释放资源,剩余资源数++
if(S.value<=0) //如果释放资源后,剩余的资源数依然小于等于0
//那么意味了等待队列中仍有资源被挂起,这是因为wait原语会把资源挂起
{
remove a process P from S.L;
wakeup(P); //调用wakeup原语唤醒等待队列中的一个进程,将该进程从阻塞态变为就绪态
}
}
signal 操作,表示进程释放一个资源,使系统中可供分配的该类资源数增 1 ,因此有 S.value ++。若加 1 后仍是 S.value <=0,则表示在 S.L 中仍有等待该资源的进程被阻塞,因此还应调用 wakeup 原语,将 S.L 中的第一个等待进程唤醒。
举个例子来看记录型信号量:
3.3.3 利用信号量实现同步
信号量机制能用于解决进程间的各种同步问题。设 S 为实现进程 ,
同步的公共信号量,初值为 0。进程
中的语句 y 要使用进程
中语句 x 的运行结果,所以只有当语句 x 执行完成之后语句 y 才可以执行。进程同步就是让各并发进程按要求有序地推进。
举个例子来看进程同步的要求:
P1() { 代码1; 代码2; 代码3; } P2() { 代码4; 代码5; 代码6; } 由于进程的并发性,可能是P2进程的代码4和代码5先上处理机运行,等待时间片用完, P1进程的代码1和代码2才上处理机运行;代码的执行顺序是我们不可预知的; 但是有时候我们必须让代码的执行顺序按照我们预置的顺序进行: 比如说进程2的代码4必须要基于进程1和代码1和代码2的执行结果才能执行; 此时我们必须保证代码4一定是在代码2之后才会执行; semaphore S=0; P1() { 代码1; 代码2; V(S); 代码3; } P2() { P(S); 代码4; 代码5; 代码6; } 通过以上代码即可实现先执行进程P1的代码1和代码2,然后再执行P2进程的代码4 分析如下: 倘若先执行P1进程,代码1和代码2上处理机运行,V操作可用资源数++,时间片用完,切换至P2进程 P操作发现可用资源数为1,表示有可用的资源,可用资源数--;执行代码4;这样就实现了 代码4一定是在代码2之后才执行; 倘若是P2进程先上处理机运行,P操作发现可用资源数小于等于0,while循环成立,则一直在while循环中等待;时间片用完,切换至进程1,最终结果还是先运行P1进程的代码1和代码2,然后再运行P2进程的代码4,同样实现了代码4一定是在代码2之后才运行的; 注意,V操作会唤醒被悬挂到等待队列的进程,也就是说只有执行了V操作才会唤醒被悬挂到等待队列的P2进程;
这就是进程同步问题,让本来异步并发的进程互相配合,有序推进。
总结一下就是:
设置同步信号量 S,初始为 0;
在想要前面操作的代码后执行 V 操作
在想要后面操作的代码前执行 P 操作
前 V 后 P
要通过信号量实现进程同步
首先要分析在什么地方需要实现同步,也就是说必须保证一前一后执行的两个操作
什么代码必须在前面运行
什么代码又必须在后面运行
设置一个同步信号量,初始值为0
semaphore S=0; //初始化信号量
P1()
{
x; //语句x
V(S); //告诉进程P2,语句x已经完成
……
}
P2()
{
……
P(S); //检查语句x是否运行完成
y; //检查无误,运行y语句
……
}
上述代码的意思就是:
倘若P1进程先上处理机运行,语句x运行完以后,V操作会使得信号量++,
在执行P2进程,P操作首先会发现信号量等于1,表示有可用的资源,
可用信号量减减,执行y语句,因此实现了先执行x语句,再执行y语句;
倘若P2进程先上处理机运行,P操作首先使得可用资源减减--,发现此时可以资源等于-1;while循环等待
进程切换至P1进程,所以最终结果还是先执行x语句,再执行y语句
若 先执行到 P(S) 操作时,S 为 0,执行 P 操作会把进程
阻塞,并放入阻塞队列;
当进程 中的 x 执行完后,执行 V(S) 操作,把
从阻塞队列中放回就绪队列,当
得到处理机时,就得以继续执行。
3.3.4 利用信号量实现进程互斥
信号量机制也可以很方便的解决进程互斥问题。设 S 为实现进程 ,
互斥的信号量,由于每次只允许一个进程进入临界区,所以 S 的初值应为 1(表示可用的资源数为 1)。只需要把临界区置于 P(S) 和 V(S) 之间,即可实现两个进程对临界资源的互斥访问。
semaphore S=1; //初始化信号量,表示可用的资源数为1
只要我们用 semaphore 来定义的信号量都是记录型信号量,默认是有排队机制的;
typedef struct
{
int value;
struct process *L;
}semaphore;
P1()
{
……
P(S); //准备开始访问临界资源,加锁
进程P1的临界区;
V(S); //访问结束,解锁
……
}
P2()
{
……
P(S); //准备开始访问临界资源,加锁
进程P2的临界区;
V(S); //访问结束,解锁
……
}
实现互斥的思路就是:初始化信号量为1,就表示可用的资源数为1
P1 进程通过P操作访问临界资源,可用资源数减一,此时可用资源数为0
当 P2 进程通过P操作访问临界资源时,if 判断语句判断可用资源数小于等于0
卡在while循环内循环等待可用资源;
当 P1 进程通过 V 操作将可用资源归还后,P2 进程就可以通过 P 操作访问临界资源了;
当没有进程在临界区时,任意一个进程要进入临界区,就要执行 P 操作,把 S 的值减为 0,然后进入临界区;当有进程存在于临界区时,S 的值为 0,再有进程要进入临界区,执行 P 操作时将会被阻塞,直至在临界区中的进程退出,这样便实现了临界区的互斥。
互斥是不同进程对同一信号量进行 P,V 操作实现的,一个进程成功对信号量执行了 P 操作后进入临界区,并在退出临界区后,由该进程本身对该信号量执行 V 操作,表示当前没有进程临界区,可以让其他进程进入。
总结 PV 操作在同步互斥下的应用:
在同步问题中,若某个行为要用到某种资源,则在这个行为前面 P 这种资源一下;若某种行为会提供某种资源,则在这个行为后面 V 这种资源一下。在互斥问题中,P,V 操作要紧夹使用互斥资源的那个行为,中间不能有其他冗余代码。
要注意:
对不同的临界资源需要设置不同的互斥信号量;比如说 进程1 和 进程2 需要打印机这种资源,那么需要设置信号量 mutex1;
进程3 和 进程4 需要摄像头这种资源,还需要设置信号量 mutex2;
3.3.5 利用信号量实现前驱关系
信号量也可用来描述程序之间或语句之间的前驱关系。如下图:
其中,、
、
、
、
、
是最简单的程序段(只有一条语句)。为使各程序段能正确执行,应设置若干初始值为 “0” 的信号量。
例如,为保证
->
,
->
的前驱关系,应分别设置信号量 a1,a2。
同样,为保证
->
,
->
,
->
,
->
,
->
,应设置信号量 b1,b2,c,d,e。
实现算法如下:
semaphore a1=a2=b1=b2=c=d=e=0; //初始化信号量
S1()
{
……;
V(a1);
V(a2); //S1 已经运行完成
}
S2()
{
P(a1); //检查 S1 是否运行完成
……;
V(b1);
V(b2); //S2 已经运行完成
}
S3()
{
P(a2); //检查 S1 是否运行完成
……;
V(c); //S3 已经运行完成
}
S4()
{
P(b1); //检查 S2 是否运行完成
……;
V(d); //S4 已经运行完成
}
S5()
{
P(b2); //检查 S2 是否运行完成
……;
V(e); //S5 已经运行完成
}
S6()
{
P(c); //检查 S3 是否运行完成
P(d); //检查 S4 是否运行完成
P(e); //检查 S5 是否运行完成
……;
}
3.3.6 分析进程同步和互斥问题的方法步骤
- 1. 关系分析。找出问题中的进程数,分析它们之间的同步和互斥关系。同步、互斥、前驱关系直接按照上面例子的经典范式改写即可。
- 2. 整理思路。找出解决问题的关键点,根据进程的操作流程确定 P 操作、V 操作的大致顺序。
- 3. 设置信号量。根据上面两步,设置需要的信号量,确定初值,完善整理。
、
、
、
、
、
这是一个非常直观的问题。以 为例,它是
的后继,所以要用到
的资源,在前面的总结中我们说过,在同步问题中,要用到某种资源,就要在行为前 P 这种资源一下。
是
,
的前驱,给
,
提供资源,所以在行为后面 V 由
和
代表的资源一下。
3.4 管程
在信号量机制中,每个要访问临界资源的进程都必须自备同步的 PV 操作,大量分散的同步操作给系统管理带来了麻烦,且容易因同步操作不当而导致系统死锁。于是,便产生了一种新的进程同步工具——管程。管程的特性保证了进程互斥,无须程序员自己实现互斥,从而降低了死锁发生的可能性。同时管程提供了条件变量,可以让程序员灵活地实现进程同步。
3.4.1 管程的定义
系统中各种硬件资源和软件资源,均可用数据结构抽象地描述其资源特性,即用少量信息和对资源所执行的操作来表征该资源,而忽略它们的内部结构和实现细节。
利用共享数据结构抽象地表示系统中的共享资源,而把该数据结构实施的操作定义为一组过程。进程对该共享资源的申请、释放等操作,都通过这组过程来实现,这组过程还可以根据资源情况,或接受或阻塞进程的访问,确保每次仅有一个进程使用共享资源,这样就可以统一管理对共享资源的所有访问,实现进程互斥。这个代表共享资源的数据结构,以及由对该共享数据结构实施操作的一组过程所组成的资源管理程序,称为管程(monitor)。管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。
由上述定义可知,管程由 4 部分组成:
① 管程的名称;② 局部于管程内部的共享数据结构说明;
③ 对该数据结构进行操作的一组过程(或函数);
④ 对局部于管程内部的共享数据设置初始值的语句;
monitor Demo
{
① 定义一个名称为 Demo 的管程;
② 定义共享数据结构,对应系统中的某种共享资源
//共享数据结构 S
④ 对共享数据结构初始化的语句
init_code()
{
S=5; //初始资源数等于5
}
//③ 过程1:申请一个资源
task_away()
{
对共享数据结构x的一系列操作处理
S--; //可用资源数-1
……;
}
//③ 过程2:归还一个资源
give_back()
{
对共享数据结构x的一系列处理;
S++; //可用资源数+1
……;
}
}
① 管程把对共享资源的操作封装起来,管程内的共享数据结构只能被管程内的过程所访问。一个进程只有通过调用管程内的过程才能进入管程访问共享资源。对于上例,外部进程只能通过调用 take_away() 过程来申请一个资源;当然归还资源也一样。
② 每次仅允许一个进程进入管程,从而实现进程互斥。若多个进程同时调用 take_away(),give_back(),则只有某个进程运行完它调用的过程后,下个进程才能开始运行它调用的过程。也就是说,各个进程只能串行执行管程内的过程,这一特性保证了进程 “互斥” 访问共享数据结构 S。
3.4.2 条件变量
当一个进程进入管程后被阻塞,直到阻塞的原因解除时,在此期间,如果该进程不释放管程,那么其他进程无法进入管程。为此,将阻塞原因定义为 条件变量 condition。通常,一个进程被阻塞的原因可以有多个,因此在管程中设置了多个条件变量。每个条件变量保存了一个等待队列,用以记录因该条件变量而阻塞的所有进程,对条件变量只能进行两种操作,即 wait 和 signal。
x.wait:当 x 对应的条件不满足时,正在调用管程的进程调用 x.wait 将自己插入 x 条件的等待队列,并释放管程。此时其他进程可以使用该管程。
x.signal:x 对应的条件发生了变化,则调用 x.signal ,唤醒一个因 x 条件而阻塞的进程。
monitor Demo
{
共享数据结构 S;
condition x; //定义一个条件变量x
init_code()
{
……;
}
take_away()
{
if(S<=0)
x.wait(); //资源不够,在条件变量x上阻塞等待
资源充足,分配资源,做一系列相应处理
}
give_back()
{
//归还资源,做一系列相应处理;
if(有进程在等待)
x.signal; //唤醒一个阻塞进程
}
}
条件变量和信号量的比较:
相似点:条件变量的 wait/signal 操作类似于信号量的 P/V 操作,可以实现进程的阻塞/唤醒。不同点:条件变量是 “没有值” 的,仅实现了 “排队等待” 功能;而信号量是 “有值” 的,信号量的值反映了剩余资源数,而在管程中,剩余资源数用共享数据结构记录。
3.5 经典同步问题
3.5.1 生产者-消费者问题
问题描述:一组生产者进程和一组消费者进程共享一个初始为空、大小为 n 的缓冲区,只有缓冲区没满时,生产者才能把消息放入缓冲区,否则必须等待;只有缓冲区不空时,消费者才能从中取出消息,否则必须等待。由于缓冲区是临界资源,它只允许一个生产者放入消息,或一个消费者从中取出消息。
问题分析:
① 关系分析;生产者和消费者对缓冲区互斥访问是互斥关系,同时生产者和消费者又是一个相互协作的关系,只有生产者生产之后,消费者才能消费,它们也是同步关系。
② 整理思路;这里只有生产者和消费者两个进程,正好也是在这两个进程存在着互斥关系和同步关系。那么需要解决的是互斥和同步 PV 操作的位置;
③ 信号量设置;
信号量 mutex 作为互斥信号量,用于控制互斥访问缓冲池,互斥信号量初始值为 1;
信号量 full 用于记录当前缓冲池中的 “满” 缓冲区数,初值为 0;
信号量 empty 用于记录当前缓冲池中的 “空” 缓冲区数,初值为 n。
semaphore mutex=1; //临界区互斥信号量
semaphore empty=n; //空闲缓冲区
semaphore full=0; //缓冲区初始化为空
producer() //生产者进程
{
while(1)
{
prodece an item in nextp; //生产数据
P(empty);(要用什么 P一下) //获取空缓冲区单元
P(mutex);(互斥夹紧) //进入临界区
add nextp to buffer; (行为) //将数据放入缓冲区
V(mutex);(互斥夹紧) //离开临界区,释放互斥信号量
V(full);(提供什么 V一下) //满缓冲区数加1
}
}
consumer() //消费者进程
{
while(1)
{
P(full); //获取满缓冲区单元
P(mutex); //进入临界区
remove an item from buffer; //从缓冲区中取走数据
V(mutex); //离开临界区,释放互斥信号量
V(empty); //空缓冲区数加1
consumer the item; //消费数据
}
}
该类问题要注意对缓冲区大小为n的处理,当缓冲区中有空时,便可对 empty 变量执行P操作,一旦取走一个产品便要执行V操作以释放空闲区。对empty 和 full 变量的P操作必须放在对mutex的P操作之前。若生产者进程先执行P(mutex),然后执行P(empty),消费者执行P(mutex),然后执行 P(full),这样是不可以的。设想生产者进程已将缓冲区放满,消费者进程并没有取产品,即 empty=0,当下次仍然是生产者进程运行时,它先执行P(mutex)封锁信号量,再执行P(empty)时将被阻塞,希望消费者取出产品后将其唤醒。轮到消费者进程运行时,它先执行P(mutex),然而由于生产者进程已经封锁 mutex信号量,消费者进程也会被阻塞,这样一来生产者、消费者进程都将阻塞,都指望对方唤醒自己,因此陷入了无休止的等待。同理,若消费者进程已将缓冲区取空,即 full=0,下次若还是消费者先运行,也会出现类似的死锁。不过生产者释放信号量时,mutex,full 先释放哪一个无所谓,消费者先释放mutex或empty 都可以。
3.5.2 读者-写者问题
问题描述:有读者和写者两组并发进程,共享一个文件,当两个或以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:
① 允许多个读者可以同时对文件执行读操作;
② 只允许一个写者往文件中写信息;
③ 任一写者在完成写操作之前不允许其他读者或写者工作;
④ 写者执行写操作前,应让已有的读者和写者全部退出。
问题分析:
① 关系分析。读者和写者是互斥的,写者和写者也是互斥的,而读者和读者不存在互斥关系。
② 整理思路。两个进程,即读者和写者。写者比较简单,它和任何进程互斥,用互斥信号量的 P 操作、V 操作即可解决。读者的问题比较复杂,它必须在实现与写者互斥的同时,实现与其他读者的同步,因此简单的一对 P 操作、V 操作是无法解决问题的。这里用到了一个计数器,用它判断当前是否有读者读文件。当有读者时,写者是无法写文件的,此时读者会一直占用文件,当没有读者时,写者才可以写文件。同时,这里不同读者对计数器的访问也应该是互斥的。
③ 信号量设置。
首先设置信号量 count 为计数器,用于记录当前读者的数量,初始为 0;
设置 mutex 为互斥信号量,用于保护更新 count 变量时的互斥;
设置互斥信号量 rw,用于保证读者和写者的互斥访问。
int count=0; //用于记录当前的读者数量
semaphore mutex=1; //用于保护更新 count 变量时的互斥
semaphore rw=1; //用于保证读者和写者互斥地访问文件
writer() //写者进程
{
while(1)
{
P(rw); //互斥访问共享文件
writing; //写入
V(rw); //释放共享文件
}
}
reader() //读者进程
{
while(1)
{
P(mutex); //互斥访问 count 变量 第一个读者在读期间,可用资源数减减,第二个读者等待
if(count==0) //当第一个读进程读共享文件时 只有第一个读者读时,才会进入if判断语句
P(rw); //阻值写进程写 保证读者互斥
count++; //读者计数器加1 读的人加加 这样就实现了多个读者一起读
第一个读者进来 count++ 读完 count-- 最后一个进程读完 V(rw) 中间的读者不会被影响
V(mutex); //释放互斥变量 count
reading; //读取
P(mutex); //互斥访问 count 变量
count--; //读者计数器减1
if(count==0) //当最后一个读进程读完共享文件
V(rw); //允许写进程写
V(mutex); //释放互斥变量 count
}
}
写者进程比较简单,只需要实现互斥即可
在上面的算法中,读进程是优先的,即当存在读进程时,写操作将被延迟,且只要有一个读进程活跃,随后而来的读进程都将被允许访问文件。这样的方式会导致写进程可能长时间等待,且存在写进程 “饿死” 的情况。
若希望写进程优先,即当有读进程正在读共享文件时,有写进程请求访问,这是应禁止后续读进程的请求,等到已在共享文件的读进程执行完毕,立即让写进程执行,只有在无写进程执行的情况下才允许读进程再次运行。为此,增加一个信号量并在上面程序的 writer() 和 reader() 函数中各增加一对 PV 操作,就可以得到写进程优先的解决程序。
int count=0; //用于记录当前的读者数量
semaphore mutex=1; //用于保护更新 count 变量时的互斥
semaphore rw=1; //用于保证读者和写者互斥地访问文件
semaphore w=1; //用于实现 “写优先”
writer() //写者进程
{
while(1)
{
P(w); //在无写进程请求时进入
P(rw); //互斥访问共享文件
writing; //写入
V(rw); //释放共享文件
V(w); //恢复对共享文件的访问
}
}
reader() //读者进程
{
while(1)
{
P(w); //在无写进程请求时进入
P(mutex); //互斥访问 count 变量
if(count==0) //当第一个读进程读共享文件时
P(rw); //阻值写进程写
count++; //读者计数器加1
V(mutex); //释放互斥变量 count
V(w); //恢复对共享文件的访问
reading; //读取
P(mutex); //互斥访问 count 变量
count--; //读者计数器减1
if(count==0) //当最后一个读进程读完共享文件
V(rw); //允许写进程写
V(mutex); //释放互斥变量 count
}
}
3.6 小结
对开头提出的问题进行解答;
1. 为什么要引入进程同步的概念?
在多道程序共同执行的条件下,进程与进程是并发执行的,不同进程之间存在不同的相互制约关系。为了协调进程之间的相互制约关系,引入了进程同步的概念。
2. 不同的进程之间会存在什么关系?进程之前存在同步与互斥的制约关系。
同步是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递信息所产生的制约关系。
互斥是指当一个进程进入临界区使用临界资源时,另一个进程必须等待,当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。
3. 当单纯的用本节介绍的方法解决问题时会出现什么新的问题?
当两个或两个以上的进程在执行过程中,因占有一些资源而又需要对方的资源时,会因为争夺资源而造成一种互相等待的现象,若无外力作用,它们都将无法推进下去。这种现象称为死锁。