CodeGym /Kursy /JAVA 25 SELF /Wprowadzenie do CompletableFuture

Wprowadzenie do CompletableFuture

JAVA 25 SELF
Poziom 55, Lekcja 0
Dostępny

1. Problem kodu synchronicznego

Wyobraźmy sobie: masz program, który musi pobrać dane z internetu albo odczytać duży plik. Piszesz coś w rodzaju:

String data = readFromFile("bigfile.txt");
System.out.println("Dane: " + data);

Wszystko byłoby dobrze, ale jeśli plik jest duży albo sieć wolna, program po prostu zawiesza się na linii odczytu. Użytkownik patrzy na „zawieszony” interfejs, serwer nie może obsługiwać innych żądań, a programista... smutnieje.

Taka sytuacja to blokada: wątek (na przykład główny wątek twojej aplikacji) jest zmuszony czekać, aż operacja się zakończy. A jeśli takich operacji jest dużo — witajcie lagi i niska wydajność.

To tak, jakbyś przyszedł do kawiarni, złożył zamówienie i... musiał stać przy ladzie, aż przygotują ci kawę. A inni klienci stoją za tobą i też czekają, aż barista skończy z tobą. Nieskuteczne, prawda?

Asynchroniczność: jak ratuje świat

Programowanie asynchroniczne to podejście, w którym długotrwałe operacje (np. odczyt pliku, zapytanie do serwera, dostęp do bazy danych) wykonują się w wątku w tle, a główny wątek pracuje dalej: obsługuje użytkowników, przyjmuje nowe żądania, reaguje na zdarzenia.

Czyli składasz zamówienie (uruchamiasz zadanie), idziesz zająć się swoimi sprawami, a kiedy kawa jest gotowa (zadanie się zakończy), po prostu słyszysz: „Gotowe!”

W Javie przed pojawieniem się CompletableFuture nie było to zbyt wygodne. Zobaczmy, jak to się rozwijało.

2. Podejścia historyczne: Future i jego ograniczenia

W Javie 5 pojawił się interfejs Future — pierwsza próba uczynienia pracy z zadaniami asynchronicznymi choć trochę wygodniejszą. Pozwalał zlecić zadanie puli wątków i kiedyś otrzymać wynik.

ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Uwaga: wątek zawiśnie, dopóki zadanie się nie wykona!

Pomysł niby dobry, ale w praktyce okazało się, że Future jest jak stara skrzynka pocztowa: list wysłałeś, ale żeby sprawdzić, czy przyszedł odpór, musisz ciągle zaglądać do środka.

Nie potrafi powiadomić, gdy wynik jest gotowy, nie wspiera łańcuchów działań w stylu „zrób to, a potem tamto”, nie pozwala elegancko obsługiwać błędów. Wszystko rozbija się o ten blokujący get(), przez który asynchroniczność znów zamienia się w czekanie.

3. Pojawienie się CompletableFuture: nowy styl asynchroniczności

W Javie 8 na miejsce przestarzałego Future wszedł prawdziwy bohater asynchroniczności — CompletableFuture. Ta klasa z pakietu java.util.concurrent stała się uniwersalnym narzędziem dla wszystkich, którzy mieli dość ręcznego czekania na wyniki i chcieli pisać kod asynchroniczny ładnie, zwięźle i jasno.

CompletableFuture potrafi niemal wszystko. Umie uruchamiać zadania w innych wątkach, układać z nich łańcuchy — np. najpierw policzyć wynik, potem go przetworzyć, a następnie zrobić coś jeszcze. Łatwo łączy też kilka zadań: można poczekać na zakończenie wszystkich naraz albo tylko pierwszego, które zdąży. Błędy obsługuje elegancko — bez zbędnych try-catch. Styl pracy staje się bardziej funkcyjny: zamiast nudnych wywołań i oczekiwania pojawiają się wyraziste metody, takie jak thenApply, thenAccept i inne.

flowchart LR
    A[Asynchroniczne uruchomienie zadania] --> B[Przetwarzanie wyniku]
    B --> C[Następna operacja]
    C --> D[Obsługa błędów]

Tak CompletableFuture zamienił asynchroniczność z ciężkiego rzemiosła w wygodne i elastyczne narzędzie, z którym kod wreszcie oddycha pełnią.

4. Najprostszy przykład: pierwszy krok w świat CompletableFuture

Zobaczmy minimalny przykład zadania asynchronicznego:

import java.util.concurrent.CompletableFuture;

public class AsyncDemo {
    public static void main(String[] args) {
        // Uruchamiamy zadanie asynchronicznie
        CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);

        // Pobieramy wynik (blokuje wątek!)
        try {
            int result = future.get();
            System.out.println("Wynik: " + result); // 4
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Ten kod już wykonuje obliczenie w osobnym wątku — główny wątek nie jest blokowany w momencie uruchomienia zadania. Ale wywołanie get() wciąż blokuje wątek, dopóki wynik nie będzie gotowy.

A jak NIE blokować wątku?

To proste: używaj metod-callbacków wywoływanych po zakończeniu zadania:

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);

future.thenAccept(result -> System.out.println("Wynik: " + result));
System.out.println("Nie blokuję się i mogę robić coś jeszcze!");

Wniosek:

  • thenAccept — to „zapisz się na wynik”: gdy zadanie się wykona, wywołaj ten kod.
  • Główny wątek nie czeka na zakończenie zadania, tylko pracuje dalej.

Wizualizacja (pseudokod zdarzeń)

[Główny wątek] --> [Uruchomienie zadania]
     |                   |
     v                   v
[Robi coś]          [Wątek w tle liczy 2+2]
     |                   |
     v                   v
[Wypisuje "Nie blokuję się..."]
     |                   |
     v                   v
                 [Gdy policzone — wywoływane jest thenAccept]

5. Jak to wygląda w aplikacji?

Wyobraź sobie, że tworzysz aplikację konsolową, w której użytkownik może zlecić ładowanie danych (np. z bazy albo z serwera), a w czasie ładowania program nie „zawiesza się”, tylko nadal przyjmuje polecenia.

Przykład: symulacja długiej operacji

import java.util.concurrent.CompletableFuture;

public class AsyncApp {
    public static void main(String[] args) {
        System.out.println("Zaczynamy ładowanie danych...");

        CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
            // Symulacja długiego ładowania
            try {
                Thread.sleep(2000); // 2 sekundy
            } catch (InterruptedException e) {
                return "Błąd ładowania";
            }
            return "Dane zostały pomyślnie załadowane!";
        });

        // Subskrybujemy wynik
        dataFuture.thenAccept(result -> System.out.println("Wynik: " + result));

        // Program dalej działa
        System.out.println("Gdy dane się ładują, mogę robić coś jeszcze!");

        // Aby program nie zakończył się zbyt szybko (tylko do dema!)
        try {
            Thread.sleep(2500);
        } catch (InterruptedException ignored) {}
    }
}

Co zobaczysz w konsoli:

Zaczynamy ładowanie danych...
Gdy dane się ładują, mogę robić coś jeszcze!
[po 2 sekundach]
Wynik: Dane zostały pomyślnie załadowane!

6. Przydatne niuanse

Słowo o wątkach pod maską

Gdy piszesz CompletableFuture.supplyAsync(...), zadanie domyślnie wykonuje się w tzw. ForkJoinPool — to specjalna pula wątków, której Java używa do zadań równoległych. Jeśli potrzebujesz większej kontroli (np. własnego ExecutorService), możesz przekazać go jako drugi parametr:

ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);

Ale dla prostych zadań wystarczy standardowa pula.

Pobieranie wyniku: get(), join(), thenAccept

  • get() — blokuje wątek, dopóki wynik nie będzie gotowy (rzuca wyjątki typu checked).
  • join() — także blokuje, ale rzuca wyjątki typu unchecked (RuntimeException).
  • thenAccept(), thenApply() i inne — NIE blokują, tylko wywołują przekazaną funkcję, gdy wynik jest gotowy.

W rzeczywistych aplikacjach asynchronicznych staraj się unikać get()/join() w głównym wątku!

7. Typowe błędy przy pierwszych krokach z CompletableFuture

Błąd nr 1: używanie get() albo join() w głównym wątku.
W ten sposób znów blokujesz program i tracisz wszystkie zalety asynchroniczności. Zamiast tego używaj thenAccept, thenApply i innych metod do obsługi wyniku.

Błąd nr 2: zapomniana obsługa błędów.
Jeśli w zadaniu asynchronicznym wystąpi wyjątek, nie „wyskoczy” on do głównego wątku. Bez obsługi przez exceptionally lub handle po prostu nie dowiesz się, że coś poszło nie tak.

Błąd nr 3: program nie doczekał końca zadania.
W przykładach demo często trzeba „przyhamować” wątek main przez Thread.sleep — inaczej program zakończy się, zanim zadanie się wykona. W prawdziwych aplikacjach (np. na serwerach webowych) to nie problem, ale w demach konsolowych — weź to pod uwagę.

Błąd nr 4: mylenie thenAccept i thenApply.
thenAccept — do „skutków ubocznych” (nic nie zwraca), thenApply — do przekształcania wyniku (zwraca nowy wynik).

Błąd nr 5: mieszanie kodu asynchronicznego i synchronicznego bez potrzeby.
Jeśli zacząłeś pisać asynchronicznie — nie ściągaj z powrotem do synchroniczności przez get()/join(), chyba że to ostateczność (np. w testach).

1
Zadanie
JAVA 25 SELF, poziom 55, lekcja 0
Niedostępne
Serce Myśliciela: Asynchroniczna Transformacja Energii
Serce Myśliciela: Asynchroniczna Transformacja Energii
1
Zadanie
JAVA 25 SELF, poziom 55, lekcja 0
Niedostępne
Strażnik Finansowy: Zarządzanie Ryzykiem w Transakcjach Asynchronicznych
Strażnik Finansowy: Zarządzanie Ryzykiem w Transakcjach Asynchronicznych
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION