四、进程同步、互斥
1.进程同步
进程具有特征异步性:各进程按各自独立的、不可预知的速度向前推进,会导致并发程序执行结果的不确定性。
进程同步:在异步环境下,一组并发进程因直接制约(协调)而互相发送消息、互相合作、互相等待,使得各进程按一定的速度执行的过程,称为进程同步。(同步也称直接制约关系)
一般有2种形式的制约关系:同步关系、互斥关系。
【注意】只有同一个进程内不同线程之间对全局共享变量才可能有互斥访问。不同进程的线程不存在互斥访问的问题。(2016年408)
1.1原语
一般是指由若干条指令组成的程序段,用来实现某个特定功能,在执行过程中不可被中断。(原语一旦开始执行,就要连续执行完,不允许中断。)
1.2忙等
所谓“忙等”,是指“不让权”的等待,也就是说,进程因为某事件的发生而无法继续执行,仍然不释放处理器,并通过不断执行循环检测指令来等待该事件的完成以便能够继续执行。
❗2.临界资源
如打印机等,进程在使用它们时需要采用互斥的方式,这样的资源称为临界资源(critical resource),它们可以是硬件也可以是软件,比如文件。
临界区定义:不论软硬件临界资源,多个进程必须互斥地对他们进行访问。人们把在每个进程中访问临界资源的那段代码称为临界区。
【注意】
- 临界区是进程中的代码。
- 正在访问临界资源的进程由于等待I/O而被中断,这时候,是允许其他进程抢占CPU的,但是不允许进入临界区。
- 非共享资源不是临界资源。它都不共享,那就不需要互斥访问。共享的才是临界的。
2.1临界区4原则
- 空闲让进
- 忙则等待
- 有限等待:对要求访问的进程,保证它在有限的时间内进入临界区,防止“死等”(饥饿)。
- 让权等待(原则遵循,但不是必须遵循):当进程不能进入临界区,应该立即释放处理机,防止进程“忙等”。(2020年408真题)
【注意】可以实现“让权等待”的是 信号量机制 及其后面的管程。(2018年408真题)
do{
entry section //进入区(上锁)
critical section //临界区(访问资源)
exit section //退出区(解锁)
remainder section //剩余区
} while(true)
下面两个方面(软件、硬件)实现进程互斥:
2.2软件实现方案
2.2.1 单标志法
两个进程访问完临界区后,把临界区交给另一个进程。即进程进入临界区的权限是被另一个进程赋予的。
int turn = 0; //当前允许进入的进程号
P0 进程: P1 进程:
while (turn != 0); while (turn != 1); // 进入区,这里使用的就是死等,一直等直到turn==1才结束循环
critical section; critical section; // 临界区
turn = 1; turn = 0; // 退出区
remainder section; remainder section; // 剩余区
违背了空闲让进的原则。p1必须在p0结束后才能进入临界区,但是p0如果一直不进入临界区,那么虽然临界区空闲,但是p1仍然不被允许访问。会饥饿。
比如:现在是桌上只有一双筷子,有A跟B两个人,一开始先把筷子给A,A吃完后直接就把筷子洗干净给B了,然后说你吃完再把筷子洗干净给我,结果B无语了,他也没说要用筷子吃东西,然后A就是说不管,你必须吃完过后再把筷子给我,结果A自己又想吃的时候结果没有筷子用,因为筷子还在B那里呢,B还在纳闷A怕不是有什么大病。
2.2.2 双标志检查法
设置一个数组flag[2],这里与前面不同之处就是,先设置自己的标志位,再检测对方的标志状态,若对方的标志位为true则等待。
//双标志先检查法
bool flag[2]= {false,false}; //表示想要进去临界区的组数,这里开始都不想
P0 进程: P1 进程:
while (flag[1]); while (flag[0]); // 进入区,检查对方是否想使用,不想(false)则可以不用
flag[0] = true; flag[1] = true; // 进入区
critical section; critical section; // 临界区
flag[0] = false; flag[1] = false; // 退出区
remainder section; remainder section; // 剩余区
//双标志后检查法
bool flag[2]= {false,false}; //表示想要进去临界区的组数,这里开始都不想
P0 进程: P1 进程:
flag[0] = true; flag[1] = true; // 进入区
while (flag[1]); while (flag[0]); // 进入区
critical section; critical section; // 临界区
flag[0] = false; flag[1] = false; // 退出区
remainder section; remainder section; // 剩余区
违背了忙则等待和空闲让进、有权等待。可能两个进程同时进入临界区,也可能两个进程都进入不了临界区的"饥饿"现象。
原因在于,进入区的“检查”和“上锁”两个处理不是一气呵成的。“检查”后,“上锁”前可能发生进程切换。
比如:现在还是桌上只有一双筷子,但是现在就不是A跟B了,换成孔融1号和孔融2号,为什么给他们这样取名字呢,后面就知道啦!现在这两个人呢,在想用筷子的时候都会先说出来表明自己的态度,然后再看对方会不会想要先用筷子,然后再判断下一步是使用筷子还是接着等待。如果一开始两个人同时表明自己想要筷子的话,对方都会考虑到礼仪问题,谦让给对方用,毕竟谁叫他们叫孔融呢,但是这样出现的问题就是明明有筷子可以用但是因为谦让而僵持住。结果两个人就只能饿着了,在操作系统里面这里就出现了"死等",即会存在进程产生"饥饿"。
❗2.2.3 Peterson算法
设置一个数组flag[2],这里与前面不同之处就是,先设置自己的标志位,再检测对方的标志状态,若对方的标志位为true则等待呗。
Peterson 算法实际上同时结合了单标志法和双标志后检查法。
它的核心就是:在一开始还是和后检查法一样,抢先进行“上锁”,但是上锁之后又将 turn 置为对方线程,表示自己虽然想要进入临界区,但是不介意“将这个机会让给对方”(所以turn是保存了最后一个谦让)。尽管如此,由于 while 的限制条件增加了,而 turn 又是公用的,所以保证了最后只会有一方的 while 满足条件。既做到了互斥访问资源,也避免了双方都访问不到资源。
【考点】turn变量的作用的表示轮到哪个进程进入临界区。
int turn; //当前允许进入的进程
bool flag[2]; //谁是true,表示谁想进入临界区
P0 进程: P1 进程:
//下面turn = 1是允许对方先进入临界区的谦让
flag[0] = true; flag[1] = true; // 进入区
turn = 1; turn = 0;
while (flag[1] && turn == 1); while (flag[0] && turn == 0);// 进入区
critical section; critical section; // 临界区
flag[0] = false; flag[1] = false; // 退出区
remainder section; remainder section; // 剩余区
不遵循"让权等待"原则,会发生“忙等"。
2.3硬件实现方案
2.3.1 关中断(中断屏蔽方法)
关中断就是禁止处理机响应中断源的请求,与原语类似。
关中断;
临界区;
开中断;
优点:关中断是最简单、高效的实现互斥的方法之一。
缺点:不适用于多处理机。只适用于操作系统内核进程,不适用于用户进程。因为开、关中断的指令只能运行在内核态,允许用户随意使用会很危险。
- 【2021年408真题】用户是否能够使用开/关中断指令实现临界区互斥?为什么?
不能。因为开/关中断指令是特权指令,运行在内核态,不能在用户态执行。
2.3.2 Test-and-Set(TS,TSL指令)
TS指令(也称Test-And-Set-Lock,TSL指令,测试并建立)是用硬件实现的,在执行的过程中不允许被中断。
若刚开始lock
是false,则TSL返回的old
值为false,while循环条件不满足,直接跳过循环,进入临界区。若刚开始lock
是true,则执行TLS后old
返回的值为true,while循环条件满足,会一直循环,直到当前访问临界区的进程在退出区进行“解锁”。
相比软件实现方法,TSL指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。
//布尔型共享变量lock表示当前临界区是否加锁
//true表示已加锁. false表示未加锁
bool TS(bool *lock){
bool old;
old = *lock; //存放原来的lock值
*lock = true; //资源正在被使用,上锁,关闭临界区
return old; //返回lock原来的值
}
do{
while( TS(&lock) ); //忙等,检查上锁
critical section;
lock = false; //解锁
remainder section;
}while(true);
*lock=false
表示资源空闲。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
2.3.3 SWAP指令(XCHG指令)
有的地方叫exchange指令,在Intel 80x86中叫XCHG指令,所有它是交换指令。
逻辑上来看Swap和TSL并无太大区别,都是先记录下此时临界区是否已经被上锁(记录在old变量上),再将上锁标记 lock 设置为 true,最后检查 old,如果 old 为 false 则说明之前没有别的进程对临界区上锁,则可跳出循环,进入临界区。
bool lock; //lock是全局变量
void swap(bool *a, bool *b){
bool temp;
temp = *a;
*a = *b;
*b = temp;
}
do{
bool old = true; //局部变量old,表示想用
do{
swap(&lock, &old); //把lock的值放到old中判断是否上锁
}while(old == true); //上锁true就一直忙等
critical section;
lock = false;
remainder section;
}while(true);
lock=false
表示资源空闲,没有被上锁。
old=false
表示之前没有被上锁。
优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境。
缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”。
-【2023年408真题】(2)题45(b)图中给出了两个变量值的函数newSwap()
的代码是否可以用函数调用语句newSwap(&key, &lock)
代替指令Swap key lock
以实现临界区的互斥?为什么?
不能。因为多个线程可以并发地行newSwap()
,它执行时传递给形参b的是共享变量lock的地址,在newSwap()
中对 lock 既有读操作又有写操作,并发执行时不能保证实现两个变量值的原子交换,从而导致并发执行的线程同时进入临界区。
例如,线程A和线程B并发执行,初始时lock值为FALSE,当线程A执行完*a=*b后发生了进程调度,切换到线程B执行,线程B执行完newSwap()
后发生线程切换,此时线程A和B都能进入临界区,不能实现互斥访问。
3.信号量机制PV
1965年荷兰学者迪杰斯特拉特出信号量(semaphores)机制。利用一对原语解决检查和上锁这两个操作无法同时进行的问题。
一对原语wait(S)和signal(S),它们可以简写成P(S),V(S),源于荷兰语(proberen,verhogen)。
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量。
【注意】PV操作是一种低级进程通信原语,不是系统调用。
3.1 整型信号量
int S = 1; //表示有一个资源
- S>0:有资源
- S<0:有|S|个等待队列进程
- S=0:无资源
void wait(int S){ //wait原语,相当于进入区
while(S <= 0); //如果资源不够,就一直循环等待
S--; //资源够了,就占用一个资源
}
void signal(int S){ //signal原语,退出区
S++; //释放资源
}
进程:
...
wait(S); //进入区,申请资源
critical section; //临界区,使用资源
signal(S); //退出区,释放资源
...
- 【2021年408真题】为什么在
wait()
和signal()
操作中对信号量S的访问必须互斥执行?
信号量S是能被多个进程共享的变量,多个进程都可以通过wait()
和 signal()
对S进行读写,那么wait()
和 signal()
操作中对S的访问就必须是互斥的。
3.2 记录型信号量
整型信号量的缺陷是存在“忙等”问题,因此人们又提出了“记录型信号量”,即用记录型数据结构表示的信号量。
有缓冲区(等待队列)
/*定义记录型信号量 结构体*/
typedef struct {
int value; //剩余资源数量
struct process *L; //等待队列
} semaphore;
void wait(semaphore S){
S.value--;
//如果剩余资源不足,就使用block原语(阻塞)使进程主动放弃处理机,并把S放到阻塞队列中
if(S.value < 0){
block(S.L);
}
}
void signal(semaphore S){
S.value++;
//释放资源后,如果阻塞队列中还有进程,就用wakeup原语(唤醒)队列中的进程,使其进入就绪态
if(S.value <=0){
wakeup(S.L);
}
}
3.3 AND型信号量
可以避免“死锁”。
为了解决一次分配多种资源,每种资源每次分配一个,一次获得进程所需要的所有资源(每种个1个),否则进程阻塞,是记录型信号量上的进一步延伸。
AND型信号量的阻塞队列机制,为每种资源设置一个阻塞队列,当最先出现资源不足的资源种类为Ri时,那么进程就被阻塞在Ri资源对应的阻塞队列中。
void wait(S1, S2, …, Sn){
if (S1 >= 1 && … && Sn >= 1 ){ //所有资源足够
for(i=1; i<=n; i++)
Si--;
}
else{将该进程放入与发现的第一个si < 1相关联的等待队列中,并将该进程的进度计数设置为等待操作的开始。}
}
void signal(S1, S2, …, Sn){
for (i=1; i<=n; i++) {
Si++;
将与si关联的队列中等待的所有进程移到就绪队列中。
}
}
S1到Sn都表示所需资源,资源数都大于1,对每个资源进行——表示资源被占用,分配好资源之后跳出循环,wait操作结束。如果其中某个资源Si得不到满足,会执行else中的内容:把进程放进Si关联的阻塞队列中,然后程序计数器把指针移向wait操作开始。(wait操作是原语,遵循要执行都执行,执行不了就从头重新执行)
AND型信号量满足了“多种资源,数量为1”的使用情景,但是实际上还会有多种资源数量不固定的情景,AND型信号量显然处理不了这种情况的进程调度。
为了解决多资源多数量的情况,出现了信号量集。
3.4 信号量集
AND的进一步延伸,设置一个最低资源数目>=1,和进程需要的资源数目>=0。
现在的使用情景是多资源多数量, 就是一个进程需要申请多个资源,每个资源数量又要求多个。描述资源的结构体做出了改动:
申请n类资源,每类资源最低t个,每类申请d个资源。
typedef struct{
int value;
int d;
int t;
struct process_control_block * list;
} semaphore;
void wait(S1, t1, d1; …; Sn, tn, dn){
if (S1>= t1 && … && Sn>=tn){
for (i=1; i<=n; i++) {
Si = Si - di;
}
}
else{
将正在执行的进程放在第一个具有si <的等待队列中,并将其程序计数器设置为等待操作的开始.
}
}
void signal(S1, d1, …, Sn, dn){
for (i=1; i<=n; i++) {
Si = Si - di;
将与si关联的队列中等待(process waiting)的所有进程移到ready queue中.
}
}
原有的value和list阻塞队列保留,新增属性t和d。
d表示进程需要的某类资源的数量,t表示进程能执行需要某类资源数量的最小值,value表示当前某类资源个数。
这里的d、t必须满足关系t>=d才能保证进程可以执行。解释一下:假设d=5,也就是进程本身需要5个A资源;t=7,也就是进程最小需要7个A类资源才能执行,多出来的两个是分给操作系统使用的,因为控制进程执行的指令也需要操作系统分配资源。当然当前i资源数S也必须大于7才能保证进程整体可以执行。
信号量集是由整形信号量一步步演变而来,每次演变都继承了上次的工作机制并且进行了缺点的改造。信号量集的已经可以适用较多的情景了。
如果wait(S,1,1)那么就是需要1种资源,需要的资源数量为1,如果S>=1这就退化成了记录型信号量;如果S=1就退化成了互斥信号量(整型信号量)。
3.5 信号量的应用
3.5.1实现进程互斥
设置互斥信号量mutex,初始值为 1,取值范围为(-1,0,1)
- mutex= 1:两个进程都没有进入互斥访问的临界区
- mutex= 0:有一个进程在临界区运行
- mutex= -1:有一个进程在临界区运行,另一个因等待而阻塞在信号量队列中
在记录型信号量的基础之上,进程访问临界区就可以直接写:
semaphore mutex = 1; //初始化信号量(记录型信号量)
P1(){
...
P(mutex);
critical section;
V(mutex);
...
}
【注意】对不同的临界资源需要设置不同的互斥信号量。
3.5.2实现进程同步(前驱关系)
进程同步:要让各并发进程按要求有序地推进。信号量初值由用户确定。
比如:代码4需要在代码1和代码2完成之后才能开始,那么就需要调度到1->2->4实现同步关系。保证一前一后地执行。
semaphore s=0;
P1(){ P2(){
code1; P(s);
code2; code4;
V(s); code5;
code3; code6;
} }
1-3一些例题
例1:不需要信号量机制就可以实现的功能是(D)。
A.进程同步 B.进程互斥 C.执行的前驱关系 D.进程的并发执行
例2:使用互斥锁进行同步互斥时,(C)情况会导致死锁。
A.一个线程对同一个互斥锁连续加锁2次。
B.一个线程尝试对一个已经加锁的互斥锁再次加锁。
C.两个线程分别对2个不同的互斥琐先后加锁,但是顺序相反。
D.一个线程对一个互斥锁连续加锁,然后忘记解锁。
4.管程机制
引入管程的原因:信号量机制在编写的时候,编写程序困难、易出错,P(S)和V(S)操作大量分散在各个进程中,不易管理,所以引入管程(monitor) 的概念。
1973年,Brinch Hansen首次在程序设计语言(Pascal)中引入了“管程”成分——一种高级同步机制。
定义:管程是一种高级的同步机制(同步工具),本质上也是用于实现进程的同步、互斥。OS资源管理模块,解决了信号量机制中大量同步操作分散的问题。
由编译器负责实现各进程互斥地进入管程中的过程,程序员不需要再手动实现”互斥“,直接调用方法,就已经互斥的进行的。
4.1组成
管程是一种特殊的软件模块(有点像类)
- 局部于管程的共享数据结构(结构体)
- 对该数据结构进行操作的一组过程(函数)
- 对局部于管程的共享数据设置初始值的语句
- 管程有一个名字
4.2条件变量
管程中包含条件变量,用于管理进程的阻塞和唤醒。这个条件变量是管程内部一种特殊变量,类似信号量机制中的信号量,用于实现进程同步。
其形式为 condition x
,对它的操作仅有wait
和signal
。
【注意】管程中signal
操作与信号量中的V
操作不同。V操作一定会更改信号量的值 S:=S+1,但是管程signal操作是针对某个条件变量的,如果不存在因为该条件变量而阻塞的进程,那么该signal操作也就不会产生任何影响。
-
x.wait
:正在调用管程的进程因 x 条件需要被阻塞或挂起,则调用x.wait
将自己插入到 x 条件的等待队列上,并释放管程,直到 x 条件变化。此时其它进程可以使用该管程。
-
x.signal
:正在调用管程的进程发现 x 条件发生了变化,则调用x.signal
,重新启动一个因 x 条件而阻塞或挂起的进程。(与信号量的signal不同,没有 S:=S+1 的操作)
4.3特征
-
局部于管程的数据只能被局部与管程的过程(函数、方法)所访问;
-
一个进程只能通过调用管程内的过程才能进入管程访问共享数据;
- 管程是被进程调用的,管程是语法范围,无法创建和撤销。
-
每次仅允许一个进程在管程内执行某个内部过程。
1、2就是管程里面的数据结构,只能被管程里面的函数修改,调用这个函数来修改。
4.4 Java中的管程
Java中用synchronized
来描述一个函数,那么这个函数同一时间内仅能被一个线程调用。
public synchronized void insert(bool lock){
...
}
//每次只能有一个线程进入insert 函数,如果多个线程同时调用 insert 函数,则后来者需要排队等待。
static class monitor {
private Item buffer[] = new Item[N];
private int count = 0;
public synchronized void insert (Item item) {
...
}
}
5.经典进程同步问题
5.1 PV操作问题解决思路
- 关系分析。找出各进程、进程之间的互斥、同步关系。
- 整理思路。根据各进程的操作流程,确定P、V操作的大致顺序。
- 设置信号量。根据题目条件确定信号量的初值。(互斥信号量一般初值为1,同步信号量要看对应的资源的数量)
设置信号量,要考虑的是临界区事件的前后关系,而不是进程的关系,后者联系会变多,信号量多。
❗5.2 生产者-消费者
producer-consumer
生产者-消费者用于解决:多个进程之间的同步、互斥问题。
生产者-消费者问题,在两者之间设立n个缓冲池,生产者进程将它的所有产品放入一个缓冲区(就是临界资源),消费者从缓冲池中取出产品。这过程中,两者进程是以异步的方式(取时不能放,放时不能取)运行的,但是它们之间需要保持同步(一前一后),即:
不允许消费者去空缓冲区去产品;(空 -> 阻塞)
不允许生产者去已经装满了产品的缓冲区放产品。
- 多生产者-消费者问题:
【技巧】可以删去互斥变量mutex的原因在于:本题中的缓冲区大小为1,在任何时刻,apple、 orange、 plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区。
❗5.3 读者-写者
reader-writer problem
有一个写者很多读者。多个读者可以同时读文件,但写者在写文件时不允许有读者在读文件,同样有读者在读文件时写者也不去能写文件。
【总结】同类进程不互斥,异类进程互斥。
要求:
- 允许多个读者可以同时对文件执行读操作;
- 只允许一个写者往文件中写信息;
- 任一写者在完成写操作之前不允许其他读者或写者工作;
- 写者执行写操作前,应让已有的读者和写者全部退出。
5.3.1读者优先算法
潜在的问题:只要有读进程还在读,写进程就要一直阻塞等待,可能“饿死”。因此,这种算法中,读进程是优先的。
❗5.3.2写者优先算法
读者想要执行count++(读者数量+1),需要在没有写者准备的情况下才能进行。
当有写者P(w),想要写,那么这样写者会阻塞在P(rw),那么新的读者就不能再进入临界区,当所有读者读完之后,执行V(rw),那么写者就可以直接开始写了。直到写完V(w),才可以有新的读者进入。
【重点】读者-写者问题为我们解决复杂的互斥问题提供了一个参考思路。
其核心思想在于设置了一个计数器count用来记录当前正在访问共享文件的读进程数。我们可以用count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而做出不同的处理。
另外,对count变量的检查和赋值需要实现“一气呵成”,自然应该想到用互斥信号量。
❗5.4 哲学家进餐
- 关系分析。系统中有5个哲学家进程,5位哲学家与左右邻居对其中间筷子的访问是互斥关系。
- 整理思路。这个问题中只有互斥关系,但与之前遇到的问题不同的事,每个哲学家进程需要同时持有两个临界资源才能开始吃饭。如何避免临界资源分配不当造成的死锁现象,是哲学家问题的精髓。
- 信号量设置。定义互斥信号量数组chopstick[5]={1,1,1,1,1}用于实现对5个筷子的互斥访问。并对哲学家按0~4编号,哲学家i左边的筷子编号为i,右边的筷子编号为(i+1)%5。
当5个哲学家进程并发执行时,某个时刻恰好每个哲学家进程都执行申请筷子,并且成功申请到第i支筷子(相当于5个哲学家同时拿起他左边的筷子), 接着他们又都执行申请右边筷子, 申请第i+1支筷子。此时每个哲学家仅拿到一支筷子, 另外一支只得无限等待下去, 引起死锁。在给出几种有效阻止死锁的方案之前,首先给出两个断言:
(1)系统中有N个并发进程。 若规定每个进程需要申请2个某类资源, 则当系统提供N+1个同类资源时,无论采用何种方式申请资源, 一定不会发生死锁。分析:N+1个资源被N 个进程竞争, 由抽屉原理可知, 则至少存在一个进程获2个以上的同类资源。这就是前面提到的哲学家就餐问题中5个哲学家提供6支筷子时一定不会发生死锁的原因。
(2)系统中有N个并发进程。 若规定每个进程需要申请R个某类资源, 则当系统提供K=N*(R-1)+1个同类资源时,无论采用何种方式申请使用,一定不会发生死锁。
分析:在最坏的情况下,每个进程都申请到R-1个同类资源, 此时它们均阻塞。 试想若系统再追加一个同类资源, 则 N 个进程中必有一个进程获得R个资源,死锁解除。
结合以上分析,哲学家就餐问题可以被抽象描述为:系统中有5个并发进程, 规定每个进程需要申请2个某类资源。 若系统提供5个该类资源, 在保证一定不会产生死锁的前提下,最多允许多少个进程并发执行?假设允许N个进程, 将R=2,K=5带入上述公式, 有N*(2-1)+1=5所以 N=4。也就意味着,如果在任何时刻系统最多允许4个进程并发执行, 则一定不会发生死锁。 大多数哲学家就餐问题死锁阻止算法都是基于这个结论。
解法:
5.4.1方案一
可以对哲学家进程施加一些限制条件,比如最多允许四个哲学家同时进餐。这样可以保证至少有一个哲学家是可以拿到左右两只筷子的。
5.4.2方案二
规定奇数号哲学家先拿他左边的筷子,然后在去拿右边的筷子;而偶数号哲学家则相反。按此规定,将是1、2号哲学家竞争1号筷子;3、4号哲学家竞争3号筷子。
即5位哲学家都先竞争奇数号筷子,获得后,再去竞争偶数号筷子,最后总会有一位哲学家能够获得两只筷子而进餐。
5.4.3方案三
采用AND型信号量(互斥访问) 机制来解决,当一个哲学家左右两边的筷子都可用时,才允许他抓起筷子,有一个筷子时候不能抓。即要求每个哲学家先获得两个临界资源(筷子)后方能进餐。
5.4.4哲学家例题
【2019年408真题】有n(n ≥ 3)名哲学家围坐在一张圆桌边,每名哲学家交替地就餐和思考。再圆桌中心有m(m ≥ 1)个碗,每两名哲学家之间有一根筷子。每名哲学家必须取到一个碗和两侧的筷子后,才能就餐,进餐完毕,将碗和筷子放回原位,并继续思考。为使尽可能多的哲学家同时就餐,且防止出现死锁现象,请使用信号量的P、V操作[wait(),signal()操作]描述上述过程中的互斥与同步,并说明所用信号量及初始值的含义。
思考:
可以使用碗的数量 m 来限制访问的人数。也就是 bowl
这个信号量既充当资源,又起到了限制访问人数的作用(mutex)。需要注意的是bowl
的数量一定要小于 n。
也就得到碗资源的数量:bowl: min{n-1, m}。
伪代码:
semaphore bowl; //碗
semaphore chopsticks[n]; //n个筷子
//赋值
for(int i=0; i<n; i++)
chopsticks[i] = 1; //每个哲学家一侧筷子个数为1
bowl = min(m,n-1); //限制访问资源的人数最多为n-1
Process Philosopher()
{
while(true)
{
思考;
P(bowl); //取碗
P(chopsticks[i]); //左手筷子
P(chopsticks[(i+1)%n] //右手筷子
干饭;
V(chopsticks[i]);
V(chopsticks[(i+1)%n];
V(bowl);
}
}
优化:
根据解法方案三:采用AND型信号量(互斥访问)机制来解决,当一个哲学家左右两边的筷子都可用时,才允许他同时抓起筷子,有一个筷子时候不能抓。即要求每个哲学家先获得两个临界资源(筷子)后方能进餐。
那么把抓筷子的动一气呵成,即上锁。
semaphore lock = 1; //用以互斥申请资源的信号量
semaphore bowl = m; //碗
semaphore chopsticks[n]; //n个筷子
//赋值
for(int i=0; i<n; i++)
chopsticks[i] = 1; //每个哲学家一侧筷子个数为1
bowl = min(m,n-1); //限制访问资源的人数最多为n-1
Process Philosopher()
{
while(true)
{
思考
P(lock); //"上锁"
P(chopsticks[i]); //左手筷子
P(chopsticks[(i+1)%n] //右手筷子
P(bowl); //取碗
V(lock);
干饭
V(chopsticks[i]);
V(chopsticks[(i+1)%n];
V(bowl);
}
}