在并发编程的上下文中,“数据竞争”和“竞态条件”实际上是相同的东西吗?

143

我经常在并发编程的上下文中发现这些术语被使用。它们是同一个东西还是不同的东西?

5个回答

201

不,它们不是同一件事。它们也不是彼此的子集。它们既不是必要条件也不是充分条件。

数据竞争的定义非常清晰,因此可以进行自动化发现。当两个不同线程的两个指令访问同一内存位置时,如果这些访问中至少有一个是写入操作,并且没有同步机制强制规定这些访问之间的任何特定顺序,则会发生数据竞争。

竞态条件是一种语义错误。它是由于事件的时间安排或排序出现错误而导致程序行为出现缺陷的错误。许多竞态条件可能由数据竞争引起,但这并非必要。

考虑以下简单示例,其中x是一个共享变量:

Thread 1    Thread 2

 lock(l)     lock(l)
 x=1         x=2
 unlock(l)   unlock(l)

在这个例子中,线程1和2对x的写入是受到锁保护的,因此它们始终以运行时获取锁的顺序强制执行。也就是说,写入的原子性不能被破坏;在任何执行过程中,两个写入之间总是存在一种先于关系。我们只是无法事先知道哪个写入发生在另一个之前。

由于锁无法提供固定的排序,因此写入之间没有固定的顺序。如果程序的正确性受到损害,例如在线程2的写入x之后紧跟线程1的写入x时,我们称之为竞争条件,尽管从技术上讲不存在数据竞争。

检测竞争条件比检测数据竞争更加有用,但这也非常难以实现。

构建反向示例也很简单。这篇博客文章还使用一个简单的银行交易示例很好地解释了两者之间的区别。


3
数据竞争(data race)是指在多线程程序中,两个或更多的线程同时访问同一个共享变量,并且至少有一个线程对该变量进行了写操作,而且没有使用同步机制来保证这些访问的顺序。在您的示例中,操作可能以任何顺序发生(先=1再=2或者反之),因此没有强制要求这些访问之间的任何特定顺序,因此它是一个数据竞争。 - josinalvo
7
这是技术定义数据竞争的一个产物。关键点在于,在两次访问之间,会有一个锁释放和一个锁获取(可能按任意一种顺序)。按照定义,锁的释放和获取在这两次访问之间建立了一个顺序,因此就不存在数据竞争问题。 - Baris Kasikci
2
@BarisKasikci,我恐怕仍然不相信它们在并发的象牙塔理论模型之外有用。对于我和大多数实际开发人员来说,竞态条件是有用且直观的日常概念。据我所知,C和C++内存模型实际上将“数据竞争”定义为竞态条件的子集,这更有意义。 - Noldorin
1
@Noldorin,那是维基百科上的一句话,不是标准中的定义。您能否在标准中的内存模型讨论中指出支持您声称“C和C++内存模型实际上将‘数据竞争’定义为竞争条件的子集”的参考资料? - Baris Kasikci
1
不好意思,我指的是维基百科的声明,引用了标准。尽管如此,这对我来说确实很有道理。你上面关于竞态条件的定义似乎要么使一切都变得微不足道,要么恰恰相反,什么都不是竞态条件... - Noldorin
显示剩余9条评论

26
根据维基百科的说法,“竞态条件”这个术语自从第一批电子逻辑门问世以来就一直在使用。在Java的上下文中,竞态条件可以涉及任何资源,比如文件、网络连接、线程池中的线程等。
“数据竞争”这个术语最好保留其由JLS定义的特定含义:
两次对同一变量的访问(读取或写入)如果至少有一次访问是写入,则称为冲突访问。
当程序包含两次冲突访问,并且这两次访问之间没有按照happens-before关系排序时,就称为存在数据竞争。
注意:上述定义中存在一个小错误,对于volatile变量的访问永远不会冲突。
最有趣的情况是一种非常类似于数据竞争但实际上并不是数据竞争的竞态条件,就像在这个简单的例子中一样:
class Race {
  static volatile int i;
  static int uniqueInt() { return i++; }
}

