Что такое 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]]