⏱️ Раздел 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.

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