2.5 经典 IPC 问题 Classical IPC Problems
2.5.1 哲学家就餐问题 The Dining Philosophers Problem
简单描述下这个问题:
若干个哲学家围着圆桌,每两个人之间有一把叉子。每个哲学家都会思考,而当哲学家思考累了后,就想要吃饭。但是只有拿到左右两边的两把叉子时,哲学家才可以吃饭。吃完后,哲学家继续思考……
让哲学家先拿起左边叉子,再拿起右边的叉子,等吃完饭后放下叉子。
#define N 5
void philosopher(int i)
{
while(true)
{
think();
take_fork(i); // 拿左手边的叉子
take_fork((i + 1) % N); // 拿右手边的叉子
eat();
put_fork(i); // 放下左手边的叉子
put_fork((i + 1) % N); // 放下右手边的叉子
}
}
假设哲学家同时拿起左手边的叉子,那么他们就都无法拿到右手边的叉子,导致死锁。
我们让哲学家在获取右手叉子的时,如果超过一段时间后,还是没有拿到叉子,那就放弃已经拿到的左边的叉子。等待一段时间后,再尝试获取叉子。
如果所有哲学家同时拿起左边的叉子,等待一段时间,再同时放下左边的叉子,然后再同时拿起,不断重复下去。像这样,进程长时间得不到所需要的资源,就叫做饥饿(starvtion)
可以让每个哲学家随机等待一段时间,再重新获取叉子,这样就可以规避上述问题。像这样的算法就叫做退避(trade off)
或者使用更简单的方法,同一时间只有一个哲学家可以获取叉子并就餐,等待该哲学家就餐结束后,其他哲学家再依次就餐。类似代码如下:
#define N 5
pthread_mutex_t mtx;
void philosopher(int i)
{
while(true)
{
think();
pthread_mutex_lock(mtx);
take_fork(i); // 拿左手边的叉子
take_fork((i + 1) % N); // 拿右手边的叉子
eat();
put_fork(i); // 放下左手边的叉子
put_fork((i + 1) % N); // 放下右手边的叉子
pthread_mutex_unlock(mtx);
}
}
上面给出的方法可以避免死锁和饥饿问题,但是无法满足最大并行的要求,存在哲学家已经满足可以就餐的情况,但是因为某些原因无法就餐。可能是处于随机等待的状态中,或者互斥锁被其他哲学家持有。
显然,只有当一个哲学家左右两边的叉子都处于空闲状态时,这个哲学家才可以拿到叉子就餐。关键点在于,两把叉子同时处于空闲状态,并且要求哲学家同时拿到这两把叉子,如果某一把叉子被其他哲学家拿走了,那就等待这把叉子被用完。
class philosopher_t
{
int32_t location;
public:
philosopher_t(int32_t loc)
:location(loc)
{
}
void life()
{
while (is_exit == false)
{
think();
eat();
}
}
private:
void think() const
{
sleep_randmon_ms(MIN_SPAN, MAX_SPAN);
}
void eat() const
{
get_forks();
log("philosopher[%u] is eating...", location);
sleep_randmon_ms(MIN_SPAN, MAX_SPAN);
log("philosopher[%u] finished eating...", location);
put_forks();
}
#define LEFT_INDEX (location % NUM)
#define RIGHT_INDEX ((location + 1) % NUM)
void get_forks() const
{
// 进入临界区
mtx.lock();
while (true)
{
// 左右两边的叉子都是空闲状态
if (forks[LEFT_INDEX] == false && forks[RIGHT_INDEX] == false)
{
// 只有这里才能获取叉子,其他地方不允许获取叉子
semas[LEFT_INDEX].acquire();
semas[RIGHT_INDEX].acquire();
// 修改叉子的状态
forks[LEFT_INDEX] = true;
forks[RIGHT_INDEX] = true;
// 成功拿到需要的叉子,离开临界区
mtx.unlock();
return;
}
// 为什么要先 down(P) 然后 up(V) ?
// 此时这把叉子处于被其他哲学家持有的状态,down 操作的目的是阻塞当前进程,等待期待进程唤起
// down 操作执行完成后,我们并不希望此时获取到这把叉子,所以要做一个 up 操作,释放这个信号量
// 如果这时候拿到了左手边的叉子,但是又被右手边的叉子阻塞,那就可能会形成循环等待
if (forks[LEFT_INDEX] == true)
{
// 接下来要等待叉子被释放,所以这里先离开临界区,防止死锁
mtx.unlock();
semas[LEFT_INDEX].acquire();
semas[LEFT_INDEX].release();
mtx.lock();
}
// 同理……
if (forks[RIGHT_INDEX] == true)
{
mtx.unlock();
semas[RIGHT_INDEX].acquire();
semas[RIGHT_INDEX].release();
mtx.lock();
}
}
}
void put_forks() const
{
std::lock_guard lock(mtx);
forks[LEFT_INDEX] = false;
forks[RIGHT_INDEX] = false;
semas[LEFT_INDEX].release();
semas[RIGHT_INDEX].release();
}
#undef LEFT_FORK
#undef RIGHT_FORK
};
再看下原书中的解决方案,每个哲学家持有一个信号量,如果信号量大于 0,说明这个哲学家可以就餐,否则需要等待信号量被释放。
在执行 TakeForks 和 PutForks 时,检查叉子的状态(或者有其他哲学家的状态),判断当前哲学家是否可以就餐,然后操作其信号量。
const int Num = 5;
object lockObj = new();
using CancellationTokenSource cts = new();
PhrStatus[] status = new PhrStatus[Num];
Array.Fill(status, PhrStatus.Thinking);
Semaphore[] semas = new Semaphore[Num];
for (int i = 0; i < Num; i++)
{
semas[i] = new(0, 1);
}
Task[] tasks = new Task[Num];
for (int i = 0; i < Num; i++)
{
tasks[i] = Life(i);
}
Thread.Sleep(10 * 1000);
cts.Cancel();
Task.WaitAll(tasks);
Task Life(int phr)
{
return Task.Run(() =>
{
while (cts.IsCancellationRequested == false)
{
Think(phr);
TakeForks(phr);
Eat(phr);
PutForks(phr);
}
});
}
void Think(int phr)
{
Thread.Sleep(Random.Shared.Next(100, 1000));
}
void Eat(int phr)
{
Console.WriteLine($"phr{phr} will eat");
Thread.Sleep(Random.Shared.Next(100, 1000));
Console.WriteLine($"phr{phr} has eat");
}
void TakeForks(int phr)
{
lock (lockObj)
{
status[phr] = PhrStatus.Hungry;
Test(phr);
}
// 如果当前哲学家不满足就餐条件,则等待
semas[phr].WaitOne();
}
void PutForks(int phr)
{
lock (lockObj)
{
// 当前哲学家就餐完毕,释放了持有的叉子
status[phr] = PhrStatus.Thinking;
// 看左右两边的哲学家是否满足就餐条件
Test(Left(phr));
Test(Right(phr));
}
}
void Test(int phr)
{
// 如果左右两边的哲学家没有持有叉子,那么执行 up 操作,允许当前哲学家就餐
if (status[phr] == PhrStatus.Hungry
&& status[Left(phr)] != PhrStatus.Eating
&& status[(Right(phr))] != PhrStatus.Eating)
{
status[phr] = PhrStatus.Eating;
// 通知对应的哲学家,可以就餐
semas[phr].Release();
}
}
static int Left(int phr) => (phr + Num - 1) % Num;
static int Right(int phr) => (phr + 1) % Num;
enum PhrStatus
{
Thinking,
Hungry,
Eating
}
2.5.2 读写者问题 The Readers and Writers Problem
经典的读写问题,比如数据库系统,多个读者可以一起读取数据,不能同时读写,也不允许多个写者同时写数据库。
一个简单的读写者程序如下:
semaphore mutex = 1;
semaphore db = 1;
int read_cnt = 0;
void reader(void)
{
while(true)
{
// 进入临界区操作
down(&mutex);
// 临界区操作
read_cnt++;
// 有读者要访问数据库,获取数据库的锁
// 读者可以共享数据库,所以这里只需要获取一次
if(read_cnt == 1)
down(&db);
// 离开临界区
up(&mutex);
read_data_base();
down(&mutex);
// 最后一个读者离开数据库,释放锁
read_cnt--;
if(read_cnt == 0)
up(&db);
up(&mutex);
// 非临界区
user_data_read();
}
}
void writer(void)
{
while(true)
{
think_up_data();
down(&db);
write_data_base();
up(&db);
}
}
显然,当一个读者持有数据库的锁时,它允许其他读者进入数据库进行操作,但是不允许其他写者进入数据库进行写操作。
并且使用 read_cnt 计数,直到所有读者都离开数据库后,释放数据库的锁,这时候写者才可以进入数据库。
但是很明显这个方案有一个问题,当一个读者持有数据库的锁后,在它之后的读者就会源源不断地进入数据库,而直到所有的读者离开数据前,写者都无法进入数据库,一直处于饥饿状态。
解决方案也比较简单,当一个新的读者将要进入数据库时,如果此时已经有一个写者被挂起,那么等待写者完成数据库操作后,读者再继续。不过这样做的话,并发性能会差一点。

浙公网安备 33010602011771号