1. Thread.interrupt() и кооперативная отмена
В реальных приложениях задачи могут быть долгими, а иногда и вовсе «зависать» — при работе с сетью, файлами или внешними сервисами. Пользователь может отменить операцию, сервер — прервать обработку запроса, или просто истечь общий тайм‑аут. Если не уметь корректно отменять задачи, приложение будет подвисать, тратить ресурсы впустую и плохо реагировать на внешние события.
Ключевая идея: отмена должна быть кооперативной — задача сама должна проверять, не попросили ли её завершиться, и корректно освобождать ресурсы.
Как работает Thread.interrupt()
У каждого потока есть флаг «прерывания». Когда вы вызываете thread.interrupt(), этот флаг устанавливается в true. Сам поток при этом не «убивается», он должен сам проверить свой статус и завершиться: периодически вызывать Thread.currentThread().isInterrupted() и корректно выходить.
Пример:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Работаем...
try {
Thread.sleep(100); // Может быть прерван
} catch (InterruptedException e) {
// Флаг сбрасывается, но мы можем снова прервать себя
Thread.currentThread().interrupt();
break;
}
}
System.out.println("Поток завершён по прерыванию.");
});
worker.start();
// ... позже
worker.interrupt();
Где флаг работает автоматически?
- Методы, которые могут блокироваться (sleep, wait, join, операции блокирующих структур), выбрасывают InterruptedException при прерывании.
- В остальных случаях (например, в вычислительном цикле) нужно вручную проверять isInterrupted().
Шаблон «установи флаг — и быстро выйди»
- В вызывающем коде: thread.interrupt()
- В задаче: периодически проверять Thread.currentThread().isInterrupted()
- При необходимости — корректно освобождать ресурсы и завершаться.
Типичная ошибка: ожидать, что interrupt() мгновенно «убьёт» поток. Нет — это только сигнал, задача должна сама реагировать.
2. Future.cancel(), CancellationException и отмена задач
Как работает Future.cancel
Когда вы запускаете задачу через ExecutorService.submit(), получаете объект Future. У него есть метод cancel(boolean mayInterruptIfRunning):
- Если задача ещё не началась — она не будет запущена.
- Если задача уже выполняется и mayInterruptIfRunning == true — будет вызвано interrupt() у потока, исполняющего задачу.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future = executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Долгая работа
}
System.out.println("Задача завершена по отмене.");
});
// ... позже
future.cancel(true); // Попросим отменить задачу
Что на самом деле происходит с задачей
Отмена через Future — это не магическая кнопка «убить поток», а фактически вежливая форма Thread.interrupt(). Если задача корректно проверяет флаг прерывания — она аккуратно завершится. Если нет — будет продолжать работать до естественного завершения.
Если вызвать future.get() после отмены, вы получите CancellationException — напоминание о том, что задача была снята.
3. CompletableFuture: отмена, тайм‑ауты и цепочки
Отмена CompletableFuture
У CompletableFuture тоже есть cancel(boolean). Если задача ещё не завершилась, она будет отменена, и все дальнейшие обработчики (thenApply, thenAccept и т.д.) не будут вызваны.
CompletableFuture<Void> cf = CompletableFuture.runAsync(() -> {
while (!Thread.currentThread().isInterrupted()) {
// Работаем...
}
System.out.println("CF завершён по отмене.");
});
// ... позже
cf.cancel(true);
Тайм‑ауты: orTimeout и completeOnTimeout
- orTimeout(timeout, unit) — завершает CompletableFuture с TimeoutException, если не успела за время.
- completeOnTimeout(value, timeout, unit) — завершает с указанным значением, если не успела.
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); // Через 2 секунды: "TIMEOUT"
Проброс отмены в цепочках
Если отменить «верхний» CompletableFuture, все последующие шаги в цепочке не вызовутся. Но при использовании thenCompose для запуска внутренних асинхронных операций отмена не пробрасывается «вверх» автоматически — её нужно проектировать явно (проверять статус, отменять дочерние задачи, использовать общий дедлайн).
Осторожно с thenCompose и кастомным Executor! Убедитесь, что внутренние задачи умеют реагировать на прерывание/отмену и/или получают общий тайм‑аут.
4. StructuredTaskScope: отмена группы задач
Structured Concurrency и отмена
StructuredTaskScope (Java 21+) позволяет запускать группу задач и управлять их жизненным циклом как единым целым. Если одна из задач завершилась с ошибкой или истёк тайм‑аут — остальные задачи автоматически отменяются.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> f1 = scope.fork(() -> fetchData1());
Future<String> f2 = scope.fork(() -> fetchData2());
scope.join(); // ждём завершения всех задач
scope.throwIfFailed(); // если хоть одна упала — бросаем исключение
String result = f1.resultNow() + f2.resultNow();
System.out.println(result);
}
- Если любая задача завершилась с ошибкой — scope отменяет все остальные задачи.
- Если истёк тайм‑аут (через scope.joinUntil(deadline)) — scope отменяет все задачи.
Политики завершения
- ShutdownOnFailure — отменяет все задачи при первой ошибке.
- ShutdownOnSuccess — отменяет остальные задачи, как только одна завершилась успешно.
5. Практика: безопасная отмена долгих операций
Пример: отмена блокирующего IO
Если задача блокируется на чтении из файла или сети, прерывание потока не всегда помогает — некоторые IO‑операции не реагируют на interrupt. В современных API (NIO, AsynchronousFileChannel) прерывание поддерживается лучше, но всё ещё не везде.
Рекомендации:
- Используйте неблокирующее IO, если нужна отмена.
- Для blocking IO — выставляйте тайм‑ауты на уровне API (например, Socket.setSoTimeout).
- Для асинхронных задач — используйте Future.cancel и корректно реагируйте на прерывание.
Пример: отмена ожидания очереди/барьера
Многие синхронизаторы (BlockingQueue.take(), CountDownLatch.await(), CyclicBarrier.await()) выбрасывают InterruptedException при прерывании. В обработчике ловите исключение, восстанавливайте флаг при необходимости и корректно завершайте задачу.
6. Паттерн «time‑budget»: общий дедлайн на группу операций
В сложных приложениях часто нужно задать общий тайм‑аут на выполнение группы операций. Например, если пользователь ждёт ответ не дольше 2 секунд, а внутри нужно сделать 3 сетевых запроса — все они должны уложиться в общий дедлайн.
Как пробрасывать дедлайн вниз по стеку?
- Передавайте объект дедлайна (например, Instant deadline) во все потенциально блокирующие методы.
- В каждом методе вычисляйте оставшееся время: Duration.between(Instant.now(), deadline).
- Используйте это время для тайм‑аутов в блокирующих операциях (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();
// Используем left для тайм-аута
queue.poll(left.toMillis(), TimeUnit.MILLISECONDS);
}
Scoped Values / контекст
В Java 21+ можно использовать Scoped Values для передачи дедлайна по стеку вызовов, чтобы не передавать его явно в каждый метод.
7. Structured Concurrency: отмена всего scope при сбое/тайм‑ауте
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("Дедлайн истёк!");
}
scope.throwIfFailed();
// ...
}
- Если дедлайн истёк — scope отменяет все задачи.
- Если одна задача упала — остальные отменяются автоматически.
8. Типичные ошибки при работе с отменой и тайм‑аутами
Ошибка №1: Ожидание, что interrupt() мгновенно завершит поток. На самом деле это только сигнал — задача должна сама проверять статус и корректно завершаться.
Ошибка №2: Не проверяется isInterrupted() в долгих циклах. Если не проверять флаг прерывания, задача будет работать вечно, даже если её попросили завершиться.
Ошибка №3: Future.cancel() не приводит к отмене, если задача не реагирует на interrupt. Если задача «глухая», cancel() не поможет.
Ошибка №4: Тайм‑ауты не пробрасываются вниз по стеку. Если не передавать дедлайн во все методы, внутренняя операция может «зависнуть» дольше, чем нужно.
Ошибка №5: В thenCompose цепочках CompletableFuture отмена не пробрасывается автоматически. Если отменить «верхний» future, внутренние задачи могут продолжать работать — обработайте отмену явно.
Ошибка №6: StructuredTaskScope не закрывается (нет try‑with‑resources). Если не закрыть scope, дочерние задачи могут остаться «висящими».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