OS操作系统系列文章目录
【OS操作系统】Operating System 第一章:操作系统的概述
【OS操作系统】Operating System 第二章:启动、中断、异常和系统调用
【OS操作系统】Operating System 第三章:连续内存分配
【OS操作系统】Operating System 第四章:非连续内容分配
【OS操作系统】Operating System 第五章:虚存技术
【OS操作系统】Operating System 第六章:页面置换算法
【OS操作系统】Operating System 第七章:进程与线程
【OS操作系统】Operating System 第八章:处理机调度
【OS操作系统】Operating System 第九章:同步互斥问题
【OS操作系统】Operating System 第十章:信号量与管程
【OS操作系统】Operating System 第十一章:死锁与进程通信
【OS操作系统】Operating System 第十二章:文件系统
目录
第十章:信号量与管程
背景
利用信号量和管程解决同步互斥的问题
- 并发问题:竞争/竞态条件
- 多程序并发存在大的问题;
-
同步
- 线程共享公共数据的协调条件;
- 包括互斥和条件同步;
- 互斥:在同一时间内只有一个线程可以执行临界区;
-
解决同步问题方法较难
- 需要高层次的编程抽象(如:锁);
- 从底层硬件支持编译;
-
解决过程图
信号量
- 抽象数据类型
- 一个整型(sem),两个原子操作;
- P操作:sem减一;
- 如果信号量sem<0,认为执行P操作进程的需要睡眠;
- 如果信号量sem>0,认为执行P操作的进程可以继续执行,可以进入临界区;
- 如果挡住了,就不能执行后续的程序,起到了一个阻挡得作用;
- V操作:sem加一;
- 如果信号量sem<=0,唤醒一个或多个等待的进程;
- 信号量是整数(有符号数)
- 一开始通常会设定为一个大于0的数,所以一开始执行P操作不会被阻塞;
- 但是多次执行P操作之后,执行P操作的进程就会等待在上面;
- 这是需要其它进程执行V操作,然后唤醒在上面等待的进程(如果只能唤醒一个进程,一般是唤醒第一个等待的进程,才有FIFO思想);
- 信号量是被保护的变量
- c初始化完成之后,唯一改变信号量的值的方法是,通过P操作和V操作;
- 操作必须是原子的;
- P操作(信号量减一操作)能够阻塞;
- V操作(信号量加一操作)不会阻塞;
- 假定信号量是公平的;
- 没有线程因为P操作被阻塞在P操作仍然阻塞如果V操作被无线频繁调用(在同一个信号量);
- 在实际中,FOFP常被使用;
- 两类信号量
- 二进制信号量:可以是0或1(与前面的lock起到同样的效果);
- 一般/计数信号量:可取任何非负值;
- 两者可以互相表现(给定一个可以思想另一个);
- 信号量可以用在
- 互斥;
- 条件同步(调度约束——一个线程等待另一个线程的事件发生);
信号量的使用
用二进制信号量实现的互斥
- 一开始设置一个初始值,为了模仿Lock操作,使初始值为1;
- 在临界区之前,做一个信号量的P操作;
- 在临界区之后,做一个信号量的V操作;
- 这就是二进制信号量的最常用法,完全可以代替前面的Lock操作;
mutex = new Semaphore(1);
mutex->P();
...
Critical Section;
...
mutex->V();
用二进制信号量完成同步操作
- 用P操作实现线程等待;
- 用V操作实现线程提醒;
//设定一个二进制信号量;
condition = new Senmaphore(0);
//Thread A
{
...
condition->P();
...
}
//Thread B
{
...
condition->V();
...
}
其它复杂问题
- 一个线程等待另一个线程处理事情;
比如生产东西或消费东西、互斥(锁机制)是不够的;
实例:有界缓冲区的生产者-消费者问题
-
P
r
o
d
u
c
e
r
→
B
u
f
f
e
r
→
C
o
n
s
u
m
e
r
Producer\rarr Buffer\rarr Consumer
Producer→Buffer→Consumer:
- 一个或多个生产者产生数据,将数据放在一个缓冲区里;
- 单个消费者每次从缓冲区取出数据;
- 在任何一个时间只有一个生产者或消费者可以访问缓冲区;
- 实现细节:
- buffer是有限的;
- 任何一个时间只能有一个线程操作缓冲区(互斥);
- 允许一个或多个生产者往buffer中写数据,但是这时候不允许消费者读数据;
- 允许一个或多个消费者往buffer中读数据,但是这时候不允许生产者写数据;
- 当缓冲区为空时,消费者要休眠,消费者必须等待生产者(调度、同步约束);
- 当缓冲区为满时,生产者必须等待消费者(调度、同步约束);
- 信号量设置:
每个约束用一个单独的信号量;- 二进制信号量互斥:
对buffer做添加或取出的保障; - 一般信号量fullBuffers:
代表一开始buffer的数据多少,初始化为0,表示一开始buffer为空; - 一般信号量emptyBuffers:
代表当前生产者可以往buffer添加的数据量,初始化为n,表示当前buffer可用添加n条数据;
- 二进制信号量互斥:
- 两个一般信号量用于同步操作,当buffer还有空间时,应唤醒生产者继续生产;
代码实现
- 数据初始化
class BoundedBuffer {
mutex = new Semaphore(1);
fullBuffers = new Semaphore(0);
emptyBuffers = new Semaphore(n);
}
- 生产者操作
//调用这个函数实现生产者不断地添加数据;
BoundedBuffer::Deposit(c) {
Add c to the buffer;
}
- 消费者操作
//调用这个函数实现消费者不停地取出数据;
BounderBuffer::Remove(c) {
Remove c from buffer;
}
- 解决互斥同步总实现代码
//信号量初始化;
class BounderBuffer {
mutex = new Semaphore(1);
fullBuffers = new Semaphore(0);
emptyBuffers = new Semaphore(n);
}
//生产者代码;
BounderBuffer::Deposit(c) {
emptyBuffers->P();
mutex->P();
Add c to the buffer;
mutex->V();
fullBuffers->V();
}
//消费者代码;
BounderBuffer::Remove(c) {
fullBuffers->P();
mutex->P();
Remove c from buffer;
mutex->V();
emptyBuffers->V();
}
以下运用互斥机制和同步机制实现生产者和消费者问题,需要注意好初值的确定:
- 对于生产者而言,因为buffer设置允许添加的数据是n,即emptyBuffers为n,进行减一操作后,生产者可以往下执行,进行buffer的生产操作;
- 但是在生产之前,需要对mutex进行减一操作,得mutex为0;生产操作完成后,对mutex进行加一操作,得mutex为1;这样可以保证buffer的互斥属性,确保每次只有一个线程在执行;
- 互斥操作完成后,对fullBuffers进行加一操作,并提醒消费者可以正常消费;
- 对于消费者而言,fullBuffers的初始值为0,表示没有数据,消费者进入等待;所以,刚刚生产者唤醒消费者后,将会和生产者的fullBuffers加一操作相匹配;而后进行互斥的进行取数据的互斥操作;
- 消费者取出数据后,会emptyBuffers进行加一操作,并唤醒生产者可以继续生产;
- 问题:
P和V操作的顺序分别调整,会有影响吗?- V操作是加一操作,不会阻塞,所以没有影响;
- P操作是减一操作,会导致阻塞,进而产生严重的情况,如出现死锁;
信号量的实现
不仅要会用信号量,还需直到信号量使用的细节;
伪代码实现
- 信号量定义
class Semaphore {
int sem; //信号量
WaitQueue q; //等待队列
}
- P操作定义
Semaphore::P() {
--sem;
if (sem < 0) {
Add this thread t to q; //将线程t放入等待队列;
block(t); //令线程t休眠;
}
}
- V操作定义
Semaphore::V() {
++sem;
if (sem <= 0) {
Remove a thread t from q; //将线程t移出队列;
wakeup(t); //唤醒线程t,采用FIFO思想;
}
}
需要注意的问题
- 信号量的双用途
- 互斥和条件同步;
- 但等待条件是独立的互斥;
- 读、开发代码比较困难
- 程序员必须非常了解信号量;
- 容易出错
- 使用的信号量已经被另一个线程占用;
- 忘记释放信号量;
- 不能处理死锁问题;
管程
管程的抽象程度更高,更加容易完成相应的同步互斥问题;
基本概念
- 目的:
分离互斥和条件同步的关注;
(一开始是完成编程语言的设计,而不是操作系统的设计,所以整体上是针对语言的并发机制来完成的)
- 什么是管程(moniter)
管程是包含了一系列的共享变量,以及针对这些变量的函数,的一个组合或模块;包括:- 一个锁:
指定临界区(确保互斥性,只能有一个线程); - 0或者多个条件变量:
等待、通知信号量用于管理并发访问共享数据;
- 一个锁:
一般方法
- 收集在对象、模块中的相关共享数据;
- 定义方法来访问共享数据;
大致的结构图,如下:
一开始,所有进程在右上角的排列队列中,排队完成后进行wait()操作,等到signal()操作唤醒后,执行这个进程的代码;
- Lock
- Lock::Acquire() —— 等待直到锁可用,然后抢占锁;
- Lock::Release() —— 释放锁,唤醒等待者;
- Condition Variable
- 允许等待状态进入临界区
- 允许处于等待(睡眠)的线程进入临界区;
- 某个时刻原子释放锁进入睡眠;
- Wait() operation
- 释放锁,睡眠,重新获得锁返回;
- Signal() operation (or broadcast() operation)
- 唤醒等待者(或者所有等待者);
- 允许等待状态进入临界区
- 实现:
- 需要维持每个条件队列
- 线程等待的条件等待signal()
class Condition {
int numWaiting = 0;
WaitQueue q;
}
Condition::Wait(lock) {
++numWaiting;
Add this thread t to q;
release(lock);
schedule(); //need mutex
acquire(lock);
}
Condition::Signal() {
if (numWaiting > 0) {
Remove a thread t from q;
wakeup(t); //need mutex
--numWaiting;
}
}
- 解析:
- numWaiting表示当前den等待线程的个数,而之前的sem表示为信号量的个数;
- 信号量的实现P操作和V操作一定会执行,即一定会执行减一和加一操作;但是在这里,wait操作会进行加操作,而signal操作不一定会进行减操作;
- 这里的wait函数,还没有require lock就要先release lock的原因在后面讲解;
- release lock操作之后,会做一次schedule(),表示会选择下一次线程执行,因为本身这个线程已经处于睡眠状态了;
- schedule()完毕后会进行一次require lock操作,这里的release和require和之前的不一样,后面进行讲解;
- signal函数是作唤醒的操作,从等待队列里面取出一个线程唤醒,与之前的schedule()是对应的;wakeup(t)是对schedule的进一步触动机制;最后waitting再进行减减操作;
- 如果等待队列为0,则啥操作也不做,这里的numWaiting表示当前等待线程的个数,而之前的sem代表信号量的个数;
使用
- 对管程进行初始化
- lock变量保证互斥的操作;
- condition条件变量,这里有两个,分别表示buffer满和buffer空;
- count表示buffer的空闲情况,如果count = 0则buffer是空的;
class BounderBuffer {
...
Lock lock;
int count = 0;
Condition notFull, notEmpty;
}
- 互斥机制
管程一次只能进入一个线程,故设立互斥机制;- 这里不仅仅要对buffer操作,还要再count中记录;
- 信号量互斥的实现是仅仅依靠这个buffer的,而这里的互斥是在函数的头尾lock进行的;
- buffer空了,消费者会进入睡眠;而buffer满了,生产者会进入睡眠;
//生产者
BounderBuffer::Deposit(c) {
lock->Acquire();
...
Add c to the buffer;
++count;
....
lock->Release();
}
//消费者
BounderBuffer::Remove(c) {
lock->Acquire();
...
Remove c from buffer;
--count;
...
lock->Release();
}
- 同步机制
- 生产者的buffer未满操作
当buffer满的时候,count = n,这时会进行notFull.wait(&lock)操作;notFull是一个条件变量,不要初始值;notFull.wait(&lock)表示当前已满,需要睡眠,同时会输入一个lock,这个是外部管程的lock;
BounderBuffer::Deposit(c) {
lock->Acquire();
while (count == 0) {
notFull.Wait(&lock);
}
Add c to the buffer;
++count;
...
lock->Releas();
}
- 解释前面的问题:为什么在管程的Wait函数中,需要先释放锁再请求一个锁;
Condition::Wait(lock) {
++numWaiting;
Add this thread t to q;
release(lock);
schedule(); //need mutex
require(lock);
}
- 解析:
- release(lock),实际上锁让当前的生产者释放这个锁,这使得其它的线程才有可能进入管程;
- 因为这时候生产者线程进入睡眠,所有必须将之前请求的锁释放;不然所有其它等待的线程都在等待,系统陷入死锁;
- 一旦将来被唤醒,意味着可用继续冲schedule中继续往下执行,故再次申请一个锁,跳出wait操作,查看count是否等于n;
- 消费者的buffer未满操作
针对生产者在buffer已满的情况下,执行消费者线程;
一旦count做了减减操作,buffer就会消费了一个数据量,故buffer就会未满,故消费者代码下面进行notFull.signal()操作,唤醒陷入睡眠的生产者线程;
BounderBuffer::Renmove(c) {
lock->Acquire();
...
Remove c from buffer;
--count;
notFull.Signal();
lock->Release();
}
- 消费者的buffer为空操作 & 生产者的buffer非空唤醒操作
消费者在buffer为空的情况中,会在while中判断count是否为0;
如果是,则进行notEmpty().Wait()操作进入睡眠,直到生产者进行notEmpty().Signal()操作唤醒消费者线程;
两者相结合就是完整的管程实现生产者和消费者问题;
//生产者
BounderBuffer::Deposit(c) {
lock->Acquire();
while (count == n) {
notFull.Wait(&lock);
}
Add c to the buffer;
++count;
notEmpty.Signal();
lock->Release();
}
//消费者
BounderBuffer::Remove(c) {
lock->Acquire();
while (count == 0) {
notEmpty.Wait(&lock);
}
Remove c from buffer;
--count;
notFull.Signal();
lock->Release();
}
管程实现与信号量实现的比较
- 管程实现
//管程初始化
class Condition {
int numWaiting = 0;
WaitQueue q;
}
Condition::Wait(lock) {
++numWaiting;
Add this thread t to q;
release(lock);
schedule(); //need mutex
require(lock);
}
Condition::Signal() {
if (numWaiting > 0) {
Remove a thread from q;
wakeup(t); //need mutex
--numWaiting;
}
}
//执行生产者和消费者问题
class BoundBuffer{
...
Lock lock;
int count = 0;
Condition notFull, notEmpty;
}
BoundBuffer::Deposit(c) {
lock->Acquire();
while (count == n) {
notFull.Wait(lock);
}
Add c to the buffer;
++count;
notEmpty.Signal();
lock->Release();
}
BounfBuffer::Remove(c) {
lock->Acquire();
while (count == 0) {
notEmpty.Wait(&lock);
}
Remove c from buffer;
--count;
notFull.Signal();
lock->Release();
}
- 信号量实现:
//信号量初始化
class Semaphore {
int sem;
WaitQueue q;
}
Semaphore::P() {
--sem;
if (sem < 0) {
Add this thread t to q;
block(t);
}
}
Semaphore::V() {
++sem;
if (sem <= 0) {
Remove a thread t from q;
wakerup(t);
}
}
//执行生产者和消费者问题
class BoundBuffer {
mutex = new Semaphore(1);
fullBuffers = new Semaphore(0); //buffer存在的数据量
emptyBuffers = new Semaphore(n); //buffer剩余空位
}
BoundBuffer::Deposit(c) {
emptyBuffers->P();
mutex->P();
Add c to the buffer;
mutex->V();
fullBuffers->V();
}
BoundBuffer::Remove(c) {
fullBuffers->P();
mutex->P();
Remove c from buffer;
mutex->V();
emptyBuffers->V();
}
管程的两种实现方式
- 问题:
当线程 i 发出signal操作后,那么就要唤醒线程 j;
应该是先继续执行线程 i 的后续代码,在执行等待的线程 j ?
还是应该先执行等待线程 j 后,再执行线程 i ?
- 方案1:Hoare方法(比较完美)
应该先执行睡眠的线程 j,而线程 i 先等待,直到线程 j 释放了锁再继续执行;
- 特点:
比较直观,但是实现困难; - 执行流程:
- 方法2:Hansen方法
当线程 i 执行唤醒操作后,先不交出CPU控制,而是继续执行,直到释放锁后,再让等待的线程 j 开始执行;
- 特点:
容易实现; - 执行流程:
- 两种方法的比较
- 对于Hansen而言,其并没有马上让等待的线程执行,而是必须当前线程执行完后才会释放锁;所以这时会有多个被唤醒的线程,它们会进行抢占,故可能某些线程在抢占的时候,count已经不满足情况了,所以要循环查询;
- 对于Hoare而言,执行之后会马上转移CPU的控制权,故只有一个线程会被唤醒,不存多个的问题;故其一定可用往下执行,因为count一定不为n;
- 总结
经典同步问题1 —— 读者与写者问题
读者优先情况(信号量实现)
- 出现场景:
共享数据的访问
-
使用者:
- 读者:
不需要修改数据; - 写者:
读取和修改数据;
- 读者:
-
问题约束:
- 允许同一时间有多个读者,但在任何时候只有一个写者;
- 当没有写者时,读者才能访问数据;
- 但没有读者和写者时,写者才能访问数据;
- 任何时候只能有一个线程可以操作共享变量;
- 读者优先,不按时间顺序进行等待;
-
共享数据的设计:
- 数据集;
- 信号量CountMutex初始化为1,保证count的读写是互斥的;
- 信号量WriteMutex初始化为1,保证写者的互斥保护,因为只允许一个写操作;
- 整数Rcount初始化为0,当前读者的数量,因为可以有多个读者同时操作;
实现过程
- Sem_wait:P操作,进行减一;
- Sem_post:V操作,进行加一;
- 写者的互斥保护
针对信号量WriteMutex的PV操作,确保只有一个线程可以进行写操作;
一旦写者在写,读者不能读,只能等待;
当读者在读时,写者不能写数据;
体现了读者与写者的互斥操作,和写者与写者的互斥操作,但是没有体现出允许多个读者读数据;
//Writer
{
sem_wait(WriteMutex);
...
write;
...
sem_post(WriteMutex);
}
//Reader
{
...
sem_wait(WriteMutex);
...
read;
...
sem_post(WriteMutex);
}
- 多读者体现
- Rcount = 0,代表当时没有读者,所以只要没有写者,就可以继续执行;
- 如果Rcount != 0,表明当前有读者线程在读数据,且写者一定不能进来,故直接++Rcount;
- 当读完后,如果Rcount = 0,则当前没有读者,故需要唤醒可能在等待的写者;
//Reader
{
...
if (Rcount == 0) {
sem_wait(WriteMutex);
}
++Rcount;
...
read;
...
--Rcount;
if (Rcount == 0) {
sem_post(WriteMutex);
}
...
}
- 多读者的互斥
确保不会存在多个读者同时对Rcount进行操作,保证Rcount数据的互斥性;
//Reader
{
sem_wait(CountMutex);
if (Rcount == 0) {
sem_wait(WriteMutex);
}
++Rcount;
sem_post(CountMutex);
...
read;
...
sem_wait(CountMutex);
--Rcount;
if (Rcount == 0) {
sem_post(CountMutex);
}
sem_post(CountMutex);
}
- 完整的读者优先的读者写者问题
//Writer
{
sem_wait(WriteMutex);
write;
sem_post(WriteMutex);
}
//Reader
{
sem_wait(CountMutex);
if (Rcount == 0) {
sem_wait(WriteMutex);
}
++Rcount;
sem_post(CountMutex);
read;
sem_wait(CountMutex);
--Rcount;
if (Rcount == 0) {
sem_post(WriteMutex);
}
sem_post(CountMutex);
}
- 读者优先于写者优先的区别
- 基于读者优先策略的方法,只要有一个读者处于活动状态,后来的读者都会被接纳;如果读者源源不断出现,那么写者则始终处于阻塞状态;
- 基于写者优先策略的方法,一旦写者就绪,那么写者会尽可能快地执行写操作;如果写者源源不断地出现,则读者则始终处于阻塞状态;
写者优先情况(管程实现)
- 大致流程:
- 读者进行读操作时,要注意当前是否有写者(两类:正在写数据的写者和正在等待的写者),这两类写者只要有一个存在,那么读者就需要等待;等都不存在才有机会进行读操作;
- 读完之后,检测是否有写者正在等待,其有责任去唤醒;
- 写者进行写操作时,需要注意是否有读者正在读,或者写者正在写,如果有需要等待(正在等待的读者不需要理会,写者优先);
- 写完后,唤醒正在等待的写者或者正在等待的读者;
Database::Read() {
Wait until no writers;
read database;
check out - wake up waiting writers;
}
Database::Write() {
Wait until no readers/writers;
write database;
check out - wake up waiting readers/writers;
}
- 数据构成
AR = 0; //当前正在读的个数
AW = 0; //当前正在写的个数
WR = 0; //当前等待的读者个数
WW = 0; //当前等待的写者个数
Condition okToRead; //表示当前可以去读
Condition okToWrite; //表示当前可以去写
Lock lock; //确保只有一个函数进入管程执行;
- 读者具体实现
- 读者读数据时,要确保没有正在读的读者和正在写的写者(写者优先),故while语句中判断的依据为
AW + WW > 0
;在等待循环中,WR加一,但被唤醒时,WR减一; - 读完之后,AR减一;若此时没有读者且有正在等待的写者时,应该唤醒写者;若还有正在读的读者时,即AR大于零,为了保证读写的互斥,就不会唤醒写者;
- 读者读数据时,要确保没有正在读的读者和正在写的写者(写者优先),故while语句中判断的依据为
- 写者具体实现
- 写者写数据时,先判断当前有无正在读的读者或者正在写的写者,等待队列不考虑;若没有,说明有机会被唤醒执行后面的操作;否则需要继续等待,知道被唤醒;
- 当写完后,AW减一(AW = 0 或 1),此时表面没有正在写的写者,而有等待的写者,就去唤醒其中一个写者;若没有等待的写者当时有等待的读者,则唤醒全部的读者;
- 注意,signal是唤醒等待在这个条件变量上的一个线程,而broadcast是唤醒等待在这个条件变量上面的所有线程;
class Database {
public:
AR = 0;
AW = 0;
WR = 0;
WW = 0;
Condition okToRead;
Condition ofToWrite;
Lock lock;
}
//Reader
Public Database::Read() {
//Wait until no writers;
StartRead();
read database;
//check out - wake up waiting writers;
DoneRead();
}
Private Database::StartRead() {
lock.Acquire();
while ((AW + WW) > 0) {
++WR;
okToRead.wait(&lock);
--WR;
}
++AR;
lock.Realease();
}
Private Database::DoneRead() {
lock.Acquire();
--AR;
if (AR == 0 && WW > 0) {
okToWrite.signal();
}
lock.Release();
}
//Writer
Public Database::Writer() {
//Wait until no readers/writers;
StartWrite();
write database;
//check out - wake up waiting readers/writers;
DoneWrite();
}
Private Database::StartWrite() {
lock.Acquire();
while ((AW + AR) > 0) {
++WW;
okToWrite.wait(&lock);
--WW;
}
++AW;
lock.Release();
}
Private Database::DoneWritr() {
lock.Acquire();
--AW;
if (WW > 0) {
okToWrite.signal();
}
else if (WR > 0) {
okToRead.broadcast();
}
lock.Release();
}
经典同步问题2 —— 哲学家就餐问题
-
问题描述:
5个哲学家围绕一张圆桌而坐,桌子上放着5支叉子,每两个哲学家之间放一支;哲学家的动作包括思考和进餐,进餐时同时需要拿起左右两把叉子,思考时则同时放下左右两把叉子;如何保证哲学家的动作有序进行;
-
共享数据
- Bowl of rice (data set)
- Semaphore fork[5] initialized to 1
//拿起叉子,进行减一P操作 take_fork(i) : P(fork[i]); //放下叉子,进行加一V操作 put_fork(i) : V(fork[i]);
不完美方案
- 方案1:
会导致死锁,谁都拿不了右边的叉子
#define N 5 //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
while (true) {
think(); //哲学家在思考
take_fork(i); //拿左边的叉子
take_fork((i + 1) % N); //拿右边的叉子
eat(); //哲学家在进餐
put_fork(i); //放左边的叉子
put_fork((i + 1) % N); //放右边的叉子
}
}
- 方案2:
会重复过程
#define N 5 //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
while (true) {
take_fork(i); //拿左边叉子
if (fork((i + 1) % N)) {
take_fork((i + 1) % N); //右边有叉子并拿起
break;
}
else {
put_fork(i); //放下左边叉子
wait_some_time(); //等待一会
}
}
}
- 方案3:
等待时间随机变化,可行;当不是很好的方法,可能等待时间长的哲学家一直在等待;
#define N 5 //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
while (true) {
take_fork(i); //拿左边叉子
if (fork((i + 1) % N)) {
take_fork((i + 1) % N); //右边有叉子并拿起
break;
}
else {
put_fork(i); //放下左边叉子
wait_random_time(); //等待随机长时间
}
}
}
- 方案4:
互斥访问可以实现不会出现死锁的情况,但每次只有一个人可以进餐;本来可以并行两个哲学家吃饭,效率较低;
将就餐(而不是叉子)看出是必须互斥访问的临界资源,因此会造成(叉子)资源的浪费;
#define N 5 //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
while (true) {
think(); //哲学家在思考
P(mutex); //进入临界区
take_fork(i); //拿左边的叉子
take_fork((i + 1) % N); //拿右边的叉子
eat(); //哲学家在进餐
put_fork(i); //放左边的叉子
put_fork((i + 1) % N); //放右边的叉子
V(mutex); //退出临界区
}
}
实现思路
- 哲学家角度:
要么不拿,要么就拿两把叉子- 思考中;
- 进入饥饿状态;
- 如果左邻居或右邻居正在进餐,等待;否则继续执行;
- 拿起两把叉子;
- 吃面条;
- 放下左边的叉子;
- 放下右边的叉子;
- 新的循环开始;
- 计算机角度:
不能浪费CPU时间;进程间相互通信- S1:思考中;
- S2:进程饥饿状态;
- S3:如果左邻居或右邻居正在进餐,进餐进入阻塞状态;否则转入S4;
- S4:拿起两把叉子;
- S5:吃面条;
- S6:放下左边的叉子,看看左邻居现在能否进餐(饥饿状态,其两把叉子都在),若能,则唤醒;
- S7:放下右边的叉子,看看右邻居现在能否进餐(饥饿状态,其两把叉子都在),若能,则唤醒;
- 新的循环,转入S1;
大致实现
- 编写思路:
- 必须有数据结构,描述每个哲学家的当前状态;
- 该状态是临界资源,每个哲学家对它的访问应该互斥地进行 —— 进程互斥;
- 一个哲学家吃饱后,可能要唤醒它的左邻右舍,两者之间存在着同步关心 —— 进程同步;
- 数据结构
//1、必须有一个数据结构,描述每个哲学家的当前状态
#define N 5 //哲学家个数
#define LEFT i //第i个哲学家的左邻居
#define RIGHT (i + 1) % N //第i个哲学家的右邻居
#define THINKING 0 //思考状态
#define HUNGRY 1 //饥饿状态
#define EATING 2 //进餐状态
int state[N]; //记录每个哲学家的状态
//2、该状态是临界资源,应该互斥访问
semaphore mutex = 1; //互斥信号量
//3、一个哲学家吃饱后,可能要唤醒邻居,存在同步关系
semaphore s[N]; //同步信号量,初值为0
- 操作方法
void philosophy(int i) {
while (true) {
think(); //S1
take_fork(i); //S2 - S4
eat(); //S5
put_fork(i); //S6 - S7
}
}
具体实现
- 函数 take_fork
//功能:要么拿到两把叉子,要么被阻塞
//hungry的状态需要互斥保护
//拿两把叉子的过程也是在互斥保护中
void take_fork(int i) {
P(mutex); //进入临界区
state[i] = HUNGRY; //进入饥饿状态
test_take_left_right_forks(i); //试图拿起两把叉子
V(mutex); //退出临界区
P(s[i]); //没有拿到叉子就进入阻塞状态
}
- 函数 test_take_left_right_forks
void test_take_left_right_forks(int i) {
if (state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] != EATING) {
state[i] = EATING; //拿起两把,正在进餐
V(s[i]); //此时已经在进餐,防止因为后面P操作进入阻塞;且有唤醒进餐的作用
}
}
- 分析:
- 首先确保自己是处于饥饿状态,然后判断两边的人是否处于进餐状态,如果都不是,表示两边有叉子,可以进餐;
- 初始化时,s[i]的初始为0,故进入进餐状态后,需要进行V操作,防止被外面的P操作而进入阻塞状态;
- 函数 put_fork
//功能:将两把叉子返回原位,并在需要的时候唤醒邻居
//这里查看左邻居是否能进餐时,需要查看左邻居的左邻居的状态;
//如果左邻居的左邻居的状态是进餐状态,则左邻居不能进餐;右邻居同理
void put_fork(int i) {
P(mutex); //进入临界区
state[i] = THINKING; //交出两把叉子
test_take_left_right_forks(LEFT); //判断左邻居能否进餐
test_take_left_right_forks(RIGHT); //判断右邻居能否进餐
V(mutex); //退出临界区
}
- 程序设计的思考过程
- 以一般的思路分析问题,写出伪代码,再将伪代码变成程序;
- 在这个过程中要设定好变量(同步和互斥的机制);
- 逐步细化的方式实现这个处理过程,一般说是会匹配的(P操作和V操作);