线程同步是克服多线程程序中竞争条件的好工具。但是,它也有阴暗面。死锁:难以发现、重现和修复的严重错误。防止它们发生的唯一可靠方法是正确设计您的代码,这是本文的主题。我们将看看死锁的起源,考虑一种发现现有代码中潜在死锁的方法,并提出设计无死锁同步的实用方法。这些概念将通过一个简单的演示项目进行说明。
假设读者已经熟悉多线程编程,并且对 Java 中的线程同步原语有很好的理解。在接下来的部分中,我们不会区分同步语句和锁 API,使用术语“锁”来表示这两种类型的可重入锁,在示例中更喜欢前者。
一、死锁机制
让我们回顾一下死锁是如何工作的。考虑以下两种方法:
java:
void increment ({
synchronized(lock1) {synchronized(lock2){variablel l;
)
l}
void decrement ({
synchronized(lock2){synchronized(lock1) {variable--;
}
}}
这些方法被有意设计为产生死锁。让我们详细考虑这是如何发生的。
increment() 和 decrement()基本上由以下5个步骤:
表格1
步 | 增量() | 递减() |
1 | 获取锁1 | 获取锁2 |
2 | 获取锁2 | 获取锁1 |
3 | 执行增量 | 执行递减 |
4 | 释放锁2 | 释放锁1 |
5 | 释放锁1 | 释放锁2 |
显然,这两种方法中的第 1 步和第 2 步只有在相应的锁空闲时才能通过,否则,执行线程将不得不等待它们的释放。
假设有两个并行线程,一个执行increment(),另一个执行decrement()。每个线程的步骤将按正常顺序执行,但是,如果我们将两个线程放在一起考虑,一个线程的步骤将与另一个线程的步骤随机交错。随机性来自系统线程调度程序强加的不可预测的延迟。可能的交织模式或场景非常多(准确地说,有 252 种)并且可以分为两组。第一组是其中一个线程足够快以获取两个锁(参见表 2)。
表 2
没有死锁 | ||
线程 1 | 线程 2 | 结果 |
1:获取锁1 | 锁 1 忙 | |
2:获取锁2 | 锁2忙 | |
1:获取锁2 | 等待lock2释放 | |
3:执行增量 | 在锁 2 等待 | |
4:释放锁2 | 拦截锁2 | lock2 更改了所有者 |
2:获取锁1 | 等待 lock1 释放 | |
5:释放锁1 | 拦截锁1 | lock1 更改了所有者 |
3:执行递减 | ||
4:释放锁1 | 无锁 | |
5:释放锁2 | 无锁 |
该组中的所有案例均成功完成。
在第二组中,两个线程都成功获取了锁。结果见表3:
表3
僵局 | ||
线程 1 | 线程 2 | 结果 |
1:获取锁1 | 锁 1 忙 | |
1:获取锁2 | Lock2忙 | |
2:获取锁2 | 等待lock2释放 | |
2:获取锁1 | 等待 lock1 释放 | |
在锁 2 等待 | 在锁 1 等待 | |
… | … |
该组中的所有情况都会导致第一个线程等待第二个线程拥有的锁,而第二个线程等待第一个线程拥有的锁,因此两个线程都无法进一步进行:
图1
这是具有所有典型属性的经典死锁情况。让我们概述重要的:
- 至少有两个线程,每个线程至少占用两个锁。
- 死锁只发生在特定的线程时序组合中。
- 死锁的发生取决于锁定顺序。
第二个属性意味着死锁不能随意重现。此外,它们的再现性取决于操作系统、CPU 频率、CPU 负载和其他因素。后者意味着软件测试的概念不适用于死锁,因为相同的代码可能在一个系统上完美运行而在另一个系统上失败。因此,交付正确应用程序的唯一方法是通过设计消除死锁。这种设计有两种基本方法,现在让我们从更简单的方法开始。
2. 粗粒度同步
从上面列表中的第一个属性可以看出,如果我们的应用程序中的任何线程都不允许同时持有多个锁,则不会发生死锁。好的,这听起来像是一个计划,但是我们应该使用多少锁以及将它们放在哪里?
最简单和最直接的答案是用一个锁保护所有事务。例如,为了保护一个复杂的数据对象,您可以将其所有公共方法声明为同步的。这种方法用于java.util.Hashtable. 简单的代价是由于缺乏并发而导致的性能损失,因为所有方法都是相互阻塞的。
幸运的是,在许多情况下,粗粒度同步可以以较少限制的方式执行,从而允许一些并发和更好的性能。为了解释它,我们应该引入一个事务连接变量的概念。假设如果满足两个条件中的任何一个,则两个变量在事务上连接:
- 存在涉及两个变量的交易。
- 两个变量都连接到第三个变量(传递性)。
因此,您首先以这样一种方式对变量进行分组,即同一组中的任何两个变量都具有事务性连接,而不同组中的任何两个变量都没有。然后通过单独的专用锁保护每个组:
图2
上面的解释对于“交易”和“涉及”这两个术语的确切含义来说有点短,但如果可能的话,如果准确地解释,这篇文章就太长了,所以留给读者的直觉和经验。
这种高级粗粒度同步的一个很好的现实例子是java.util.concurrent.ConcurrentHashMap. 在这个对象内部,有许多相同的数据结构(“桶”),每个桶都由自己的锁保护。事务被分派到由键的哈希码确定的存储桶。因此,具有不同密钥的事务大多会进入不同的存储桶,这使得它们可以并发执行而不会牺牲线程安全性,由于存储桶的事务独立性,这是可能的。
但是,某些解决方案需要比粗粒度同步可以实现的更高级别的并发性。稍后,我们将考虑如何处理这个问题,但首先,我们需要介绍一种分析同步方案的有效方法。
3. 锁定图
假设您需要确定给定的代码是否包含潜在的死锁。让我们称这种任务为“同步分析”或“死锁分析”。你会如何处理这个问题?
最有可能的是,您会尝试对线程争用锁的所有可能场景进行排序,试图找出是否存在不良场景。在第 1 节中,我们采用了如此简单的方法,结果发现场景太多了。即使在最简单的情况下,也有 252 个,因此彻底检查它们是不可能的。在实践中,您可能最终只会考虑几个场景,并希望您没有遗漏一些重要的东西。换句话说,公平的死锁分析无法通过幼稚的方法完成,我们需要一种专门的、更有效的方法。
此方法包括构建锁定图并检查它是否存在循环依赖关系。锁定图是显示锁和线程在这些锁上的交互的图形。此类图中的每个闭环都表示可能存在死锁,并且没有闭环保证了代码的死锁安全性。
这是绘制锁定图的秘诀。它以第 1 节中的代码为例:
- 对于代码中的每个锁,在图表上放置一个相应的节点;在这个例子中,这些是lock1和lock2
- 对于所有线程试图在已经持有锁 A 的情况下获取锁 B 的语句,画一个从节点 A 到节点 B 的箭头;在该示例中,将有“锁1 - >锁2”在increment()和lock2 -> lock1中decrement()。如果一个线程按顺序使用多个锁,则为每两个连续的锁绘制一个箭头。
第 1 节中示例的最终图表如下所示:
图 3
它有一个闭环: lock1 -> lock2 -> lock1,它立即告诉我们代码包含潜在的死锁。可爱,不是吗?
让我们再做一个练习。考虑以下代码:
java:
让我们看看这段代码是否是死锁安全的。有3把锁:lock1,lock2,lock3和3条锁定路径:lock1 -> lock2在transaction1(),lock2 -> lock3在transaction2(),lock3 -> lock1在transaction3()。
结果图如图 4-A 所示:
图 4
同样,此图立即表明我们的设计包含潜在的死锁。但是,不仅如此。它还提示我们如何修复设计;我们只需要打破循环!例如,我们可以交换方法中的锁transaction3()。相应的箭头改变方向,图 4-B 中的图变为无循环,这保证了固定代码的死锁安全性。
现在我们已经熟悉了图表的神奇之处,我们准备继续使用更复杂但更有效的方法来设计无死锁同步。
4. 带锁排序的细粒度同步
这一次,我们走的是让同步尽可能细粒度的路线,希望得到最大可能的事务并发度作为回报。这种设计基于两个原则。
第一个原则是禁止任何变量同时参与多个交易。为了实现这一点,我们将每个变量与一个唯一的锁相关联,并通过获取与相关变量关联的所有锁来启动每个事务。以下代码说明了这一点:
java:
一旦获得锁,其他事务就不能访问这些变量,因此它们不会被并发修改。这意味着系统中的所有事务都是一致的。同时,允许在不相交变量集上的事务并发运行。因此,我们获得了一个高度并发但线程安全的系统。
但是,这样的设计会立即导致死锁的可能性,因为现在我们处理多个线程和每个线程的多个锁。
然后,第二个设计原则开始发挥作用,它指出必须以规范的顺序获取锁以防止死锁。这意味着我们将每个锁与一个唯一的常量索引相关联,并始终按照它们的索引定义的顺序获取锁。将这个原理应用到上面的代码中,我们得到了细粒度设计的完整说明:
java:
但是,确定规范排序确实可以防止死锁吗?我们能证明吗?答案是肯定的,我们可以使用锁定图来完成。
假设我们有一个有 N 个变量的系统,所以有 N 个关联的锁,因此图中有 N 个节点。如果没有强制排序,锁会以随机顺序被抓取,所以在图中,会有两个方向的随机箭头,并且肯定会存在表示死锁的闭环:
图 5
如果我们强制执行锁排序,从高到低索引的锁路径将被排除,所以唯一剩下的箭头将是那些从左到右的箭头:
图 6
无论我们多么努力,我们都不会在这个图上找到一个闭环,因为只有当箭头在两个方向上时才可能存在闭环,但事实并非如此。而且,没有闭环意味着没有死锁。证明是完整的。
好吧,通过使用细粒度锁和锁排序,我们可以构建一个高并发、线程安全和无死锁的系统。但是,提高并发性是否需要付出代价?让我们考虑一下。
首先,在低并发的情况下,与粗粒度的方法相比,存在一定的速度损失。每个锁捕获是一个相当昂贵的操作,但细粒度设计假设锁捕获至少是两倍。但是,随着并发请求数量的增加,由于使用了多个 CPU 内核,细粒度设计很快就会变得更好。
其次,由于大量的锁对象,存在内存开销。幸运的是,这很容易解决。如果受保护的变量是对象,我们可以摆脱单独的锁对象,并将变量本身用作自己的锁。否则,例如,如果变量是原始数组元素,我们可能只需要有限数量的额外对象。为此,我们定义了从变量 ID 到中等大小的锁数组的映射。在这种情况下,锁必须按它们的实际索引排序,而不是按变量 ID。
最后但并非最不重要的是代码的复杂性。虽然粗粒度的设计可以通过声明一些方法同步来完成,细粒度的方法需要编写相当数量的相当长的代码,有时我们甚至可能需要弄乱业务逻辑。这样的代码需要仔细编写并且更难维护。不幸的是,这个困难无法解决,但结果值得麻烦,这将在下面演示。
5. 演示项目
要了解提议的设计模式在实际代码中的外观,让我们看一下简单的演示项目。该项目的目标是构建一个模拟银行一些基本功能的库。为简洁起见,它使用一组固定的账户,仅实现四种操作:查询余额、存款、取款和账户间资金转移。为了使任务更有趣,要求账户余额不能为负,也不能超过某个值。违反这些规则的交易应该被拒绝。库 API 在接口MockBank 中定义。
此接口有三种使用上述不同同步方法的实现:
- CoarseGrainedImpl使用粗粒度同步。
- FineGrainedImpl使用细粒度同步的基本版本。
- FineGrainedWithMappingImpl使用细粒度同步的内存高效版本。
还有一个对实现的性能和正确性的测试,MockBankCrashTest。每个源文件在类注释中包含算法的详细描述。多次测试运行未显示线程安全违规或死锁。在多核系统上,细粒度设计的性能是粗粒度设计的几倍,正如预期的那样。
所有项目文件都在这里。
6. 隐形锁
到目前为止,所提出的设计模式似乎可以自动解决任何同步问题。虽然这并非完全不真实,但存在一些您应该注意的问题。
上述部分中的注意事项虽然本身是正确和有效的,但并未考虑环境。通常,这是一个错误,因为我们的代码不可避免地要与操作系统和库进行交互,其中可能存在隐藏的锁,这些锁可能会干扰我们的同步代码,从而导致意外死锁。让我们看一个例子。考虑以下代码:
java:
这么一看,这段代码应该是无死锁的,因为updateVersion(). 但是,这种印象是错误的,因为Hashtable实例中实际上存在一个额外的隐藏锁。调用链put()-updateVersion()并increment()-computeIfPresent()-updateVersion()以相反的顺序获取这两个锁,这会导致潜在的死锁。
一位有经验的读者可能会在这里正确地争辩说,上面的代码相当蹩脚,并且是故意设计来导致死锁的。然后,这里是更简洁的示例,我们尝试在映射中原子地交换两个值:
java:
这一次,根本没有锁,代码看起来完全合法,但是,我们再次遇到了潜在的死锁。原因是 的内部设计ConcurrentHashMap,这已在第 2 节中概述 。以相反的顺序调用swapValues(1,2)和swapValues(2,1)获取相应桶的锁,这意味着代码可能会死锁。这就是文档 ConcurrentHashMap.compute()强烈不鼓励尝试从回调中更改地图的原因。不幸的是,在许多情况下,文档中缺少此类警告。
如上例所示,对隐藏锁的干扰最有可能发生在回调方法中。因此,建议保持回调简短、简单且不调用同步方法。如果这是不可能的,您应该始终牢记执行回调的线程可能持有一个或多个隐藏锁,并相应地计划同步。
结论
在本文中,我们探讨了多线程编程中的死锁问题。我们发现如果按照一定的设计模式编写同步代码,可以完全避免死锁。我们还研究了此类设计为何以及如何工作,其适用性的限制是什么,以及如何有效地发现和修复现有代码中的潜在死锁。预计所提供的材料为设计完美的无死锁同步提供了足够的实用指南。