哲学家就餐问题
1965年,Dijkstra提出并解决了一个他称之为哲学家就餐的同步问题。这个问题可以简单地描述如下∶五个哲学家围坐在一张圆桌周围,每个哲学家面前都有一盘通心粉。由于通心粉很滑,所以需要两把叉子才能夹住。相邻两个盘子之间放有一把叉子。哲学家的动作包括思考和进餐,当一个哲学家觉得饿了时,他就试图分两次去取其左边和右边的叉子,每次拿一把,但不分次序。如果成功地得到了两把叉子,就开始吃饭,吃完后放下叉子继续思考。问∶能为每一个哲学家写一段描述其行为的程序,且决不会死锁吗?
方案1
#define N 5 // 哲学家个数
void philosopher(int i) { // 哲学家编号:0 - 4
while(TRUE) {
think(); // 哲学家在思考
take_fork(i); // 去拿左边的叉子
take_fork((i + 1) % N); // 去拿右边的叉子
eat(); // 哲学家在进餐
put_fork(i); // 放下左边的叉子
put_fork((i + 1) % N); // 放下右边的叉子
}
}
如果是单线程,这段代码没有任何问题。但是如果是5个线程同时运行,假设都运行到了 P(fork[i]);
,所有的哲学家都拿到了左边的叉子,那么运行 P(fork[(i + 1) % N]);
时就会出现死锁。没有任何哲学家能够拿到右边的叉子。
方案2
为了避免死锁,拿不到右边的叉子时,就将左边的放下,这样就不会一直占有这个资源了。
#define N 5 // 哲学家个数
void philosopher(int i) { // 哲学家编号:0 - 4
while(TRUE) {
think(); // 哲学家在思考
take_fork(i); // 去拿左边的叉子
if (fork((i + 1) % N)) { // 右边的叉子还在吗?
take_fork((i + 1) % N); // 去拿右边的叉子
break; // 两个叉子均到手
} else {
put_fork(i); // 放下左边的叉子
wait_some_time(); // 等待一会
}
eat(); // 哲学家在进餐
}
}
这样仍然存在问题,如果大家都是按同一个步调执行的话,大家同时拿到左边的叉子,发现拿不到右边的叉子,又都放下了左边的叉子。
方案3
如果哲学家在拿不到右边叉子时等待一段随机时间,而不是等待相同的时间,这样发生互锁的可能性不就很小了吗?
#define N 5 // 哲学家个数
void philosopher(int i) { // 哲学家编号:0 - 4
while(TRUE) {
think(); // 哲学家在思考
take_fork(i); // 去拿左边的叉子
if (fork((i + 1) % N)) { // 右边的叉子还在吗?
take_fork((i + 1) % N); // 去拿右边的叉子
break; // 两个叉子均到手
} else {
put_fork(i); // 放下左边的叉子
wait_random_time(); // 等待随机时长
}
eat(); // 哲学家在进餐
}
}
但是这种方案存在概率性问题。并不能保证百分之百,甚至可能出现某个哲学家一直等待下去无法进餐的情况。
方案4
既然如此,将获取叉子、进餐、放下叉子放在临界区中,这样不就没有互锁了吗?
#define N 5 // 哲学家个数
semaphore mutex = 1; // 互斥信号量,初始值为1
void philosopher(int i) { // 哲学家编号:0 - 4
while(TRUE) {
think(); // 哲学家在思考
P(mutex); // 进入临界区
take_fork(i); // 去拿左边的叉子
take_fork((i + 1) % N); // 去拿右边的叉子
eat(); // 哲学家在进餐
put_fork(i); // 放下左边的叉子
put_fork((i + 1) % N); // 放下右边的叉子
V(mutex); // 退出临界区
}
}
方案可行,但是每次只能有一个人进餐,而实际上最多是可以有两个人一起进餐的。
方案5
和其他方案将叉子作为临界资源不一样,该方案站着哲学家自身的角度来思考问题:
1、思考中。。。
2、进入饥饿状态
3、如果左邻或有右邻正在进餐,进餐进入阻塞状态,否则执行4
4、拿起两把叉子
5、进餐
6、放下左边的叉子,看看左邻现在是否能进餐(即是否为饥饿状态且两把叉子都在),若能则将其唤醒
7、放下右边的叉子,看看右邻现在是否能进餐(即是否为饥饿状态且两把叉子都在),若能则将其唤醒
8、跳到1循环
转换为代码需要考虑:
1、必须有数据结构来描述每个哲学家的当前状态(饥饿、思考、进餐)
2、该状态是一个临界资源,各个哲学家对它的访问应该互斥地进行
3、一个哲学家吃饱后,可能要唤醒其左邻右舍,两者之间存在同步关系
#define N 5 /* 哲学家个数*/
#define LEFT (i+N-1)%N /*i的左邻居编号*/
#define RIGHT (i+1)%N /*i的右邻居编号*/
#define THINKING 0 /*哲学家在思考*/
#define HUNGRY 1 /*哲学家试图拿起叉子*/
#define EATING 2 /*哲学家进餐 */
typedef int semaphore;
int state[N]; /*数组用来跟踪记录每位哲学家的状态*/
semaphore mutex = 1; /*互斥信号量,初始为1 */
semaphore s[N]; /*每个哲学家一个同步信号量,初始为0*/
void philosopher(int i) /*i∶哲学家编号,从0到N-1*/
{
while(TRUE){
think();
take_forks(i); /*拿到两把叉子或被阻塞*/
eat();
put_forks(i); /*把两把叉子放回原处并通知左邻右舍*/
}
}
void take_forks(int i) /*i∶哲学家编号,从0到N-1*/
{
P(&mutex); /*进入临界区*/
state[i] = HUNGRY; /*记录哲学家i处于饥饿的状态*/
test(i); /*尝试获取2把叉子*/
V(&mutex); /*离开临界区*/
P(&s[i); /*如果得不到需要的叉子则阻塞*/
}
void put_forks(i) /*i∶哲学家编号,从0到N-1*/
{
P(&mutex); /*进入临界区*/
state[i] = THINKING; /*哲学家已经就餐完毕*/
test(LEFT); /*检查左边的邻居现在能否进餐*/
test(RIGHT); /*检查右边的邻居现在能否进餐*/
V(&mutex); /*离开临界区*/
}
void test(i) /*i∶哲学家编号,从0到N-1*/
{
if( state[i] == HUNGRY && /*i为自身或其他人*/
state[LEFT]!= EATING &&
state[RIGHT]!= EATING)
{
state[i] = EATING; /*拿到两把叉子*/
V(&s[i); /*通知第i个人可以吃饭了*/
}
}
读者-写者问题
哲学家就餐问题对于互斥访问有限资源的竞争问题(如I/O设备)一类的建模过程十分有用。另一个著名的问题是读者-写者问题,它为数据库访问建立了一个模型。例如,设想一个飞机订票系统,其中有许多竞争的进程试图读写其中的数据。多个进程同时读数据库是可以接受的,但如果一个进程正在写数据库,则所有的其他进程都不能访问该数据库,即使读操作也不行。这里的问题是如何对读者和写者进行编程?
问题的约束:
- 任何时候只能有一个线程可以操作共享变量
- “读-读” 允许:同一时刻,允许有多个读者同时读
- “读-写” 互斥:没有写者时读者才能读,没有读者时写者才能写
- “写-写” 互斥:没有其他写者时写者才能写
- 读者优先策略:只要有读者正在读状态,后来的读者都能直接进入。如读者持续不断进入,则写者就处于饥饿。
相反,也有写者优先策略:只要有写者就绪,写者应尽快执行写操作。如写者持续不断就绪,则读者就处于饥饿
用信号量解决读者-写者问题
用信号量描述每个约束
- 信号量db:初始值为1,控制对数据库的互斥访问
- 读者计数rc:初始值为0,正在读或者即将读的进程数目
- 信号量mutex:初始值为1,控制对rc的互斥修改
typedef int semaphore;
semaphore mutex = 1; /*控制对rc的访问*/
semaphore db = 1; /* 控制对数据库的访问*/
int rc = 0; /*正在读或者即将读的进程数目*/
void reader(void)
while(TRUE){
P(&mutex); /*获得对rc的互斥访问权*/
if(rC == 0) { /*如果这是第一个读者*/
P(&db);
}
rC++; /*现在又多了一个读者*/
V(&mutex); /*释放对rc的互斥访问*/
read_data_base(); /* 访问数据*/
P(&mutex); /*获取对rc的互斥访问*/
rC--; /*现在减少了一个读者*/
if(rc == 0){ /*如果这是最后一个读者*/
V(&db);
}
V(&mutex); /*释放对rc的互斥访问*/
use_data_read(); /*非临界区*/
}
}
void writer(void) {
while(TRUE){
think_up_data(); /*非临界区*/
P(&db); /*获取互斥访问*/
write_data_base(); /*更新数据*/
V(&db); /*释放互斥访问*/
}
}
对进程在进行读数据操作的前后( read_data_base();
)必然需要使用信号量的PV操作来防止写进程进行写操作。如何来表示读者优先呢?代码中,是当第一个读进程要读取数据时才会进行 P($db)
,防止写进程进行写操作,然后 rc++
。设想下,假设进程的顺序是读、读、写。当第一个读进程来时会进行 P($db)
,然后 rc++
。当第二个读进程来时,由于第一个读进程已经执行 P($db)
,保证了写进程无法访问数据库,所以它后面的读进程可以直接进行读操作,而无需再执行 P($db)
。但是由于 rC 是一个共享变量,所以对 rC 的操作也需要保证互斥,防止多个读进程对其进行操作导致数据混乱。同理,当读进程的最后一个读进程时,执行 V($db)
,这样就可以保证之前的读操作都发生在写进程之前。
用管程解决读者-写者问题
用管程描述写者优先的情况。
读者的伪代码:
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; // # of active readers
AW = 0; // # of active writers
WR = 0; // # of waiting readers
WW = 0; // # of waiting writers
Lock lock;
Condition okToRead;
Condition okToWrite;
读者
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.Release();
}
Private Database::DoneRead() {
lock.Acquire();
AR--;
if (AR == 0 && WW > 0) {
okToWrite.signal();
}
lock.Release();
}
写者
Public Database::Write() {
//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::DoneWrite() {
lock.Acquire();
AW--;
if (WW > 0) {
okToWrite.signal();
}
else if (WR > 0) {
okToRead.broadcast();
}
lock.Release();
}