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

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