1. Poznajemy warunek wyścigu (race condition)
Przypomnijmy sobie o warunku wyścigu (race condition) — sytuacji, w której wynik działania programu zależy od tego, w jakiej kolejności wątki uzyskują dostęp do wspólnych danych lub zasobów. Jeśli kolejność wykonania się zmienia — wynik staje się nieprzewidywalny. To jak wtedy, gdy ty i znajomy jednocześnie próbujecie edytować ten sam dokument: kto szybciej coś wpisze, ten wygrywa, a końcowy tekst może okazać się bardzo dziwny.
W Javie (i w każdym innym języku z obsługą wielowątkowości) warunek wyścigu pojawia się, gdy kilka wątków jednocześnie odczytuje i/lub modyfikuje tę samą zmienną bez właściwej synchronizacji.
Dlaczego powstaje warunek wyścigu?
Wątki Javy działają równolegle. Jeśli dwa wątki jednocześnie odwołują się do jednej zmiennej (na przykład zwiększają wspólny licznik), mogą się nawzajem „przebijać”. Nawet jeśli operacja wydaje się atomowa (na przykład counter++), w rzeczywistości tak nie jest!
Jak działa counter++?
Operacja inkrementacji składa się z kilku kroków:
- Odczytać bieżącą wartość zmiennej z pamięci.
- Zwiększyć tę wartość o jeden.
- Zapisać nową wartość z powrotem do pamięci.
Jeśli w tym samym momencie inny wątek też wykona counter++, oba mogą odczytać tę samą wartość, oba ją zwiększyć i oba zapisać ten sam wynik — w efekcie jeden przyrost „znika”.
2. Przykład warunku wyścigu: inkrementacja licznika
Napiszmy prosty program, który uruchamia kilka wątków, a każdy z nich zwiększa wspólny licznik o 1. Wydawałoby się, że jeśli uruchomimy 1000 wątków, wartość końcowa licznika powinna wynieść 1000. Sprawdźmy!
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++; // NIEBEZPIECZNA operacja!
});
threadArray[i].start();
}
// Czekamy na zakończenie wszystkich wątków
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("Oczekiwano: " + threads);
System.out.println("Otrzymano: " + counter);
}
}
Oczekiwany wynik:
Oczekiwano: 1000
Otrzymano: 843
Wartość może być inna przy każdym uruchomieniu: czasem 900, czasem 700, a czasem i 1000 — ale bardzo rzadko.
Dlaczego tak się dzieje?
Wątki jednocześnie odczytują wartość counter, zwiększają ją i zapisują z powrotem. Jeśli dwa wątki odczytują tę samą wartość, oba ją zwiększają i oba zapisują — jeden przyrost się gubi. W rezultacie wartość końcowa zawsze jest mniejsza od oczekiwanej.
3. Kolejny przykład: bank bez synchronizacji
Wyobraźmy sobie, że mamy rachunek bankowy i dwa wątki jednocześnie wypłacają pieniądze.
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// Symulacja długiej operacji
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("Oczekiwano: 0 lub 100");
System.out.println("Rzeczywisty stan: " + account.getBalance());
}
}
Czasem oba wątki zobaczą, że na koncie jest 100, i oba dokonają wypłaty. W rezultacie saldo wyniesie -100! (W prawdziwym życiu tak nie bywa, ale w kodzie — jak najbardziej.)
4. Przydatne niuanse
Konsekwencje warunku wyścigu
Warunek wyścigu to nie tylko „dziwne” wyniki. To prawdziwy ból głowy dla programisty, ponieważ:
- Błędy nie zawsze się ujawniają. Czasem program działa poprawnie, a czasem — nie. Wszystko zależy od tego, jak wątki „zdążyły” wykonać swoje działania.
- Testowanie nie gwarantuje sukcesu. Możesz uruchamiać program wiele razy — i wszystko będzie dobrze, a potem nagle wszystko się posypie.
- Błędy trudno wyłapać. Zachowanie zależy od szybkości procesora, obciążenia systemu, innych uruchomionych programów.
- Mogą wystąpić krytyczne awarie: utrata danych, niepoprawne obliczenia, awaria aplikacji.
Przykłady z rzeczywistości
- Aplikacje finansowe: nieprawidłowe wyliczenie salda, podwójne obciążenia.
- Serwery: utrata wiadomości, niepoprawne przetwarzanie żądań.
- Gry: „teleporty” postaci, błędne naliczanie punktów.
Dlaczego testowanie nie ratuje przed warunkiem wyścigu?
Warunek wyścigu to typowy „Heisenbug” (bug, który znika, gdy próbujesz go złapać). Nawet jeśli uruchomisz testy tysiąc razy i nie zobaczysz błędu — to nie znaczy, że go nie ma! Wszystko zależy od tego, jak system operacyjny zaplanuje pracę wątków. Czasem wszystko przebiega gładko, a czasem wątki się „zderzają” i pojawia się problem.
Jak uniknąć warunku wyścigu?
- Synchronizacja: używaj słowa kluczowego synchronized dla metod lub bloków kodu, aby w danej chwili tylko jeden wątek mógł zmieniać wspólne dane.
- Operacje atomowe: używaj klas z pakietu java.util.concurrent.atomic (na przykład AtomicInteger), które zapewniają bezpieczne operacje bez jawnej synchronizacji.
- Niezmienność: jeśli obiektu nie da się zmienić, warunek wyścigu jest niemożliwy.
Przykład z synchronizacją
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
Teraz, jeśli kilka wątków wywoła increment(), w danej chwili tylko jeden wątek będzie mógł wykonać tę metodę.
5. Typowe błędy przy pracy ze współdzielonymi zmiennymi w wątkach
Błąd nr 1: Naiwna wiara w bezpieczeństwo prostych operacji.
Wielu sądzi, że counter++ — to jedna operacja i nic złego nie może się wydarzyć. W rzeczywistości to trzy operacje i w międzyczasie inny wątek może się „wcisnąć”.
Błąd nr 2: Używanie zwykłych zmiennych do wymiany między wątkami.
Jeśli kilka wątków zapisuje i odczytuje tę samą zmienną bez synchronizacji — to prosta droga do warunku wyścigu!
Błąd nr 3: Oczekiwanie, że błąd będzie występował zawsze.
Warunek wyścigu może ujawniać się tylko czasami, co czyni go szczególnie podstępnym. Nie należy zakładać, że skoro wszystko działało na testach — to wszystko jest w porządku.
Błąd nr 4: Ignorowanie synchronizacji przy pracy z kolekcjami.
Zwykłe kolekcje, takie jak ArrayList, nie są bezpieczne dla wątków. Jeśli kilka wątków dodaje lub usuwa elementy — możliwe są błędy, a nawet awarie programu.
Błąd nr 5: Próba „naprawiania” warunku wyścigu za pomocą opóźnień.
Na przykład przez Thread.sleep(10) lub inne „magiczne” pauzy. Takie podejście nie rozwiązuje problemu, a jedynie go maskuje. Prawdziwym rozwiązaniem jest synchronizacja albo operacje atomowe.
GO TO FULL VERSION