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 计数,直到所有读者都离开数据库后,释放数据库的锁,这时候写者才可以进入数据库。

但是很明显这个方案有一个问题,当一个读者持有数据库的锁后,在它之后的读者就会源源不断地进入数据库,而直到所有的读者离开数据前,写者都无法进入数据库,一直处于饥饿状态。

解决方案也比较简单,当一个新的读者将要进入数据库时,如果此时已经有一个写者被挂起,那么等待写者完成数据库操作后,读者再继续。不过这样做的话,并发性能会差一点。

posted @ 2025-05-10 01:19  DantalianLib  阅读(15)  评论(0)    收藏  举报