6. 分析进程同步和互斥问题的方法步骤

2026 旧版 · 非 2027 主源 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照

## 6. 分析进程同步和互斥问题的方法步骤

1)关系分析。找出问题中的进程数,并分析它们之间的同步和互斥关系。同步、互斥、前驱关系直接按照上面例子中的经典范式改写。
2)整理思路。找出解决问题的关键点,并根据做过的题目找出求解的思路。根据进程的操作流程确定P操作、V操作的大致顺序。
3)设置信号量。根据上面的两步,设置需要的信号量,确定初值,完善整理。

## 2.3.5 经典同步问题

**前题追踪** ➔ 程序并发执行的分析(2011、2018)

**前题追踪** ➔ PV操作的应用题(2009、2011、2013、2014、2015、2017、2019)

### 1. 生产者-消费者问题

#### 问题描述:

系统中有一组生产者进程和一组消费者进程,生产者每次生产一个产品并放入缓冲区,消费者每次从缓冲区中取出一个产品并消费。生产者和消费者共享一个初始为空、大小为 $n$ 的缓冲区。只有当缓冲区不满时,生产者才能将产品放入缓冲区;否则必须等待。只有当缓冲区不空时,消费者才能从中取出产品;否则必须等待。缓冲区是临界资源,各进程必须互斥访问。

#### 问题分析:

1)关系分析。生产者和消费者对缓冲区的访问是互斥关系,同时生产者和消费者又是一个相互协作的关系,只有生产者生产之后,消费者才能消费,它们也是同步关系。
2)整理思路。这里比较简单,只有生产者和消费者两个进程,正好是这两个进程存在着互斥关系和同步关系。那么需要解决的是互斥和同步PV操作的位置。
3)信号量设置。信号量 mutex 作为互斥信号量,用于控制互斥访问缓冲池,互斥信号量初值为 1;信号量 full 用于记录当前缓冲池中的“满”缓冲区数,初值为 0。信号量 empty 用于记录当前缓冲池中的“空”缓冲区数,初值为 $n$。

我们对同步互斥问题的介绍是一个循序渐进的过程。上面介绍了一个同步问题的例子和一个互斥问题的例子,下面来看生产者-消费者问题的例子是什么样的。

生产者-消费者进程的描述如下:

