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.
This commit is contained in:
2026-09-01 23:27:47 +05:30
commit 243cccd4ca
53 changed files with 1891 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
prev: [Context propagation](06-context-propagation.md) · [README](../README.md) · next: [Virtual threads and pinning](08-virtual-threads-and-pinning.md)
# 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](output/executor-report.txt)) | `applicationTaskExecutor` | `task-1` |
| one custom `Executor` ([output](output/executor-one-custom.txt)) | `myExecutor` | `mine-1` |
| two custom `Executor`s ([output](output/executor-two-custom.txt)) | `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](output/executor-force-mode.txt)). 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](08-virtual-threads-and-pinning.md)