CodeGym /Kursy /JAVA 25 SELF /Anulowanie zadań i timeouty na całym stosie

Anulowanie zadań i timeouty na całym stosie

JAVA 25 SELF
Poziom 58 , Lekcja 1
Dostępny

1. Thread.interrupt() i kooperatywne anulowanie

W rzeczywistych aplikacjach zadania mogą być długie, a czasem wręcz się „zawieszać” — podczas pracy z siecią, plikami lub zewnętrznymi usługami. Użytkownik może przerwać operację, serwer — przerwać obsługę żądania albo po prostu upłynie ogólny timeout. Jeśli nie umiemy poprawnie anulować zadań, aplikacja będzie się zawieszać, marnować zasoby i słabo reagować na zdarzenia zewnętrzne.

Kluczowa idea: anulowanie powinno być kooperatywne — zadanie samo powinno sprawdzać, czy nie poproszono go o zakończenie, i poprawnie zwalniać zasoby.

Jak działa Thread.interrupt()

Każdy wątek ma flagę „przerwania”. Gdy wywołujesz thread.interrupt(), ta flaga jest ustawiana na true. Sam wątek nie jest „zabijany” — powinien sam sprawdzać swój status i się zakończyć: okresowo wywoływać Thread.currentThread().isInterrupted() i poprawnie wychodzić.

Przykład:

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // Pracujemy...
        try {
            Thread.sleep(100); // Może zostać przerwany
        } catch (InterruptedException e) {
            // Flaga zostaje wyczyszczona, ale możemy ponownie ustawić przerwanie
            Thread.currentThread().interrupt();
            break;
        }
    }
    System.out.println("Wątek zakończony na skutek przerwania.");
});
worker.start();

// ... później
worker.interrupt();

Gdzie flaga działa automatycznie?

  • Metody, które mogą się blokować (sleep, wait, join, operacje struktur blokujących), zgłaszają InterruptedException po przerwaniu.
  • W pozostałych przypadkach (np. w pętli obliczeniowej) trzeba ręcznie sprawdzać isInterrupted().

Wzorzec „ustaw flagę i szybko wyjdź”

  1. W kodzie wywołującym: thread.interrupt()
  2. W zadaniu: okresowo sprawdzaj Thread.currentThread().isInterrupted()
  3. W razie potrzeby — poprawnie zwalniaj zasoby i kończ zadanie.

Typowy błąd: oczekiwanie, że interrupt() natychmiast „zabije” wątek. Nie — to tylko sygnał, a zadanie musi samo zareagować.

2. Future.cancel(), CancellationException i anulowanie zadań

Jak działa Future.cancel

Gdy uruchamiasz zadanie przez ExecutorService.submit(), dostajesz obiekt Future. Ma on metodę cancel(boolean mayInterruptIfRunning):

  • Jeśli zadanie jeszcze się nie rozpoczęło — nie zostanie uruchomione.
  • Jeśli zadanie już się wykonuje i mayInterruptIfRunning == true — zostanie wywołane interrupt() na wątku wykonującym zadanie.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // Długa praca
    }
    System.out.println("Zadanie zakończone na skutek anulowania.");
});

// ... później
future.cancel(true); // Poproś o anulowanie zadania

Co tak naprawdę dzieje się z zadaniem

Anulowanie przez Future to nie magiczny przycisk „zabij wątek”, lecz w gruncie rzeczy grzeczna forma Thread.interrupt(). Jeśli zadanie poprawnie sprawdza flagę przerwania — zakończy się elegancko. Jeśli nie — będzie pracować dalej do naturalnego końca.

Jeśli wywołasz future.get() po anulowaniu, otrzymasz CancellationException — przypomnienie, że zadanie zostało zdjęte.

3. CompletableFuture: anulowanie, timeouty i łańcuchy

Anulowanie CompletableFuture

CompletableFuture również ma cancel(boolean). Jeśli zadanie jeszcze się nie zakończyło, zostanie anulowane, a wszystkie dalsze przetworniki (thenApply, thenAccept itd.) nie zostaną wywołane.

CompletableFuture<Void> cf = CompletableFuture.runAsync(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // Pracujemy...
    }
    System.out.println("CF zakończony na skutek anulowania.");
});

// ... później
cf.cancel(true);

Timeouty: orTimeout i completeOnTimeout

  • orTimeout(timeout, unit) — kończy CompletableFuture z TimeoutException, jeśli nie zdąży w czasie.
  • completeOnTimeout(value, timeout, unit) — kończy z podaną wartością, jeśli nie zdąży.
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    try { Thread.sleep(5000); } catch (InterruptedException e) {}
    return "OK";
});

cf.orTimeout(2, TimeUnit.SECONDS)
  .exceptionally(ex -> "TIMEOUT")
  .thenAccept(System.out::println); // Po 2 sekundach: "TIMEOUT"

Propagacja anulowania w łańcuchach

Jeśli anulujesz „nadrzędny” CompletableFuture, wszystkie kolejne kroki w łańcuchu nie zostaną wywołane. Jednak przy użyciu thenCompose do uruchamiania wewnętrznych operacji asynchronicznych anulowanie nie propaguje się automatycznie „w górę” — trzeba je zaprojektować jawnie (sprawdzać status, anulować zadania podrzędne, używać wspólnego deadline’u).

Uwaga na thenCompose i własny Executor! Upewnij się, że wewnętrzne zadania potrafią reagować na przerwanie/anulowanie i/lub otrzymują wspólny timeout.

4. StructuredTaskScope: anulowanie grupy zadań

Structured Concurrency i anulowanie

