CodeGym /Kursy /JAVA 25 SELF /Najlepsze praktyki programowania równoległego

Najlepsze praktyki programowania równoległego

JAVA 25 SELF
Poziom 54, Lekcja 4
Dostępny

1. Nie wszystko, co można zrównoleglić, powinno być zrównoleglane

W Javie jest wiele sposobów na zrównoleglanie zadań. Ale „równoległość = zawsze szybciej” to jak myśleć, że jeśli dosypiesz do zupy więcej soli, będzie smaczniejsza: do pewnego momentu — tak, dalej — lepiej nie próbować.

ExecutorService świetnie się sprawdza, gdy masz jawne zadania, które trzeba uruchamiać i kontrolować: obsługa żądań, asynchroniczne ładowanie danych, niezależne obliczenia. Sam decydujesz, ile wątków będzie w puli, i zarządzasz cyklem życia zadań.

parallelStream — szybki sposób na zrównoleglanie przetwarzania kolekcji, gdy operacje są niezależne i bez efektów ubocznych. Opłacalny dla „ciężkich” kolekcji (dziesiątki tysięcy elementów i więcej).

ForkJoinPool — wybór dla zadań dobrze dzielących się na podzadania (divide & conquer): sortowanie, wyszukiwanie, agregacja dużych tablic. Używany wewnątrz parallelStream, ale można nim zarządzać bezpośrednio.

Nie stosuj równoległości „na wszelki wypadek”. Jeśli zadanie jest małe, narzuty na planowanie, przełączanie kontekstu i synchronizację mogą zjeść cały zysk.

Przykład: kiedy równoległość nie jest potrzebna

List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
    .mapToInt(x -> x)
    .sum(); // Równoleglanie dla 5 liczb — overkill!

2. Bezpieczeństwo wątkowe: unikamy wspólnych modyfikowalnych danych

W świecie równoległym głównym zagrożeniem są wyścigi danych (race conditions). Jeśli kilka wątków zmienia jedną zmienną, wynik może być nieprzewidywalny.

  • Unikaj wspólnych zmiennych modyfikowalnych. Nawet wyrażenie takie jak counter++ nie jest atomowe.
  • Używaj kolekcji thread-safe i operacji atomowych. Na przykład ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
  • Bez efektów ubocznych w strumieniach równoległych. Nie modyfikuj zewnętrznych struktur z parallelStream.

Przykład złego kodu

List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // NIEBEZPIECZNE!

Tutaj result.add() nie jest bezpieczne wątkowo. Skutek — utracone elementy lub wyjątki.

Jak zrobić to poprawnie?

List<Integer> result = numbers.parallelStream()
    .map(n -> n * 2)
    .collect(Collectors.toList());

3. Wydajność: więcej wątków nie zawsze znaczy lepiej

Drobnych zadań nie opłaca się zrównolegliać. Jeśli praca trwa milisekundy, uruchomienie równoległe często tylko spowolni wykonanie z powodu narzutów.

Mierz wydajność. Do szybkich pomiarów nada się System.nanoTime():

long start = System.nanoTime();
// ... twój kod ...
long end = System.nanoTime();
System.out.println("Czas wykonania: " + (end - start) + " ns");

Do poważnych mikrobenchmarków używaj JMH (Java Microbenchmark Harness).

Przykład: porównanie strumienia sekwencyjnego i równoległego

List<Integer> bigList = IntStream.range(0, 1_000_000)
    .boxed().collect(Collectors.toList());

long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();

System.out.println("Sekwencyjnie: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("Równolegle:   " + (t3 - t2) / 1_000_000 + " ms");

Wypróbuj na swoim komputerze — zysk widać przy naprawdę dużych kolekcjach i ciężkich operacjach.

4. Obsługa błędów: nie ignoruj wyjątków w wątkach

Future i obsługa wyjątków

Jeśli uruchomiłeś zadanie przez ExecutorService.submit(), wyjątki nie „przeniosą się” automatycznie — trzeba je obsłużyć przez Future.get():

Future<Integer> future = executor.submit(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("Ups!");
    return 42;
});
try {
    Integer result = future.get(); // może rzucić ExecutionException
} catch (ExecutionException e) {
    System.err.println("Błąd w zadaniu: " + e.getCause());
}

ForkJoin i obsługa błędów

W ForkJoinPool wyjątki są „zapakowane” w zadanie. Przy wywołaniu join()/get() wypłyną:

ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
    int result = pool.invoke(task);
} catch (Exception e) {
    System.err.println("Błąd w ForkJoin: " + e);
}

Pamiętaj o obsłudze InterruptedException

