Недавно наткнулся на коварную проблему, которая отлично демонстрирует важность правильной конфигурации пула потоков при работе с асинхронным программированием. Эта ситуация может возникнуть в любом проекте, где используются вложенные асинхронные операции, и её последствия могут быть катастрофическими для production-системы.
Описание проблемы
В рамках реализации задачи использовался механизм асинхронного программирования CompletableFuture с методом supplyAsync() для запуска вычислительных задач. Для управления и распределения потоков применялся ThreadPoolTaskExecutor, который был сконфигурирован с параметрами по умолчанию — всего один рабочий поток.
Архитектура выполнения задач:
В главном потоке приложения последовательно стартовали три независимые асинхронные задачи (назовём их Task 1, Task 2 и Task 3), которые передавались на исполнение в ThreadPoolTaskExecutor. После запуска всех задач в основном потоке использовалась операция join() для блокировки до завершения всех трёх задач.
При этом Task 1 внутри себя порождала две дочерние асинхронные задачи (Task 1.1 и Task 1.2), которые также исполнялись в рамках того же ThreadPoolTaskExecutor. Для дочерних задач также применялся join(), что означало блокировку текущего потока до их полного завершения.

Механизм возникновения deadlock
Проблема заключается в том, что конфигурация пула с одним потоком создаёт взаимную блокировку:
Единственный рабочий поток начинает выполнять Task 1
Task 1 создаёт Task 1.1 и Task 1.2, которые помещаются в очередь
ThreadPoolTaskExecutorTask 1 блокируется на
join(), ожидая завершения Task 1.1 и Task 1.2Task 1.1 и Task 1.2 не могут начать выполнение, так как единственный поток занят выполнением Task 1
Система входит в состояние deadlock — Task 1 ждёт дочерние задачи, а дочерние задачи ждут освобождения потока
Образуется классическая циклическая зависимость: родительская задача блокирует единственный рабочий поток, ожидая завершения дочерних задач, которые не могут получить доступ к этому потоку для своего выполнения.
Первое, что приходит в голову
Увеличение размера пула потоков:
Минимально необходимое количество потоков должно покрывать максимальную глубину вложенности асинхронных операций. В нашем случае:
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // Достаточно для параллельного выполнения
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
Формула расчёта размера пула:
Для задач с вложенными асинхронными операциями:
Минимальный размер пула = количество параллельных задач × (1 + максимальная глубина вложенности)
В нашем примере: 3 задачи × (1 + 1 уровень вложенности) = 6 потоков (с запасом можно взять 8-10).
Но пул с большим числом потоков и очередью не устраняет корневую причину — блокировку рабочего потока из‑за join внутри того же пула, где должны стартовать дочерние задачи, поэтому риск starvation/дедлока остаётся и лишь «маскируется» масштабированием.
Решение
Не блокировать воркеры: компоновать этапы через thenCompose/thenCombine/allOf и делать единственный join на границе, либо выносить дочерние задачи в отдельный исполнитель или виртуальные потоки для изоляции от блокировок.
Альтернативные подходы
1. Использование отдельных пулов потоков:
// Пул для родительских задач
ThreadPoolTaskExecutor parentExecutor = new ThreadPoolTaskExecutor();
parentExecutor.setCorePoolSize(3);
// Пул для дочерних задач
ThreadPoolTaskExecutor childExecutor = new ThreadPoolTaskExecutor();
childExecutor.setCorePoolSize(5);
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
CompletableFuture<String> task11 = CompletableFuture.supplyAsync(() -> {
return "Result 1.1";
}, childExecutor); // Используем отдельный пул!
return task11.join();
}, parentExecutor);
2. Использование ForkJoinPool:
ForkJoinPool.commonPool() автоматически управляет потоками и лучше подходит для рекурсивных задач:
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
// Автоматически использует ForkJoinPool.commonPool()
CompletableFuture<String> task11 = CompletableFuture.supplyAsync(() -> "Result 1.1");
return task11.join();
});
3. Избегание блокирующих операций:
Вместо join() используйте неблокирующие комбинаторы:
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
CompletableFuture<String> task11 = CompletableFuture.supplyAsync(() -> "Result 1.1", taskExecutor);
CompletableFuture<String> task12 = CompletableFuture.supplyAsync(() -> "Result 1.2", taskExecutor);
return task11.thenCombine(task12, (r1, r2) -> r1 + r2); // Неблокирующая комбинация
}, taskExecutor).thenCompose(Function.identity());
Best Practices
Анализируйте глубину вложенности асинхронных вызовов на этапе проектирования и соотносите её с размером пула потоков
Используйте разные пулы для разных типов задач (CPU-bound и IO-bound)
Избегайте блокирующих операций внутри асинхронных задач — отдавайте предпочтение неблокирующим комбинаторам (
thenCompose,thenCombine,thenApply)Настраивайте мониторинг пулов потоков для раннего обнаружения проблем
Документируйте зависимости между асинхронными задачами в архитектуре приложения
Проводите нагрузочное тестирование для выявления deadlock в реальных условиях
Так же я веду телеграмм-канал для BE-разработчиков, где делюсь своими мыслями и рассказываю про подобные кейсы: Слушай, я тут разобрался!
Комментарии (12)

