🧵 Раздел 9 · Вопрос #20

Что такое race condition

В отличие от Deadlock, когда приложение полностью замирает (0% CPU), при Race Condition программа продолжает внешне успешно работать, но производит ошибочные расчёты или разруша...


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Race Condition (состояние гонки) — это архитектурная ошибка многопоточного приложения, при которой корректность работы программы, согласованность данных или результат вычислений зависят от случайного, недетерминированного порядка чередования (interleaving) и скорости исполнения инструкций параллельными потоками.

В отличие от Deadlock, когда приложение полностью замирает (0% CPU), при Race Condition программа продолжает внешне успешно работать, но производит ошибочные расчёты или разрушает внутреннее состояние данных (Silent Data Corruption).

Классические проявления Race Condition делятся на три категории:

  1. Read-Modify-Write: чтение, изменение и запись разделяемой переменной (например, count++).
  2. Check-Then-Act: проверка условия и действие на основе устаревшего результата проверки (if (condition) act();).
  3. Unsafe Publication: использование объекта до того, как его конструктор полностью завершил публикацию полей.

Почему count++ не потокобезопасен

Обычная операция инкремента count++ кажется атомарной в синтаксисе Java, но на уровне байткода и ассемблера состоит из трёх раздельных операций:

  1. read: прочитать текущее значение из памяти в регистр процессора.
  2. modify: увеличить значение в регистре на 1.
  3. write: записать новое значение обратно в память.
Поток 1:  [ Читает 0 ] ───> [ Увеличивает до 1 ] ───────────────────> [ Пишет 1 ]
Поток 2:       [ Читает 0 ] ───────────────────> [ Увеличивает до 1 ] ───> [ Пишет 1 (ПЕРЕЗАПИСЬ!) ]

В результате двух параллельных инкрементов значение счётчика увеличилось только на 1 вместо ожидаемых 2. Произошла потеря обновления (Lost Update).


Базовый пример на Java 21

public class RaceConditionDemo {
    private int counter = 0;

    // ОПАСНО: Read-Modify-Write без синхронизации
    public void increment() {
        counter++;
    }

    public int getCounter() {
        return counter;
    }

    public static void main(String[] args) throws InterruptedException {
        RaceConditionDemo demo = new RaceConditionDemo();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 10_000; i++) demo.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 10_000; i++) demo.increment(); });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        // Ожидаем 20_000, но получим случайное число (например, 14_352)
        System.out.println("Итоговый счетчик: " + demo.getCounter());
    }
}

🟡 Middle Level

Паттерн Check-Then-Act (Проверил, затем выполнил)

Второй по распространенности сценарий гонки — принятие решения на основе состояния, которое перестаёт быть актуальным в следующий же такт процессора.

// КЛАССИЧЕСКАЯ ОШИБКА: Check-Then-Act
public class UnsafeAccountService {
    private int balance = 100;

    public void withdraw(int amount) {
        // 1. CHECK: проверяем баланс
        if (balance >= amount) {
            // В этот момент поток может быть вытеснен ОС!
            // Другой поток успеет пройти проверку и снять остаток!
            
            // 2. ACT: совершаем действие на основе устаревшей проверки
            balance -= amount; // Отрицательный баланс!
        }
    }
}

Check-Then-Act в коллекциях:

Замена стандартной HashMap на ConcurrentHashMap не защищает от ошибок составной бизнес-логики:

// ОШИБКА: Race Condition присутствует даже с ConcurrentHashMap!
if (!concurrentMap.containsKey(key)) { // CHECK
    concurrentMap.put(key, new ExpensiveService()); // ACT: создастся дважды!
}

// ПРАВИЛЬНО: Использовать атомарные композитные методы ConcurrentHashMap
concurrentMap.computeIfAbsent(key, k -> new ExpensiveService());

Почему volatile НЕ защищает от Race Condition?

Часто начинающие разработчики пытаются исправить счётчик добавлением volatile:

private volatile int count = 0;
public void increment() { count++; } // ВСЁ ЕЩЁ СОДЕРЖИТ RACE CONDITION!
  • Модификатор volatile решает проблему видимости (Visibility) и предотвращает переупорядочивание инструкций (Reordering).
  • Но volatile не обеспечивает атомарности составных операций! Операция count++ по-прежнему транслируется в три раздельные инструкции: getfield, iadd, putfield. Между чтением значения из памяти и записью обновлённого значения другой поток может успеть выполнить свой инкремент.