Wiele metod (na przykład Future.get(), Thread.sleep()) może rzucić InterruptedException. Nie „połykaj” go — reaguj poprawnie: ustawiaj znacznik przerwania albo kończ zadanie.

5. Debugowanie i testowanie kodu równoległego

Logowanie i debugowanie

Błędy w kodzie równoległym są podstępne i często ujawniają się niestabilnie. Loguj z podaniem wątku: Thread.currentThread().getName(). To pomaga zrozumieć, kto i kiedy wykonuje kod.

W trudnych przypadkach użyj debuggera z obsługą wielowątkowości (na przykład IntelliJ IDEA). Tymczasowe Thread.sleep() czasem pomagają „złapać” rzadki wyścig danych.

Testowanie scenariuszy wielowątkowych

Wydziel osobne testy dla operacji równoległych i używaj narzędzi oczekiwania na warunki, na przykład Awaitility. Uruchamiaj takie testy wielokrotnie: niektóre problemy wychodzą dopiero przy 100‑nym lub 1000‑nym uruchomieniu.

6. Przydatne niuanse i wskazówki

Czytelność i utrzymywalność: pisz zrozumiały kod równoległy

  • Dokumentuj. Komentuj złożone fragmenty i wybór narzędzi.
  • Używaj wysokopoziomowych abstrakcji. Preferuj ExecutorService, parallelStream, ForkJoinPool zamiast ręcznego zarządzania wątkami.
  • Unikaj „magii”. Nie komplikuj synchronizacji, jeśli można prościej.

Tabela: kiedy jakiego narzędzia użyć

Scenariusz Zalecane narzędzie
Wiele niezależnych zadań
ExecutorService
Przetwarzanie dużej kolekcji parallelStream lub ForkJoin
Zadanie „dziel i rządź”
ForkJoinPool + RecursiveTask
Proste zadanie asynchroniczne
CompletableFuture
Wiele małych zadań Strumień sekwencyjny
Zadania z efektami ubocznymi Tylko kolekcje thread-safe!

„Przykazania” programisty równoległego

  • Nie twórz wspólnych zmiennych modyfikowalnych — jeśli nie masz pewności, że są thread-safe.
  • Nie zrównoleglaj dla samej równoległości: oceń potencjalny zysk.
  • Nie zapominaj zamykać pul: shutdown()/shutdownNow().
  • Nie używaj parallelStream do operacji z efektami ubocznymi.
  • Nie zapominaj obsługiwać wyjątków z Future i ForkJoinTask.
  • Nie „połykaj” InterruptedException — poprawnie kończ zadanie.

7. Typowe błędy w programowaniu równoległym

Błąd nr 1: Zrównoleglanie małych zadań. Początkujący często zrównoleglają wszystko, nawet gdy praca trwa mikrosekundy. Efekt — wolniej z powodu narzutów.

Błąd nr 2: Efekty uboczne w strumieniach. W parallelStream nie wolno modyfikować zmiennych zewnętrznych ani kolekcji — dostaniesz wyścigi danych i nieprzewidywalne błędy.

Błąd nr 3: Ignorowanie wyjątków. Jeśli nie obsłużysz błędów z Future.get() lub ForkJoinTask, nie dowiesz się, dlaczego zadanie się nie wykonało.

Błąd nr 4: Zapomniane shutdown() w ExecutorService. Bez jawnego zamknięcia aplikacja może „zawisnąć” przy wyjściu.

Błąd nr 5: Używanie kolekcji nie-thread-safe. Pisanie z wielu wątków do zwykłego ArrayList to prosta droga do błędów.

Błąd nr 6: „Połykanie” InterruptedException. Jeśli wątek został przerwany — uszanuj to i zakończ pracę poprawnie.

Błąd nr 7: Zbyt skomplikowana logika synchronizacji. Nadmierne bloki synchronized prowadzą do deadlock/livelock. Preferuj abstrakcje wyższego poziomu.

1
Zadanie
JAVA 25 SELF, poziom 54, lekcja 4
Niedostępne
Eksperyment chaotycznej magii z duplikowaniem artefaktów 🧪
Eksperyment chaotycznej magii z duplikowaniem artefaktów 🧪
1
Zadanie
JAVA 25 SELF, poziom 54, lekcja 4
Niedostępne
Analiza starożytnych zwojów w bibliotece cyfrowej 📜
Analiza starożytnych zwojów w bibliotece cyfrowej 📜
1
Ankieta/quiz
Równoległość i ForkJoin, poziom 54, lekcja 4
Niedostępny
Równoległość i ForkJoin
Równoległość i ForkJoin
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION