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.
68 lines
3.2 KiB
Markdown
68 lines
3.2 KiB
Markdown
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)
|