之所以又开始研究IPC问题,是因为昨天在51cto上阅读学习了《多处理器编程的艺术(修订版)》一书的第一章。第一章的内容确实让我加深了多线程环境的印象,感觉很好,结果这一章最后的习题也就很自然的进入了我的任务列表。哲学家就餐问题是第一章习题的第一道题,博文内容属于笔者思考的结果,如有转载,请注明出处。

经典IPC问题:“哲学家就餐”

问题描述

习题1.

哲学家就餐问题是由并发处理的先驱E.W. Dijkstra所提出的,主要用于阐述死锁和无饥饿概念。

假设五个哲学家一生只在思考和就餐。他们围坐在一个大圆桌旁,桌上有一大盘米饭。然而只有五根可用的筷子。所有的哲学家都在思考。若某个哲学家饿了,则拿起自己身边的两根筷子。如果他能够拿到这两根筷子,则可以就餐。当这个哲学家吃完后,又放下筷子继续思考。


解决方案要求

   1. 试编写模仿哲学家就餐行为的程序,其中每个哲学家为一个线程而筷子则是共享对象。注意,必须防止出现两个哲学家同时使用同一根筷子的情形。

   2. 修改所编写的程序,不允许出现死锁情形,也就是说,保证不会出现这样的情形:每个哲学家都已拥有一根筷子,并且正在等待获得另一个人手中的筷子。

   3. 修改所编写的程序使得不会出现饥饿现象。

   4. 编写能够保证任意n个哲学家无饥饿就餐的程序。


问题分析与解决

   首先根据问题描述,可以确定这样的程序结构:每个哲学家是一个单独的线程,而筷子数组则是这些线程的共享对象

   根据题目意思,为所有哲学家设定相同的行动逻辑,如下:

思考一段时间;

拿起左筷;

拿起右筷;

吃饭;

放下右筷;

放下左筷;

继续思考,返回①。


   如果为每个哲学家配备了足够的筷子,那么这个问题就简单了,只要启动线程让哲学家们行动就行了,它们之间不需要交互就能够思考、吃饭。但这里并没有分配足够的筷子给哲学家们用餐。因此问题的关键点在于,对一根筷子来说,需要保证在同一时刻只有一个哲学家在使用它。这其实就是多线程中的“互斥”概念。

多线程中的“互斥”,到了程序中的表现就是“互斥锁”。“互斥”的意思就是,在稀缺资源(如单核计算机系统的CPU资源)的竞争中,仅能够保证一个工作线程使用资源,其他工作线程需要在稀缺资源被释放后才有机会获得稀缺资源的使用权。可以用生活中的例子做比喻,比如大城市上班高峰期的公交座位,春节前夕的火车上的洗手间等等。

带入到这个问题去理解,筷子就是稀缺资源。更有趣的是,只有一根筷子是吃不了饭的

既然在哲学家成功拿起一根筷子之后,不允许其他哲学家动这根筷子,那么很简单就要为筷子加“互斥锁“。Java中的内置锁synchronized就是一种“互斥锁”。


================解决“哲学家就餐“问题的第一种方案说明========================

       使用“互斥锁“管理每一根筷子。在哲学家”拿起“筷子之前,首先要获得这根筷子的”互斥锁“,这就表示持有了这根筷子。也就是说,首先获取左筷的锁,然后获取右筷的锁,接着就是成功吃到饭,然后放下右筷,放下左筷,继续思考。

=============================================================================

下面是解决题目要求1的代码(哲学家线程的工作代码):

