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

Чому важливо уникати блокуючих операцій в CompletableFuture

Клас CompletableFuture був створений спеціально для побудови неблокуючих (non-blocking) реактивних конвеєрів. Блокуючі операції (такі як Thread.sleep(), future.get(), синхронні...


🟢 Junior Level

Клас CompletableFuture був створений спеціально для побудови неблокуючих (non-blocking) реактивних конвеєрів. Блокуючі операції (такі як Thread.sleep(), future.get(), синхронні походи в БД через JDBC або повільні мережеві виклики) всередині асинхронних ланцюжків зводять нанівець усю користь від асинхронності.

Головні причини уникати блокувань:

  1. Голодування пулу потоків (Thread Pool Starvation): за замовчуванням методи CompletableFuture використовують ForkJoinPool.commonPool(). Кількість потоків у ньому обмежена ($\text{CPU cores} - 1$). Якщо у 8-ядерній системі 7 потоків стануть у блокуюче очікування відповіді від бази даних або стороннього API, вся асинхронна робота в JVM зупиниться.
  2. Взаємні блокування (Deadlocks): якщо завдання, що виконується в пулі, блокуюче очікує (join()) інше завдання, яке стоїть у черзі того самого пулу, настає гарантований дедлок.
  3. Марна витрата пам’яті: кожен заблокований потік операційної системи утримує стек пам’яті (~1 МБ), створюючи колосальні накладні витрати при нульовій утилізації CPU.
// ❌ АНТИПАТЕРН: блокуючий виклик заморожує потік воркера
CompletableFuture.supplyAsync(() -> {
    Thread.sleep(5000); // Блокування! Воркер простоює 5 секунд
    return "done";
});

// ✅ ПРАВИЛЬНО: використання неблокуючих API та спеціалізованих таймерів
CompletableFuture.supplyAsync(() -> "done", 
    CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS) // Не тримає потік 5 секунд!
);

🟡 Middle Level

1. Анатомія самоблокування пулу (Self-Deadlock)

Один із найпідступніших дефектів у багатопотоковому програмуванні — запуск залежних завдань із блокуючим очікуванням в одному пулі:

ExecutorService pool = Executors.newFixedThreadPool(2);

// Відправляємо два зовнішні завдання:
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
    // Всередині запускаємо підзавдання в ТОЙ САМИЙ пул і блокуємося в очікуванні!
    CompletableFuture<String> subTask = CompletableFuture.supplyAsync(() -> "sub1", pool);
    return subTask.join(); // Потік 1 чекає sub1
}, pool);

CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
    CompletableFuture<String> subTask = CompletableFuture.supplyAsync(() -> "sub2", pool);
    return subTask.join(); // Потік 2 чекає sub2
}, pool);

Що відбувається:

  1. Потік 1 зайнятий task1, потік 2 зайнятий task2. Вільних потоків у пулі більше немає ($2/2$).
  2. Завдання sub1 та sub2 стають у чергу завдань пулу.
  3. task1 та task2 нескінченно чекають виконання sub1 та sub2, а sub1 та sub2 не можуть розпочатися, доки не звільняться потоки.
  4. Результат: миттєвий взаємний дедлок (Pool Deadlock).

2. Чому Work-Stealing не рятує від блокувань?

У ForkJoinPool реалізовано механізм Work-Stealing: якщо воркер вільний, він може «вкрасти» завдання з черги сусіда. Але якщо потік пішов у синхронний системний виклик ядра ОС (наприклад, SocketInputStream.read() або ReentrantLock.lock()), процесор переводить потік у режим сну (WAITING/BLOCKED). Потік не може виконувати алгоритм крадіжки завдань, тому що він заморожений ядром операційної системи! Уся його локальна черга завдань залишається мертвим вантажем.


🔴 Senior Level

Проблема «Пінінгу» (Thread Pinning) у Virtual Threads (Java 21+)

З появою Virtual Threads багато розробників вирішили, що про блокування можна забути. Проте у зв’язці з CompletableFuture існує критична пастка — Pinning (прив’язка віртуального потоку до несучого потоку ОС).

Віртуальний потік при блокуванні розмонтовується від Carrier Thread ядра, ЯКЩО ТІЛЬКИ блокування не відбувається всередині synchronized-блоку або нативного методу:

// ❌ НЕБЕЗПЕЧНО: блокування всередині synchronized
CompletableFuture.supplyAsync(() -> {
    synchronized (lock) {
        // Якщо тут станеться блокуючий виклик I/O або JDBC:
        return httpClient.sendBlocking(request); 
    }
}, virtualThreadExecutor);

У цей момент віртуальний потік пришпилюється (pinned) до потоку платформи (Carrier Thread). Якщо таких операцій стане багато, пул несучих потоків платформи (ForkJoinPool під капотом Project Loom) виявиться повністю заблокованим, повернувши систему до класичного Thread Starvation! Рішення: Замінювати synchronized на ReentrantLock, який у Java 21 повністю підтримує неблокуюче розмонтування віртуальних потоків.

Використання ManagedBlocker при неминучих блокуваннях

Якщо в архітектурі неможливо позбутися синхронної блокуючої бібліотеки всередині ForkJoinPool, єдиним безпечним способом є використання контракту ForkJoinPool.ManagedBlocker:

