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

Что такое race condition?

Race condition (состояние гонки) — это баг, при котором правильность программы зависит от непредсказуемого порядка выполнения потоков. В отличие от deadlock (где потоки навсегда...

Версии по языкам: English Russian Ukrainian

Race condition (состояние гонки) — это баг, при котором правильность программы зависит от непредсказуемого порядка выполнения потоков. В отличие от deadlock (где потоки навсегда заблокированы), при race condition программа работает, но выдаёт неправильный результат. Это делает race condition особенно коварным — приложение может месяцами работать на тестовом стенде и взорваться на продакшне.


Junior уровень

Базовое понимание

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

Почему это не случайность: race condition возникает из-за конкретного паттерна — минимум один поток читает данные, минимум один поток пишет данные, и между чтением и записью нет барьера синхронизации.

Простая аналогия

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

  • Человек A читает: “5”
  • Человек B читает: “5”
  • Человек A пишет: “6”
  • Человек B пишет: “6”
  • Результат: 6 вместо ожидаемых 7!

Классический пример: Счётчик

public class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++; // НЕ безопасно! Это 3 операции:
        // 1. Прочитать count из памяти
        // 2. Увеличить на 1
        // 3. Записать обратно
    }

    public int getCount() {
        return count;
    }
}

// Два потока:
for (int i = 0; i < 1000; i++) counter.increment();
for (int i = 0; i < 1000; i++) counter.increment();
// Ожидаем: 2000
// Получаем: 1500-1999 (потерянные обновления!)

Почему count++ не атомарен?

Поток 1:           Поток 2:
LOAD count (0)
                   LOAD count (0)  ← Тот же начальный значение!
INC (0 → 1)        INC (0 → 1)
STORE count (1)    STORE count (1) ← Перезаписывает!
// Результат: 1 вместо 2

Типы race condition

Тип Описание Пример
Read-Modify-Write Читаем → изменяем → пишем на основе старого count++
Check-then-Act Проверяем → действуем на основе проверки if (map.get(key) == null) put(key, value)
Lazy Init Race Объект может быть частично инициализирован Singleton без volatile

Middle уровень

Read-Modify-Write

// Проблема:
AtomicInteger counter = new AtomicInteger(0);
int value = counter.get();    // READ
counter.set(value + 1);       // MODIFY + WRITE (не атомарно вместе!)
// Два потока могут прочитать одинаковое значение

Check-then-Act

// Проблема:
if (!map.containsKey(key)) {    // CHECK
    map.put(key, new Value());  // ACT
}
// Два потока могут одновременно пройти проверку и оба сделать put

Решение:

// Безопасно:
map.putIfAbsent(key, new Value()); // Атомарная check-then-act

Причина на уровне железа: CPU Reordering

Процессоры выполняют инструкции вне очереди (Out-of-order execution):

// Исходный код:
data = new Data();   // Инструкция 1
ready = true;        // Инструкция 2

// Процессор может переупорядочить:
ready = true;        // Сначала — дешевле
data = new Data();   // Потом

// Другой поток видит ready = true, но data ещё null!

Без Memory Barriers (volatile, synchronized) процессор волен переупорядочивать.

Volatile НЕ спасает от Race Condition

// Частая ошибка:
volatile int count = 0;

count++; // ВСЁ ЕЩЁ не безопасно!
// volatile гарантирует видимость, но НЕ атомарность
// Это всё ещё 3 операции: read → modify → write

Heisenbugs

Race condition — это “гейзенбаги”:

  • Не проявляются на машине разработчика (1-2 ядра)
  • Взрываются на продакшн-сервере (64 ядра)
  • Невозможно воспроизвести в отладчике (отладка меняет timing)
// Тест, который "иногда проходит, иногда нет":
@Test
public void testCounter() {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> { for(int i=0;i<1000;i++) counter.increment(); });
    Thread t2 = new Thread(() -> { for(int i=0;i<1000;i++) counter.increment(); });
    t1.start(); t2.start();
    t1.join(); t2.join();
    assertEquals(2000, counter.getCount()); // Иногда 1998, иногда 1995...
}

Senior уровень

Under the Hood: Interleaving

Race condition возникает из-за interleaving — перемешивания инструкций разных потоков:

Поток 1:  LOAD count    INC    STORE count
Поток 2:          LOAD count    INC    STORE count
                 ↑
                 Здесь Поток 2 читает СТАРОЕ значение

Возможные interleaving для двух потоков с count++:

1. P1: LOAD→INC→STORE, P2: LOAD→INC→STORE  → Результат: 2 (OK)
2. P1: LOAD, P2: LOAD→INC→STORE, P1: INC→STORE  → Результат: 1 (BUG!)
3. P1: LOAD→INC, P2: LOAD→INC→STORE, P1: STORE  → Результат: 1 (BUG!)

ABA Проблема (вариант Race Condition)

Поток 1: прочитал значение A
Поток 2: изменил A → B → A
Поток 1: "Значение не менялось" — продолжает на основе устаревших данных

Benign Races

В редких случаях race condition допуска осознанно:

// Аппроксимированный счётчик — допускаем потерю обновлений ради скорости
volatile int approxCount = 0;

public void increment() {
    approxCount++; // Race condition — но для статистики это OK
}

Visibility vs Atomicity

Проблема Описание Решение
Visibility Поток не видит изменения другого volatile
Atomicity Составная операция прерывается synchronized, Atomic*
// volatile решает visibility, но НЕ atomicity:
volatile int count = 0;
count++; // Race condition всё ещё есть!

