⏱️ Розділ 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 спрацює навіть у разі скасування завдання.

Пов’язані теми