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.
65 lines
3.9 KiB
Markdown
65 lines
3.9 KiB
Markdown
# async
|
|
|
|
Companion project for **@Async in Spring Boot 4: Executors, Virtual Threads and the
|
|
Self-Invocation Trap** on [ankurm.com](https://ankurm.com).
|
|
|
|
Everything here is asserted by a test and captured under [`docs/output/`](docs/output). The
|
|
measurement is always the same: the name of the thread the method body actually ran on, returned
|
|
by the method itself. Timing cannot distinguish a fast synchronous call from an asynchronous one;
|
|
a thread name can.
|
|
|
|
## Verified stack
|
|
|
|
| Component | Version | Source of the number |
|
|
|---|---|---|
|
|
| JDK | 25.0.4.1+1 (Temurin) | `java -version` |
|
|
| Spring Boot | 4.1.1 | `maven-metadata.xml` on Maven Central |
|
|
| Spring Framework | 7.0.9 | `spring-boot-dependencies-4.1.1.pom` |
|
|
| micrometer context-propagation | Boot-managed | `spring-boot-dependencies-4.1.1.pom` |
|
|
| JDK used for the pinning comparison | 21.0.12.1+1 (Temurin) | `java -version` |
|
|
|
|
## Quickstart
|
|
|
|
```bash
|
|
mvn test # 22 tests, all of the transcripts
|
|
scripts/run-all.sh # regenerates every docs/output/ file
|
|
scripts/run-all.sh /path/to/jdk21/bin/java # adds the JDK 21 vs 25 pinning comparison
|
|
```
|
|
|
|
## Documentation
|
|
|
|
| Chapter | What it settles |
|
|
|---|---|
|
|
| [01 What `@Async` actually does](docs/01-what-async-actually-does.md) | The proxy is the whole mechanism, and `@EnableAsync` is not automatic |
|
|
| [02 The self-invocation trap](docs/02-the-self-invocation-trap.md) | Why an internal call runs inline, and the three fixes |
|
|
| [03 What the proxy cannot see](docs/03-what-the-proxy-cannot-see.md) | `final` and `private` methods, and how to tell that case from a self-invocation |
|
|
| [04 Return types and exceptions](docs/04-return-types-and-exceptions.md) | A plain return type throws; it does not return null |
|
|
| [05 Pool sizing](docs/05-pool-sizing.md) | Why `max-size` does nothing until `queue-capacity` is bounded |
|
|
| [06 Context propagation](docs/06-context-propagation.md) | `spring.task.execution.propagate-context`, new in Boot 4.1.0 |
|
|
| [07 Which executor runs it](docs/07-which-executor-runs-it.md) | Two `Executor` beans and `@Async` quietly uses neither |
|
|
| [08 Virtual threads and pinning](docs/08-virtual-threads-and-pinning.md) | JEP 491 measured on JDK 21 against JDK 25 |
|
|
|
|
## Captured output
|
|
|
|
| File | What it shows |
|
|
|---|---|
|
|
| [`self-invocation.txt`](docs/output/self-invocation.txt) | The same method on `task-5`, on `main`, and on `task-6` |
|
|
| [`visibility.txt`](docs/output/visibility.txt) | `final` is inert; `protected` is not |
|
|
| [`return-types.txt`](docs/output/return-types.txt) | The `IllegalArgumentException` a plain return type throws |
|
|
| [`executor-report.txt`](docs/output/executor-report.txt) | Stock pool numbers, read off the live bean |
|
|
| [`executor-one-custom.txt`](docs/output/executor-one-custom.txt) | Boot backing off in favour of one custom `Executor` |
|
|
| [`executor-two-custom.txt`](docs/output/executor-two-custom.txt) | Two custom executors and neither is used |
|
|
| [`executor-force-mode.txt`](docs/output/executor-force-mode.txt) | `spring.task.execution.mode=force` restoring it |
|
|
| [`pool-unbounded-queue.txt`](docs/output/pool-unbounded-queue.txt) | 16 tasks, 4 threads, `max-size=12` ignored |
|
|
| [`pool-bounded-queue.txt`](docs/output/pool-bounded-queue.txt) | The same 16 tasks on 12 threads |
|
|
| [`virtual-threads.txt`](docs/output/virtual-threads.txt) | `SimpleAsyncTaskExecutor`, `virtual=true`, pool properties inert |
|
|
| [`context-propagation.txt`](docs/output/context-propagation.txt) | A `ThreadLocal` surviving the hop |
|
|
| [`pinning-probe.txt`](docs/output/pinning-probe.txt) | 4806 ms on JDK 21, 301 ms on JDK 25 |
|
|
| [`tests.txt`](docs/output/tests.txt) | The test run behind all of the above |
|
|
|
|
## The one diagnostic
|
|
|
|
`ExecutorDiagnostics` prints every `Executor` bean in the context with its real core size, max
|
|
size and queue capacity. It is the fastest way to answer "which executor is actually running
|
|
this". Delete it before shipping.
|