«Гейзенбаги» (Heisenbugs) и сложность воспроизведения

Ошибки Race Condition называют Heisenbugs (по аналогии с принципом неопределенности Гейзенберга):

  • Они не воспроизводятся при отладке в IDE, так как точки останова (breakpoints) и пошаговое выполнение кардинально меняют временные интервалы (timing) работы потоков.
  • Они могут годами не проявляться на 2-ядерном ноутбуке разработчика, но мгновенно обрушивать систему в Production на 64-ядерных серверах с высокой плотностью параллельных запросов.

🔴 Senior Level

Data Race против Race Condition: Академическое различие

На интервью уровня Senior/Lead часто проверяют понимание строгого теоретического различия между терминами Data Race и Race Condition:

+--------------------------------------------------------------------------+
| Data Race (Гонка данных)                                                 |
| Низкоуровневая ошибка памяти JMM:                                        |
| Как минимум два потока одновременно обращаются к одной ячейке памяти,   |
| хотя бы одно обращение является записью, и между ними нет                |
| отношения Happens-Before.                                                |
+--------------------------------------------------------------------------+
                                     vs
+--------------------------------------------------------------------------+
| Race Condition (Состояние гонки)                                         |
| Высокоуровневая ошибка бизнес-логики:                                    |
| Недетерминированный результат алгоритма из-за относительного тайминга     |
| выполнения потоков (может происходить даже при полном отсутствии        |
| низкоуровневых Data Races!).                                             |
+--------------------------------------------------------------------------+

Пример: Race Condition БЕЗ Data Race

// Все операции потокобезопасны (нет Data Race, нет нарушений JMM)
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();

public void buyProduct(String item) {
    // Две потокобезопасные операции вызываются без единой синхронизации:
    if (stock.get(item) > 0) { // Никакого Data Race!
        stock.compute(item, (k, v) -> v - 1); // Атомарно!
    }
    // Но здесь ПРИСУТСТВУЕТ RACE CONDITION:
    // Два потока могут одновременно увидеть остаток 1 и продать товар дважды!
}

Безопасные гонки (Benign Data Race): String.hashCode()

В кодовой базе JDK есть редкие примеры намеренного использования гонки данных для оптимизации производительности.

Классический пример — вычисление хеш-кода строки java.lang.String:

// Исходный код java.lang.String:
private int hash; // Поле НЕ volatile!

public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h; // Data Race! Потоки могут перезаписывать hash параллельно
        }
    }
    return h;
}

Почему это безопасно (Benign)?

  1. Иммутабельность: массив символов value неизменяем.
  2. Идемпотентность: функция вычисления хеша чистая (детерминированная). Если пять потоков параллельно посчитают h, они получат абсолютно одинаковое целое число.
  3. Атомарность 32-битного int: в Java чтение и запись 32-битного int гарантированно неделимы (нет Word Tearing).
  4. Экономия: отсутствие volatile на миллиардах объектов String экономит барьеры памяти и повышает общую производительность платформы.

Тестирование гонок: JCStress (Java Concurrency Stress)

Традиционные тесты JUnit бессильны против состояний гонки. Для верификации многопоточного кода OpenJDK использует специализированный фреймворк JCStress:

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = "1", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Потеряно обновление!")
@Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "Корректно")
@State
public class CounterStressTest {
    int count = 0;

    @Actor
    public void actor1() {
        count++; // Первый поток
    }

    @Actor
    public void actor2() {
        count++; // Второй поток
    }

    @Arbiter
    public void arbiter(I_Result r) {
        r.r1 = count; // Наблюдатель фиксирует итоговое значение
    }
}

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


🎯 Шпаргалка для интервью

1. 30-секундный эссенс (Elevator Pitch)

Race Condition (состояние гонки) — логическая ошибка многопоточности, при которой результат выполнения программы зависит от случайного чередования инструкций процессора. Проявляется в формах Read-Modify-Write (i++) и Check-Then-Act (if-check-then-put). Отличие от Data Race: Data Race — это нарушение JMM при обращении к памяти без Happens-Before, тогда как Race Condition — это высокоуровневое нарушение инварианта бизнес-логики (может возникать даже на потокобезопасных коллекциях ConcurrentHashMap). Ключевое слово volatile не спасает от составных гонок.


