Недавно наткнулся на коварную проблему, которая отлично демонстрирует важность правильной конфигурации пула потоков при работе с асинхронным программированием. Эта ситуация может возникнуть в любом проекте, где используются вложенные асинхронные операции, и её последствия могут быть катастрофическими для production-системы.

Описание проблемы

В рамках реализации задачи использовался механизм асинхронного программирования CompletableFuture с методом supplyAsync() для запуска вычислительных задач. Для управления и распределения потоков применялся ThreadPoolTaskExecutor, который был сконфигурирован с параметрами по умолчанию — всего один рабочий поток.

Архитектура выполнения задач:

В главном потоке приложения последовательно стартовали три независимые асинхронные задачи (назовём их Task 1, Task 2 и Task 3), которые передавались на исполнение в ThreadPoolTaskExecutor. После запуска всех задач в основном потоке использовалась операция join() для блокировки до завершения всех трёх задач.

При этом Task 1 внутри себя порождала две дочерние асинхронные задачи (Task 1.1 и Task 1.2), которые также исполнялись в рамках того же ThreadPoolTaskExecutor. Для дочерних задач также применялся join(), что означало блокировку текущего потока до их полного завершения.

Механизм возникновения deadlock

Проблема заключается в том, что конфигурация пула с одним потоком создаёт взаимную блокировку:

  1. Единственный рабочий поток начинает выполнять Task 1

  2. Task 1 создаёт Task 1.1 и Task 1.2, которые помещаются в очередь ThreadPoolTaskExecutor

  3. Task 1 блокируется на join(), ожидая завершения Task 1.1 и Task 1.2

  4. Task 1.1 и Task 1.2 не могут начать выполнение, так как единственный поток занят выполнением Task 1

  5. Система входит в состояние 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

  1. Анализируйте глубину вложенности асинхронных вызовов на этапе проектирования и соотносите её с размером пула потоков

  2. Используйте разные пулы для разных типов задач (CPU-bound и IO-bound)

  3. Избегайте блокирующих операций внутри асинхронных задач — отдавайте предпочтение неблокирующим комбинаторам (thenComposethenCombinethenApply)

  4. Настраивайте мониторинг пулов потоков для раннего обнаружения проблем

  5. Документируйте зависимости между асинхронными задачами в архитектуре приложения

  6. Проводите нагрузочное тестирование для выявления deadlock в реальных условиях

Так же я веду телеграмм-канал для BE-разработчиков, где делюсь своими мыслями и рассказываю про подобные кейсы: Слушай, я тут разобрался!