Статьи

Опасность гоночных условий за пять минут

Состояние гонки — это нежелательное свойство многопоточного кода. Он выражает, что результат программы зависит от определенного порядка операций, но базовая платформа (в случае Java, JVM) не гарантирует этот порядок. Как следствие, результат часто колеблется в зависимости от того, как именно чередуются операции из разных потоков. В Java условия гонки возникают чаще всего, когда несколько потоков совместно используют один объект и видоизменяют его.

Единственным условием для этой статьи является базовое знание потоков .

Условия гонки

Простой пример

Давайте начнем с примера. Здесь я реализовал Runnable который увеличивает общий счетчик в десять тысяч раз:

 class IncrementingRunnable implements Runnable { private final MutableInteger mutableInteger; public IncrementingRunnable(MutableInteger mutableInteger) { this.mutableInteger = mutableInteger; } @Override public void run() { for (int i = 0; i < 10_000; i++) { mutableInteger.increment(); } } } 

Поскольку значение стандартного Java Integer не может быть обновлено после его создания, я реализовал простой класс MutableInteger который имеет два метода:

  • increment чтобы увеличить его значение
  • getValue чтобы получить значение результата.
 class MutableInteger { private int value = 0; public void increment() { value++; } public int getValue() { return value; } } 

При этом мы можем передать один и тот же счетчик нескольким потокам, которые будут увеличивать его параллельно:

 List<Thread> threads = new ArrayList<>(); // Variable to increment from multiple threads MutableInteger integer = new MutableInteger(); // Run 10 threads to increment the same variable for (int i = 0; i < 10; i++) { Thread thread = new Thread(new IncrementingRunnable(integer)); thread.start(); threads.add(thread); } // Wait until all threads are finished for (Thread thread : threads) { thread.join(); } System.out.println("Result value: " + integer.getValue()); 

(Если вы не уверены, как работает Thread::start или join , прочтите это пятиминутное введение в темы .)

Поскольку у нас есть десять потоков, и каждый поток выполняет 10 000 приращений, мы можем ожидать, что конечное значение будет 100 000. Но реальный результат может вас удивить:

 Result value: 61505 

Что еще более удивительно, общая сумма меняется каждый раз, когда выполняется приложение, но всегда меньше 100 000.

Как инкремент создает условие гонки

Чтобы понять, что здесь происходит, нам нужно разобраться, как работает операция приращения. Вы можете подумать, что это неделимая операция и не имеет промежуточных состояний, но на самом деле процессор выполняет несколько низкоуровневых инструкций, таких как эти:

 //i++ $tmp = i; // Store value to a temporary storage $tmp = $tmp + 1; // Increment value in the temporary storage i = $tmp; // Write result value back 

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

 // T1 is Thread 1; T2 is Thread 2 T1: $tmp_1 = i; T1: $tmp_1 = $tmp_1 + 1; T2: $tmp_2 = i; T2: $tmp_2 = $tmp_2 + 1; T2: i = $tmp_2; T1: i = $tmp_1; 

В этом конкретном примере потоки 1 и 2 загружают один i тот же i , скажем, 5 , в свою соответствующую временную переменную. Следовательно, после увеличения обе записи 6 обратно, но желаемый результат для двух приращений был бы 7 .

Вот почему при запуске нашего приложения конечный результат всегда меньше 100 000. Отдельный поток не знает, что есть другие потоки, которые также увеличивают тот же номер. В результате конечный результат зависит от того, какой поток обращается к памяти в каком порядке.

Не такой простой пример

Условия гонки также могут возникать, когда мы работаем со сложными структурами данных. Давайте рассмотрим пример, когда несколько потоков обновляют один и тот же экземпляр LinkedList :

 class ListAddRunnable implements Runnable { private final LinkedList<Integer> list; public ListAddRunnable(LinkedList<Integer> list) { this.list = list; } @Override public void run() { for (int i = 0; i < 10_000; i++) { list.add(i); } } } 

Используя этот Runnable , мы можем снова запустить 10 потоков, каждый из которых должен добавить 10_000 элементов в один и тот же список и дождаться их завершения. Вот вывод, который я получил:

 Result value: 44157 

Что здесь случилось? Чтобы понять это, сначала нужно понять, как работает LinkedList и, в частности, add . Внутренне LinkedList представлен в виде цепочки узлов, и каждый узел содержит две ссылки: одну на элемент, добавленный в список, а другую на следующий узел. Метод add добавляет узел в конец списка — вот упрощенная версия этого метода:

 void add(E element) { Node<E> newSecondLast = this.last; // create the new node (null says there is no next node) Node<E> newNode = new Node<>(element, null); // use it as the last node and let the old 'last' refer to it this.last = newNode; newSecondLast.next = newNode; } 

(Вы можете прочитать больше о том, как LinkedList реализован в Википедии .)

Теперь давайте рассмотрим одну последовательность событий, которая может произойти, если два потока добавляют элементы одновременно:

 // First thread starts adding an element to the end of the list T1: Node<E> newSecondLast = this.last; T1: Node<E> newNode = new Node<>(element, null); // Second thread adds an element to the end of the list T2: Node<E> newSecondLast = this.last; T2: Node<E> newNode = new Node<>(element, null); T2: this.last = newNode; T2: newSecondLast.next = newNode; // First thread continues and overwrites the last element of the list T1: this.last = newNode; T1: newSecondLast.next = newNode1 

Обратите внимание, что все переменные являются локальными для двух потоков, кроме this.last . Это порядок, в котором они читают и пишут в это общее поле, что создает или разрушает программу. В вышеописанном случае оба читают один и тот же последний узел, и оттуда нет никакого способа добраться до правильного состояния, потому что либо T1 переопределяет T2, либо наоборот.

Опять же, поведение программы зависит от непредсказуемого взаимодействия между потоками.

Состояние гонки на лошади

Выводы

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

Есть несколько способов предотвратить гонку. Самый простой способ сделать это — использовать синхронизацию и блокировки — низкоуровневые примитивы для построения параллельных алгоритмов. JDK также предоставляет широкий спектр одновременных коллекций и атомарных значений, которые можно использовать как строительные блоки высокого уровня.

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