Почему кто-то может назвать состояние гонки приятным?

Почему кто-то может назвать состояние гонки приятным?

27 февраля 2024 г.

Условия гонки — горькая правда о параллельных приложениях. Но почему кто-то может назвать состояние гонки приятным? Очарование этого гоночного состояния заключается в его утонченности снаружи, которой противостоят замысловатые сложности под капотом. Вероятно, именно поэтому люди называют это хорошим состоянием гонки.

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

В этом блоге мы рассмотрим это состояние гонки. Мы поймем, как и почему возникает это состояние гонки и каковы некоторые способы устранения этого состояния гонки в наших приложениях.

Давайте начнем…

Рассмотрим следующий код Java:

public class NiceRC {

    public static void main(String[] args) {
        final Map<Object, Object> unsafeMap = new HashMap<>();
        final List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 5; ++i) {
            threads.add(new Thread(() -> {
                for (int j = 0; j < 100000; ++j) {
                    unsafeMap.put(new Object(), new Object());
                }
            }));
        }
        threads.forEach(Thread::start);
        threads.forEach(thread -> {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        System.out.println("Size of unsafeMap: " + unsafeMap.size());
    }

}

Я настоятельно рекомендую внимательно прочитать приведенный выше код, прежде чем продолжить.

В этом коде мы запускаем 5 потоков, и каждый поток добавляет около 100 тысяч записей на общую карту под названием unsafeMap.

Если вам придется запустить этот код несколько раз, вскоре вы столкнетесь со случаем, когда код не завершится. Это происходит в каком-то бесконечном цикле! Но почему? Ни один раздел нашего кода не может вызвать бесконечный цикл.

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

Но прежде чем говорить о состоянии гонки, давайте еще раз вспомним Java HashMap. Это понадобится, чтобы понять, что происходит под капотом!

Внутренняя часть HashMap

HashMap — это реализация карты на основе хэш-таблицы. Хэш-функция используется для сопоставления ключа с сегментом/слотом, из которого можно найти желаемое значение путем перемещения по связанному списку.

Internal structure of HashMap. Keys are divided into buckets. Each bucket points to a Linked List.

Ниже приведено определение класса Entry, используемого для представления узлов связанного списка внутри HashMap.

static class Entry<K,V> implements Map.Entry<K,V> 
{
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
}

Когда количество пар ключ-значение, хранящихся в HashMap, превышает пороговое значение, ему необходимо изменить размер внутреннего массива и повторно хешировать все элементы. Этот процесс называется перефразированием или изменением размера.

Вот часть кода, выполняющая это изменение размера:

  // Transfer method in java.util.HashMap -
  // called to resize the hashmap

  for (int j = 0; j < src.length; j++) {
    Entry e = src[j];
    if (e != null) {
      src[j] = null;
      do {
        Entry next = e.next;
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
     } while (e != null);
   }
 }

Давайте рассмотрим пример, чтобы понять, как работает этот код перефразирования.

Рассмотрим карту с двумя сегментами и тремя элементами.

HashMap with two buckets and three elements. All three elements falls under Bucket 1.

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

Все три элемента карты будут перефразированы. Ключи A и B будут хешированы с сегментом 3, а ключ C — с сегментом 1.

После одной итерации цикла состояние этой карты будет следующим.

Node A is removed from the original bucket and is added to the head of the new bucket

Узел A извлекается из исходного сегмента и добавляется в начало нового сегмента.

После еще одной итерации этого цикла к заголовку сегмента 3 будет добавлен узел B.

Node B is removed from the original bucket and is added to the head of the new bucket

Еще одной итерацией процесс перефразирования завершается. Это окончательное состояние карты.

Node C is added to Bucket 1. This completes the rehashing process.

К состоянию гонки…

Обладая всеми этими знаниями внутреннего устройства HashMap, мы готовы выявить состояние гонки, которое поразило наш класс NiceRC.

Рассмотрим еще раз следующую карту с двумя сегментами и тремя элементами.

Но на этот раз два потока пытаются выполнить операцию изменения размера/перефэширования одновременно. Назовем эти потоки «Потоком 1» и «Потоком 2».

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

Сначала рассмотрим первый поток, входящий в цикл изменения размера. Он инициализирует переменные e и next. Затем по какой-то причине поток 1 останавливается. Его выкидывают из процессора по любой возможной причине.

(Для ясности к переменным потока добавляется номер потока)

// Transfer method in java.util.HashMap -
  // called to resize the hashmap

  for (int j = 0; j < src.length; j++) {
    Entry e = src[j];
    if (e != null) {
      src[j] = null;
      do {
        Entry next = e.next;
        // THREAD 1 STOPS HERE
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
     } while (e != null);
   }
 }

Это состояние локальных переменных потока 1 на момент вытеснения.

Thread 1 initialized its e1 and next1 variables and got evicted

Предположим, что поток 1 вытесняется из ЦП, а поток 2 — нет и завершает процесс изменения размера. Теперь это новое состояние нашей карты.

HashMap is rehashed by another thread

:::информация Обратите внимание, что процесс изменения размера не клонирует объекты Entry, поэтому e1 и next1 по-прежнему ссылаются на допустимые объекты в новом состоянии. Однако следующее соотношение теперь изменилось неправильным образом.

:::

В этот момент поток 1 снова запланирован и начинает выполнение с того места, где остановился.

Он добавляет узел A в начало нового сегмента.

Note that while node A is rehashed, the next pointer from B to A is not removed!

На следующей итерации переменные e1 и next1 обновляются, чтобы указывать на узел B и узел A соответственно. При этом мы добавляем узел B в начало нового сегмента.

B is added to the front of the new list

Затем мы обновляем переменную e1, чтобы она указывала на узел A. Поскольку у узла A нет следующего узла, next1 теперь имеет указатель на ноль.

Наш алгоритм (снова) пытается добавить узел A в начало нового сегмента.

Adding node A to the front of new bucket creates a loop!

При этом структура нашей карты успешно повреждена!

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

Именно это произошло с нашим классом NiceRC.


Использование непотокобезопасных классов в параллельной среде сопряжено с неизбежными рисками. Даже если ваша система может справиться с условиями гонки, всегда существует вероятность неожиданного чередования операций, которое может привести к сбоям системы. Поэтому настоятельно рекомендуется использовать поточно-ориентированные реализации структур данных при работе в многопоточной среде.

:::информация Также опубликовано здесь.

:::


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE