CodeGym /Kursy /JAVA 25 SELF /ExecutorService, Callable, Future: uruchamianie zadań

ExecutorService, Callable, Future: uruchamianie zadań

JAVA 25 SELF
Poziom 54 , Lekcja 1
Dostępny

1. ExecutorService: zarządzamy wątkami jak profesjonaliści

Dlaczego nie warto po prostu tworzyć wątków przez new Thread

Na początku pracy z wielowątkowością wszystko wydaje się proste:

Thread t = new Thread(() -> {
    // coś robimy
});
t.start();

Taki sposób działa, ale szybko staje się uciążliwy, gdy zadań robi się dużo. Każde wywołanie new Thread() tworzy nowy wątek, a dziesiątki czy setki wątków zaczynają przeciążać system. Do tego dochodzi niewygodne zarządzanie: trzeba pilnować, kiedy się kończą, co robić przy błędach, jak je zatrzymywać i ponownie wykorzystywać.

Właśnie tutaj na scenę wchodzi ExecutorService — sprytny dyspozytor wątków. Po prostu przekazujesz mu zadania, a on sam decyduje, którym wątkiem i kiedy je wykonać. W efekcie wszystko działa szybciej, stabilniej i bez bólu głowy.

Jak działa ExecutorService

ExecutorService działa według prostego, lecz skutecznego mechanizmu.

  • W środku ma pulę wątków — z góry utworzony zestaw wątków roboczych (stały lub dynamiczny).
  • Zadania trafiają do kolejki i są podejmowane przez wolne wątki.
  • Serwis zarządza cyklem życia: możesz poczekać na zakończenie, poprawnie zatrzymać pulę i zwolnić zasoby.

Tworzenie ExecutorService

Najczęstszy sposób — użyć metod fabrycznych z klasy Executors:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

ExecutorService executor = Executors.newFixedThreadPool(4); // 4 wątki
  • newFixedThreadPool(N) — pula z N wątków (odpowiednia dla większości zadań).
  • newCachedThreadPool() — dynamiczna pula, tworzy wątki w razie potrzeby (uwaga: przy lawinie zadań można wyczerpać pamięć).
  • newSingleThreadExecutor() — jeden wątek (sekwencyjne wykonywanie).

Przykład: uruchomienie Runnable przez ExecutorService

executor.submit(() -> {
    System.out.println("Pozdrowienia z puli wątków!");
});

Gdy skończysz pracę z ExecutorService, należy go poprawnie zakończyć:

executor.shutdown(); // Zabrania dodawania nowych zadań, czeka na zakończenie bieżących

Ważne: Jeśli nie wywołasz shutdown(), program może się nie zakończyć — wątki z puli będą czekać na nowe zadania.

2. Runnable vs Callable: zadania są różne

Przed Java 5, jeśli chciałeś coś wykonać w wątku, pisałeś implementację interfejsu Runnable. To zadanie, które nic nie zwraca i nie wyrzuca wyjątków sprawdzanych.

Runnable task = () -> {
    System.out.println("Po prostu pracuję, nic nie zwracam!");
};
executor.submit(task);

Callable: zadanie z wynikiem (i wyjątkami)

Czasem chcemy, aby zadanie nie tylko „coś robiło”, ale też zwracało wynik — na przykład sumę liczb, rezultat obliczeń, dane z serwera. Do tego powstał interfejs Callable<T>.

import java.util.concurrent.Callable;

Callable<Integer> sumTask = () -> {
    int sum = 0;
    for (int i = 1; i <= 100; i++) sum += i;
    return sum;
};
  • Metoda call() zwraca wynik typu T.
  • Metoda call() może wyrzucać wyjątek sprawdzany.

Analogia: Runnable — „idź i pozmywaj naczynia” (wynik nie jest ważny), Callable — „idź, przynieś herbatę i powiedz, jaką ma temperaturę” (wynik jest ważny).

Uruchamianie Callable: aby otrzymać wynik, użyj executor.submit(...). Zwróci on obiekt Future<T>.

3. Future: obietnica rezultatu

Future to „obietnica” zwrotu wyniku w przyszłości. Gdy wysyłasz zadanie do ExecutorService, otrzymujesz Future, z którego później możesz pobrać wynik, sprawdzić, czy zadanie się zakończyło, lub je anulować.

Najważniejsze metody Future

  • T get() — pobiera wynik (czeka, aż zadanie się zakończy).
  • boolean isDone() — czy zadanie zostało zakończone.
  • boolean cancel(boolean mayInterruptIfRunning) — próba anulowania zadania.
  • boolean isCancelled() — czy zadanie zostało anulowane.

Przykład: uruchomienie Callable i pobranie wyniku

import java.util.concurrent.*;

public class ParallelSumApp {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        Callable<Integer> sumTask = () -> {
            int sum = 0;
            for (int i = 1; i <= 100; i++) sum += i;
            return sum;
        };

        Future<Integer> future = executor.submit(sumTask);

        System.out.println("Zadanie uruchomione, można robić coś jeszcze...");

        // Pobieramy wynik (metoda blokuje wątek, jeśli zadanie jeszcze się nie zakończyło)
        Integer result = future.get();
        System.out.println("Wynik obliczeń: " + result);

        executor.shutdown();
    }
}
  • Zadanie trafia do puli wątków.
  • Gdy zadanie się wykonuje, wątek główny może robić coś innego.
  • Gdy wynik jest potrzebny, wywołujemy future.get() — wątek poczeka, jeśli zadanie jest w toku.
  • Gdy tylko zadanie się zakończy, wynik zostanie zwrócony.

4. Praktyka: wiele zadań i oczekiwanie na zakończenie

