Недавно наткнулся на коварную проблему, которая отлично демонстрирует важность правильной конфигурации пула потоков при работе с асинхронным программированием. Эта ситуация может возникнуть в любом проекте, где используются вложенные асинхронные операции, и её последствия могут быть катастрофическими для 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-разработчиков, где делюсь своими мыслями и рассказываю про подобные кейсы: Слушай, я тут разобрался!

Комментарии (12)


  1. remindscope
    20.10.2025 09:35

    С появлением виртуальных потоков я бы добавил, что для IO-bound задач в принципе больше нет необходимости в пуле потоков, будет достаточно Executors.newVitrualThreadPerTaskExecutor()


    1. kolpakovee Автор
      20.10.2025 09:35

      Валидно, но я не придумал как подружить их с TaskDecorator (так как контекст логирования ThreadLocal и не передается между потоками по умолчанию)


      1. remindscope
        20.10.2025 09:35

        Можно попробовать использовать InheritableThreadLocal


        1. kolpakovee Автор
          20.10.2025 09:35

          Рассмотрю это решение


  1. cuba001
    20.10.2025 09:35

    Еще бы увидеть код с этой ситуацией.


    1. 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(); // Вечное ожидание...


  1. ris58h
    20.10.2025 09:35

    Странно, что решением выбран костыль с докинуть памя потоков, вместо отказа от блокировок.


    1. kolpakovee Автор
      20.10.2025 09:35

      Валидно, но пул потоков в любом случае нужно было расширять


      1. Deosis
        20.10.2025 09:35

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


        1. kolpakovee Автор
          20.10.2025 09:35

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


  1. shtanko-a-o
    20.10.2025 09:35

    Можно использовать эластичный пул
    public ExecutorService myExecutorService() {

    BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000); ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1000, 1000, 60L, TimeUnit.SECONDS, queue, new ThreadPoolExecutor.AbortPolicy()); threadPoolExecutor.allowCoreThreadTimeOut(true);

    return threadPoolExecutor;

    }


    1. kolpakovee Автор
      20.10.2025 09:35

      «эластичный» пул с большим числом потоков и очередью не устраняет корневую причину — блокировку рабочего потока из‑за join внутри того же пула, где должны стартовать дочерние задачи, поэтому риск starvation/дедлока остаётся и лишь «маскируется» масштабированием. Решение — не блокировать воркеры: компоновать этапы через thenCompose/thenCombine/allOf и делать единственный join на границе, либо выносить дочерние задачи в отдельный исполнитель или виртуальные потоки для изоляции от блокировок.