什么是争用条件-Java快速入门教程
1. 简介
多线程应用程序中最常见的问题之一是竞争条件问题。
在本教程中,我们将了解什么是争用条件、检测它们的方法以及处理它们的方法。
2. 争用条件
根据定义,争用条件是程序的一种条件,其行为取决于多个线程或进程的相对计时或交错。一个或多个可能的结果可能是不希望的,从而导致错误。我们将这种行为称为非确定性行为。
线程安全是我们用来描述程序、代码或数据结构的术语,当被多个线程访问时,没有竞争条件。
让我们考虑一个在两个银行账户之间执行资金转账的简单函数:
在此实施中,我们考虑了尝试提取源账户中不可用的资金的可能性。因此,我们有一张可用金额的支票,我们预计账户余额永远不会低于零。假设我们有账户 A 和 B,每个账户的余额为 500,我们执行两次尝试将 300 从 A 转移到 B:
但是,如果这两个尝试在不同的进程或线程中同时启动,我们可能会观察到一些不希望的行为:
给定不可预测的线程调度,特定步骤的顺序是任意的。由于执行流的交错,我们遇到了争用条件。
为了避免争用条件,对共享资源(即可以在线程之间共享的资源)的任何操作都必须以原子方式执行。实现原子性的一种方法是使用关键部分 - 程序的互斥部分。另一种方法是使用原子操作来利用硬件的能力来确保不可分割性。
3. 先检查后行动
在我们的银行资金转帐示例中,我们观察到了先检查后行动的模式。
这是最常见的争用条件类型。它由程序流定义,其中潜在的过时观察用于决定下一步要做什么。我们将此条件产生的错误称为检查时间到使用时间或TOCTOU错误。
TOCTOU 争用经常被发现是各种平台上安全漏洞的原因,特别是在文件系统访问方面。攻击者利用这些漏洞来提升权限或执行拒绝服务攻击。
延迟初始化是检查然后操作模式的另一个示例。
4. 读-修改-写
虽然先检查后行动类型的争用条件确实是我们在多线程应用程序中可能遇到的最常见的类型,但还有另一种更容易掌握的类型。请考虑以下伪代码,它使用常规增量操作:
在大多数语言中,常规增量运算符表示三个顺序操作 — 读取、修改和写入。
由于我们没有为此执行指示任何原子保证,如果启动了多个执行,我们可能会得到与之前看到的相同的操作交错:
这种类型的条件与数据竞争密切相关。我们将在下面讨论这种微妙的差异,但让我们先谈谈实际方面。
5. 检测
争用条件通常难以重现、调试和消除。我们将竞争条件引入的错误描述为Heisenbug。
由于争用条件与应用程序语义相关联,因此没有检测它们的常规方法。专注于测试结果稳定性的多线程单元测试会有所帮助,但不太可能提供 100% 的保证。
幸运的是,有几种技术可以避免或消除竞争条件。了解这些技术后,我们可能希望确保它们在代码审查中的使用。
6. 消除
有两种方法可以对抗竞争条件:
- 避免共享状态
- 使用同步和原子操作
6.1. 避免共享状态
由于我们需要共享状态才能显示争用条件,因此消除共享状态是解决任何问题的最佳方法。
不可变对象(其状态在构造后无法更改)本质上是线程安全的。尽可能多地使用不可变对象始终是可取的。
线程局部变量的本地化方式是每个线程都有其私有副本,也是线程安全的,因为它们是每个线程的局部变量。
对于先检查然后操作争用条件,一种常规技术是使用异常处理而不是检查。在此方法中,在使用时检测到假设的失败,从而引发异常。俗话说,请求宽恕比请求许可更容易。
更激进的处理方法是使用完全禁止共享状态的并发模型,例如基于参与者的并发。
6.2. 使用同步和原子操作
同步基元(如关键部分)用于确保程序的特定部分不能由多个线程同时执行。锁是一种同步机制,用于在线程级别强制实施关键节行为。互斥锁是存在于多个系统进程中的相同抽象。
虽然同步是摆脱竞争条件的最有效方法,但它是有代价的。锁因其开销而给我们带来了性能打击。它们也可能很难处理。锁不组成,这意味着在将基于锁的模块组合成更大的基于锁的程序时,需要额外的努力来保持正确性。最后,锁可能会引入死锁。
原子操作意味着它作为单个工作单元执行。具体的保证取决于所使用的语言,但主要思想是实现依赖于硬件在执行结束之前防止中断的能力。
我们可以使用原子操作来实现更高级别的无锁抽象。软件事务内存 - 另一种并发模型 - 利用这一概念对内存中的操作启用类似数据库的事务。
在应用程序级别,使用并发数据结构和语言提供的原子包可能被证明是有用的。
7. 数据争用
虽然上面描述的全局计数器增量示例是争用条件的经典演示,但它也代表了另一个概念。上述示例中的争用条件是由通过并行指令访问(包括写入)相同的内存位置引起的,没有任何原子性协定。
我们将这种情况称为数据争用。当两个线程同时访问同一变量,并且至少有一个访问是写入时,就会发生数据争用。数据争用概念更特定于特定并发模型中的内存访问,因此因平台而异。
在大多数情况下,数据争用会创建一个争用条件,与我们的计数器增量示例完全相同。尽管如此,有可能在没有数据争用的情况下出现争用条件,并且根据特定的平台定义,有可能出现不会产生不良结果的数据争用。通常,数据争用不是争用条件的子集。
在我们的银行资金转账示例中,我们专注于先检查后行动序列,而没有关注余额递增/递减操作的原子性。事实上,银行资金转账的天真实施也存在数据争用。
这些问题可以通过确保增量/递减操作的原子性(例如,通过使用锁)来解决,与计数器增量示例相同。通过保护每个操作,我们将摆脱数据争用,但除非我们将整个实现放入关键部分,否则竞争条件仍然存在。
与竞争条件不同,特定平台上的数据竞争具有不依赖于程序语义的严格定义。这提供了自动检测数据争用的能力。
有许多工具可用于此目的,包括 RV-Predict、ThreadSanitizer 和 Intel Inspector。
8. 结论
在本文中,我们讨论了多线程应用程序中显示的争用条件。
我们了解了先检查后行动模式和数据争用。
最后,我们考虑了一些避免和消除竞争条件的方法,以确保程序的正确性。