Rozdział 13: Współbieżność
O co chodzi w sekcji Współbieżność
Współbieżność to przede wszystkim strategia oddzielenia tego, co jest wykonywane, od tego, kiedy jest wykonywane. W aplikacjach jednowątkowych te dwa aspekty są ze sobą nierozerwalnie złączone, co ułatwia debugowanie, ale ogranicza wydajność i narzuca sztywną strukturę kodu. Głównym celem stosowania wątków jest poprawa przepustowości i struktury aplikacji, jednak cena za to jest wysoka: pisanie poprawnego kodu wielowątkowego jest ekstremalnie trudne, ponieważ błędy często ujawniają się dopiero pod dużym obciążeniem lub w rzadkich, trudnych do powtórzenia warunkach.
Kluczowe zasady i reguły
- Zasada pojedynczej odpowiedzialności (SRP): Kod zarządzający współbieżnością powinien być całkowicie odizolowany od reszty logiki biznesowej. Zarządzanie wątkami ma własny cykl życia, wyzwania i sposoby testowania.
- Ograniczanie zakresu danych: Należy rygorystycznie stosować hermetyzację i minimalizować liczbę miejsc, w których dane są współużytkowane. Każda dodatkowa sekcja krytyczna to większe ryzyko przeoczenia blokady lub powielenia błędów.
- Wątki powinny być niezależne: Najbezpieczniejszą metodą jest projektowanie wątków tak, aby każdy z nich przetwarzał żądanie w izolacji, korzystając wyłącznie ze zmiennych lokalnych.
- Korzystanie z kopii danych: Jeśli to możliwe, zamiast synchronizować dostęp do obiektu, lepiej pracować na jego kopii przeznaczonej tylko do odczytu lub połączyć wyniki z wielu wątków na samym końcu.
- Znajomość bibliotek klasy
java.util.concurrent: Zamiast ręcznie budować blokady, należy używać gotowych, bezpiecznych kolekcji (np.ConcurrentHashMap), wykonawców (Executor) do zarządzania zadaniami oraz rozwiązań nieblokujących (np.AtomicInteger). - Minimalizacja sekcji synchronizowanych: Blokady są kosztowne i zwiększają rywalizację o zasoby, dlatego sekcje chronione przez
synchronizedpowinny być tak małe, jak to tylko możliwe. - Unikanie zależności między metodami synchronizowanymi: Jeśli obiekt posiada więcej niż jedną metodę synchronizowaną, ich łączne wywoływanie przez klienta często prowadzi do subtelnych błędów, które wymagają dodatkowego blokowania po stronie serwera lub adaptera.
Przykłady kodu
Pułapka operacji atomowych
Naiwny programista może sądzić, że prosta inkrementacja jest bezpieczna. W rzeczywistości jeden wiersz kodu Java odpowiada wielu instrukcjom kodu bajtowego, co przy dwóch wątkach może prowadzić do utraty danych.
// Przykład potencjalnego błędu - operacja ++ nie jest atomowa
public class IdGenerator {
private int lastIdUsed;
public int getNextId() {
return ++lastIdUsed; // Tutaj może wystąpić wyścig (race condition)
}
}
Refaktoryzacja: Oddzielenie zarządzania wątkami
Zamiast mieszać logikę serwera z tworzeniem wątków, należy wydzielić harmonogram do osobnej klasy.
Przed (logika wymieszana z wątkami):
// Serwer tworzy wątki bezpośrednio w metodzie procesującej
void process(final Socket socket) {
if (socket == null) return;
Runnable clientHandler = new Runnable() {
public void run() {
// ... logika obsługi klienta ...
}
};
Thread clientConnection = new Thread(clientHandler);
clientConnection.start();
}
Po (zastosowanie SRP):
// Logika biznesowa nie wie o istnieniu wątków - zarządza nimi scheduler
public void run() {
while (keepProcessing) {
try {
ClientConnection clientConnection = connectionManager.awaitClient();
ClientRequestProcessor requestProcessor = new ClientRequestProcessor(clientConnection);
clientScheduler.schedule(requestProcessor); // Jedno miejsce kontroli wątków
} catch (Exception e) {
e.printStackTrace();
}
}
}
Rozwiązania nieblokujące zamiast blokad
Użycie klas atomowych jest zazwyczaj wydajniejsze niż słowo kluczowe synchronized, ponieważ wykorzystuje sprzętowe wsparcie procesora dla operacji Compare and Swap (CAS).
// Zamiast synchronizacji...
public class ObjectWithValue {
private int value;
public synchronized void incrementValue() { ++value; }
}
// ...lepiej użyć AtomicInteger
public class ObjectWithValue {
private AtomicInteger value = new AtomicInteger(0);
public void incrementValue() {
value.incrementAndGet(); // Wydajniejsze i bezpieczne
}
}
Blokowanie na serwerze vs na kliencie
Blokowanie na kliencie jest ryzykowne, bo każdy programista musi pamiętać o blokadzie. Lepiej zmienić API serwera, by było bezpieczne z natury.
Ryzykowne blokowanie na kliencie:
// Klient musi pamiętać o synchronizacji iteratora
synchronized (iterator) {
if (iterator.hasNext()) {
nextValue = iterator.next();
}
}
Bezpieczne blokowanie na serwerze:
// Serwer udostępnia jedną, bezpieczną metodę
public class IntegerIteratorServerLocked {
private Integer nextValue = 0;
public synchronized Integer getNextOrNull() {
if (nextValue < 100000) return nextValue++;
else return null;
}
}
Praktyczne wnioski
- Traktuj każdą przypadkową awarię poważnie: W świecie wielowątkowości nie istnieją "sytuacje jednorazowe". Jeśli test zawiódł raz na tysiąc uruchomień, oznacza to realny błąd w logice współbieżności, a nie szum systemowy.
- Najpierw uruchamiaj kod w jednym wątku: Upewnij się, że logika biznesowa (POJO) działa poprawnie poza środowiskiem wielowątkowym. Nie szukaj jednocześnie błędów w algorytmie i w synchronizacji.
- Wymuszaj błędy przez "wstrząsanie" kodem: Podczas testów wstawiaj ręcznie lub automatycznie wywołania
Thread.yield()lubThread.sleep()w sekcjach krytycznych. Pozwala to przetestować różne ścieżki wykonania i znacznie zwiększa szansę na wykrycie ukrytych wyścigów. - Testuj na wielu platformach i z różnym obciążeniem: Zasady szeregowania wątków różnią się między systemami operacyjnymi (np. OS X vs Windows). Kod musi być testowany w każdym środowisku docelowym.
- Uruchamiaj więcej wątków niż masz rdzeni procesora: Przełączanie kontekstu jest momentem, w którym najczęściej dochodzi do błędów; wymuszenie częstszego przełączania pomaga ujawnić brakujące sekcje krytyczne.
- Używaj standardowych algorytmów: Naucz się rozpoznawać problemy typu Producent-Konsument czy Czytelnik-Pisarz i stosuj dla nich sprawdzone rozwiązania, zamiast wymyślać własne mechanizmy sygnalizacji.