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:
57
async/docs/05-pool-sizing.md
Normal file
57
async/docs/05-pool-sizing.md
Normal 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)
|
||||
Reference in New Issue
Block a user