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ź”
- W kodzie wywołującym: thread.interrupt()
- W zadaniu: okresowo sprawdzaj Thread.currentThread().isInterrupted()
- 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”.
GO TO FULL VERSION