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

Можно ли вручную завершить CompletableFuture результатом

Для этого используются методы:


🟢 Junior Level

Да, можно. В отличие от классического java.util.concurrent.Future, который представлял собой исключительно пассивный дескриптор запущенной задачи, CompletableFuture реализует концепцию Promise (Обещание): его можно создать пустым и завершить вручную из любого потока в любой момент времени.

Для этого используются методы:

  • complete(T value) — переводит CompletableFuture в завершенное состояние с указанным значением.
  • completeExceptionally(Throwable ex) — завершает CompletableFuture аварийно с исключением.
// 1. Создаем пустой CompletableFuture (результата еще нет)
CompletableFuture<String> promise = new CompletableFuture<>();

// 2. В фоновом потоке или обработчике событий завершаем его:
new Thread(() -> {
    try {
        String data = loadDataFromSocket();
        promise.complete(data); // Передаем результат
    } catch (Exception ex) {
        promise.completeExceptionally(ex); // Либо пробрасываем ошибку
    }
}).start();

// 3. Вызывающий код подписывается или ожидает результат:
promise.thenAccept(data -> System.out.println("Received: " + data));

Основное правило

Метод complete(value) срабатывает только один раз. Если CompletableFuture уже был завершен (вызовом complete(), исключением, отменой или фоновым расчетом), повторные вызовы complete() просто игнорируются и возвращают false.


🟡 Middle Level

1. Возвращаемое значение и потокобезопасность complete()

Метод complete(T value) возвращает boolean:

  • true — если текущий вызов действительно перевел CompletableFuture в завершенное состояние.
  • false — если CompletableFuture уже был завершен ранее другим потоком или операцией.
CompletableFuture<String> cf = new CompletableFuture<>();

boolean first = cf.complete("Success");      // true (победитель)
boolean second = cf.complete("Another one"); // false (проигнорировано)

System.out.println(cf.join()); // Выведет "Success"

Переход состояния является строго потокобезопасным и атомарным (основан на CAS-операциях на уровне JVM). Гонки данных исключены: первый завершивший поток побеждает, все последующие получают отказ.

2. Завершение с ошибкой: completeExceptionally()

Если асинхронный процесс завершился сбоем (таймаут сокета, сбой валидации, ошибка протокола):

CompletableFuture<User> userFuture = new CompletableFuture<>();

// Завершаем аварийно:
userFuture.completeExceptionally(new UserNotFoundException("User 42 not found"));

// Любая попытка получить результат выбросит CompletionException:
userFuture.exceptionally(ex -> {
    log.error("Failed to load user: {}", ex.getMessage());
    return User.ANONYMOUS;
});

3. Java 9: completeAsync()

В Java 9 появился метод completeAsync(Supplier<? extends T> supplier, Executor executor), позволяющий асинхронно вычислить значение для ручного завершения на указанном пуле потоков:

CompletableFuture<String> cf = new CompletableFuture<>();
// Завершит cf результатом supplier в пуле customExecutor:
cf.completeAsync(() -> heavyCalculation(), customExecutor);

4. Опасный метод: obtrudeValue() и obtrudeException()

В API есть методы obtrudeValue(T value) и obtrudeException(Throwable ex). Они принудительно перезаписывают результат CompletableFuture, даже если он уже был завершен:

CompletableFuture<String> cf = CompletableFuture.completedFuture("Original");
cf.obtrudeValue("Hacked");

System.out.println(cf.join()); // "Hacked"

[!CAUTION] Почему obtrudeValue категорически не рекомендуется использовать в production: Он грубо нарушает инвариант неизменяемости (immutability) завершенного состояния. Стадии, которые уже успели выполниться по исходному значению "Original", не будут перезапущены. Новые же подписчики получат "Hacked". Это неизбежно приведет к рассинхронизации данных и непредсказуемым race conditions. Данный метод предназначен исключительно для утилит восстановления после фатальных ошибок или для тестирования.


🔴 Senior Level

Внутренний механизм завершения: Treiber Stack и postComplete()

Что физически происходит внутри JVM при вызове cf.complete(value)?

// Упрощенный исходный код OpenJDK:
public boolean complete(T value) {
    boolean triggered = RESULT.compareAndSet(this, null, (value == null) ? NIL : value);
    if (triggered)
        postComplete(); // Вызов коллбэков!
    return triggered;
}
  1. Атомарный CAS: Поле volatile Object result переводится из null в значение (или маркер NIL при передаче null, либо AltResult при ошибке).
  2. postComplete(): Если поток выиграл CAS, он берет на себя ответственность за выталкивание всех зарегистрированных зависимых стадий (Completion) из внутреннего неблокирующего стека (Treiber stack).

Критическая ловушка: В каком потоке выполняются коллбэки?

Если к моменту вызова cf.complete("data") к нему уже были привязаны синхронные стадии (например, .thenApply(func) или .thenAccept(consumer)), эти стадии выполнятся синхронно прямо в потоке, который вызвал complete()!

// Поток Netty EventLoop обрабатывает входящий TCP-пакет:
void onChannelRead(ByteBuf buf) {
    String message = parse(buf);
    // ВНИМАНИЕ: если к future привязана тяжелая цепочка thenApply:
    future.complete(message); 
    // ВСЕ синхронные коллбэки выполнятся прямо ЗДЕСЬ, в потоке Netty!
    // Поток ввода-вывода окажется заблокирован вычислениями бизнес-логики!
}

Решение: Для изоляции потоков всегда используйте асинхронные версии коллбэков с указанием специализированного пула потоков: thenApplyAsync(func, businessExecutor).

Защита от несанкционированного завершения (Encapsulation)

