⏱️ Розділ 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, результат не був прийнятий.

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