Як реалізувати 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-значення).