Що таке race condition
На відміну від Deadlock, коли застосунок повністю завмирає (0% CPU), при Race Condition програма продовжує зовні успішно працювати, але продукує помилкові розрахунки або руйнує...
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
Race Condition (стан гонки) — це архітектурна помилка багатопотокового застосунку, при якій коректність роботи програми, узгодженість даних або результат обчислень залежать від випадкового, недетермінованого порядку чергування (interleaving) та швидкості виконання інструкцій паралельними потоками.
На відміну від Deadlock, коли застосунок повністю завмирає (0% CPU), при Race Condition програма продовжує зовні успішно працювати, але продукує помилкові розрахунки або руйнує внутрішній стан даних (Silent Data Corruption).
Класичні прояви Race Condition поділяються на три категорії:
- Read-Modify-Write: читання, зміна та запис розділюваної змінної (наприклад,
count++). - Check-Then-Act: перевірка умови та дія на основі застарілого результату перевірки (
if (condition) act();). - Unsafe Publication: використання об’єкта до того, як його конструктор повністю завершив публікацію полів.
Чому count++ не є потокобезпечним
Звичайна операція інкременту count++ здається атомарною в синтаксисі Java, але на рівні байткоду та асемблера складається з трьох окремих операцій:
read: прочитати поточне значення з пам’яті в регістр процесора.modify: збільшити значення в регістрі на 1.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)?
- Імутабельність: масив символів
valueнезмінний. - Ідемпотентність: функція обчислення хешу є чистою (детермінованою). Якщо п’ять потоків паралельно порахують
h, вони отримають абсолютно однакове ціле число. - Атомарність 32-бітного int: у Java читання та запис 32-бітного
intгарантовано неподільні (немає Word Tearing). - Економія: відсутність
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), оскільки:
- Рядок іммутабельний, алгоритм детермінований — усі потоки обчислять у точності одне й те саме число.
- Запис 32-бітного примітиву
intє атомарним (немає ефекту пошкодження слова / Word Tearing). - Потік запише те саме значення, яке вже лежить у полі або обчислюється сусідом, тому жодної втрати коректності не відбувається, а 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:
- Компілює тестові методи
@Actorз агресивними прапорцями JIT C2. - Використовує фазові синхронізатори та асемблерні спін-цикли для одночасного спуску потоків «такт у такт».
- Розкладає виконання на різні фізичні ядра та сокети процесора для провокації реального між’ядерного перевпорядкування інструкцій і затримок кешів L1/L2.
- Проганяє сценарій мільйони разів і будує статистичну матрицю результатів (
@Outcome).
3. Типові помилки та Red Flags
❌ “Додавання volatile до змінної захищає операцію інкременту count++.”
Правильно: volatile дає лише видимість та бар’єри порядку, але складена операція читання-зміна-запис залишається неатомарною. Потрібен AtomicInteger або synchronized.
❌ “Якщо метод викликається у ConcurrentHashMap, то весь блок коду навколо нього потокобезпечний.”
Правильно: Потокобезпечними є лише окремі виклики методів колекції. Логічні ланцюжки виду «перевірив-потім-записав» зобов’язані використовувати складені функції (computeIfAbsent, merge).
❌ “Race Condition завжди призводить до викидання винятків у Java.”
Правильно: У 99% випадків Race Condition протікає мовчки: дані спотворюються, значення затираються, але JVM не викидає помилок.