Как реализовать 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):
- Объект
CompletableFutureпереходит в состояние ошибкиTimeoutException. - Все последующие этапы (
thenApply) получают сигнал об ошибке. - Однако исходная фоновая задача в пуле потоков ПРОДОЛЖАЕТ ВЫПОЛНЯТЬСЯ!
Если задача выполняла долгий 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);
}
}
- Один демон-поток на всю JVM: единственный поток таймера обслуживает очереди задержек миллионов фьючеров.
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-значение).