JavaRush /Курсы /JAVA 25 SELF /Отмена задач и тайм‑ауты сквозь стек

Отмена задач и тайм‑ауты сквозь стек

JAVA 25 SELF
58 уровень , 1 лекция
Открыта

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().

Шаблон «установи флаг — и быстро выйди»

  1. В вызывающем коде: thread.interrupt()
  2. В задаче: периодически проверять Thread.currentThread().isInterrupted()
  3. При необходимости — корректно освобождать ресурсы и завершаться.

Типичная ошибка: ожидать, что 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, дочерние задачи могут остаться «висящими».

1
Задача
JAVA 25 SELF, 58 уровень, 1 лекция
Недоступна
Мониторинг Космического Зонда: Сигнал из Глубин Космоса 🛰️
Мониторинг Космического Зонда: Сигнал из Глубин Космоса 🛰️
1
Задача
JAVA 25 SELF, 58 уровень, 1 лекция
Недоступна
Операция "Критический Срок": Обезвреживание Киберугрозы ⏱️
Операция "Критический Срок": Обезвреживание Киберугрозы ⏱️
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