0

当程序中有两次内存访问时,就会发生数据竞争,其中:

  • 定位到同一个位置
  • 由两个线程同时执行
  • 不是读取
  • 不是同步操作

这个定义取自它是从一篇研究论文中借用的,所以我们可以假设它是正确的。

现在考虑这个例子:

import java.util.concurrent.*;

class DataRace{
   static boolean flag = false;
   static void raiseFlag() {
      flag = true;
   }
   public static void main(String[] args) {
      ForkJoinPool.commonPool().execute(DataRace::raiseFlag);
      System.out.println(flag);
  }
}

据我了解,这满足了数据竞赛的定义。我们有两条指令访问相同的位置(标志),它们都不是读取,都是并发的并且不是同步操作。所以输出取决于线程如何交错,可以是“真”或“假”。

如果我们假设这是一场数据竞争,那么我可以在访问之前添加锁并解决这个问题。但是即使我在两个线程中都添加了锁,我们也知道锁中也存在竞争条件。所以任何线程都可以获得锁,并且输出仍然可以是“真”或“假”。

所以这是我的困惑,这是我想问的两个问题:

  1. 这是一场数据竞赛吗?如果没有,为什么不呢?

  2. 如果这是一场数据竞赛,为什么提议的解决方案不起作用?

4

1 回答 1

0

首先,线程执行的任意顺序不是数据竞争本身,即使它可能导致它。如果您需要同步 2 个或更多线程以按特定顺序执行它们的代码,则必须使用像监视器这样的等待机制。监视器是可以进行互斥(锁定)等待的结构。监视器也称为条件变量,Java支持它们。

现在的问题是数据竞赛是什么。当 2 个或更多线程同时访问同一内存位置并且其中一些访问是写入时,就会发生数据竞争。这种情况会导致内存位置可能包含的不可预测的值。

一个经典的例子。让我们有一个 32 位操作系统和 64 位长的变量,如longordouble类型。让我们有long变量。

long SharedVariable;

以及执行以下代码的线程 1。

SharedVariable=0;

以及执行以下代码的线程 2。

SharedVariable=0x7FFF_FFFF_FFFF_FFFFL;

如果对该变量的访问不受锁保护,则在两个线程执行后,SharedVariable可以具有以下值之一。

SharedVariable==0
SharedVariable==0x7FFF_FFFF_FFFF_FFFFL
**SharedVariable==0x0000_0000_FFFF_FFFFL**
**SharedVariable==0x7FFF_FFFF_0000_0000L**

最后 2 个值是意外的 - 由数据竞争引起。

这里的问题是,在 32 位操作系统上,可以保证对 32 位变量的访问是原子的 - 因此平台保证即使 2 个或更多线程同时访问相同的 32 位内存位置,访问该内存位置是原子的 - 只有一个线程可以访问这样的变量。但是因为我们有 64 位变量,在 CPU 级别上,写入 64 位长变量将转换为 2 个 CPU 指令。所以代码SharedVariable=0;被翻译成这样的:

mov SharedVariableHigh32bits,0
mov SharedVariableLow32bits,0

代码SharedVariable=0x7FFF_FFFF_FFFF_FFFFL;被翻译成这样的:

mov SharedVariableHigh32bits,0x7FFFFFFF
mov SharedVariableLow32bits,0xFFFFFFFF

在没有锁的情况下,CPU 可以按以下顺序执行这 4 条指令。

订单 1。

mov SharedVariableHigh32bits,0 // T1
mov SharedVariableLow32bits,0 // T1
mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableLow32bits,0xFFFFFFFF // T2

结果是:0x7FFF_FFFF_FFFF_FFFFL

订单 2。

mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableLow32bits,0xFFFFFFFF // T2
mov SharedVariableHigh32bits,0  // T1
mov SharedVariableLow32bits,0  // T1

结果是:0

订单 3。

mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableHigh32bits,0 // T1
mov SharedVariableLow32bits,0 // T1
mov SharedVariableLow32bits,0xFFFFFFFF // T2

结果是:0x0000_0000_FFFF_FFFFL

订单 4。

mov SharedVariableHigh32bits,0 // T1
mov SharedVariableHigh32bits,0x7FFFFFFF // T2
mov SharedVariableLow32bits,0xFFFFFFFF // T2
mov SharedVariableLow32bits,0 // T1

结果是:0x7FFF_FFFF_0000_0000L

因此,竞争条件导致了一个严重的问题,因为您可以获得一个完全出乎意料且无效的值。通过使用锁,您可以阻止它,但是仅仅使用锁并不能保证执行顺序——哪个线程首先执行它的代码。因此,如果您使用锁,您将只获得 2 个执行顺序 - 顺序 1 和顺序 2,不会获得意外值0x0000_0000_FFFF_FFFFL0x7FFF_FFFF_0000_0000L. 但是,如果您需要同步哪个线程先执行哪个第二个,您不仅需要使用锁,还需要使用监视器(条件变量)提供的等待机制。

顺便说一句,根据这篇文章long,Java 保证对除and之外的所有原始类型变量进行原子访问double。在 64 位平台上,甚至可以访问long并且double应该是原子的,但看起来标准并不能保证它。

即使标准保证原子访问,使用锁总是更好。锁定义了阻止某些编译器优化的内存屏障,这些优化可以在 CPU 指令级别对代码重新排序,并在使用变量控制执行顺序时导致问题。

所以这里的简单建议是,如果你不是并发编程专家(我也不是),并且不要编写需要通过使用无锁技术获得绝对最大性能的软件,请始终使用锁——即使访问保证具有原子访问的变量。

于 2018-10-15T02:37:00.130 回答