// synchronized решает и visibility, и atomicity:
synchronized(lock) {
    count++; // Безопасно
}

Диагностика

JCStress

@JCStressTest
@Outcome(id = "1", expect = ACCEPTABLE_INTERESTING, desc = "Race condition!")
@Outcome(id = "2", expect = ACCEPTABLE, desc = "OK")
public class CounterStressTest {
    int count = 0;

    @Actor
    public void actor1() { count++; }

    @Actor
    public void actor2(I_IntResult1 r) { r.r1 = count; }
}

// Запуск:
// java -jar jcstress.jar -f CounterStressTest

Static Analysis: Error Prone

# Google Error Prone найдёт Check-then-Act паттерны
javac -Xplugin:ErrorProne MyClass.java
// Error Prone предупредит:
if (map.get(key) == null) {    // ⚠️ Check-then-act race
    map.put(key, value);
}

Concurrency Testing с CountDownLatch

@Test
public void testRaceCondition() throws Exception {
    int threads = 10;
    CountDownLatch startLatch = new CountDownLatch(1);
    CountDownLatch doneLatch = new CountDownLatch(threads);

    for (int i = 0; i < threads; i++) {
        new Thread(() -> {
            try {
                startLatch.await(); // Все ждут
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            } finally {
                doneLatch.countDown();
            }
        }).start();
    }

    startLatch.countDown(); // "GO!" — все стартуют одновременно
    doneLatch.await();

    assertEquals(threads * 1000, counter.getCount());
}

Best Practices

  1. Используйте Atomic* для простых операций increment/set
  2. Используйте synchronized для составных операций
  3. Используйте Concurrent collections — ConcurrentHashMap, ConcurrentLinkedQueue
  4. Избегайте Check-then-Act — используйте атомарные аналоги (putIfAbsent)
  5. volatile ≠ thread-safe — volatile только для visibility
  6. Тестируйте на многопроцессорных системах — не только локально
  7. Используйте JCStress для стресс-тестирования
  8. Static Analysis — Error Prone найдёт типичные паттерны гонок

Когда НЕ нужно беспокоиться о race condition

  • Однопоточное приложение — если нет параллелизма, нет и гонок
  • Все данные immutable — если объекты не изменяются после создания, race condition невозможен (нет записи)
  • Данные не покидают метод — локальные переменные хранятся на стеке потока, другие потоки к ним не имеют доступа
  • Только атомарные read-only операции — если все потоки только читают (даже без volatile), race condition невозможен
  • Аппроксимированные счётчики/метрики — иногда race condition допустим: потеря 1-2 обновлений на миллион операций не влияет на бизнес-логику (например, счётчик просмотров)

Race Condition vs Deadlock: ключевые отличия

Критерий Race Condition Deadlock
Симптом Неправильный результат Полная остановка
Причина Недостаточная синхронизация Избыточная/неправильная синхронизация
Обнаружение Стресс-тесты, JCStress, production-аномалии Thread dump, jstack, ThreadMXBean
Воспроизведение Нестабильное, зависит от timing Стабильное (если условия выполнены)
Решение Добавить синхронизацию (synchronized, Atomic) Убрать синхронизацию или упорядочить блокировки
Опасность Тихая порча данных (хуже) Видимая проблема (лучше)

Race condition часто опаснее deadlock: deadlock сразу виден (приложение зависло), а race condition может тихо портить данные месяцами.


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

Обязательно знать:

  • Race condition — баг, при котором результат программы зависит от непредсказуемого порядка выполнения потоков
  • Три типа: Read-Modify-Write (count++), Check-then-Act (if-not-then-put), Lazy Init Race
  • count++ — это 3 операции (read → modify → write), не атомарно даже для volatile int
  • volatile гарантирует visibility, но НЕ atomicity
  • Race condition vs Deadlock: race = недостаточная синхронизация (тихая порча данных), deadlock = избыточная синхронизация (полная остановка)
  • Heisenbugs: не воспроизводятся на машине разработчика, взрываются на продакшне с большим числом ядер

Частые уточняющие вопросы:

  • Почему volatile не спасает от race condition? — volatile гарантирует, что все потоки видят последнее значение, но операция read-modify-write всё ещё может быть прервана другим потоком между чтением и записью
  • Что такое ABA проблема? — Поток 1 прочитал A, поток 2 изменил A→B→A, поток 1 “не заметил изменений” и продолжает на основе устаревших данных
  • Что такое benign race condition? — Осознанно допустимая гонка, например аппроксимированный счётчик, где потеря 1-2 обновлений на миллион не влияет на бизнес-логику
  • Как тестировать race condition? — CountDownLatch для одновременного старта, JCStress для стресс-тестирования, тестирование на ARM/M1 (агрессивнее к reorder)

Красные флаги (НЕ говорить):

  • ❌ “volatile достаточно для thread-safe счётчика” — volatile не даёт атомарности для составных операций
  • ❌ “Race condition — это когда потоки блокируют друг друга” — это deadlock, race condition — это неправильный результат
  • ❌ “На моём 2-ядерном ноутбуке тест всегда проходит” — race condition проявляется при большем числе ядер, это не значит что бага нет
  • ❌ “Если программа иногда работает правильно — race condition нет” — race condition зависит от timing, “иногда работает” — это и есть симптом

Связанные темы:

  • [[19. Какие условия необходимы для возникновения deadlock]]
  • [[22. Как избежать race condition]]
  • [[27. В чём разница между Thread и Runnable]]
  • [[28. Что такое Callable и Future]]