Często trzeba uruchomić kilka zadań naraz i poczekać, aż wszystkie się zakończą. Na przykład przetwarzamy tablicę danych, dzielimy ją na części i liczymy sumę każdej części w osobnym zadaniu.

Przykład: suma elementów tablicy w częściach

import java.util.*;
import java.util.concurrent.*;

public class ParallelArraySum {
    public static void main(String[] args) throws Exception {
        int[] array = new int[1000];
        Arrays.setAll(array, i -> i + 1); // Wypełniamy liczbami od 1 do 1000

        ExecutorService executor = Executors.newFixedThreadPool(4);

        int chunkSize = array.length / 4;
        List<Future<Integer>> futures = new ArrayList<>();

        for (int i = 0; i < 4; i++) {
            int from = i * chunkSize;
            int to = (i == 3) ? array.length : (i + 1) * chunkSize;

            Callable<Integer> sumTask = () -> {
                int sum = 0;
                for (int j = from; j < to; j++) sum += array[j];
                System.out.println("Suma od " + from + " do " + (to - 1) + " = " + sum);
                return sum;
            };

            futures.add(executor.submit(sumTask));
        }

        int totalSum = 0;
        for (Future<Integer> f : futures) {
            totalSum += f.get(); // Czekamy na każde zadanie po kolei
        }

        System.out.println("Suma całkowita: " + totalSum);

        executor.shutdown();
    }
}

Tutaj tablica jest dzielona na 4 części. Dla każdej części tworzone jest zadanie (Callable), które liczy sumę. Wszystkie zadania są wysyłane do ExecutorService, zwracane są Future. Na końcu zbieramy wyniki wszystkich zadań i sumujemy.

W realnych zadaniach wygodnie jest użyć invokeAll, aby od razu poczekać na wykonanie wszystkich zadań.

5. Obsługa błędów podczas pracy z Future

Gdy wywołujesz future.get(), jeśli zadanie zakończyło się wyjątkiem, zostanie on opakowany i rzucony jako ExecutionException. To ważne: jeśli w zadaniu coś poszło nie tak, dowiesz się o tym dopiero przy wywołaniu get().

Przykład: obsługa wyjątków

Callable<Integer> errorTask = () -> {
    throw new IllegalArgumentException("Coś poszło nie tak!");
};

Future<Integer> badFuture = executor.submit(errorTask);

try {
    badFuture.get();
} catch (ExecutionException e) {
    System.out.println("Zadanie zakończyło się błędem: " + e.getCause());
}
  • Wewnątrz zadania wyrzucany jest wyjątek.
  • Przy wywołaniu get() jest „opakowywany” w ExecutionException.
  • Prawdziwą przyczynę można uzyskać przez getCause().

6. Przydatne niuanse

Jak anulować zadanie

Future<?> f = executor.submit(() -> {
    while (true) {
        // Nieskończona praca
        if (Thread.currentThread().isInterrupted()) {
            System.out.println("Poproszono mnie o zakończenie!");
            break;
        }
    }
});

Thread.sleep(100); // Poczekajmy chwilkę
f.cancel(true); // Spróbujmy anulować zadanie
  • cancel(true) próbuje przerwać zadanie, jeśli jeszcze się nie zakończyło.
  • Wewnątrz zadania warto sprawdzać Thread.currentThread().isInterrupted() i poprawnie się kończyć.

shutdown vs shutdownNow

shutdown() — łagodne zatrzymanie: zabrania dodawania nowych zadań i pozwala aktualnym spokojnie się zakończyć. Używane najczęściej.

shutdownNow() — twarde zatrzymanie: próbuje przerwać aktywne wątki i zwraca listę zadań, które nie zdążyły wystartować. Stosuj ostrożnie.

invokeAll i invokeAny

invokeAll(Collection<Callable<T>> tasks) uruchamia wszystkie przekazane zadania i czeka, aż wszystkie się zakończą. Zwraca listę Future.

invokeAny(Collection<Callable<T>> tasks) czeka tylko na pierwsze pomyślnie wykonane zadanie, zwraca jego wynik i anuluje pozostałe. Wygodne, gdy liczy się pierwsza udana odpowiedź.

7. Typowe błędy przy pracy z ExecutorService, Callable i Future

Błąd nr 1: Niezamykanie ExecutorService. Jeśli zapomnisz wywołać shutdown(), program może „wisieć” po zakończeniu main, ponieważ wątki puli czekają na nowe zadania.

Błąd nr 2: Oczekiwanie na wynik zaraz po wysłaniu zadania. Jeśli od razu po submit() wywołasz get(), nie zyskasz zalet asynchroniczności — wątek i tak będzie czekał. Rób pożyteczną pracę równolegle i pobieraj wynik wtedy, gdy jest naprawdę potrzebny.

Błąd nr 3: Ignorowanie wyjątków w zadaniach. Jeśli nie obsłużysz ExecutionException przy wywołaniu get(), możesz przeoczyć ważne błędy, które wystąpiły w zadaniu.

Błąd nr 4: Używanie wspólnych zmiennych modyfikowalnych bez synchronizacji. Jeśli kilka zadań pracuje na tych samych danych — potrzebna jest synchronizacja lub kolekcje bezpieczne dla wątków.

Błąd nr 5: Tworzenie zbyt dużej liczby wątków. Nie warto robić puli z liczbą wątków znacznie większą niż liczba rdzeni procesora — może to nawet spowolnić wykonanie.

Błąd nr 6: Zapominanie o anulowaniu zadań. Jeśli zadanie nie jest już potrzebne, anuluj je przez cancel(), aby nie marnować zasobów.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION