⏱️ Раздел 19 · Вопрос #17

Как отменить выполнение CompletableFuture

Для отмены вычисления в CompletableFuture используется метод cancel(boolean mayInterruptIfRunning), унаследованный от интерфейса Future.


🟢 Junior Level

Для отмены вычисления в CompletableFuture используется метод cancel(boolean mayInterruptIfRunning), унаследованный от интерфейса Future<T>.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    try {
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        System.out.println("Прервано!");
    }
    return "Готово";
});

// Отменяем задачу:
boolean cancelled = future.cancel(true);

System.out.println("Отменен? " + future.isCancelled()); // true
System.out.println("Завершен? " + future.isDone());      // true

[!WARNING] Главная ловушка CompletableFuture: вызов cancel(true) НЕ ПРЕРЫВАЕТ поток, исполняющий задачу! Параметр mayInterruptIfRunning полностью игнорируется рантаймом.

При попытке получить результат отменённого фьючера через .get() или .join() немедленно выбрасывается java.util.concurrent.CancellationException.


🟡 Middle Level

Почему cancel() не прерывает поток

В классическом FutureTask (из Java 5) вызов cancel(true) запоминает поток-исполнитель и вызывает у него thread.interrupt().

В CompletableFuture дизайн принципиально иной:

  1. CompletableFuture — это не задача (Runnable), а дескриптор состояния (Promise). Он может завершаться не потоком пула, а сетевым пакетом, таймером или сторонним событием.
  2. Исходный код метода в JDK:
    public boolean cancel(boolean mayInterruptIfRunning) {
        boolean cancelled = (result == null) &&
            internalComplete(new AltResult(new CancellationException()));
        postComplete();
        return cancelled || isCancelled();
    }
    

    Параметр mayInterruptIfRunning вообще не используется в реализации. Метод просто атомарно записывает AltResult(CancellationException) в поле result.

  3. Фоновый поток воркера продолжит крутить цикл или висеть на сокете до естественного завершения, тратя ресурсы CPU и памяти.

Как реализовать реальную отмену (Cooperative Cancellation)

Чтобы фоновая задача реально прекратила работу при отмене, необходимо использовать флаг совместной отмены (Cancellation Token):

public class CancellableWorker {
    private final AtomicBoolean isCancelled = new AtomicBoolean(false);

    public CompletableFuture<Void> doWorkAsync() {
        return CompletableFuture.runAsync(() -> {
            while (!isCancelled.get()) {
                // Выполняем порцию работы
                processBatch();
            }
            if (isCancelled.get()) {
                System.out.println("Фоновый поток обнаружил отмену и мягко завершился.");
            }
        });
    }

    public void cancel() {
        isCancelled.set(true);
    }
}

🔴 Senior Level

Направление распространения отмены (Downstream vs Upstream)

В графе зависимостей CompletableFuture (DAG) отмена распространяется строго сверху вниз (Downstream) и никогда не идёт снизу вверх (Upstream).

CompletableFuture<String> source = CompletableFuture.supplyAsync(() -> fetchFromDb());
CompletableFuture<String> step1 = source.thenApply(s -> transform(s));
CompletableFuture<Void> step2 = step1.thenAccept(s -> save(s));

// Сценарий 1: Отмена родителя (Upstream)
source.cancel(true);
// Результат: source, step1 и step2 ВСЕ переходят в CancellationException.
// Сигнал отмены каскадно течет вниз по дереву зависимостей!

// Сценарий 2: Отмена дочернего узла (Downstream)
step2.cancel(true);
// Результат: только step2 переходит в CancellationException.
// Исходный source и step1 ПРОДОЛЖАЮТ РАБОТАТЬ и успешно завершаются!

[!IMPORTANT] Если клиент отменил HTTP-запрос на контроллере (step2.cancel()), запрос в базу данных (source) не отменится, если вы не настроите обратную связь вручную!

Реакция whenComplete и handle на отмену

Отмена фьючера переводит его в статус завершённого с ошибкой (isCompletedExceptionally() == true). Поэтому методы whenComplete и handle обязательно вызываются при отмене, получая CancellationException в качестве аргумента Throwable:

CompletableFuture<Resource> resourceFuture = acquireResourceAsync();

resourceFuture.whenComplete((resource, ex) -> {
    if (ex instanceof CancellationException) {
        log.warn("Задача была отменена! Освобождаем захваченные дескрипторы...");
    }
    closeSilently(resource);
});

// Отмена триггерит блок очистки в whenComplete:
resourceFuture.cancel(true);

4 Tricky Questions

1. Прерывает ли вызов future.cancel(true) поток, исполняющий задачу в CompletableFuture.supplyAsync?

Ответ: Категорически нет. В реализации CompletableFuture.cancel(boolean mayInterruptIfRunning) булев флаг mayInterruptIfRunning полностью игнорируется. Метод лишь переводит состояние самого объекта CompletableFuture в завершённое со значением AltResult(CancellationException). Сигнал Thread.currentThread().interrupt() на поток воркера не отправляется. Поток продолжит исполнять переданную лямбду до конца.

2. Что выбросит вызов .join() и вызов .get() на отменённом CompletableFuture? Оборачивается ли ошибка в ExecutionException?

Ответ: Оба метода — и join(), и get() — при вызове на отменённом фьючере выбрасывают напрямую java.util.concurrent.CancellationException (непроверяемое исключение). Оно НЕ оборачивается ни в ExecutionException (для get()), ни в CompletionException (для join()). Это специальное требование спецификации интерфейса Future: отмена всегда диагностируется отдельным типом исключения верхнего уровня.

3. Если цепочка состоит из 3 шагов: f1.thenApply(...).thenApply(...), и мы вызвали cancel(true) на последнем 3-м звене, прервутся ли вычисления на шагах 1 и 2?

Ответ: Нет, шаги 1 и 2 не прервутся и не отменятся! В CompletableFuture отмена не имеет механизма обратного распространения (Back-propagation / Upstream cancellation). Шаги 1 и 2 успешно завершатся в своих потоках, и только 3-й шаг перейдёт в состояние CancellationException. Если необходимо отменить всю цепочку, ссылку нужно сохранять на корневой источник (f1) и отменять именно его.

4. Может ли метод cf.complete(val) вернуть true, если фьючерс уже был отменён через cf.cancel(true)?

Ответ: Нет, метод complete(val) вернёт false. Метод cancel() переводит поле result в терминальное состояние AltResult(CancellationException) с помощью атомарной операции CAS. Любые последующие попытки завершить фьючерс через complete(val) или completeExceptionally(ex) увидят, что result != null, и вернут false, не оказав никакого влияния на состояние объекта.


🎯 Шпаргалка для интервью

Главное за 60 секунд

  • Метод cancel(boolean mayInterruptIfRunning) помечает фьючер как отменённый, но не прерывает исполняющий поток (параметр игнорируется).
  • При вызове get() или join() на отменённом фьючере напрямую выбрасывается CancellationException (без обёртки).
  • Отмена распространяется только вниз по цепочке (downstream): отмена корня отменяет всех детей, но отмена ребенка не отменяет родителей.
  • Обработчики whenComplete и handle вызываются при отмене и получают CancellationException.
  • Для реальной остановки потока требуется Cooperative Cancellation (проверка внешнего флага AtomicBoolean или флага прерывания).

Типичные ошибки на интервью

  • Утверждать, что cancel(true) прерывает поток через Thread.interrupt().
  • Полагать, что отмена дочернего CompletableFuture останавливает выполнение родительских запросов.
  • Забывать, что whenComplete сработает даже в случае отмены задачи.

Связанные вопросы