⏱️ Розділ 19 · Питання #9

Що робить метод allOf() і коли його використовувати

Статичний метод CompletableFuture.allOf(CompletableFuture... cfs) використовується, коли необхідно паралельно запустити довільну кількість незалежних завдань ($N$ штук) і доч...


🟢 Junior Level

Статичний метод CompletableFuture.allOf(CompletableFuture<?>... cfs) використовується, коли необхідно паралельно запустити довільну кількість незалежних завдань ($N$ штук) і дочекатися завершення всіх переданих обчислень.

Метод повертає новий CompletableFuture<Void>, який переходить у завершений стан тільки тоді, коли всі вихідні ф’ючери закінчили свою роботу.

Базовий приклад

CompletableFuture<String> priceFuture = CompletableFuture.supplyAsync(() -> fetchPrice());
CompletableFuture<String> stockFuture = CompletableFuture.supplyAsync(() -> fetchStock());
CompletableFuture<String> reviewsFuture = CompletableFuture.supplyAsync(() -> fetchReviews());

// Об'єднуємо очікування всіх 3 завдань:
CompletableFuture<Void> allTasks = CompletableFuture.allOf(priceFuture, stockFuture, reviewsFuture);

// Спрацьовує тільки тоді, коли ВСІ 3 завдання готові:
allTasks.thenRun(() -> {
    System.out.println("Усі дані успішно завантажено!");
    // Виклик .join() тут безпечний і не блокує потік, оскільки всі завдання вже завершені:
    String price = priceFuture.join();
    String stock = stockFuture.join();
    String reviews = reviewsFuture.join();
    renderProductPage(price, stock, reviews);
});

[!IMPORTANT] Метод allOf() повертає CompletableFuture<Void>, а не масив чи колекцію результатів. Результати кожного окремого ф’ючера вилучаються викликом .join() на вихідних об’єктах.


🟡 Middle Level

Патерн безпечного збору результатів у List<T>

У реальних проєктах кількість завдань зазвичай динамічна (наприклад, обробка списку сутностей). Для збору типізованого списку результатів використовується ідіоматичний шаблон:

public <T> CompletableFuture<List<T>> fetchAll(List<Long> ids, Function<Long, CompletableFuture<T>> taskFactory) {
    // 1. Створюємо список асинхронних завдань
    List<CompletableFuture<T>> futures = ids.stream()
        .map(taskFactory)
        .toList();

    // 2. Створюємо бар'єр очікування
    return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
        .thenApply(ignoredVoid -> 
            futures.stream()
                // join() тут не блокує потік: allOf гарантує, що кожен future вже isDone()
                .map(CompletableFuture::join)
                .toList()
        );
}

Поведінка при виникненні помилок

За замовчуванням allOf має критичну особливість:

  • Якщо хоча б один ф’ючер завершується з помилкою, результуючий ф’ючер allOf також завершується з винятком.
  • При цьому allOf.join() викине CompletionException, причиною якого стане виняток першого ф’ючера, що зазнав невдачі.
  • Усі результати решти успішно виконаних завдань втрачаються, якщо ви не подбали про індивідуальну обробку помилок!
// ✅ Рішення: ізолювати збої на рівні кожного окремого ф'ючера
CompletableFuture<User> safeUserFuture = userService.getUserAsync(id)
    .exceptionally(ex -> User.ANONYMOUS); // Fallback

CompletableFuture<Account> safeAccountFuture = accountService.getAccountAsync(id)
    .exceptionally(ex -> Account.DEFAULT); // Fallback

// Тепер allOf гарантовано не викине виняток
CompletableFuture.allOf(safeUserFuture, safeAccountFuture)
    .thenAccept(v -> showProfile(safeUserFuture.join(), safeAccountFuture.join()));

🔴 Senior Level

Внутрішня будова: бінарне дерево BiRelay

На відміну від наївних реалізацій із лічильниками (CountDownLatch), у CompletableFuture.allOf застосовано суто lock-free алгоритм без утримання потоків.

При передачі масиву з $N$ елементів метод рекурсивно будує збалансоване бінарне дерево залежностей із вузлів BiRelay:

// Спрощена концепція з java.util.concurrent.CompletableFuture:
public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs) {
    return andTree(cfs, 0, cfs.length - 1);
}

static CompletableFuture<Void> andTree(CompletableFuture<?>[] cfs, int lo, int hi) {
    CompletableFuture<Void> d = new CompletableFuture<>();
    if (lo > hi) {
        d.result = NIL;
    } else {
        int mid = (lo + hi) >>> 1;
        CompletableFuture<?> a = (lo == mid ? cfs[lo] : andTree(cfs, lo, mid));
        CompletableFuture<?> b = (lo == hi ? a : (hi == mid + 1 ? cfs[hi] : andTree(cfs, mid + 1, hi)));
        // Зв'язуємо вершини через внутрішній вузол BiRelay
        ...
    }
    return d;
}
  1. Глибина дерева залежностей становить $O(\log N)$.
  2. При завершенні будь-якого листка сигнал про завершення поширюється вгору деревом.
  3. Потік, що завершив найостанніший дочірній вузол у дереві, і переводить кореневий ф’ючер у стан готовності.

