在操作系统中引入进程后,虽然改善了资源利用率、提高了系统的吞吐量。但由于进程的异步性,也给系统造成了混乱,尤其是进程使用临界资源时,问题更为严重。例如,当多个进程争用一台打印机时,有可能多个进程的输出结果交织在一起,难于区分。
一、并发的原理
在单处理机多道程序环境系统中,进程被交替地执行,表现出一种并发执行的特征,即使不能实现真正的并行处理,进程间来回切换需要一定的开销,这种交替执行在处理效率上还是带来了很大的好处。但是由于并发执行的进程之间相对执行速度是不可预测的,它取决于其他进程的活动、操作系统的调度策略等。这就带来了以下的困难:
(1)全局变量的共享充满了危险。如果两个进程都使用了同一个全局变量,并且都对该变量进行了读写操作,那么不同的读写执行顺序是非常关键的。关于这个问题会在本节的下面举例说明。
(2)操作系统很难使资源的分配得到最佳的管理。如果某个进程请求使用某个特定的I/O 设备,并得到了这个设备,但该进程在使用该设备前被挂起了,操作系统仍然把这个设备锁定给该进程,不能分配给其他进程,因为操作系统不知道被挂起的进程何时又将执行。此外,资源分配还会导致死锁的危险。
(3)定位程序的错误是很困难的。这是因为并发程序的不确定性和不可再现性。
因此“并发”给操作系统的设计和管理带来了很多问题,操作系统为此要关注的事情有:
(1)操作系统必须记录每个进程的情况,并通过进程控制块实现。
(2)操作系统必须为每个进程分配和释放各种资源,这些资源包括:处理机、存储器、文件和 I/O 设备。
(3)操作系统必须保护每个进程的数据和资源,避免遭到其他进程的干涉和破坏。
(4)保证进程执行结果的正确性,进程的执行结果与速度无关。
以上四个问题中第( 2)、第( 3)个问题涉及到与存储管理、文件管理和设备管理相关的技术,本节重点要解决的是第(4)个问题。
我们以进程之间是否知道对方的存在,按进程的交互方式划分,可以分为以下三种情况:
(1)进程之间不知道对方的存在。这是一些独立的进程,它们不会一起工作。只是无意地同时存在着。尽管这些进程不一起工作,但是操作系统需要知道它们对资源的竞争情况。例如,两个无关的进程都要使用同一磁盘文件或打印机,操作系统必须控制和管理对它们的访问。
(2)进程间接知道对方。进程并不需要知道对方的进程标识符,但它们共享某些数据,它们在共享数据时要进行合作。
(3)进程直接得知对方。进程通过进程标识符互相通信,用于合作完成某些任务。进程的并发执行,使进程之间存在着交互,进程间的交互关系包括互斥、同步和通信。进程互斥是指由于共享资源所要求的排它性,进程之间要相互竞争,某个进程使用这种资源时,其他进程必须等待。换句话说,互斥是指多个进程不能同时使用同一个资源。这种情况下,进程之间知道对方的程度最低。
进程同步是指多个进程中发生的事件存在着某种时序关系,必须协同动作、相互配合,以共同完成一个任务。进程同步的主要任务,是使并发执行的诸进程有效地共享资源和相互合作,从而使程序的执行具有可再现性。这种情况,比进程之间的互斥知道对方的程度要高,因为进程之间要合作。
进程通信是指多进程之间要传递一定的信息。这种情况下,进程之间知道对方的程度最高,需要传递的信息量也最大。
二、临界资源与临界区
1.临界区与临界资源
在计算机中许多资源只能允许一个进程使用,如果多个进程同时使用这类资源就会引起激烈的竞争。操作系统必须保护这些资源,以防止两个或两个以上的进程同时访问它们。我们把那些在某段时间内只允许一个进程使用的资源称为临界资源(Critical Resource) 。
几个进程共享同一临界资源,它们必须以互相排斥的方式使用临界资源,即当一个进程正在使用临界资源且尚未使用完毕时,其他进程必须延迟对该资源的进一步操作,在当前的进程使用完毕之前,不能从中插入使用这个临界资源,否则将会造成信息混乱和操作出错。系统中同时存在许多进程,它们共享各种资源,然而有些资源每次只能让一个进程使用。
两个进程在执行时必须等一个进程执行完毕,另一个进程才可以执行。
不论是硬件临界资源,还是软件临界资源,多个进程必须互斥地对它们进行访问。我们把在每个进程中访问临界资源的那段代码称为临界区(Critical Section)。显然,若能保证诸进程互斥地进入临界区,就可实现它们对临界资源的互斥访问。为此,每个进程在进入临界区之前应对要访问的临界资源进行检查,看它是否正在被访问。如果此刻临界资源未被访问,进程便可以进入临界区,对资源进行访问,并设置它正被访问的标志;如果此刻临界资源正被某进程访问,则进程不能进入临界区。因此,必须在临界区前面增加一段用于进行上述检查的代码,这段代码称为进入区(Enter Section)。相应地,在临界区后面也要加上一段称为退出区(Exit Section)的代码,用于将临界区正被访问的标志恢复为未被访问标志。进程中除去上述进入区、临界区及退出区之外的其他部分的代码,称为剩余区(Remainder Section)。这样,可把一个访问临界资源的进程描述为:
2.同步机制必须遵循的四个准则
为了实现进程互斥,可用软件的方法在系统中设置专门的同步机制来协调多个进程,但所有的同步机制都必须遵循下述四个准则:
(1)空闲让进。当无进程处于临界区时,临界资源处于空闲状态。此时允许进程进入临界区。
(2)忙则等待。当已有进程进入临界区时,临界资源正在被访问,其他想进入临界区的进程必须等待。
(3)有限等待。对于要求访问临界资源的进程,应保证在有效的时间内进入,以免进入“死等”状态。
(4)让权等待。当进程不能进入临界区时,应立即释放处理机,以免其他进程进入“忙等”。
有许多方法可以实现互斥,一种方法是让希望并发执行的进程自己来完成。不论是系统
程序还是应用程序,当需要与另一个进程互斥时,不需要操作系统提供任何支持,自己通过
软件来完成。
尽管该方法已经被证明会增加许多处理开销和错误,但通过对这种方法的分析,可以更好地理解并发处理的复杂性,第二种方法是使用专门的机器指令来完成,这种方法的优点是可以减少开销,但与具体的硬件系统相关,很难成为一种通用的解决方案。第三种方法是由操作系统中提供某种支持。
三、信号量
1965 年,荷兰学者 Dijkstra 提出的信号量机制是一种卓有成效的解决进程同步问题的工具。该机制提出后,在很长时期内得到了广泛的应用,并取得了很大的进展,它从整型信号量发展到记录型信号量,继而又发展到一般“信号量集”。
1.信号量和 PV 操作
Dijkstra 最初定义的信号量包括一个整型值 S 和一个等待队列 s.queue,信号量只能通过两个原语 P V 操作来访问它,信号量的定义为:
struct semaphore{
int value;
struct QUEUETYPE *queue;
}
P 原语所执行的操作可用下面的函数 wait(s)来表示:
void wait(semaphore s)
{
s.value = s.value - 1;
if (s.value < 0)
block(s.queue); /* 将进程阻塞,并将其投入等待队列 s.queue */
}
V 原语所执行的操作可用下面的函数 signal(s)来表示:
void signal(semaphore s)
{
s.value = s.value + 1;
if (s.value <= 0)
wackup(s.queue);/* 唤醒被阻塞进程,将其从等待队列 s.queue 取出,投入就绪队列*/
}
在信号量机制中,信号量的初值 s.value 表示系统中某种资源的数目,因而又称为资源信号量。
P 操作意味着进程请求一个资源,因此描述为 s.value=s.value−1;当 s.value<0 时,表示资源已经分配完毕,因而不能够满足进程所申请的资源,进程无法继续执行,所以进程执行block(s.queue)自我阻塞,放弃处理机,并插入到等待该信号量的等待队列。
V 操作意味着进程释放一个资源,因此描述为 s.value=s.value+1;当 s.value≤0 时,表示在该信号量的等待队列中有等待该资源的进程被阻塞,故应调用 wakeup(s.queue)原语将等待队列中的一个进程换醒。
如果信号量的初值为 1,表示仅允许一个进程访问临界区,此时的信号量转换为互斥信号量。P 操作和 V 操作分别置于进入区和退出区,如定义 mutex 为互斥信号量,其初值为 1,代码段如下:
利用信号量可以实现进程之间的同步,即可以控制进程执行的先后次序。如果有两个进程 P1 和 P2,要求 P 2 必须在 P1 执行完毕之后才可以执行,则只需要设置一个信号量 S,其初值为 0,将 V(S)操作放在进程 P1 的代码段 C1 后面,将 P(S )操作放在进程 P2 的代码段C2 前面,即:
对于图 2-22 所示的进程关系:有四个并发执行的进程 P1、P2、P3 和 P4,它们之间的关系是 P1 首先被执行;P1 执行完毕 P2、P3 才能执行;而 P4 只有在 P2 执行完毕后才能执行。为了实现它们之间的同步关系,可以写出如下的并发程序。
2. AND 信号量
当利用信号量解决了单个资源的互斥访问后,下面我们讨论控制进程对多个资源的互斥访问问题。在有些应用中,一个进程需要先获得两个或更多的共享资源后,方能执行其任务。假如有两个进程 P1 和 P2,它们要共享两个共享资源 R1 和 R2,为此要设置两个互斥信号量mutex1 和 mutex2,并令它们的初值为 1,相应地,两个进程都要包含对信号量 mutex1和mutex2:
/* 进程 P1 */ /* 进程 P2 */
P(mutex1); P(mutex2);
P(mutex2); P(mutex1);
...... ......
如果进程 P1 和 P2 交替地执行 P 操作如下:
进程 P1 执行 P(mutex1);于是 mutex1=0;
进程 P2 执行 P(mutex2);于是 mutex2=0;
进程 P1 执行 P(mutex2);于是 mutex2=-1;进程 P1 阻塞;
进程 P2 执行 P(mutex1);于是 mutex1=-1;进程 P2 阻塞;
此时,两个进程处于僵持状态,都无法继续运行。
AND 信号量同步机制就是要解决这样的问题,其基本思想是,将进程在整个运行期间所需要的所有临界资源,一次性地全部分配给进程,待该进程使用完后再一起释放。只要尚有一个资源不能满足进程的要求,其他所有能分配给该进程的资源都不予以分配。为此,在 P操作上增加一个“AND”条件,故称为 AND 信号量。P 操作的原语为 Swait, V 操作的原语为 Ssignal。在 Swait 中,各个信号量的次序并不重要,尽管会影响进程进入哪个等待队列。由于 Swait 实施对资源的全部分配,进程获得全部资源并执行之后再释放全部资源,因此避免了前面所述的僵持状态。下面是 Swait 和 Ssignal 的伪代码。
四、经典进程同步问题
1.读者、写者问题
一个数据对象若被多个并发进程所共享,且其中一些进程只要求读该数据对象的内容,而另一些进程则要求写操作,对此,我们把只想读的进程称为“读者”,而把要求写的进程称为“写者”。在读者、写者问题中,任何时刻要求“写者”最多只允许有一个执行,而“读者”则允许有多个同时执行。因为多个“读者”的行为互不干扰,他们只是读数据,而不会改变数据对象的内容,而“写者”则不同,他们要改变数据对象的内容,如果他们同时操作,则数据对象的内容将会变得不可知。所以对共享资源的读写操作的限制条件是:
允许任意多的读进程同时读;
一次只允许一个写进程进行写操作;
如果有一个写进程正在进行写操作,禁止任何读进程进行读操作。
(1)用信号量解决“读者、写者”问题
为了解决该问题,我们只需解决“写者与写者”和“写者与第一个读者”的互斥问题即可,为此我们引入一个互斥信号量 Wmutex,为了记录谁是第一个读者,我们用一个共享整型变量 Rcount 作一个计数器。而在解决问题的过程中,由于我们使用了共享变量 Rcount,该变量又是一个临界资源,对于它的访问仍需要互斥进行,所以需要一个互斥信号量 Rmutex,算法如下:
2.哲学家进餐问题
哲学家进餐问题是一个典型的同步问题,它由 Dijstra 提出并解决。该问题描述有五个哲学家,他们的生活方式是交替地思考和进餐。哲学家们共用一张圆桌,围绕着圆桌而坐,在圆桌上有五个碗和五支筷子,平时哲学家进行思考,饥饿时拿起其左、右的两支筷子,试图进餐,进餐完毕又进行思考。这里的问题是哲学家只有拿到靠近他的两支筷子才能进餐,而拿到两支筷子的条件是他的左、右邻居此时都没有进餐。
分析可知,筷子是临界资源,一次只允许一个哲学家使用。因此,我们可以用互斥信号量来实现。其描述如下:
在以上的描述中,虽然解决了两个相邻的哲学家不会同时进餐的问题,但是有一个严重的问题,如果所有的哲学家总是先拿左边的筷子,再拿右边的筷子,那么就有可能出现这样的情况,五个哲学家都拿起了左边的筷子,而当他们想拿右边的筷子时,却因为筷子已被别的哲学家拿去,而无法拿到。此时所有的哲学家都不能进餐。这就出现了死锁现象。
信号量在解决进程互斥和同步问题时是一个非常有效的工具,但是应该看到,如果使用不当,可能引起死锁。请读者思考,写出一个用信号量解决哲学家进餐问题的不产生死锁的算法。
下面我们讨论用 AND 信号量解决哲学家进餐问题。在该问题中,筷子是临界资源,而题目中的临界资源有五个,每个哲学家需要拿到两个临界资源才可以进餐,所以为了避免死锁的产生,哲学家在申请临界资源时必须一次性地申请其所需要的所有资源。具体解法如下:
五、关于“锁”和“条件变量”
进程锁是操作系统为方便用户使用信号量而提出的一种机制,最典型的互斥锁就是初始值为1的信号量。而与互斥锁配合使用的条件变量则是一组信号量,初始值分别为1和0,信号量A初始值为1,用户对互斥资源的保护,初始值为0的信号量用户两个进程之间同步。
下面给出用信号量实现互斥锁和条件变量的实现方式:
互斥锁与条件变量:
用信号量实现:
本文摘自:
清华大学出版社《计算机操作系统》郁红英、李春强编著