哲学家就餐问题是用于描述多线程环境中的同步问题并阐释解决这些问题的技术的经典问题之一。该问题最初由Dijkstra提出,是关于计算机访问磁带驱动器外设的情况。当前的表述由Tony Hoare给出,他还发明了快速排序算法。
在本文中,我们将分析这个著名的问题并编写一个常用的解决方案。
一、问题描述
经典的哲学家就餐问题
上述图表展示了哲学家就餐问题。有五位沉默的哲学家(P1 - P5)围坐在一张圆形餐桌旁,他们一生都在进食和思考。
有五把叉子供他们共享(1 - 5),为了能够进食,一位哲学家需要双手都持有叉子。用餐完毕后,他放下两把叉子,然后其他哲学家可以拿起它们,重复相同的循环。
目标是设计一种方案,帮助哲学家们实现进食和思考的目标,而不会饿死。
二、解决方案
最初的解决方案是让每个哲学家遵循以下逻辑:
while (true) {
// 初始状态,思考人生、宇宙及万物
think();
// 停止思考,感到饥饿
pick_up_left_fork();
pick_up_right_fork();
eat();
put_down_right_fork();
put_down_left_fork();
// 不再饥饿,继续思考!
}
如上述伪代码所述,每个哲学家最初都在思考。一段时间后,哲学家感到饥饿并希望进食。
此时,他拿起左右两边的叉子,一旦拿到两把叉子,就开始进食。进食完成后,哲学家放下叉子,以便邻居可以使用。
三、实现
我们将每个哲学家建模为实现Runnable接口的类,以便可以作为单独的线程运行它们。每个哲学家都可以访问其左右两侧的两把叉子:
public class Philosopher implements Runnable {
// 此哲学家左右两侧的叉子
private Object leftFork;
private Object rightFork;
public Philosopher(Object leftFork, Object rightFork) {
this.leftFork = leftFork;
this.rightFork = rightFork;
}
@Override
public void run() {
// 待填充此方法
}
}
我们还有一个方法,用于指示哲学家执行某个动作——进食、思考或获取叉子准备进食:
public class Philosopher implements Runnable {
// 成员变量、标准构造函数
private void doAction(String action) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " " + action);
Thread.sleep(((int) (Math.random() * 100)));
}
// 之前编写的其他方法
}
如上述代码所示,每个动作通过随机暂停调用线程一段时间来模拟,这样执行顺序就不仅仅由时间决定。
现在,我们实现哲学家的核心逻辑。
为了模拟获取叉子,我们需要锁定它,以确保没有两个哲学家线程同时获取它。
为了实现这一点,我们使用synchronized关键字获取叉子对象的内部监视器,并防止其他线程执行相同操作。现在我们继续在Philosopher类中实现run()方法:
public class Philosopher implements Runnable {
// 之前定义的成员变量、方法
@Override
public void run() {
try {
while (true) {
// 思考
doAction(System.nanoTime() + ": Thinking");
synchronized (leftFork) {
doAction(System.nanoTime() + ": Picked up left fork");
synchronized (rightFork) {
// 进食
doAction(System.nanoTime() + ": Picked up right fork - eating");
doAction(System.nanoTime() + ": Put down right fork");
}
// 回到思考状态
doAction(System.nanoTime() + ": Put down left fork. Back to thinking");
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
此方案准确实现了前面描述的内容:哲学家思考一段时间后决定进食。
之后,他获取左右两边的叉子并开始进食。用餐完毕后,他放下叉子。我们还为每个动作添加了时间戳,这将有助于我们了解事件发生的顺序。
为了启动整个过程,我们编写一个Main函数,创建5个哲学家线程并启动它们:
public class DiningPhilosophers {
public static void main(String[] args) throws Exception {
Philosopher[] philosophers = new Philosopher[5];
Object[] forks = new Object[philosophers.length];
for (int i = 0; i < forks.length; i++) {
forks[i] = new Object();
}
for (int i = 0; i < philosophers.length; i++) {
Object leftFork = forks[i];
Object rightFork = forks[(i + 1) % forks.length];
philosophers[i] = new Philosopher(leftFork, rightFork);
Thread t = new Thread(philosophers[i], "Philosopher " + (i + 1));
t.start();
}
}
}
我们将每个叉子建模为通用的Java对象,并创建与哲学家数量相同的叉子。我们将每个哲学家的左右叉子传递给他,尝试使用synchronized关键字锁定这些叉子。
运行此代码将产生类似以下的输出。你的输出很可能与下面给出的不同,主要是因为sleep()方法被调用的时间间隔不同:
Philosopher 1 8038014601251: Thinking
Philosopher 2 8038014828862: Thinking
Philosopher 3 8038015066722: Thinking
Philosopher 4 8038015284511: Thinking
Philosopher 5 8038015468564: Thinking
Philosopher 1 8038016857288: Picked up left fork
Philosopher 1 8038022332758: Picked up right fork - eating
Philosopher 3 8038028886069: Picked up left fork
Philosopher 4 8038063952219: Picked up left fork
Philosopher 1 8038067505168: Put down right fork
Philosopher 2 8038089505264: Picked up left fork
Philosopher 1 8038089505264: Put down left fork. Back to thinking
Philosopher 5 8038111040317: Picked up left fork
所有哲学家最初都在思考,我们看到哲学家1拿起了左右叉子,然后进食并放下两把叉子,之后哲学家5拿起了叉子。
四、解决方案的问题:死锁
虽然上述解决方案看起来是正确的,但存在死锁问题。
死锁是指系统中的每个进程都在等待获取其他进程持有的资源,从而导致系统进展停滞的情况。
我们可以通过多次运行上述代码并检查有时代码是否挂起来确认这一点。以下是一个示例输出,展示了上述问题:
Philosopher 1 8487540546530: Thinking
Philosopher 2 8487542012975: Thinking
Philosopher 3 8487543057508: Thinking
Philosopher 4 8487543318428: Thinking
Philosopher 5 8487544590144: Thinking
Philosopher 3 8487589069046: Picked up left fork
Philosopher 1 8487596641267: Picked up left fork
Philosopher 5 8487597646086: Picked up left fork
Philosopher 4 8487617680958: Picked up left fork
Philosopher 2 8487631148853: Picked up left fork
在这种情况下,每个哲学家都拿到了他左边的叉子,但无法拿到右边的叉子,因为他的邻居已经拿到了。这种情况通常称为循环等待,是导致死锁并阻止系统进展的条件之一。
五、解决死锁
如上文所述,死锁的主要原因是循环等待条件,即每个进程都在等待其他进程持有的资源。因此,为了避免死锁情况,我们需要确保打破循环等待条件。有几种方法可以实现这一点,最简单的方法如下:
除了一个哲学家先拿右边的叉子外,所有哲学家都先拿左边的叉子。
我们通过在现有代码中进行相对较小的更改来实现这一点:
public class DiningPhilosophers {
public static void main(String[] args) throws Exception {
final Philosopher[] philosophers = new Philosopher[5];
Object[] forks = new Object[philosophers.length];
for (int i = 0; i < forks.length; i++) {
forks[i] = new Object();
}
for (int i = 0; i < philosophers.length; i++) {
Object leftFork = forks[i];
Object rightFork = forks[(i + 1) % forks.length];
if (i == philosophers.length - 1) {
// 最后一个哲学家先拿起右边的叉子
philosophers[i] = new Philosopher(rightFork, leftFork);
} else {
philosophers[i] = new Philosopher(leftFork, rightFork);
}
Thread t = new Thread(philosophers[i], "Philosopher " + (i + 1));
t.start();
}
}
}
我们引入了一个条件,使最后一个哲学家先拿起右边的叉子,而不是左边的。这打破了循环等待条件,我们可以避免死锁。
以下输出显示了所有哲学家都有机会思考和进食而不导致死锁的一种情况:
Philosopher 1 88519839556188: Thinking
Philosopher 2 88519840186495: Thinking
Philosopher 3 88519840647695: Thinking
Philosopher 4 88519840870182: Thinking
Philosopher 5 88519840956443: Thinking
Philosopher 3 88519864404195: Picked up left fork
Philosopher 5 88519871990082: Picked up left fork
Philosopher 4 88519874059504: Picked up left fork
Philosopher 5 88519876989405: Picked up right fork - eating
Philosopher 2 88519935045524: Picked up left fork
Philosopher 5 88519951109805: Put down right fork
Philosopher 4 88519997119634: Picked up right fork - eating
Philosopher 5 88519997113229: Put down left fork. Back to thinking
Philosopher 5 88520011135846: Thinking
Philosopher 1 88520011129013: Picked up left fork
Philosopher 4 88520028194269: Put down right fork
Philosopher 4 88520057160194: Put down left fork. Back to thinking
Philosopher 3 88520067162257: Picked up right fork - eating
Philosopher 4 88520067158414: Thinking
Philosopher 3 88520160247801: Put down right fork
Philosopher 4 88520249049308: Picked up left fork
Philosopher 3 88520249119769: Put down left fork. Back to thinking
通过多次运行代码可以验证,系统不再出现之前的死锁情况。
文末总结
在本文中,我们探讨了著名的哲学家就餐问题以及循环等待和死锁的概念。我们编写了一个导致死锁的简单解决方案,并进行了简单的更改以打破循环等待并避免死锁。这只是一个开始,实际上还有更复杂的解决方案。