在一个进程使用这些变量尚未结束期间,另一个进程也开始使用,这种错误通常称为“与时间有关的错误”。导致出错的原因有两个:共享了变量、同时使用了这些共享变量。较好的解决办法是允许共享,但不允许同时使用。
在多道程序环境下,系统中可能有许多并发的进程,在这些进程之间存在以下两种关系:间接相互制约关系、直接相互制约关系。
多个进程彼此无关,它们并不知道其他进程的存在。由于各进程要求共享资源,而有些资源需要互斥使用,因此各个进程间竞争使用这些资源,进程间的这种关系称为进程的互斥
(Mutual Exclusion)。进程的互斥会产生两个额外的控制问题:
死锁。考虑两个进程P1和P2,以及两个资源R1和R2。假设每个进程都需要访问这两个资源,那么就有可能出现这样的情况:操作系统把R1分配给P2,把R2分配给P1,每个进程都在等待对方占有自己需要的另一个资源,并且各自在获得对方的资源以完成需要的功能之前,谁都不会释放自己已经拥有的资源。结果,这两个进程就发生了“死锁”。
饥饿。假设有三个进程P1、P2和P3,每个进程都周期性地访问互斥资源R。考虑这种情况:P1首先拥有资源R,P2和P3都在等待这个资源。当P1访问完毕,操作系统把访问权授予P3,并且在P3完成访问之前,P1又需要访问资源R,如果在P3结束后操作系统又把访问权授予P1,如此轮流将访问权授予P1和P3,将导致P2无限期地等待。把此时P2的状态称为“饥饿”。
多个进程间知道对方的存在,表现出来的是一种相互合作的关系。此时要保证相互合作的各个进程在执行次序上的协调,不会出现与时间有关的差错。系统中多个进程中发生的事件存在某种时序关系,需要相互合作,共同完成一项任务,进程间的这种关系称为进程的同步
(Synchronism)。进程的同步同样存在“死锁”和“饥饿”的问题。例如,有两个阻塞的进程,相互都在等待来自对方的通信,就会发生“死锁”。考虑三个进程P1、P2和P3,它们都有如下特性:P1不断地试图与P2或P3通信,P2和P3都试图与P1通信。如果P1和P2不断地交换信息,而P3一直被阻塞,等待与P1通信,那么由于P1一直是活跃的,因此虽不存在“死锁”,但P3处于“饥饿”状态。
从某种意义上讲,互斥也是一种同步,因为它也是进程之间如何使用共享资源的一种协调。
系统中某些资源一次只允许一个进程使用,这样的资源称为临界资源
(Critical Resource),多个进程必须互斥地对它进行访问。在每个进程中访问临界资源的那段代码称为临界区
(Critical Section)。
几个进程共享同一临界资源,它们必须以互斥的方式使用临界资源,即当一个进程正在使用临界资源且尚未使用完毕时,其他进程必须延迟对该资源的进一步操作,在当前的进程使用完毕之前,不能从中插入使用这个临界资源,否则将会造成信息混乱的操作错误。因此,必须在临界区前面增加一段用于进行检测的代码,把这段代码称为进入区
(Entry Section)。相应地,在临界区后面也要加上一段称为退出区
(Exit Section)的代码,用于将临界区正被访问的标志恢复为未被访问标志。进程中的除上述进入区、临界区及退出区之外的其他部分代码,称为剩余区
(Remainder Section)。
# 进程访问临界区的一般结构
while(true)
{
进入区
临界区
退出区
剩余区
}
为实现进程互斥,可在系统中设置专门的同步机制来协调各个进程间的运行。
空闲让进。当无进程处于临界区时,相应的临界资源处于空闲状态,因而可允许一个请求进入临界区的进程立即进入自己的临界区,以有效地利用临界资源。
忙则等待。当已有进程进入临界区时,意味着相应的临界资源正被访问,因而所有其他试图进入临界资源的进程必须等待,以保证各个进程互斥地访问临界资源。
有限等待。对要求访问临界资源的进程,应保证该进程在有效的时间内进入临界区,以免陷入“死等”状态。这一“死等”的现象或称为“饥饿”(若一个进程无限地等待),或称为“死锁”(若两个以上进程相互无限地等待)。
让权等待。当进程不能进入临界区,则立即释放处理机,避免进程出现“忙等”现象。
为了解决进程互斥进入临界区的问题,需要采取有效措施。利用硬件实现互斥的方法有禁止中断和专用机器指令两种方法。
在单处理机环境中,并发执行的进程不能在CPU上同时执行。另外,对一个进程而言,它将一直运行,直至被中断。因此,为了保证互斥,只要保证一个进程不被中断就可以了,这可以通过系统内核开启、禁止中断来实现。
while(true)
{
禁止中断;
临界区;
启用中断;
其余部分;
}
由于在临界区内进程不能被中断,故保证了互斥。但该方法的代价很高,进程被限制只能接替执行。另外,在多处理机环境中,禁止中断仅对执行本指令的CPU起作用,对其他CPU不起作用,也就不能保证对临界区的互斥进入。
在很多计算机(特别是多处理机)中设有专用指令来解决互斥问题。依据所采用指令的不同,硬件方法分为TS指令和Swap指令两种。
TS(Test and Set)指令的管理思想与日常生活中互斥进入房间某种管理机制类似,为了保证一个房间只让一个人进入使用,开始房间是开着的,且锁头和钥匙都挂在房门上,先到者看到房门开着,于是取下锁头和钥匙,进入房间后反锁,这样后到者看到房门反锁,见不到锁头和钥匙,只能在门外等候,一直等到房间内的那个人开锁出来并把锁和钥匙挂在房门原处,这时门外等待队列中的第一个人以同样的操作方法进入房间,如此往复…这样保证了多个人互斥使用同一房间。
int ts(static int lock)
{
int ts = lock; // 将锁原来的状态赋予函数名ts
lock = 1; // 把锁锁上
return(ts); // 返回锁原来的状态
}
while(true)
{
...
while(ts(lock)) // 测试锁是否已锁上?如果已锁上,继续循环测试等待
No-op;
<critical section>; // 如果未锁上,进入临界区
lock = 0; // 完成执行临界区,退出来后开锁
...
}
这里,ts函数作为一个原语执行(即执行时不存在中断);lock有两种状态,当lock=0时(未锁上),表示该资源空闲;当lock=1时(已锁上),表示该资源正在被使用。
Swap指令的管理思想与日常生活中互斥进入房间的某种管理机制类似,首先一把锁头配n把钥匙,每一人一把钥匙,房门外有一个守门的老头,又聋又哑,只能完成交换手中一样物品(锁头或钥匙)的工作,开始守门老头手上只有一把锁头,想进入房间者先用自己的钥匙与守门老头交换,如果换出的是锁头,则请求进入者就可以拿着锁头进入房间然后反锁,保证自己一个人在房间内,这样后来者想进入房间也要用自己的钥匙跟守门老头手中的物品交换,但此时换出来的只能是钥匙,拿不到锁头就只能在门外等候,直到房间内的那个人出来,用锁头跟老头交换回钥匙为止,后来者才能换到锁头,进入房间…
void swap(static int a, b)
{
int temp;
temp = a;
a = b;
b = temp;
}
while(true)
{
...
keyi = 1
do
{
swap(lock, keyi);
}
while(keyi); // 循环交换锁和钥匙,直到keyi=0
<critical section>; // 如果拿到锁头,即keyi=0,进入临界区
swap(lock, keyi); // 完成执行临界区,交回锁头换回钥匙
...
}
在利用Swap实现进程互斥时,可为临界资源设置一个全局变量lock,其初值为0,在每个进程中再利用一个局部变量key。然后通过Swap指令实现互斥。在进入区利用Swap指令交换lock与key的内容,然后检查key的状态;有进程在临界区时,循环交换和检查过程,直到其他进程退出时检查通过,进入临界区。
硬件方法由于采用硬件处理器指令能很好地把修改和检查操作结合在一起而具有明显的优点:适用范围广、简单、支持多个临界区。但是硬件方法也有无法克服的缺点:不能做到“让权等待”、有可能产生“饥饿”、有可能产生“死锁”。
基本思路是在进入区检查和设置一些标志,如果已有进程在临界区,则在进入区通过循环检查进行等待,在退出区修改标志。
# 单标志算法
P0: P1
while(true) while(true)
{ {
... ...
while(turn != 0) while(turn != 1)
No-op; No-op;
critical section; critical section;
turn = 1; turn = 1;
... ...
} }
对单标志算法的评价:
该算法可确保每次只允许一个进程进入临界区
2个进程轮流进入临界区
不能保证实现“空闲让进”的准则。如当进程P1暂时并未要求访问该临界资源,而P0又想再次访问该资源,但它却无法进入临界区。
基本思想:在每一个进程访问临界资源之前,先查看一下临界资源是否正被访问。若正被访问,该进程需等待;否则进入自己的临界区。为此,设置一个标志数组flag[2],使其中每个元素的初值为false,表示所有进程都未进入临界区。如flag[0]=true时,表示进程P0正在临界区内执行;若flag[1]=true时,表示进程P1正在临界区内执行。
# 双标志、先检查算法
P0: P1
while(true) while(true)
{ {
... ...
while(flag[1]) while(flag[0])
No-op; No-op;
flag[0] = true; flag[1] = true;
critical section; critical section;
flag[0] = false; flag[1] = false;
... ...
} }
对双标志、先检查算法的评价:
该算法解决了“空闲让进”的问题
违背了“忙则等待”的准则。即当P0和P1都未进入临界区
算法2的问题是,当进程P0观察到进程P1的标志为false便将自己的标志flag改为true,这需要极短的一段时间,而正是在此期间,进程P1观察进程P0的标志为false,而进入临界区,因而造成了两个进程同时进入的问题。解决该问题的方法是先修改后检查,这时标志flag的含义是进程想进入临界区。
# 双标志、先修改后检查算法
P0: P1
while(true) while(true)
{ {
... ...
flag[0] = true; flag[1] = true;
while(flag[1]) while(flag[0])
No-op; No-op;
critical section; critical section;
flag[0] = false; flag[1] = false;
... ...
} }
对双标志、先修改后检查算法的评价:
结合算法1和算法3的概念,标志数组flag[0]为true标志进程P0想进入临界区,标志turn表示要在进入区等待的进程标识。在进入区先修改、后检查,通过修改同一标志turn来描述标志修改的先后;检查对方标志flag,如果对方不想进入,自己再进入。如果对方想进入,则检查标志turn,由于turn中保存的是较晚的一次赋值,因此较晚修改标志的进程等待,较早修改标志的进程进入临界区。
# 先修改、后检查、后修改者等待算法
P0: P1
while(true) while(true)
{ {
... ...
flag[0] = true; flag[1] = true;
turn = 1; turn = 0;
while(flag[1] && turn==1) while(flag[0] && turn==0)
No-op; No-op;
critical section; critical section;
flag[0] = false; flag[1] = false;
... ...
} }
算法4可以正常工作,即实现了同步机制中前两条:“空闲让进”和“忙则等待”。
但从以上软件方法中可以发现,对于三个以上进程的互斥又要区别对待。因此用软件方法解决进程互斥的问题有一定的难度,且有很大的局限性。
总之,无论是硬件方法实现互斥,还是软件方法实现互斥,它们都是基于繁忙等待的策略。从本质上来说,它们都可以被归纳为同一种形式,即当一个进程想要进入它的临界区时,首先检查一下是否允许进入,如果允许,就直接进入;如果不允许,就在那里循环地等待。这种基于繁忙等待的策略,虽然能够满足我们的需要,实现进程之间的互斥访问,但它们有两个缺点:
浪费CPU时间。因为当一个进程暂时无法进入临界区的时候,是在那里不断地循环等待,此时CPU一直处于运行状态,但又没做什么有价值的事情。
可能会导致系统“死锁”。例如,假设系统采用优先级调度算法,有一个低优先级的进程正在临界区中,此时,有一个高优先级的进程也就绪了,并试图进入临界区。解决方法就是:当一个进程无法进入临界区时,应该把它阻塞起来,从而把CPU让出来,而当一个进程离开临界区时,如果此时有其他的进程正在等待进入临界区,那么还需要去唤醒被阻塞的进程。
另外,还有一个新的问题:两个或多个进程都想进入各自的临界区,但是在任何时刻,只能允许N个进程同时进入临界区,这个N大于1。
(最近更新:2019年09月18日)