2. 4 каверзных вопроса с глубокими ответами

Вопрос 1: В чём строгое теоретическое отличие между терминами «Data Race» и «Race Condition»?

Ответ:

  • Data Race (гонка данных): низкоуровневое понятие модели памяти (JMM). Возникает, когда два потока одновременно обращаются к одной переменной в куче, хотя бы одно обращение — запись, и между ними нет ребра Happens-Before.
  • Race Condition (состояние гонки): семантический дефект бизнес-логики приложения, когда результат работы зависит от относительного тайминга потоков.
    Важно: программа может содержать Race Condition при полном отсутствии Data Race (например, два потока проверяют баланс в потокобезопасной ConcurrentHashMap, видят 100$ и оба снимают по 100$). И наоборот: программа может содержать Data Race, который не нарушает бизнес-логику (Benign Data Race в String.hashCode()).

Вопрос 2: Почему кэширование хеш-кода в java.lang.String содержит Data Race, но остаётся абсолютно корректным?

Ответ: Поле hash в классе String объявлено как обычный int (без volatile). Несколько потоков могут параллельно прочитать hash == 0 и начать вычисление, перезаписав результат друг друга. Это явный Data Race по спецификации JMM. Однако он безопасен (Benign), потому что:

  1. Строка иммутабельна, алгоритм детерминирован — все потоки вычислят в точности одно и то же число.
  2. Запись 32-битного примитива int атомарна (нет эффекта повреждения слова / Word Tearing).
  3. Поток запишет то же самое значение, что уже лежит в поле или вычисляется соседом, поэтому никакой потери корректности не происходит, а JVM экономит на барьерах памяти StoreLoad.

Вопрос 3: Почему код if (!map.containsKey(key)) { map.put(key, value); } содержит Race Condition даже если map — это ConcurrentHashMap?

Ответ: Потому что методы containsKey() и put() атомарны только по отдельности. Между моментом, когда первый поток выполнил containsKey() и получил false, и моментом, когда он вызвал put(), управление процессором может перейти ко второму потоку. Второй поток успеет проверить ключ и положить своё значение. Когда управление вернётся к первому потоку, он слепо перезапишет значение второго потока. Чтобы устранить гонку Check-Then-Act, необходимо объединить проверку и вставку в единую атомарную транзакцию: map.putIfAbsent(key, value) или map.computeIfAbsent(key, k -> value).

Вопрос 4: Как фреймворк JCStress обнаруживает состояния гонки, которые не ловятся обычными юнит-тестами?

Ответ: В обычных JUnit-тестах потоки стартуют с небольшим смещением, а JIT-компилятор держит код неоптимизированным, из-за чего вероятность попадания инструкций в узкое окно уязвимости (1–2 такта CPU) ничтожно мала.
JCStress:

  1. Компилирует тестовые методы @Actor с агрессивными флагами JIT C2.
  2. Использует фазовые синхронизаторы и ассемблерные спин-циклы для одновременного спуска потоков «такт в такт».
  3. Раскладывает исполнение на разные физические ядра и сокеты процессора для провокации реального межъядерного переупорядочивания инструкций и задержек кешей L1/L2.
  4. Прогоняет сценарий миллионы раз и строит статистическую матрицу исходов (@Outcome).

3. Типичные ошибки и Red Flags

❌ “Добавление volatile к переменной защищает операцию инкремента count++.”
Правильно: volatile даёт только видимость и барьеры порядка, но составная операция чтение-изменение-запись остаётся неатомарной. Требуется AtomicInteger или synchronized.

❌ “Если метод вызывается у ConcurrentHashMap, то весь блок кода вокруг него потокобезопасен.”
Правильно: Потокобезопасны только отдельные вызовы методов коллекции. Логические цепочки вида «проверил-затем-записал» обязаны использовать составные функции (computeIfAbsent, merge).

❌ “Race Condition всегда приводит к выбросу исключений в Java.”
Правильно: В 99% случаев Race Condition протекает молча: данные искажаются, значения затираются, но JVM не выбрасывает ошибок.


4. Связанные вопросы