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) або інші «магічні» паузи. Такий підхід не розв’язує проблему, а лише маскує її. Справжнє рішення — синхронізація або атомарні операції.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