@Scheduled @Async and Custom ThreadPools
Caveats of Springs AutoConfiguration
Table of contents
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:
@Bean("customThreadPool")
fun customThreadPool(): Executor {
val corePoolSize = 10
return Executors.newFixedThreadPool(corePoolSize)
}
// in some service
@Asnyc
fun asyncDefaultPool() {
logger.info { "Check my thread - I am async with the default ThreadPool" }
}
@Async("customThreadPool")
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
@Lazy
@Bean(
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
.
@Override
@Nullable
protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
Executor defaultExecutor = super.getDefaultExecutor(beanFactory);
return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor());
}
applicationTaskExecutor
is present in your context. If it is missing, you might want to change that.Also Using @Scheduled
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 ๐จ๐ผโ๐ป