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

Як реалізувати timeout для CompletableFuture

В асинхронних розподілених системах будь-який зовнішній виклик (HTTP, БД, черга повідомлень) зобов'язаний бути обмежений у часі, щоб запобігти зависанню потоків та витоку ресурсів.


🟢 Junior Level

В асинхронних розподілених системах будь-який зовнішній виклик (HTTP, БД, черга повідомлень) зобов’язаний бути обмежений у часі, щоб запобігти зависанню потоків та витоку ресурсів.

Спосіб реалізації таймауту залежить від версії Java:

1. У Java 9+ (Вбудовані неблокуючі методи)

Починаючи з Java 9, у CompletableFuture з’явилися два штатні методи:

  • orTimeout(long timeout, TimeUnit unit): якщо завдання не встигає виконатися за вказаний час, ф’ючер завершується з винятком TimeoutException.
  • completeOnTimeout(T fallbackValue, long timeout, TimeUnit unit): якщо час вичерпано, ф’ючер плавно завершується з переданим дефолтним значенням (без викидання помилки).
CompletableFuture<String> future = externalServiceClient.fetchDataAsync();

// Варіант А: впасти з TimeoutException через 3 секунди
future.orTimeout(3, TimeUnit.SECONDS)
      .exceptionally(ex -> "Резервні дані при таймауті");

// Варіант Б: повернути fallback через 3 секунди без винятку
future.completeOnTimeout("Резервні дані", 3, TimeUnit.SECONDS)
      .thenAccept(System.out::println);

2. У Java 8 (Ручна реалізація через ScheduledExecutorService)

У Java 8 не було вбудованих неблокуючих таймаутів, тому використовувався фоновий шедулер:

// Єдиний глобальний планувальник на весь застосунок:
private static final ScheduledExecutorService SCHEDULER = 
    Executors.newScheduledThreadPool(1, new ThreadFactoryBuilder().setDaemon(true).build());

public static <T> CompletableFuture<T> withTimeout(CompletableFuture<T> target, long timeout, TimeUnit unit) {
    // 1. Плануємо аварійне завершення за таймером
    ScheduledFuture<?> timer = SCHEDULER.schedule(() -> {
        target.completeExceptionally(new TimeoutException("Час очікування минув"));
    }, timeout, unit);

    // 2. Якщо завдання завершилося раніше — скасовуємо таймер для запобігання витоку пам'яті
    target.whenComplete((res, ex) -> timer.cancel(false));

    return target;
}

🟡 Middle Level

Різниця між Future.get(timeout) та CompletableFuture.orTimeout()

Характеристика future.get(timeout, unit) cf.orTimeout(timeout, unit)
Блокування Блокує викликаючий потік ОС Не блокує потік (реактивний таймер)
Повернене значення Значення T або виняток Новий CompletableFuture<T> для продовження ланцюжка
Стиль коду Імперативний try-catch Неблокуючий реактивний конвеєр

Фатальна пастка: таймаут НЕ перериває саме завдання!

Коли спрацьовує orTimeout(3, SECONDS):

  1. Об’єкт CompletableFuture переходить у стан помилки TimeoutException.
  2. Усі наступні етапи (thenApply) отримують сигнал про помилку.
  3. Проте початкове фонове завдання в пулі потоків ПРОДОВЖУЄ ВИКОНУВАТИСЯ!

Якщо завдання виконувало довгий SQL-запит або важкий розрахунок на CPU, воно не зупиниться саме по собі. Якщо на кожен запит спрацьовуватиме таймаут, а завдання не глушитимуться, настане лавиноподібне перевантаження сервера.


🔴 Senior Level

Внутрішня будова orTimeout у Java 9+: Delayer

Розробники часто побоюються: чи не створює виклик orTimeout() окремий потік таймера на кожен запит? Відповідь — ні, не створює.

Усередині JDK реалізовано статичний легковажний синглтон:

static final class Delayer {
    static final ScheduledThreadPoolExecutor delayer;
    static {
        (delayer = new ScheduledThreadPoolExecutor(
            1, new DaemonThreadFactory())).setRemoveOnCancelPolicy(true);
    }
}
  1. Один демон-потік на всю JVM: єдиний потік таймера обслуговує черги затримок мільйонів ф’ючерів.
  2. setRemoveOnCancelPolicy(true): при успішному завершенні завдання раніше таймауту внутрішній слухач викликає scheduledFuture.cancel(false). Завдяки цьому прапорцю скасоване завдання негайно видаляється з черги таймерів, не засмічуючи купу (Heap).

Повноцінний патерн: Таймаут із реальним скасуванням мережевого I/O

Щоб при таймауті не витрачати ресурси бекенда, необхідно пов’язувати таймаут зі скасуванням низькорівневого з’єднання:

public CompletableFuture<Response> fetchWithHardTimeout(Request request, Duration timeout) {
    Call httpCall = httpClient.newCall(request); // OkHttp / Spring Reactive Call
    CompletableFuture<Response> promise = new CompletableFuture<>();

    // Запускаємо асинхронний мережевий запит:
    httpCall.enqueue(new Callback() {
        @Override
        public void onResponse(Call call, Response response) { promise.complete(response); }
        @Override
        public void onFailure(Call call, IOException e) { promise.completeExceptionally(e); }
    });

    // Навішуємо таймаут:
    return promise.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
        .whenComplete((res, ex) -> {
            // Якщо стався таймаут або скасування — примусово обриваємо HTTP-сокет!
            if (ex instanceof TimeoutException || promise.isCancelled()) {
                httpCall.cancel(); // Сокет закривається, сервер звільняє потік!
            }
        });
}

4 Tricky Questions

1. Чи створює метод CompletableFuture.orTimeout() новий потік таймера при кожному виклику?

Відповідь: Ні, не створює. Усередині CompletableFuture використовується приватний внутрішній клас Delayer, який тримає один-єдиний статичний системний демон-потік ScheduledThreadPoolExecutor(1) на всю віртуальну машину. Цей потік керує пріоритетною чергою завдань таймауту для всіх викликів orTimeout та completeOnTimeout у застосунку.


2. Що станеться із запланованим завданням таймера, якщо цільовий CompletableFuture успішно завершиться за 10 мс при встановленому таймауті в 60 секунд? Чи висітиме воно в пам’яті 60 секунд?

Відповідь: Ні, у пам’яті воно висіти не буде. При виклику orTimeout() реєструється внутрішній зворотний виклик whenComplete, що створює екземпляр Canceller. Щойно цільовий ф’ючер завершується (успішно або з помилкою), Canceller негайно викликає timerTask.cancel(false). А завдяки системному налаштуванню delayer.setRemoveOnCancelPolicy(true) завдання миттєво вичищається з черги шедулера, запобігаючи витоку оперативної пам’яті.


3. Який виняток викине виклик .join(), якщо на ф’ючері спрацював .orTimeout()?

Відповідь: Метод .join() викине java.util.concurrent.CompletionException, причиною якого (getCause()) буде java.util.concurrent.TimeoutException. Для порівняння: метод .get() інтерфейсу Future викине java.util.concurrent.ExecutionException, причиною якого також буде TimeoutException.


4. У чому принципова різниця між .orTimeout() та .completeOnTimeout()?

Відповідь:

  • .orTimeout(timeout, unit) переводить ф’ючер в аварійний стан (exceptional completion) з TimeoutException. Будь-які подальші кроки thenApply пропускаються, і керування передається у гілку обробки помилок (exceptionally або handle).
  • .completeOnTimeout(fallbackValue, timeout, unit) завершує ф’ючер у нормальний успішний стан із переданим значенням за замовчуванням fallbackValue. Конвеєр продовжує штатне виконання (thenApply, thenAccept), наче операція повернула цей дефолтний результат.

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

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

  • У Java 9+ є вбудовані неблокуючі методи: orTimeout() (кидає TimeoutException) та completeOnTimeout() (повертає fallback-значення).
  • У Java 8 таймаут реалізується через спільний ScheduledExecutorService з обов’язковим скасуванням таймера у whenComplete.
  • Усі виклики orTimeout у JVM використовують один спільний демон-потік Delayer, накладні витрати мінімальні.
  • Головний підводний камінь: таймаут позначає CompletableFuture помилкою, але не зупиняє виконання завислого завдання в пулі! Для звільнення ресурсів сокети та з’єднання потрібно скасовувати вручну.

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

  • Вважати, що orTimeout примусово вбиває потік, що виконує завдання.
  • Створювати new ScheduledThreadPool на кожен запит у Java 8 (критичний витік ресурсів).
  • Плутати orTimeout (помилка) та completeOnTimeout (успішне fallback-значення).

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