1. Знакомимся с состоянием гонки (race condition)
Давайте вспомним о состоянии гонки (race condition) — ситуации, когда результат работы программы зависит от того, в каком порядке потоки получают доступ к общим данным или ресурсам. Если порядок выполнения меняется — результат становится непредсказуемым. Это похоже на то, как если бы вы с другом одновременно пытались редактировать один и тот же документ: кто быстрее напишет, тот и победил, а итоговый текст может оказаться очень странным.
В Java (да и в любом другом языке с поддержкой многопоточности) race condition появляется, когда несколько потоков одновременно читают и/или изменяют одну и ту же переменную без должной синхронизации.
Почему возникает race condition?
Java-потоки работают параллельно. Если два потока одновременно обращаются к одной переменной (например, увеличивают общий счётчик), они могут «перебивать» друг друга. Даже если операция кажется атомарной (например, counter++), на самом деле это не так!
Как работает counter++?
Операция инкремента включает несколько шагов:
- Прочитать текущее значение переменной из памяти.
- Увеличить это значение на единицу.
- Записать новое значение обратно в память.
Если в этот момент другой поток тоже делает counter++, они могут оба прочитать одно и то же значение, оба увеличить его и оба записать одинаковый результат — в итоге один инкремент «теряется».
2. Пример race condition: инкремент счётчика
Давайте напишем простую программу, которая запускает несколько потоков, каждый из которых увеличивает общий счётчик на 1. Казалось бы, если мы запускаем 1000 потоков, итоговое значение счётчика должно быть 1000. Проверим!
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // ОПАСНАЯ операция!
});
threadArray[i].start();
}
// Ждём завершения всех потоков
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("Ожидалось: " + threads);
System.out.println("Получено: " + counter);
}
}
Ожидаемый вывод:
Ожидалось: 1000
Получено: 843
Значение может быть разным при каждом запуске: иногда 900, иногда 700, а иногда и 1000 — но очень редко.
Почему так происходит?
Потоки одновременно читают значение counter, увеличивают его и записывают обратно. Если два потока читают одно и то же значение, оба увеличивают его и оба записывают — один инкремент теряется. В результате итоговое значение всегда меньше ожидаемого.
3. Ещё пример: банк без синхронизации
Давайте представим, что у нас есть банковский счёт, и два потока одновременно снимают деньги.
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// Имитация долгой работы
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Ожидалось: 0 или 100");
System.out.println("Фактический остаток: " + account.getBalance());
}
}
Иногда оба потока увидят, что на счёту есть 100, и оба снимут деньги. В результате баланс станет -100! (В реальной жизни так не бывает, но в коде — запросто.)
4. Полезные нюансы
Последствия race condition
Состояние гонки — это не просто «странные» результаты. Это настоящая головная боль для программиста, потому что:
- Ошибки проявляются не всегда. Иногда программа работает правильно, иногда — нет. Всё зависит от того, как потоки «успели» выполнить свои действия.
- Тестирование не гарантирует успеха. Вы можете запускать программу много раз — и всё будет хорошо, а потом внезапно всё сломается.
- Ошибки сложно отлавливать. Поведение зависит от скорости процессора, нагрузки на систему, других запущенных программ.
- Могут возникать критические сбои: потеря данных, некорректные вычисления, падение приложения.
Реальные примеры
- Финансовые приложения: неверный расчёт баланса, двойные списания.
- Серверы: потеря сообщений, некорректная обработка запросов.
- Игры: «телепорт» персонажей, неправильное начисление очков.
Почему тестирование не спасает от race condition?
Race condition — это типичный «Heisenbug» (баг, который исчезает, когда вы пытаетесь его поймать). Даже если вы тысячу раз прогоните тесты и не увидите ошибки — это не значит, что её нет! Всё зависит от того, как ОС распланирует работу потоков. Иногда всё проходит гладко, а иногда потоки «сталкиваются» и возникает проблема.
Как избежать race condition?
- Синхронизация: используйте ключевое слово synchronized для методов или блоков кода, чтобы только один поток мог изменять общие данные в каждый момент времени.
- Атомарные операции: используйте классы из пакета java.util.concurrent.atomic (например, AtomicInteger), которые обеспечивают безопасные операции без явной синхронизации.
- Иммутабельность: если объект нельзя изменить, race condition невозможна.
Пример с синхронизацией
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
Теперь, если несколько потоков вызывают increment(), только один поток сможет выполнить этот метод в каждый момент времени.
5. Типичные ошибки при работе с общими переменными в потоках
Ошибка №1: Наивная уверенность в безопасности простых операций.
Многие думают, что counter++ — это одна операция и ничего плохого быть не может. На самом деле, это три операции, и между ними другой поток может «вклиниться».
Ошибка №2: Использование обычных переменных для обмена между потоками.
Если несколько потоков пишут и читают одну переменную без синхронизации — привет, race condition!
Ошибка №3: Ожидание, что ошибка проявится всегда.
Race condition может проявиться только иногда, и это делает её особенно коварной. Не стоит надеяться, что если всё работало на тестах — значит, всё хорошо.
Ошибка №4: Игнорирование синхронизации при работе с коллекциями.
Обычные коллекции вроде ArrayList не потокобезопасны. Если несколько потоков добавляют или удаляют элементы — возможны сбои и даже падения программы.
Ошибка №5: Попытка «чинить» race condition с помощью задержек.
Например, через Thread.sleep(10) или другие «магические» паузы. Такой подход не решает проблему, а только маскирует её. Настоящее решение — синхронизация или атомарные операции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