由于i是易失性的,所以不存在数据竞争;然而,从程序正确性的角度来看,由于两个操作的非原子性,存在竞争条件:读取i,写入i+1。多个线程可能从uniqueInt接收到相同的值。

1
你能在回答中简要描述一下JLS中的“数据竞争”是什么意思吗? - Geek
@geek 单词“JLS”是指向JLS相关章节的超链接。 - Marko Topolnik
@MarkoTopolnik,我对这个例子有点困惑。您能否解释一下:“由于i是volatile,所以没有数据竞争”?Voltility只确保它是可见的,但仍然存在以下问题: 1)它没有同步,多个线程可以同时读/写 2)它是共享的非final字段因此,根据《Java并发实践》(如下所引用),这是数据竞争而不是竞态条件,对吗? - aniliitb10
@aniliitb10,你应该查看我在答案中提供的链接,而不是依赖于被撕开上下文的二手引用。访问volatile变量是一个同步操作,如§17.4.2中定义的那样。请参阅JLS第17.4节。 - Marko Topolnik
@aniliitb10 Volatile变量不会导致数据竞争,因为它们的访问可以被排序。也就是说,你可以按照这种方式或那种方式来推理它们的顺序,从而得出不同的结果。而对于数据竞争,你无法推理出顺序。例如,每个线程的i++操作可能只会在它们各自的本地缓存值i上发生。从程序员的角度来看,全局上你无法对这些操作进行排序 - 除非你有一定的语言内存模型。 - Xiao-Feng Li
为了简化上述内容,对于每个单独的读写访问易失性变量,它们出现在全局“同步顺序”中,在同一JVM实例内的所有线程中是唯一的。但是,对于普通变量的访问,每个线程可能具有不同的顺序,并且唯一的保证是给定线程按照Java代码指定的方式正确地排序其自己的访问。当一个线程观察另一个线程的操作时,这些操作可以以任何顺序进行,并且可能永远不会被观察到。 - Marko Topolnik

9
TL;DR:数据竞争和竞争条件的区别取决于问题表述的性质,以及在未定义行为和明确定义但不确定行为之间划分边界的位置。当前的区分是传统的,并且最能反映处理器架构和编程语言之间的接口。
1. 语义
数据竞争特指对同一内存位置进行非同步冲突的“内存访问”(或操作)的情况。如果内存访问没有冲突,但由于操作顺序引起了不确定的行为,那么就会出现竞争条件。
这里需要注意,“内存访问”具有特定的含义。它们指的是“纯粹”的内存加载或存储操作,没有施加任何其他语义。例如,一个线程的内存存储可能不知道将数据写入内存需要多长时间,并最终传播到另一个线程。再比如,同一线程先向一个位置存储数据,再向另一个位置存储数据,并不能保证第一个数据写入内存的时间早于第二个数据。因此,这些纯粹的内存访问的顺序不一定能够被“推理”,除非有其他定义。
当通过同步明确定义内存访问的顺序时,附加语义可以确保,即使内存访问的时间顺序是不确定的,它们的顺序也可以通过同步“推理”。需要注意的是,虽然内存访问之间的顺序可以被“推理”,但它们不一定是确定的,因此就会出现竞争条件。
2. 为什么要区分?
但如果竞争条件的顺序仍然是不确定的,为什么还要区分它和数据竞争?原因在于实践而非理论。这是因为区分存在于编程语言和处理器架构之间的接口中。
现代架构中的内存加载/存储指令通常实现为“纯粹”的内存访问,由于乱序流水线、预测、多级缓存、CPU-RAM连接、特别是多核等的性质。有许多因素导致时间顺序和顺序的不确定性。在支持多核的处理器设计中,为每个内存指令强制排序的语义会带来巨大的惩罚。因此,提供附加指令(如各种屏障或障碍物)来维护排序语义。
数据竞争是执行处理器指令时没有额外的屏障来帮助推理冲突内存访问顺序的情况。结果不仅是不确定的,而且可能非常奇怪,例如不同线程对同一单词位置进行两次写入可能导致每个线程写入一半的单词,或者只对其本地缓存值进行操作。--这些是从程序员的角度来看未定义的行为。但它们通常是从处理器架构师的角度来看已经明确定义的。
程序员必须有一种理解代码执行的方式。数据竞争是他们无法理解的事情,因此通常应该避免。这就是为什么低级别的语言规范通常将数据竞争定义为未定义行为,与竞争条件的明确定义内存行为不同。
三、语言内存模型
不同的处理器可能具有不同的内存访问行为,即处理器内存模型。程序员研究每个现代处理器的内存模型然后开发可以从中受益的程序是很尴尬的。如果语言能够定义内存模型,那么该语言的程序将始终如预期地按照内存模型定义运行,这是可取的。这就是Java和C++定义其内存模型的原因。编译器/运行时开发人员的负担是确保在不同的处理器架构上执行语言内存模型。
也就是说,如果语言不想暴露处理器的底层行为(并愿意牺牲现代体系结构的某些性能优势),它们可以选择定义一个完全隐藏“纯”内存访问细节的内存模型,但对所有内存操作应用排序语义。然后编译器/运行时开发人员可以选择在所有处理器架构中将每个内存变量视为易失性的。对于这些支持线程间共享内存的语言,没有数据竞争,但可能仍然存在竞争条件,即使有完全顺序一致性的语言。
另一方面,处理器内存模型可以更严格(或更放松,或更高级),例如实现早期处理器的顺序一致性。然后所有内存操作都是有序的,并且在任何运行在该处理器上的语言中不存在数据竞争。
四、结论
回到最初的问题,我认为将数据竞争定义为竞争条件的特例是可行的。在某个层次上的竞争条件可能在更高层次上成为数据竞争。这取决于问题的本质和在未定义行为和明确定义但不确定的行为之间划分界限的位置。目前的约定将边界定义在语言-处理器接口处,并不一定总是必须这样;但当前的约定可能最能反映处理器架构和编程语言之间接口(和智慧)的最新状态。