// 哲学家就餐逻辑
public void run() {
    int leftIndex = getLeftKzIndex(index);
    int rightIndex = getRightKzIndex(index);
    while (doWork) {
        // 尝试拿起左筷
        synchronized (kz[leftIndex]) {
            // 拿起左筷成功
            // 尝试拿起右筷
            synchronized (kz[rightIndex]) {
                // 拿起右筷成功
                // 成功吃饭
                System.out.println("哲学家" + index + "成功吃到饭");
            }
        }
    }
}

       核心部分就这么点代码,嗯,简单,漂亮,精辟可惜的是,点击运行之后不一会就没有了输出,表示没有哲学家吃到饭了。难道他们都吃饱了?开个玩笑。

       这段代码会导致“死锁“发生。这个问题的死锁状态就是:5位哲学家都成功拿起了左筷,但都在等待获取已经被别人拿去的另一根筷子。原因是没有人会主动放弃筷子,所以全部”饿死“。

       出现“死锁“的原因找到了,状态也分析了,那么接下来的关键就是如何破坏掉”死锁“状态。思前想后,鄙人认为只有两种方法合适:第一种是允许哲学家查看其它哲学家的状态,从而改变自己的行为(比如哲学家拿起左手筷子之后,看到右边那位仁兄拿了他的左手筷子,自己不知道他何时吃完,就主动放弃自己的左手筷子);第二种是采用超时的判断,当尝试获取右筷(注意只是右筷)的”互斥锁“超过一段时间,则主动放弃尝试并舍弃已获得的左筷。

       这两种方法的主要思想是,哲学家需要根据具体情况来放弃左筷,从而破坏“死锁“条件

       分析一下第一种方法,发现用这种方法来实现会比较麻烦,而且会多出很多额外的线程安全问题。因为如果要让哲学家进程正确访问到另一个哲学家线程的状态,必须要有一组共享变量保存每个哲学家线程的状态。与此同时,对这些状态的读写都要做好并发控制,防止出现可见性问题。这些额外的工作会让整个解决过程看起来很臃肿,不能够很好的体现中心思想。另外,既然要将每个哲学家设为一个线程,那么相互之间的状态最好不要相互告知为好。

       另一种通过判断超时获取锁的方法就比较好,一方面Java并发库有提供相应的实现,实现起来本身就不麻烦,同时代码也不复杂,能够较好地体现解决方法。

       经过分析,笔者决定采用第二种方法。在代码中需要使用到java.util.concurrent.locks.ReentrantLock类,它是一种相对于内置锁synchronized更加灵活的锁。不熟悉的可以参考API文档,或者参考《Java Concurrency in Practice(即《Java并发编程实战》)13章内容。


================解决“哲学家就餐“问题的第二种方案说明========================

       使用“互斥锁“管理每一根筷子。在哲学家”拿起“筷子之前,首先要获得这根筷子的”互斥锁“,这就表示持有了这根筷子。也就是说,首先获取左筷的锁,然后获取右筷的锁,接着就是成功吃到饭,然后放下右筷,放下左筷,继续思考。

为了破坏“死锁”条件,在哲学家尝试获取右筷的锁时,设定一个超时时间timeout。当尝试获取右筷的锁的时间超过timeout,则主动放弃左筷,继续思考。

=============================================================================


下面是能够解决题目所有要求的代码(哲学家线程的工作代码):

public void run() {
    int leftIndex = getLeftKzIndex(index);
    int rightIndex = getRightKzIndex(index);
    while (doWork) {
        // 尝试拿起左筷
        synchronized (kz[leftIndex]) {
            // 拿起左筷成功
            // 尝试拿起右筷
            try {
                // 每个筷子实例中都包含一个公有的ReentrantLock类实例lock
                if (!kz[rightIndex].lock.tryLock(TIMEOUT,
                        TimeUnit.MILLISECONDS)) {
                    // 拿起右筷失败,继续思考
                    continue;
                }
                // 拿起右筷成功
                // 成功吃饭
                System.out.println("哲学家" + index + "成功吃到饭");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                kz[rightIndex].lock.unlock();
            }
        }
    }
}

     看到这里,总算把这个问题给完美的解决了。上边的方法是我独立思考后认为最好的方案。

但不是唯一的方案,下面我还要讲一个最最简单的方法。


================解决“哲学家就餐“问题的第三种方案说明========================

       使用一个独立的Object对象作为唯一的“互斥“资源,哲学家每次进餐都只要获得这个Object对象的”互斥锁“,而不需要获取两个筷子的”互斥锁“。也就是说,在同一时间只有一个哲学家可以进餐,也最多只有两根筷子被拿起

=============================================================================


   下面是解决方案三的代码,同样也能够达到题目的所有要求(哲学家线程的工作代码):

public void run() {
    int leftIndex = getLeftKzIndex(index);
    int rightIndex = getRightKzIndex(index);
    while (doWork) {
        System.out.println("哲学家" + index + "尝试进餐...");
        // lock对象声明的位置没有写出来,只要认为所有哲学家访问到的是同一个Object lock就行了
        synchronized (lock) {
            // 尝试拿起左筷
            // 拿起左筷成功
            // 尝试拿起右筷
            // 拿起右筷成功
            // 成功吃饭
            System.out.println("哲学家" + index + "成功吃到饭");
        }
    }
}


       这个方法的核心在于,保证了“哲学家就餐“这一复合操作的原子性。仅此而已。

额,这个方法是不是太简单了?觉得干脆一开始就讲出来就万事大吉了吧。可是鄙人不认为这种方法更好。具体的深入探讨“哲学家就餐“问题见下一篇博客,敬请期待。