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

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

CompletableFuture зазвичай виконується у ForkJoinPool.commonPool(), який має дуже мало потоків (зазвичай = число ядер CPU - 1).

Мовні версії: English Russian Ukrainian

🟢 Junior Level

CompletableFuture зазвичай виконується у ForkJoinPool.commonPool(), який має дуже мало потоків (зазвичай = число ядер CPU - 1).

Якщо один із потоків заблокується, інші задачі не зможуть виконуватися — це називається thread pool starvation.

// ❌ Небезпечно — блокує потік ForkJoinPool
CompletableFuture.supplyAsync(() -> {
    return httpClient.get(url);  // блокуючий I/O
});

// ✅ Безпечно — свій Executor для I/O
ExecutorService ioExecutor = Executors.newFixedThreadPool(20);
CompletableFuture.supplyAsync(() -> {
    return httpClient.get(url);
}, ioExecutor);

Проста аналогія:

  • ForkJoinPool — як 4 каси в магазині
  • Якщо одна касирка засне — черга не рухається
  • Потрібно не давати касирам спати!

🟡 Middle Level

Thread pool starvation

// commonPool має 7 потоків (на 8-ядерному CPU)
// Якщо 7 задач заблокуються — всі нові задачі чекають

for (int i = 0; i < 100; i++) {
    CompletableFuture.supplyAsync(() -> {
        Thread.sleep(5000);  // блокує потік
        return "done";
    });
}
// Тільки перші 7 виконуються
// Решта 93 чекають звільнення потоку

Наслідки

  1. Deadlock: ```java CompletableFuture cf1 = CompletableFuture.supplyAsync(() -> { return cf2.join(); // чекає cf2 });

CompletableFuture cf2 = CompletableFuture.supplyAsync(() -> { return cf1.join(); // чекає cf1 — deadlock! });


2. **Performance degradation:**
```java
// Всі потоки зайняті блокуючими операціями
// Latency росте, throughput падає

Типові помилки

  1. Blocking у thenApply: ```java // ❌ thenApply виконується у ForkJoinPool cf.thenApply(s -> { return httpClient.sendBlocking(url); // блокує! });

// ✅ thenApplyAsync з IO Executor cf.thenApplyAsync(s -> httpClient.sendBlocking(url), ioExecutor);


---

## 🔴 Senior Level

### Internal Implementation

**ForkJoinPool.commonPool():**
```java
// Розмір = Runtime.getRuntime().availableProcessors() - 1
// Для 8 ядер = 7 потоків
// Для CPU-bound задач — ідеально
// За замовчуванням розмір пула = availableProcessors() - 1 (можна змінити через
// `java.util.concurrent.ForkJoinPool.common.parallelism`). Для CPU-bound задач
// підходить добре, для I/O — може стати вузьким місцем при великій кількості
// блокуючих операцій.

Work-stealing:

// ForkJoinPool використовує work-stealing
// Але якщо всі потоки blocked — stealing не допомагає

// Thread 1: [BLOCKED on I/O]
// Thread 2: [BLOCKED on I/O]
// Thread 3: [BLOCKED on I/O]
// ...
// Queue: [waiting tasks...]  // ніхто не обробляє

Архітектурні Trade-offs

Підхід Плюси Мінуси
commonPool Не потрібно створювати Тільки CPU-bound
Свій Executor Контроль Потрібно керувати
Virtual Threads Best of both Java 21+

Edge Cases

1. Cascading blocking:

// Один blocking виклик тягне інші
cf1.thenApply(s -> blockingCall1(s))
   .thenCompose(r -> blockingCall2(r))
   .thenAccept(result -> blockingCall3(result));
// Один CF = 3 blocking операції = 3x час блокування

2. Mixed workload:

// CPU-bound + I/O в одному пулі
CompletableFuture.supplyAsync(() -> heavyCalculation());  // CPU
CompletableFuture.supplyAsync(() -> httpClient.get(url)); // I/O

// I/O блокує потік — CPU задачі чекають

Продуктивність

Thread pool starvation ефект:
- У гіршому випадку, коли пул повністю насичений задачами, одна блокуюча задача
  знижує throughput приблизно на 1/N, де N — розмір пула. Фактична втрата
  залежить від характеру задач і роботи work-stealing.
- 7 blocking tasks = 100% втрата (повний deadlock)

Virtual Threads рішення:
- 100000+ блокуючих задач = без проблем
- Кожен blocking — suspend, не блокує OS thread

Production Experience

Detecting starvation:

# Thread dump
jstack <pid> | grep -A 10 "ForkJoinPool"

# Метрики
- Active threads < pool size
- Queue size росте
- Latency росте

Prevention:

// ✅ Розділення пулів
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors());
ExecutorService ioExecutor = Executors.newFixedThreadPool(50);

// CPU задачі
CompletableFuture.supplyAsync(cpuTask, cpuExecutor);

// I/O задачі
CompletableFuture.supplyAsync(ioTask, ioExecutor);

// ✅ Virtual Threads (Java 21+)
ExecutorService vThreads = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(anyTask, vThreads);

Best Practices

// ✅ Свій Executor для I/O
CompletableFuture.supplyAsync(ioTask, ioExecutor);

// ✅ Virtual Threads
Executors.newVirtualThreadPerTaskExecutor();

// ✅ Моніторинг thread pool
metrics.recordQueueSize(executor.getQueue().size());

// ❌ Blocking у commonPool
// ❌ join()/get() без таймауту
// ❌ Ігнорування thread starvation

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • ForkJoinPool.commonPool() має мало потоків (availableProcessors - 1)
  • Thread pool starvation: всі потоки заблоковані, нові задачі чекають
  • Blocking у commonPool знижує throughput для УСІХ задач (Parallel Streams, інші CF)
  • Cascading blocking: один blocking виклик тягне інші в ланцюжку
  • Рішення: свій Executor для I/O, Virtual Threads (Java 21+), розділення пулів

Часті уточнюючі питання:

  • Що буде якщо 7 задач заблокують commonPool? — Всі нові задачі чекають, повний deadlock
  • Work-stealing допомагає при blocking? — Ні, якщо всі потоки blocked — stealing не працює
  • Як detect starvation? — jstack: ForkJoinPool-worker-X у BLOCKED/WAITING, queue size росте
  • Virtual Threads вирішують проблему? — Так, 100000+ blocking задач без problems (Java 21+)

Червоні прапорці (НЕ говорити):

  • «Blocking у thenApply нормально — він же легкий» — thenApply в тому ж потоці, blocking вбиває пул
  • «commonPool масштабується автоматично» — обмежений availableProcessors - 1
  • «join() без таймауту в production — ок» — нескінченне очікування, cascading failure

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

  • [[14. Що таке блокуючий код і як його відрізнити від неблокуючого]]
  • [[12. Який пул потоків використовується за замовчуванням для async методів]]
  • [[13. Як вказати свій Executor для CompletableFuture]]
  • [[16. Що робить метод supplyAsync() і коли його використовувати]]