cuba001
20.10.2025 09:35Еще бы увидеть код с этой ситуацией.

kolpakovee Автор
20.10.2025 09:35// Плохая конфигурация (причина deadlock) ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(1); // Всего ОДИН поток! executor.initialize(); // Главный поток CompletableFuture<?> task1 = CompletableFuture.supplyAsync(() -> { // Внутри задачи 1... CompletableFuture<?> subTask1 = CompletableFuture.supplyAsync(..., executor); CompletableFuture<?> subTask2 = CompletableFuture.supplyAsync(..., executor); // БЛОКИРОВКА! Используем allOf().join() для ожидания всех подзадач return CompletableFuture.allOf(subTask1, subTask2).join(); }, executor); CompletableFuture<?> task2 = CompletableFuture.supplyAsync(..., executor); CompletableFuture.allOf(task1, task2).join(); // Вечное ожидание...

ris58h
20.10.2025 09:35Странно, что решением выбран костыль с докинуть
памяпотоков, вместо отказа от блокировок.
kolpakovee Автор
20.10.2025 09:35Валидно, но пул потоков в любом случае нужно было расширять

Deosis
20.10.2025 09:35Вместо решения проблемы, её замели под ковер.

kolpakovee Автор
20.10.2025 09:35В конечном итоге пришли к тому, чтобы не блокировать потоки в дочерних задачах

shtanko-a-o
20.10.2025 09:35Можно использовать эластичный пул
publicExecutorService myExecutorService() {BlockingQueue<Runnable> queue =newArrayBlockingQueue<>(1000); ThreadPoolExecutor threadPoolExecutor =newThreadPoolExecutor(1000, 1000, 60L, TimeUnit.SECONDS, queue,newThreadPoolExecutor.AbortPolicy()); threadPoolExecutor.allowCoreThreadTimeOut(true);returnthreadPoolExecutor;}
kolpakovee Автор
20.10.2025 09:35«эластичный» пул с большим числом потоков и очередью не устраняет корневую причину — блокировку рабочего потока из‑за join внутри того же пула, где должны стартовать дочерние задачи, поэтому риск starvation/дедлока остаётся и лишь «маскируется» масштабированием. Решение — не блокировать воркеры: компоновать этапы через thenCompose/thenCombine/allOf и делать единственный join на границе, либо выносить дочерние задачи в отдельный исполнитель или виртуальные потоки для изоляции от блокировок.
remindscope
С появлением виртуальных потоков я бы добавил, что для IO-bound задач в принципе больше нет необходимости в пуле потоков, будет достаточно Executors.newVitrualThreadPerTaskExecutor()
kolpakovee Автор
Валидно, но я не придумал как подружить их с TaskDecorator (так как контекст логирования ThreadLocal и не передается между потоками по умолчанию)
remindscope
Можно попробовать использовать InheritableThreadLocal
kolpakovee Автор
Рассмотрю это решение