Если сервис возвращает CompletableFuture<Data>, вызывающий код может злоупотребить API и вызвать .complete(...), сломав внутреннее состояние сервиса:

// Сервис:
public CompletableFuture<Data> fetchData() { ... }

// Злонамеренный или некорректный клиентский код:
CompletableFuture<Data> f = service.fetchData();
f.complete(new FakeData()); // Сервер еще работает, а клиент уже подменил результат!

Паттерны защиты (Инвариант надежности):

  1. Возврат интерфейса CompletionStage:
    public CompletionStage<Data> fetchData() {
        return internalFuture; // В CompletionStage методов complete() нет!
    }
    

    Оговорка: Клиент может вызвать stage.toCompletableFuture().complete(...).

  2. Использование minimalCompletionStage() (Java 9+):
    public CompletionStage<Data> fetchData() {
        return internalFuture.minimalCompletionStage();
    }
    

    У полученного объекта вызов .toCompletableFuture() выбросит UnsupportedOperationException, делая подмену результата физически невозможной.

  3. Использование copy() (Java 9+):
    public CompletableFuture<Data> fetchData() {
        return internalFuture.copy(); // Завершение копии не завершит оригинал!
    }
    

4 Tricky Questions

1. В каком потоке выполняются коллбэки (thenApply, thenAccept), если cf.complete("done") вызван вручную?

Ответ: Если зависимая стадия (thenApply, thenAccept) была зарегистрирована до того, как был вызван complete(), она будет выполнена в том же самом потоке, который вызвал complete() (внутри метода postComplete()).

Если же complete() уже отработал, а зависимая стадия регистрируется позже, она выполнится синхронно в потоке, вызвавшем thenApply(). Чтобы гарантировать выполнение коллбэка в изолированном пуле потоков независимо от тайминга завершения, необходимо использовать асинхронные методы thenApplyAsync(..., executor).

2. Что происходит, если два потока одновременно вызывают cf.complete("A") и cf.completeExceptionally(new RuntimeException("B"))?

Ответ: Происходит конкурентная гонка CAS (Compare-And-Swap) за поле result.

  • Ровно один из потоков успеет атомарно сменить result с null на свое значение ("A" или AltResult(ex)). Этот поток получит возвращаемое значение true.
  • Второй поток обнаружит, что поле result уже не равно null, его CAS завершится неудачей, метод вернет false, а переданное им значение или исключение будет полностью проигнорировано. Никаких исключений гонки (типа IllegalStateException) выброшено не будет.

3. Если вызвать cf.obtrudeValue("new") после того, как cf.complete("old") уже сработал, перезапустятся ли ранее прикрепленные к cf стадии?

Ответ: Нет, ранее прикрепленные стадии не перезапустятся.

В момент первого вызова complete("old") внутренний стек зависимостей (Completion stack) полностью опустошается и обнуляется в ходе работы postComplete(). При вызове obtrudeValue("new") поле result перезаписывается новым значением, но стек подписчиков уже пуст. В итоге все стадии, зависевшие от cf, останутся со старым результатом "old", в то время как прямые вызовы cf.join() или новые зарегистрированные подписчики получат "new". Это приводит к нарушению целостности графа вычислений.

4. Как спроектировать API, возвращающий асинхронный результат, чтобы потребитель не мог вручную завершить ваш CompletableFuture?

Ответ: В Java 9+ лучшим решением является возврат CompletableFuture.minimalCompletionStage():

public CompletionStage<Order> createOrderAsync(...) {
    CompletableFuture<Order> internalPromise = new CompletableFuture<>();
    // ... логика инициализации ...
    return internalPromise.minimalCompletionStage();
}

Интерфейс CompletionStage в принципе не объявляет методов завершения (complete, obtrudeValue). А реализация minimalCompletionStage защищена от обхода: если вызывающий код попытается вызвать .toCompletableFuture(), он получит экземпляр MinimalStage, любой модифицирующий метод которого выбрасывает UnsupportedOperationException.

Альтернатива — вернуть защитную копию через internalPromise.copy(): если клиент вызовет complete() на копии, оригинал продолжит выполняться без изменений.


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

Основные методы завершения

Метод Поведение Когда применять
complete(value) Завершает со значением, если еще не был завершен. Возвращает true/false. Основной метод реализации паттерна Promise / callback-адаптеров.
completeExceptionally(ex) Завершает с ошибкой, если еще не был завершен. Возвращает true/false. Передача ошибок из асинхронных драйверов или обработчиков событий.
completeAsync(supplier, exec) Планирует асинхронный расчет значения в заданном пуле (Java 9+). Ленивая передача вычислений без блокировки текущего потока.
obtrudeValue(value) Принудительно перезаписывает результат, даже если CF уже завершен. ⚠️ Табу в production. Только для recovery-утилит и тестов.

Ключевые правила

  1. Первый побеждает: complete() идемпотентен с точки зрения последующих вызовов — только первый вызов переводит стадию в финальное состояние.
  2. Опасность I/O блокировки: Поток, вызывающий complete(), сам выполняет синхронные стадии thenApply. Не вызывайте complete() в I/O потоках (Netty EventLoop, Swing EDT), если к future привязана тяжелая бизнес-логика.
  3. Инкапсуляция: Не отдавайте клиентам наружу сырой экземпляр CompletableFuture, если вы не хотите, чтобы они завершили его вместо вас. Возвращайте minimalCompletionStage() или copy().

Антипаттерны

  • ❌ Использовать obtrudeValue() для повторного использования объекта: приводит к рассинхронизации графа коллбэков.
  • ❌ Игнорировать возвращаемое значение complete() при координации потоков: если метод вернул false, результат не был принят.

Связанные темы