public class ManagedDbQuery<T> implements ForkJoinPool.ManagedBlocker {
    private final Supplier<T> query;
    private T result;
    private boolean done = false;

    public ManagedDbQuery(Supplier<T> query) { this.query = query; }

    @Override
    public boolean block() {
        result = query.get(); // Блокуючий виклик JDBC
        done = true;
        return true;
    }

    @Override
    public boolean isReleasable() { return done; }

    public T getResult() { return result; }
}

// Використання:
CompletableFuture.supplyAsync(() -> {
    ManagedDbQuery<User> blocker = new ManagedDbQuery<>(() -> userRepository.findById(id));
    ForkJoinPool.managedBlock(blocker); // ForkJoinPool ТИМЧАСОВО СТВОРЮЄ НОВИЙ ПОТІК замість заблокованого!
    return blocker.getResult();
});

4 Tricky Questions

1. Що станеться, якщо всередині cf.thenApply(...) викликати anotherCf.join(), і чому це вважається найгіршим антипатерном в асинхронному коді?

Відповідь: Це призводить до двох катастрофічних наслідків:

  1. Руйнування неблокуючого конвеєра: потік, що завершив батьківський ф’ючер, замість швидкої передачі даних заморожується в очікуванні anotherCf.
  2. Ризик самоблокування пулу (Self-Deadlock): якщо anotherCf повинен виконатися в тому самому пулі потоків, а всі потоки вже заблоковані аналогічними викликами join(), система встане намертво. Правильне рішення: Використовувати монадичну композицію — замість thenApply + join завжди викликати thenCompose або thenCombine.

2. Як виявити приховане голодування пулу потоків (Thread Starvation) у продакшені під моніторингом Prometheus/Grafana?

Відповідь: Симптоми голодування пулу потоків на графіках:

  1. Queue Size зростає: розмір черги завдань (executor.queue.size) лавиноподібно збільшується при незмінній або навіть знижуваній кількості активних обчислень.
  2. Active Threads = Max Threads: кількість активних воркерів постійно впирається в стелю пулу.
  3. Різкий стрибок Latency (P99/P99.9): час очікування завдання в черзі (task.wait.time) перевищує сам час його виконання в сотні разів.
  4. Зняття jstack / thread dump: показує десятки потоків воркерів у стані WAITING (parking) на CompletableFuture.waitingGet або SocketInputStream.socketRead0.

3. Чому блокуючий I/O всередині ForkJoinPool.commonPool() завдає більшої шкоди, ніж блокуючий I/O у звичайному ThreadPoolExecutor?

Відповідь: Тому що ThreadPoolExecutor зазвичай ізольований для конкретного сервісу, а ForkJoinPool.commonPool() — глобальний синглтон на всю JVM. Блокування потоків у commonPool паралізує не лише ваш метод, а й:

  • Паралельну обробку потоків даних stream().parallel().
  • Системні асинхронні операції JDK (наприклад, реактивні інтерфейси HttpClient).
  • Асинхронні завдання в усіх інших модулях застосунку, що використовують CompletableFuture без кастомного екзекутора.

4. Припустимо, ми викликаємо метод із затримкою: що краще використовувати — Thread.sleep(5000) всередині supplyAsync чи CompletableFuture.delayedExecutor(5, SECONDS)? У чому різниця за ресурсами?

Відповідь: Різниця колосальна:

  • Thread.sleep(5000) всередині завдання пулу захоплює й утримує живий системний потік ОС на 5 секунд. Потік простоює, споживаючи пам’ять стека і зменшуючи пропускну здатність пулу.
  • CompletableFuture.delayedExecutor(5, SECONDS) (з’явився в Java 9) використовує внутрішній оптимізований системний таймер (ScheduledThreadPoolExecutor). Він не займає потік пулу під час очікування! Завдання фізично відправляється в пул потоків тільки після закінчення 5 секунд, не витрачаючи обчислювальні потоки під час паузи.

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

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

  • Блокування в CompletableFuture призводять до Thread Pool Starvation (голодування пулу) та Pool Deadlock (взаємних блокувань).
  • Пул за замовчуванням ForkJoinPool.commonPool() має всього $\text{cores} - 1$ потоків і призначений тільки для CPU-bound обчислень.
  • Алгоритм Work-Stealing безсилий проти потоків, заблокованих на рівні ядра ОС (I/O, м’ютекси, Thread.sleep).
  • Для будь-яких блокуючих операцій обов’язково використовувати:
    • Ізольований ThreadPoolExecutor з обмеженою чергою.
    • Virtual Threads (newVirtualThreadPerTaskExecutor) у Java 21+.
    • CompletableFuture.delayedExecutor замість Thread.sleep.
    • Заміну thenApply + join() на thenCompose() / thenCombine().

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

  • Викликати .get() або .join() всередині лямбд CompletableFuture (найгрубіший антипатерн, що веде до дедлоків).
  • Вважати, що ForkJoinPool автоматично збільшує кількість потоків при звичайних блокуваннях (він робить це тільки через ManagedBlocker).
  • Вважати, що Virtual Threads у Java 21 повністю позбавляють проблем блокувань (небезпека Thread Pinning всередині synchronized).

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