我对顺序一致性保证全局先于关系,从而保证数据竞争自由的说法有些困惑。我的意思是,每个线程的指令都将按照顺序一致性进行排序,但我不清楚为什么它应该强制执行不同线程之间的指令排序。因此,在我看来,仍然可能存在数据竞争。 - Krishanu Singh
1
内存一致性是指其他处理器看到的内存访问顺序。SC强制所有处理器(即核心上的线程)在一个执行中看到相同的内存访问顺序。这意味着不同的处理器以所有处理器都观察到的相同全局顺序执行其内存指令 - 来自不同处理器的任何两个内存指令都是“有序”执行的,换句话说,“没有数据竞争”。另一方面,全局顺序在同一程序的不同执行之间不一定相同,这可能导致不同的执行结果。 - Xiao-Feng Li
好的,我现在明白了。谢谢! - Krishanu Singh

6
不是的,它们是不同的,也没有一个是另一个的子集或反之亦然。
“竞态条件”一词经常与相关的“数据竞争”一词混淆,后者在共享的非最终字段上未使用同步来协调所有访问时产生。每当一个线程写入可能由另一个线程读取的变量或读取可能由另一个线程最后写入的变量时,您都会面临数据竞争,如果两个线程都不使用同步,则具有数据竞争的代码在Java内存模型下没有有用的定义语义。并非所有竞态条件都是数据竞争,也不是所有数据竞争都是竞态条件,但它们都可能导致并发程序以不可预测的方式失败。
摘自杰出的书籍 - Brian Goetz和Co.的《Java并发实践》。

请注意,该问题具有与语言无关的标签。 - martinkunev

4

数据竞争和竞态条件

[原子性、可见性、有序性]

在我看来,这绝对是两件不同的事情。

数据竞争是指多个线程(至少其中一个进行写入)在没有同步的情况下共享相同的内存

竞态条件是指未同步的代码块(可能是相同的),它们使用相同的共享资源,在不同的线程上同时运行,其结果是不可预测的。

竞态条件的示例:

//increment variable
1. read variable
2. change variable
3. write variable

//cache mechanism
1. check if exists in cache and if not
2. load
3. cache

解决方案:

数据竞争竞态条件是原子性问题,它们可以通过同步机制来解决。

  • 数据竞争 - 当对共享变量的写访问将被同步化时
  • 竞态条件 - 当代码块作为原子操作运行时

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接