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,57 @@
prev: [Return types and exceptions](04-return-types-and-exceptions.md) · [README](../README.md) · next: [Context propagation](06-context-propagation.md)
# 5. Pool sizing, and why `max-size` usually does nothing
The stock executor, printed from the live bean in
[`docs/output/executor-report.txt`](output/executor-report.txt):
```
applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
```
Eight core threads, an effectively unbounded maximum, and an effectively unbounded queue.
`ThreadPoolTaskExecutor` delegates to `java.util.concurrent.ThreadPoolExecutor`, whose growth
rule is the part people misremember. It is not "grow under load". It is:
1. fewer than `corePoolSize` threads → create a thread;
2. otherwise → offer the task to the queue;
3. **only if the queue refuses** → create a thread, up to `maxPoolSize`;
4. otherwise → reject.
An unbounded queue never refuses. So with the defaults, step 3 is unreachable and `maxPoolSize`
is decoration.
Two runs of the same 16 blocking tasks, differing in one property:
| properties | distinct threads used |
|---|---|
| `core-size=4`, `max-size=12` | **4** ([output](output/pool-unbounded-queue.txt)) |
| `core-size=4`, `max-size=12`, `queue-capacity=4` | **12** ([output](output/pool-bounded-queue.txt)) |
Triple the concurrency, one line of YAML. The corollary is that raising `max-size` alone — the
usual response to a slow async pipeline — changes nothing at all.
## What to set instead
- **Set `queue-capacity` deliberately.** It is the backpressure boundary. Unbounded means an
incident consists of a heap filling up rather than tasks being rejected, which is the worse of
the two failure modes because it takes the whole process with it.
- **Size `core-size` to the work, not to the CPU.** Async work in most Spring applications is I/O
bound, so the useful number is closer to "how many concurrent downstream calls will that
service tolerate" than to `Runtime.availableProcessors()`.
- **Decide what rejection means.** The default policy is `AbortPolicy`, so a full queue and a
full pool produce `RejectedExecutionException` at the *call site* — synchronously, in whichever
thread called the `@Async` method. If you want the caller to absorb the load instead, use
`CallerRunsPolicy`, and understand that you have just made the method synchronous under
saturation.
## Shutdown
`spring.task.execution.shutdown.await-termination` is `false` by default. On shutdown, tasks that
are still queued are simply dropped. If your async work is "send the email" rather than "warm the
cache", set it to `true` and give
`spring.task.execution.shutdown.await-termination-period` a bound.
next: [Context propagation](06-context-propagation.md)