Путаница в определении гонки данных

Гонка данных происходит, когда в программе есть два обращения к памяти, где оба:

  • цель в том же месте
  • выполняются одновременно двумя потоками
  • не читает
  • не являются операциями синхронизации

Это определение взято из, которое заимствует его из исследовательской работы, поэтому мы можем предположить, что это правильно.

Теперь рассмотрим этот пример:

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);
  }
}

Насколько я понимаю, это удовлетворяет определению гонки данных. У нас есть две инструкции для доступа к одному и тому же местоположению (флагу), обе не читаются, обе являются одновременными и не являются операциями синхронизации. И поэтому вывод зависит от того, как потоки чередуются, и может иметь значение "True" или "False".

Если мы предположим, что это гонка данных, тогда я могу просто добавить блокировки перед доступом и решить это. Но даже если я добавлю блокировки в оба потока, мы знаем, что в замках также есть условие гонки. Таким образом, любой поток может получить блокировку, и на выходе все равно может быть 'True' или 'False'.

Так что это мое замешательство, и вот два вопроса, которые я хотел бы задать:

  1. Это гонка данных? Если нет, то почему нет?

  2. Если это гонка данных, почему предлагаемое решение не работает?

1 ответ

Прежде всего, произвольный порядок выполнения потока не является самой гонкой данных, даже если это может вызвать ее. Если вам нужно синхронизировать 2 или более потоков для выполнения их кода в определенном порядке, вы должны использовать механизм ожидания, такой как мониторы. Мониторы - это конструкции, которые могут выполнять как взаимное исключение (блокирование), так и ожидание. Мониторы также известны как условные переменные, и Java поддерживает их.

Теперь вопрос в том, что такое гонка данных. Гонка данных происходит, когда 2 или более потоков обращаются к одной и той же ячейке памяти в одно и то же время, и некоторые из обращений являются записями. Эта ситуация может привести к непредсказуемым значениям, которые может содержать ячейка памяти.

Классический пример. Позволяет иметь 32-битную ОС и переменную длиной 64 бита, как long или же double типы. Давайте иметь 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-битная переменная, на уровне ЦП переменная записи в 64-битную длину преобразуется в 2 инструкции ЦП. Итак, код SharedVariable=0; переводится примерно так:

mov SharedVariableHigh32bits,0
mov SharedVariableLow32bits,0

И код SharedVariable=0x7FFF_FFFF_FFFF_FFFFL; переводится примерно так:

mov SharedVariableHigh32bits,0x7FFFFFFF
mov SharedVariableLow32bits,0xFFFFFFFF

Без блокировки ЦП может выполнять эти 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_FFFFL а также 0x7FFF_FFFF_0000_0000L, Но все же, если вам нужно синхронизировать, какой поток выполняется первым, а какой - вторым, вам нужно использовать не только блокировку, но и механизм ожидания, который отслеживает (условные переменные).

Кстати, согласно этой статье, Java гарантирует атомарный доступ ко всем переменным примитивного типа, кроме long а также double, На 64-битных платформах даже доступ к long а также double должно быть атомарным, но похоже, что стандарт не гарантирует этого.

И даже если бы стандарт гарантировал атомарный доступ, всегда было бы лучше использовать блокировки. Блокировки определяют барьеры памяти, которые препятствуют некоторым оптимизациям компилятора, которые могут переупорядочить ваш код на уровне команд ЦП и вызвать проблему, если использовать переменные для управления порядком выполнения.

Таким образом, простой совет заключается в том, что если вы не являетесь экспертом по параллельному программированию (а я тоже нет) и не пишете SW, который должен получить абсолютную максимальную производительность с помощью методов без блокировок, всегда используйте блокировки - даже если доступ к переменным, которые гарантированно имеют атомарный доступ.

Другие вопросы по тегам