引言
在构建高性能、高并发的系统时,我们常常会遇到进程“卡住”的情况。但这究竟是系统陷入了僵局,还是某些进程被无情地“忽视”了?区分 死锁 和 饥饿 是操作系统设计和并发编程中至关重要的一环。虽然它们都表现为任务无法执行,但背后的成因和解决方案却大相径庭。
在这篇文章中,我们将深入探讨这两种现象的本质区别。我们会从枯燥的理论定义出发,通过直观的图解和实际的代码示例(使用 Java 和 C++),一步步剖析它们是如何发生的,以及作为开发者,我们该如何在代码层面预防和解决这些问题。准备好了吗?让我们开始探索操作系统中这两个棘手的挑战。
—
什么是死锁?
死锁是操作系统中一种极为特殊且危险的状态。想象一下,两个或多个进程被永久阻塞,每个进程都在等待另一个进程持有的资源,形成了一个完美的“闭环”。在这个闭环中,如果没有外力介入,所有涉及的进程都将永远停滞。
死锁发生的四个必要条件
为了让我们更深入地理解,著名计算机科学家 Dijkstra 提出了死锁发生的四个必要条件。只有当这四个条件同时满足时,死锁才会发生。我们的目标通常是通过破坏其中至少一个条件来预防死锁。
- 互斥条件:资源是不可共享的。例如,打印机一次只能被一个进程使用。
- 持有并等待:一个进程至少持有一个资源,但正在等待获取其他被别的进程持有的资源。
- 不可剥夺:资源不能被强行从持有它的进程中拿走,只能由持有者主动释放。
- 循环等待:存在一种进程的循环链,链中的每一个进程都在等待下一个进程所持有的资源。
#### 生活化类比:窄桥上的两辆车
让我们先看一个生活中的例子来辅助理解。这就好比在狭窄的独木桥上,两辆车相向而行:
- 汽车 A 从左端进入,占据了桥面,但它需要右端的空位才能通过,此时它阻挡了汽车 B。
- 汽车 B 从右端进入,占据了桥面,但它需要左端的空位才能通过,此时它阻挡了汽车 A。
结果是:双方都在等待对方后退,但谁都不动(或者都无法动),于是僵局形成——这就是死锁。
实战代码示例:经典的死锁场景
在代码中,死锁通常发生在两个线程以不同的顺序获取锁的时候。让我们看一个 Java 的实际案例。
public class DeadlockDemo {
// 定义两把锁,对应两个不同的资源
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// 线程 1:尝试先获取 lock1,再获取 lock2
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("线程 1: 持有 锁 1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("线程 1: 等待 锁 2...");
synchronized (lock2) {
System.out.println("线程 1: 成功获取两把锁!");
}
}
});
// 线程 2:尝试先获取 lock2,再获取 lock1
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("线程 2: 持有 锁 2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("线程 2: 等待 锁 1...");
synchronized (lock1) {
System.out.println("线程 2: 成功获取两把锁!");
}
}
});
thread1.start();
thread2.start();
// 程序将永远挂起,不会退出
}
}
#### 代码深度解析
在这个例子中:
- 线程 1 运行后立刻获取了 INLINECODE8efd7441。随后,它试图去获取 INLINECODEbd4282bd,但此时
lock2还没被释放,所以它进入等待状态(持有并等待)。 - 线程 2 运行后立刻获取了 INLINECODE822f94e2。随后,它试图去获取 INLINECODE7153d260,但此时
lock1被线程 1 持有。 - 循环等待链形成:线程 1 等 2,线程 2 等 1。
解决方案:
我们可以通过 有序锁 策略来解决这个问题。也就是说,所有的线程都必须按照相同的顺序来获取锁(例如,都先获取 INLINECODEb123ff8d 再获取 INLINECODE768ba50bINLINECODE81b74f83std::priorityqueueINLINECODE6e1b6ecapriorityINLINECODE562d5283priorityINLINECODE69322dd2priorityINLINECODE243b4649waitTimeINLINECODE1d3e4845waitTimeINLINECODE385c62a0priorityINLINECODEa9c1a2b3tryLockINLINECODEffbc57bfsynchronizedINLINECODE6af7f937lockINLINECODEb36a991aReentrantLock 构造函数中,你可以传入 true` 来开启公平模式。公平锁会严格按照请求锁的先后顺序来分配锁,能有效防止饥饿,但代价是性能吞吐量会下降。
// Java 公平锁示例
private final ReentrantLock fairLock = new ReentrantLock(true); // true 表示公平策略
- 轮询调度:对于类似的资源请求,采用 Round-Robin(轮询)算法,确保大家都有机会。
—
总结与后续步骤
通过这篇文章,我们不仅理清了死锁与饥饿的理论定义,更重要的是,我们通过代码看到了它们是如何在真实软件中发生的。
关键要点回顾:
- 死锁 是“大家一起死”,通常由循环等待引起,可以通过破坏循环条件或有序加锁来解决。
- 饥饿 是“有人饿死”,通常由优先级倒置引起,可以通过“老化”算法或公平锁来解决。
给你的建议:
下次在编写多线程程序时,不妨多问自己几个问题:“我的锁释放顺序是否安全?”、“我的低优先级任务会永远等下去吗?”。保持这种警惕性,是迈向资深架构师的第一步。
希望这篇文章对你有帮助!如果你想了解更多关于操作系统底层原理或并发编程的实战技巧,欢迎继续关注我们的技术专栏。