StructuredTaskScope (Java 21+) umożliwia uruchomienie grupy zadań i zarządzanie ich cyklem życia jako całością. Jeśli jedno z zadań zakończy się błędem lub upłynie limit czasu — pozostałe zadania są automatycznie anulowane.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> f1 = scope.fork(() -> fetchData1());
    Future<String> f2 = scope.fork(() -> fetchData2());

    scope.join(); // czekamy na zakończenie wszystkich zadań
    scope.throwIfFailed(); // jeśli którekolwiek zakończyło się błędem — zgłoś wyjątek

    String result = f1.resultNow() + f2.resultNow();
    System.out.println(result);
}
  • Jeśli dowolne zadanie zakończy się błędem — scope anuluje wszystkie pozostałe zadania.
  • Jeśli upłynie timeout (przez scope.joinUntil(deadline)) — scope anuluje wszystkie zadania.

Polityki zakończenia

  • ShutdownOnFailure — anuluje wszystkie zadania przy pierwszym błędzie.
  • ShutdownOnSuccess — anuluje pozostałe zadania, gdy tylko jedno zakończy się powodzeniem.

5. Praktyka: bezpieczne anulowanie długich operacji

Przykład: anulowanie blokującego IO

Jeśli zadanie blokuje się na odczycie z pliku lub sieci, przerwanie wątku nie zawsze pomaga — niektóre operacje IO nie reagują na interrupt. W nowoczesnych API (NIO, AsynchronousFileChannel) wsparcie dla przerwań jest lepsze, ale wciąż nie wszędzie.

Zalecenia:

  • Używaj nieblokującego IO, jeśli potrzebne jest anulowanie.
  • Dla blokującego IO — ustawiaj limity czasu na poziomie API (np. Socket.setSoTimeout).
  • Dla zadań asynchronicznych — używaj Future.cancel i poprawnie reaguj na przerwanie.

Przykład: anulowanie oczekiwania na kolejkę/barierę

Wiele synchronizatorów (BlockingQueue.take(), CountDownLatch.await(), CyclicBarrier.await()) zgłasza InterruptedException po przerwaniu. W obsłudze złap wyjątek, w razie potrzeby przywróć flagę i poprawnie zakończ zadanie.

6. Wzorzec „time‑budget”: wspólny deadline dla grupy operacji

W złożonych aplikacjach często trzeba ustawić wspólny limit czasu na wykonanie grupy operacji. Na przykład, jeśli użytkownik czeka na odpowiedź nie dłużej niż 2 sekundy, a wewnątrz trzeba wykonać 3 żądania sieciowe — wszystkie muszą zmieścić się we wspólnym deadline’ie.

Jak propagować deadline w dół stosu?

  • Przekazuj obiekt deadline’u (np. Instant deadline) do wszystkich potencjalnie blokujących metod.
  • W każdej metodzie oblicz pozostały czas: Duration.between(Instant.now(), deadline).
  • Użyj tego czasu do timeoutów w operacjach blokujących (await(timeout), poll(timeout), orTimeout(timeout)).
Instant deadline = Instant.now().plusSeconds(2);

void doWork(Instant deadline) throws TimeoutException, InterruptedException {
    Duration left = Duration.between(Instant.now(), deadline);
    if (left.isNegative() || left.isZero()) throw new TimeoutException();
    // Używamy left jako limitu czasu
    queue.poll(left.toMillis(), TimeUnit.MILLISECONDS);
}

Scoped Values / kontekst

W Javie 21+ można używać Scoped Values do przekazywania deadline’u w dół stosu wywołań, aby nie przekazywać go jawnie do każdej metody.

7. Structured Concurrency: anulowanie całego scope przy błędzie/timeoutcie

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> f1 = scope.fork(() -> fetchData1());
    Future<String> f2 = scope.fork(() -> fetchData2());

    boolean completed = scope.joinUntil(Instant.now().plusSeconds(2));
    if (!completed) {
        scope.shutdown();
        throw new TimeoutException("Upłynął limit czasu!");
    }
    scope.throwIfFailed();
    // ...
}
  • Jeśli upłynął deadline — scope anuluje wszystkie zadania.
  • Jeśli jedno zadanie się nie powiedzie — pozostałe zostaną automatycznie anulowane.

8. Typowe błędy przy pracy z anulowaniem i timeoutami

Błąd nr 1: Oczekiwanie, że interrupt() natychmiast zakończy wątek. W rzeczywistości to tylko sygnał — zadanie musi samo sprawdzać status i poprawnie się zakończyć.

Błąd nr 2: Brak sprawdzania isInterrupted() w długich pętlach. Jeśli nie sprawdzasz flagi przerwania, zadanie będzie działać w nieskończoność, nawet jeśli poproszono je o zakończenie.

Błąd nr 3: Future.cancel() nie prowadzi do anulowania, jeśli zadanie nie reaguje na interrupt. Jeśli zadanie jest „głuche”, cancel() nie pomoże.

Błąd nr 4: Timeouty nie są propagowane w dół stosu. Jeśli nie przekazujesz deadline’u do wszystkich metod, wewnętrzna operacja może „zawisnąć” dłużej, niż potrzeba.

Błąd nr 5: W łańcuchach thenCompose z CompletableFuture anulowanie nie propaguje się automatycznie. Jeśli anulujesz „nadrzędny” future, wewnętrzne zadania mogą dalej pracować — obsłuż anulowanie jawnie.

Błąd nr 6: StructuredTaskScope nie jest zamykany (brak try‑with‑resources). Jeśli nie zamkniesz scope, zadania podrzędne mogą pozostać „wiszące”.

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