@Scheduled @Async and Custom ThreadPools

@Scheduled @Async and Custom ThreadPools

Caveats of Springs AutoConfiguration


4 min read

In growing larger application you most likely get to the point where you want to introduce @Async methods to leverage Springs threading. You do set a @EnableAsync annotation to a @Configuration Bean and you are done. Spring does the rest for you.

@Async and Custom ThreadPool

For larger demands where you don't want to have an uncapped amount of threads or applications where you want to manage e.g. queues, you most likely need a custom ThreadPool and define something equivalent to this:

fun customThreadPool(): Executor {
  val corePoolSize = 10
  return Executors.newFixedThreadPool(corePoolSize)

// in some service

fun asyncDefaultPool() {
  logger.info { "Check my thread - I am async with the default ThreadPool" }

fun asyncCustomPool() {
  logger.info { "Check my thread - I am async with custom ThreadPool" }

Starting an application with this setup and calling these methods will lead to the following log line messages:

// calling asyncCustomPool()
2023-09-18 11:57:29.513  INFO 83349 --- [pool-1-thread-1] c.e.asyncschedulingdemo.SomethingAsyncs  : Check my thread - I am async with custom ThreadPool

// calling asyncDefaultPool()
2023-09-18 12:00:06.293  INFO 83661 --- [nio-8082-exec-1] .s.a.AnnotationAsyncExecutionInterceptor : No task executor bean found for async processing: no bean of type TaskExecutor and no bean named 'taskExecutor' either
2023-09-18 12:00:06.322  INFO 83661 --- [cTaskExecutor-1] c.e.asyncschedulingdemo.SomethingAsyncs  : Check my thread - I am async with the default ThreadPool

The first log line for the asyncCustomPool() looks fine. We see that it uses a thread pool name from the DefaultThreadFactory, but what about the other method? We see two lines, the first one comes from the web context and tells us that there is no bean present for the second thread pool and in the following line we can see that a pool named cTaskExecutor-1 is used. The actual name is SimpleAsyncTaskExecutor and the logger did just shorten it. The SimpleAsyncTaskExecutor is probably your least favorable executor because it creates a new thread for every async invocation which is very expensive.

But why is this the case? Let's have a look at the TaskExecutionAutoConfiguration and its default ThreadPoolTaskExecutor

  name = {"applicationTaskExecutor", "taskExecutor"}
@ConditionalOnMissingBean({Executor.class}) // <- note this line here!!
public ThreadPoolTaskExecutor applicationTaskExecutor(TaskExecutorBuilder builder) {
    return builder.build();

This means as long as there is already a Bean of class Executor is present in the context, the default pool will not be instantiated and Spring then falls back to the SimpleAsyncTaskExecutor.

protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
    Executor defaultExecutor = super.getDefaultExecutor(beanFactory);
    return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor());
So to check whether your default pool is affected or not, just check if a Bean named applicationTaskExecutor is present in your context. If it is missing, you might want to change that.

Also Using @Scheduled

In Spring Boot 3 virtual threads are introduced for TaskSchedulingConfigurations. The following snippets and examples might differ, as this article uses Spring Boot 2.X as a basis

If your application also features scheduled methods e.g. some cleanups, then the behavior slightly differs from the one described above.

2023-09-18 12:21:54.451  INFO 86166 --- [pool-1-thread-1] c.e.asyncschedulingdemo.SomethingAsyncs  : Check my thread - I am async with custom ThreadPool
2023-09-18 12:21:54.452  INFO 86166 --- [   scheduling-1] c.e.asyncschedulingdemo.SomethingAsyncs  : Check my thread - I am async with the default ThreadPool
2023-09-18 12:21:57.563  INFO 86166 --- [   scheduling-1] c.e.a.SomethingSchedules                 : Check my thread - I am scheduled

The default async task now uses the scheduling-1 thread pool. This is also not good, as the default configuration only allows for one execution in parallel. You now have a queue running for your default @Async methods. The scheduling pool is used because the AsyncExecutionAspectSupport.getDefaultExecutor method resolves the executor by type and the TaskSchedulingAutoConfiguration provides a taskScheduler which implements the SchedulingTaskExecutor interface (and with this the AsyncTaskExecutor interface and as such finally the TaskExecutor interface)

What do we do now?

I would just recommend re-introducing the default thread pool with a Bean like this:

@Bean(name = ["applicationTaskExecutor", "taskExecutor"])
fun defaultTaskExecutor(builder: TaskExecutorBuilder): Executor{
  return builder.build()

This just adds the standard pool as it is defined by spring back to the context and everything will work out as expected. Overall it is always very helpful to check old and new pools when changing touching configurations regarding @Async and @Scheduled.

Thanks for reading ๐Ÿ‘จ๐Ÿผโ€๐Ÿ’ป