Фатальний нюанс: Чи чекає allOf завершення всіх завдань при падінні одного?

Існує поширений міф, що allOf працює за принципом fail-fast: «щойно один упав, allOf негайно завершується помилкою». У стандартній реалізації JDK це НЕ ТАК!

Дерево andTree чекає на фізичне завершення (успішне чи аварійне) абсолютно всіх підзавдань! Кореневий вузол зафіксує помилку, але він не буде позначений як завершений доти, доки решта $N - 1$ завдань не закінчать виконання у своїх потоках. Якщо одне з паралельних завдань зависло намертво без таймауту, результуючий allOf зависне назавжди, навіть якщо інше завдання вже впало з фатальним винятком.


4 Tricky Questions

1. Що станеться, якщо передати в CompletableFuture.allOf() порожній масив або взагалі 0 аргументів?

Відповідь: Метод завершиться негайно та коректно, повернувши екземпляр CompletableFuture<Void>, вже завершений зі значенням null (completedFuture(null)). Жодних винятків (IllegalArgumentException чи IndexOutOfBoundsException) не викидається. Це робить метод абсолютно безпечним для динамічних колекцій нульового розміру: allOf(futures.toArray(new CompletableFuture[0])) поверне готовий ф’ючер.


2. Якщо з 10 переданих в allOf ф’ючерів 3 впали з різними винятками, як витягти всі три помилки, а не тільки першу?

Відповідь: Метод allOf.join() або allOf.exceptionally() покаже лише перший за часом виняток. Інші два залишаться недоступними через корінь. Щоб зібрати всі помилки, необхідно після завершення allOf (через .whenComplete або handle) вручну пройтися вихідною колекцією ф’ючерів:

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
    .whenComplete((v, overallEx) -> {
        List<Throwable> allErrors = futures.stream()
            .filter(CompletableFuture::isCompletedExceptionally)
            .map(f -> {
                try { f.join(); return null; }
                catch (CompletionException ce) { return ce.getCause(); }
            })
            .filter(Objects::nonNull)
            .toList();
        log.error("Усього помилок у пачці: {}", allErrors.size());
    });

3. Чому всередині .thenApply(...), навішаного на allOf, виклик .join() на дочірніх ф’ючерах не вважається поганою практикою (code smell)?

Відповідь: Виклик .join() вважається антипатерном у реактивному/асинхронному програмуванні, коли він викликається на незавершеному ф’ючері, блокуючи потік виконання. Проте всередині колбека, зареєстрованого на allOf, метод join() викликається в момент, коли allOf вже перейшов у завершений стан. Це гарантує, що кожен дочірній ф’ючер вже має isDone() == true. У цьому випадку .join() не переводить потік у режим очікування (не паркує тред), а миттєво зчитує значення з volatile Object result за 1–2 наносекунди.


4. Припустимо, ми викликали allDone = CompletableFuture.allOf(f1, f2) і потім allDone.cancel(true). Чи скасуються вихідні завдання f1 та f2?

Відповідь: Ні, завдання f1 та f2 не скасуються! У CompletableFuture скасування ніколи не поширюється вгору деревом залежностей (upstream). Виклик allDone.cancel(true) лише переведе сам об’єкт allDone у статус CancellationException. Дочірні завдання f1 та f2 продовжать виконуватися у своїх пулах до завершення. Для каскадного скасування розробник зобов’язаний явно викликати cancel() на кожному дочірньому ф’ючері.


🎯 Шпаргалка для інтерв’ю

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

  • CompletableFuture.allOf() очікує на завершення всіх переданих завдань ($N$ штук).
  • Повертає CompletableFuture<Void>. Результати вихідних завдань вилучаються індивідуально через cf.join().
  • Загальна тривалість виконання дорівнює часу найповільнішого завдання: $\max(t_1, t_2, \dots, t_N)$.
  • Під капотом будує бінарне дерево BiRelay глибиною $O(\log N)$.
  • Важливий нюанс: allOf не скасовує дочірні завдання при скасуванні самого allOf і чекає на завершення всіх завдань навіть при падінні одного з них.

Типові помилки на інтерв’ю

  • Вважати, що allOf повертає масив або список значень (CompletableFuture<List<T>>).
  • Думати, що при помилці одного завдання allOf негайно перериває виконання решти.
  • Забувати загортати виклики в таймаути (orTimeout), через що зависання одного завдання підвішує весь allOf.

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