Files
spring-async-demo/async/docs/07-which-executor-runs-it.md
Ankur Mhatre 243cccd4ca Add the async module
Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2026-09-01 23:27:47 +05:30

3.2 KiB

prev: Context propagation · README · next: Virtual threads and pinning

7. Which executor actually runs it

Two independent decisions are involved, and conflating them is the source of most of the surprise.

Decision one — does Boot create applicationTaskExecutor? TaskExecutorConfigurations gates it on OnExecutorCondition, an AnyNestedCondition whose arms are "there is no Executor bean" and "spring.task.execution.mode is force". So any Executor bean of your own removes Boot's — along with its properties, its thread-name prefix, and its context-propagation decorator.

Decision two — which executor does @Async resolve? AsyncExecutionAspectSupport asks the bean factory for a unique bean of type TaskExecutor. Failing that, it looks for a bean named exactly taskExecutor. Failing that, it falls back to a plain SimpleAsyncTaskExecutor.

Three contexts, all captured:

context executor beans @Async ran on
stock (output) applicationTaskExecutor task-1
one custom Executor (output) myExecutor mine-1
two custom Executors (output) reportsExecutor, emailsExecutor SimpleAsyncTaskExecutor-1

The third row is the one that hurts. Neither of your carefully sized two-thread pools is used. SimpleAsyncTaskExecutor starts a brand new platform thread for every single call and has no bound, so an application that was throttled to two concurrent report generations is now unthrottled, and the symptom is thread exhaustion under load rather than anything at the point of the change.

It is not entirely silent — the interceptor logs it, once, at INFO:

o.s.a.i.AnnotationAsyncExecutionInterceptor : More than one TaskExecutor bean found within
the context, and none is named 'taskExecutor'. Mark one of them as primary or name it
'taskExecutor' (possibly as an alias) in order to use it for async processing:
[reportsExecutor, emailsExecutor]

INFO, on first use, in the middle of startup noise. In practice nobody sees it.

The three ways out

  • @Async("reportsExecutor") — name the executor at each call site. Explicit, and it survives someone adding a third executor later. This is the right answer when the executors genuinely differ in purpose.
  • spring.task.execution.mode=force — Boot creates applicationTaskExecutor alongside yours, and @Async resolves it (output). Use it when your extra Executor beans exist for something other than @Async and you did not mean to disturb it.
  • Name one of them taskExecutor, or mark it @Primary. Works, and reads like an accident to the next person.

The diagnostic

ExecutorDiagnostics prints every Executor bean, its class, and the real pool numbers off the live object. Two lines of it answer questions that otherwise take an afternoon:

Bean named 'taskExecutor' present: false
Bean named 'applicationTaskExecutor' present: false

Delete it before shipping. It is a diagnostic, not a feature.

next: Virtual threads and pinning