Що таке race condition?
Race condition (стан перегонів) — це баг, при якому коректність програми залежить від непередбачуваного порядку виконання потоків. На відміну від deadlock (де потоки назавжди за...
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
- Використовуйте Atomic* для простих операцій increment/set
- Використовуйте synchronized для складних операцій
- Використовуйте Concurrent collections — ConcurrentHashMap, ConcurrentLinkedQueue
- Уникайте Check-then-Act — використовуйте атомарні аналоги (putIfAbsent)
- volatile ≠ thread-safe — volatile лише для visibility
- Тестуйте на багатопроцесорних системах — не лише локально
- Використовуйте JCStress для стрес-тестування
- 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 intvolatileгарантує 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]]