Питання 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]]