semaphore mutex=1; //临界区互斥信号量
semaphore empty=n; //空闲缓冲区
semaphore full=0; //缓冲区初始化为空
producer() { //生产者进程
while(1) {
生产一个产品
P(empty); (要用什么,P一下) //获取空缓冲区单元
P(mutex); (互斥夹紧) //进入临界区
将产品放入缓冲区
V(mutex); (互斥夹紧) //离开临界区,释放互斥信号量


<!-- page: 118 -->

```txt
V(full); (提供什么,V一下) //满缓冲区数加1
}
consumer() { //消费者进程
while(1) {
P(full); //获取满缓冲区单元
P(mutex); //进入临界区
从缓冲区中取出一个产品
V(mutex); //离开临界区,释放互斥信号量
V(empty); //空缓冲区数加1
消费产品
}
该类问题要注意对缓冲区大小为 $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 都可以。 其实生产者-消费者问题只是一个同步互斥问题的综合而已。 下面再看一个较为复杂的生产者-消费者问题。 ## 问题描述: 桌子上有一个盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等吃盘子中的橘子,女儿专等吃盘子中的苹果。只有盘子为空时,爸爸或妈妈才可向盘子中放一个水果;仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出。 ## 问题分析: 1)关系分析。由每次只能向盘中放一个水果可知,爸爸和妈妈是互斥关系。爸爸和女儿、妈妈和儿子是同步关系,而且这两对进程必须连起来,儿子和女儿之间没有互斥和同步关系,因为他们是选择条件执行,不可能并发。进程之间的关系如图2.11所示。 图2.11 进程之间的关系 2)整理思路。这里有4个进程,实际上可抽象为两个生产者和两个消费者被连接到大小为1的缓冲区上。 3)信号量设置。首先将信号量 plate 设置互斥信号量,表示是否允许向盘子放入水果,初值为1表示允许放入,且只允许放入一个。信号量apple表示盘子中是否有苹果,初值为0 <!-- page: 119 --> 表示盘子为空,不许取,apple = 1 表示可以取。信号量 orange 表示盘子中是否有橘子,初值为 0 表示盘子为空,不许取,orange = 1 表示可以取。 解决该问题的代码如下:
semaphore plate=1, apple=0, orange=0;
dad() { //父亲进程
while(1) {
准备一个苹果
P(plate); //互斥向盘中取、放水果
把苹果放入盘子
V(apple); //允许取苹果
}
}
mom() { //母亲进程
while(1) {
准备一个橘子
P(plate); //互斥向盘中取、放水果
把橘子放入盘子
V(orange); //允许取橘子
}
}
son() { //儿子进程
while(1) {
P(orange); //互斥向盘中取橘子
从盘子中取出橘子
V(plate); //允许向盘中取、放水果
吃掉橘子
}
}
daughter() { //女儿进程
while(1) {
P(apple); //互斥向盘中取苹果
从盘子中取出苹果
V(plate); //允许向盘中取、放水果
吃掉苹果
}
}
进程间的关系如图 2.11 所示。dad() 和 daughter()、mom() 和 son() 必须连续执行,正因为如此,也只能在女儿拿走苹果后或儿子拿走橘子后才能释放盘子,即 V(plate) 操作。 ## 2. 读者-写者问题 ### 问题描述: 有读者和写者两组并发进程,共享一个文件,当两个或以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:① 允许多个读者可以同时对文件执行读操作;② 只允许一个写者往文件中写信息;③ 任意一个写者在完成写操作之前不允许其他读者或写者工作;④ 写者执行写操作前,应让已有的读者和写者全部退出。 ### 问题分析: 1)关系分析。由题目分析读者和写者是互斥的,写者和写者也是互斥的,而读者和读者不存在互斥问题。 2)整理思路。两个进程,即读者和写者。写者是比较简单的,它和任何进程互斥,用互斥 <!-- page: 120 --> 信号量的 P 操作、V 操作即可解决。读者的问题比较复杂,它必须在实现与写者互斥的同时,实现与其他读者的同步,因此简单的一对 P 操作、V 操作是无法解决问题的。这里用到了一个计数器,用它来判断当前是否有读者读文件。当有读者时,写者是无法写文件的,此时读者会一直占用文件,当没有读者时,写者才可以写文件。同时,这里不同读者对计数器的访问也应该是互斥的。 3)信号量设置。首先设置信号量 count 为计数器,用于记录当前读者的数量,初值为 0;设置 mutex 为互斥信号量,用于保护更新 count 变量时的互斥;设置互斥信号量 rw,用于保证读者和写者的互斥访问。 代码如下:
int count=0; //用于记录当前的读者数量
semaphore mutex=1; //用于保护更新 count 变量时的互斥
semaphore rw=1; //用于保证读者和写者互斥地访问文件
writer(){ //写者进程
while(1){ //互斥访问共享文件
P(rw); //写文件
V(rw); //释放共享文件
}
}
reader(){ //读者进程
while(1){ //互斥访问 count 变量
P(mutex); //当第一个读进程读共享文件时
if(count==0) //阻止写进程写
count++; //读者计数器加 1
V(mutex); //释放互斥变量 count
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(rw); //互斥访问共享文件
}
<!-- page: 121 -->
写文件
V(rw); //释放共享文件
V(w); //恢复对共享文件的访问
}
}
reader() { //读者进程
while(1) {
P(w); //在无写进程请求时进入
P(mutex); //互斥访问 count 变量
if(count==0) //当第一个读进程读共享文件时
P(rw); //阻止写进程写
count++; //读者计数器加 1
V(mutex); //释放互斥变量 count
V(w); //恢复对共享文件的访问
读文件
P(mutex); //互斥访问 count 变量
count--; //读者计数器减 1
if(count==0) //当最后一个读进程读完共享文件
V(rw); //允许写进程写
V(mutex); //释放互斥变量 count
}
这里的写进程优先是相对而言的,有些书上将这个算法称为读/写公平法,即读/写进程具有一样的优先级。当一个写进程访问文件时,若先有一些读进程要求访问文件,后有另一个写进程要求访问文件,则当前访问文件的进程结束对文件的写操作时,会是一个读进程而不是一个写进程占用文件(在信号量 w 的阻塞队列上,因为读进程先来,因此排在阻塞队列队首,而 V 操作唤醒进程时唤醒的是队首进程),所以说这里的写优先是相对的,想要了解如何做到真正写者优先,可参考其他相关资料。 读者-写者问题有一个关键的特征,即有一个互斥访问的计数器 count,因此遇到一个不太好解决的同步互斥问题时,要想一想用互斥访问的计数器 count 能否解决问题。 ## 3. 哲学家进餐问题 ### 问题描述: 一张圆桌边上坐着 5 名哲学家,每两名哲学家之间的桌上摆一根筷子,两根筷子中间是一碗米饭,如图 2.12 所示。哲学家们倾注毕生精力用于思考和进餐,哲学家在思考时,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根地拿起)。若筷子已在他人手上,则需要等待。饥饿的哲学家只有同时拿到了两根筷子才可以开始进餐,进餐完毕后,放下筷子继续思考。 图 2.12 5 名哲学家进餐 <!-- page: 122 --> # 问题分析: 1)关系分析。5名哲学家与左右邻居对其中间筷子的访问是互斥关系。 2)整理思路。显然,这里有5个进程。本题的关键是如何让一名哲学家拿到左右两根筷子而不造成死锁或饥饿现象。解决方法有两个:一是让他们同时拿两根筷子;二是对每名哲学家的动作制定规则,避免饥饿或死锁现象的发生。 3)信号量设置。定义互斥信号量数组chopstick[5] = {1, 1, 1, 1, 1},用于对5个筷子的互斥访问。哲学家按顺序编号为 $0 \sim 4$ ,哲学家 $i$ 左边筷子的编号为 $i$ ,哲学家右边筷子的编号为 $(i + 1)\% 5$ 。
semaphore chopstick[5] = {1,1,1,1,1}; //定义信号量数组 chopstick[5],并初始化
Pi() { //i 号哲学家的进程
do {
P(chopstick[i]); //取左边筷子
P(chopstick[(i+1)%5]); //取右边筷子
进餐
V(chopstick[i]); //放回左边筷子
V(chopstick[(i+1)%5]); //放回右边筷子
思考
} while(1);
}
该算法存在以下问题:当5名哲学家都想要进餐并分别拿起左边的筷子时(都恰好执行完wait(chopstick[i]).)筷子已被拿光,等到他们再想拿右边的筷子时(执行wait(chopstick[(i+1)%5]).)就全被阻塞,因此出现了死锁。 为防止死锁发生,可对哲学家进程施加一些限制条件,比如:①至多允许4名哲学家同时进餐,以保证至少有一名哲学家能拿到左右两边的筷子;②仅当一名哲学家左右两边的筷子都可用时,才允许他拿起筷子;③对哲学家顺序编号,要求奇数号哲学家先拿左边的筷子,然后拿右边的筷子,而偶数号哲学家刚好相反,以保证相邻的两名哲学家都想进餐时,只有一名哲学家可以拿起第一根筷子,而另一名哲学家会被阻塞。 制定的正确规则如下:假设采用第二种方法,当一名哲学家左右两边的筷子都可用时,才允许他抓起筷子。
semaphore chopstick[5] = {1,1,1,1,1}; //初始化信号量
semaphore mutex=1; //设置取筷子的信号量
Pi() { //i 号哲学家的进程
do {
P(mutex); //在取筷子前获得互斥量
P(chopstick[i]); //取左边筷子
P(chopstick[(i+1)%5]); //取右边筷子
V(mutex); //释放取筷子的信号量
进餐
V(chopstick[i]); //放回左边筷子
V(chopstick[(i+1)%5]); //放回右边筷子
思考
} while(1);
}
熟悉ACM或有过相关训练的读者都应知道贪心算法,哲学家进餐问题的思想其实与贪心算法的思想截然相反,贪心算法强调争取眼前认为最好的,而不考虑后续会有什么后果。若哲学家进餐问题用贪心算法来解决,即只要眼前有筷子能拿起就拿起的话,就会出现死锁。然而,若不仅考虑眼前的一步,还考虑下一步,即不因为有筷子能拿起就拿起,而考虑能不能一次拿起两根 <!-- page: 123 --> 筷子才做决定的话,就会避免死锁问题,这就是哲学家进餐问题的思维精髓。 大部分习题和真题用消费者-生产者模型或读者-写者问题就能解决,但对哲学家进餐问题仍然要熟悉。考研复习的关键在于反复多次和全面,“偷工减料”是要吃亏的。 ## 2.3.6 管程 在信号量机制中,每个要访问临界资源的进程都必须自备同步的 PV 操作,大量分散的同步操作给系统管理带来了麻烦,且容易因同步操作不当而导致系统死锁。于是,便产生了一种新的进程同步工具——管程。管程的特性保证了进程互斥,无须程序员自己实现互斥,从而降低了死锁发生的可能性。同时管程提供了条件变量,可以让程序员灵活地实现进程同步。 ## 1. 管程的定义 ### 问题点答 管程的特点(2016) 系统中的各种硬件资源和软件资源,均可用数据结构抽象地描述其资源特性,即用少量信息和对资源所执行的操作来表征该资源,而忽略它们的内部结构和实现细节。 利用共享数据结构抽象地表示系统中的共享资源,而将对该数据结构实施的操作定义为一组过程。进程对共享资源的申请、释放等操作,都通过这组过程来实现,这组过程还可以根据资源情况,或接受或阻塞进程的访问,确保每次仅有一个进程使用共享资源,这样就可以统一管理对共享资源的所有访问,实现进程互斥。这个代表共享资源的数据结构,以及由对该共享数据结构实施操作的一组过程所组成的资源管理程序,称为管程(monitor)。管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。 由上述定义可知,管程由 4 部分组成: ① 管程的名称; ② 局部于管程内部的共享数据结构说明; ③ 对该数据结构进行操作的一组过程(或函数); ④ 对局部于管程内部的共享数据设置初始值的语句。 管程的定义描述举例如下:
monitor Demo{ //①定义一个名称为 Demo 的管程
//②定义共享数据结构,对应系统中的某种共享资源
共享数据结构 S;
//④对共享数据结构初始化的语句
init_code(){
S=5; //初始资源数等于 5
}
take_away(){ //③过程 1:申请一个资源
对共享数据结构 S 的一系列处理;
S--; //可用资源数-1
...
}
give_back(){ //③过程 2:归还一个资源
对共享数据结构 S 的一系列处理;
S++; //可用资源数+1
...
}
}
熟悉面向对象程序设计的读者看到管程的组成后,会立即联想到管程很像一个类(class)。 1)管程将对共享资源的操作封装起来,管程内的共享数据结构只能被管程内的过程所访 <!-- page: 124 --> 问。一个进程只有通过调用管程内的过程才能进入管程访问共享资源。对于上例,外部进程只能通过调用 take_away()过程来申请一个资源:归还资源也类似。 2)每次仅允许一个进程进入管程,从而实现进程互斥。若多个进程同时调用 take_away(),give_back(),则只有某个进程运行完它调用的过程后,下一进程才能开始运行它调用的过程。即各进程只能串行执行管程内的过程,这一特性保证了进程互斥访问 S。 ## 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&amp;lt;=0) x.wait(); //资源不够,在条件变量 x 上阻塞等待
资源足够,分配资源,做一系列相应处理;
}
give_back(){
归还资源,做一系列相应处理;
if (有进程在等待) x.signal(); //唤醒一个阻塞进程
}
}
条件变量和信号量的比较: 相似点:条件变量的 wait/signal 操作类似于信号量的 P/V 操作,可以实现进程的阻塞/唤醒。 不同点:条件变量是“没有值”的,仅实现了“排队等待”功能;而信号量是“有值”的,信号量的值反映了剩余资源数,而在管程中,剩余资源数用共享数据结构记录。 ## 2.3.7 本节小结 本节开头提出的问题的参考答案如下。 1)为什么要引入进程同步的概念? 在多道程序共同执行的条件下,进程与进程是并发执行的,不同进程之间存在不同的相互制约关系。为了协调进程之间的相互制约关系,引入了进程同步的概念。 2)不同的进程之间会存在什么关系? 进程之间存在同步与互斥的制约关系。 同步是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递信息所产生的制约关系。 互斥是指当一个进程进入临界区使用临界资源时,另一个进程必须等待,当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。 3)当单纯用本节介绍的方法解决这些问题时会遇到什么新的问题吗? 当两个或两个以上的进程在执行过程中,因占有一些资源而又需要对方的资源时,会因为争 <!-- page: 125 --> 夺资源而造成一种互相等待的现象,若无外力作用,它们都将无法推进下去。这种现象称为死锁,具体介绍和解决方案请参考下一节。