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:
50
async/docs/01-what-async-actually-does.md
Normal file
50
async/docs/01-what-async-actually-does.md
Normal file
@@ -0,0 +1,50 @@
|
||||
[README](../README.md) · next: [The self-invocation trap](02-the-self-invocation-trap.md)
|
||||
|
||||
# 1. What `@Async` actually does
|
||||
|
||||
`@Async` is not a keyword and it is not a thread. It is a marker that
|
||||
[`AsyncAnnotationBeanPostProcessor`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/AsyncAnnotationBeanPostProcessor.html)
|
||||
looks for while the context is being built. When it finds one, it wraps the bean in a proxy. The
|
||||
proxy's version of the method does not call your code; it wraps your code in a `Callable`, hands
|
||||
that to an `AsyncTaskExecutor`, and returns immediately.
|
||||
|
||||
Three consequences follow from that sentence, and between them they explain almost every
|
||||
`@Async` question ever asked:
|
||||
|
||||
1. **The proxy is the whole mechanism.** A call that does not go through the proxy is not
|
||||
asynchronous. See [chapter 2](02-the-self-invocation-trap.md) and
|
||||
[chapter 3](03-what-the-proxy-cannot-see.md).
|
||||
2. **The return value has to be produced before your code runs.** So the return type is
|
||||
constrained, and exceptions have nowhere obvious to go. See
|
||||
[chapter 4](04-return-types-and-exceptions.md).
|
||||
3. **Which executor gets the `Callable` is resolved separately, by type and then by name.** It is
|
||||
not necessarily the one you configured. See [chapter 7](07-which-executor-runs-it.md).
|
||||
|
||||
## `@EnableAsync` is not automatic
|
||||
|
||||
Spring Boot auto-configures the *executor*. It does not enable the *annotation*. Without
|
||||
`@EnableAsync` somewhere in the context, no post-processor is installed, no proxy is created, and
|
||||
every `@Async` method in the application runs on its caller's thread. There is no warning at any
|
||||
log level, because from Spring's point of view nothing unusual has happened — you have a bean
|
||||
with an annotation nobody asked it to process.
|
||||
|
||||
`AsyncDemoApplication` carries the annotation for exactly this reason.
|
||||
|
||||
## The evidence in this module
|
||||
|
||||
Every claim in these chapters is asserted by a test and captured in `docs/output/`. The
|
||||
measurement is always the same one: the name of the thread the method body actually ran on,
|
||||
returned from the method itself.
|
||||
|
||||
```
|
||||
caller thread : main (virtual=false)
|
||||
service.annotated() : task-5 (virtual=false)
|
||||
service.viaSelfInvocation() : main (virtual=false)
|
||||
service.viaSelfReference() : task-6 (virtual=false)
|
||||
```
|
||||
|
||||
Timing cannot tell you this. A method that runs synchronously in 3 ms and a method that runs on a
|
||||
pool thread in 3 ms look identical from the outside, which is why `@Async` failures survive so
|
||||
long in production.
|
||||
|
||||
next: [The self-invocation trap](02-the-self-invocation-trap.md)
|
||||
63
async/docs/02-the-self-invocation-trap.md
Normal file
63
async/docs/02-the-self-invocation-trap.md
Normal file
@@ -0,0 +1,63 @@
|
||||
prev: [What `@Async` actually does](01-what-async-actually-does.md) · [README](../README.md) · next: [What the proxy cannot see](03-what-the-proxy-cannot-see.md)
|
||||
|
||||
# 2. The self-invocation trap
|
||||
|
||||
The proxy wraps the bean. It does not replace `this`.
|
||||
|
||||
```java
|
||||
@Async
|
||||
public CompletableFuture<String> annotated() { ... }
|
||||
|
||||
public CompletableFuture<String> viaSelfInvocation() {
|
||||
return annotated(); // this.annotated() — the proxy is not involved
|
||||
}
|
||||
```
|
||||
|
||||
When another bean calls `service.annotated()`, it holds a reference to the *proxy*, so the call
|
||||
is intercepted. When `viaSelfInvocation()` calls `annotated()`, the compiler emits
|
||||
`this.annotated()`, and `this` inside the method body is the *target* object, not the proxy.
|
||||
There is no interception, so the method runs inline and the `CompletableFuture` you get back is
|
||||
already complete.
|
||||
|
||||
Measured, in [`docs/output/self-invocation.txt`](output/self-invocation.txt):
|
||||
|
||||
```
|
||||
service.annotated() : task-5 (virtual=false)
|
||||
service.viaSelfInvocation() : main (virtual=false)
|
||||
service.viaSelfReference() : task-6 (virtual=false)
|
||||
```
|
||||
|
||||
Nothing is logged. The method returns a valid future with the correct value in it. The only
|
||||
symptom is that the caller waited.
|
||||
|
||||
## The fixes, in the order you should prefer them
|
||||
|
||||
**Move the method to another bean.** If work is asynchronous, it belongs behind a boundary, and
|
||||
the boundary is a good place for a class. This is the fix that survives a refactor.
|
||||
|
||||
**Go back out through the proxy on purpose.** `SelfInvocationService` does this with an
|
||||
`ObjectProvider<SelfInvocationService>`:
|
||||
|
||||
```java
|
||||
public CompletableFuture<String> viaSelfReference() {
|
||||
return self.getObject().annotated();
|
||||
}
|
||||
```
|
||||
|
||||
`ObjectProvider` is lazy, so there is no circular dependency to resolve at construction time.
|
||||
Injecting `SelfInvocationService` directly also works — Spring special-cases self-references —
|
||||
but it looks like a bug to every reviewer who has not read this chapter.
|
||||
|
||||
**`@EnableAsync(mode = AdviceMode.ASPECTJ)`** removes the limitation entirely, because
|
||||
load-time weaving rewrites the method itself rather than wrapping the object. It also adds an
|
||||
agent to your startup and a weaving configuration to your build. Very few applications should
|
||||
pay that.
|
||||
|
||||
## Why this keeps happening
|
||||
|
||||
The trap is not that people do not know about proxies. It is that the calling code often did not
|
||||
start out in the same class. A method gets extracted, a controller's logic moves into the
|
||||
service that already had the `@Async` method, and one day a call that used to arrive from outside
|
||||
arrives from inside. Nothing fails. The throughput just changes.
|
||||
|
||||
next: [What the proxy cannot see](03-what-the-proxy-cannot-see.md)
|
||||
48
async/docs/03-what-the-proxy-cannot-see.md
Normal file
48
async/docs/03-what-the-proxy-cannot-see.md
Normal file
@@ -0,0 +1,48 @@
|
||||
prev: [The self-invocation trap](02-the-self-invocation-trap.md) · [README](../README.md) · next: [Return types and exceptions](04-return-types-and-exceptions.md)
|
||||
|
||||
# 3. What the proxy cannot see
|
||||
|
||||
Spring Boot proxies with CGLIB by default, so the proxy is a generated *subclass* of your bean:
|
||||
|
||||
```
|
||||
proxy class : com.ankurm.async.VisibilityService$$SpringCGLIB$$0
|
||||
isCglibProxy: true
|
||||
```
|
||||
|
||||
A subclass can override public and protected methods. It cannot override `final` ones, and it
|
||||
cannot see `private` ones. `@Async` on either is inert, and Spring does not complain.
|
||||
|
||||
From [`docs/output/visibility.txt`](output/visibility.txt):
|
||||
|
||||
```
|
||||
publicMethod() : task-3 (virtual=false)
|
||||
finalMethod() : main (virtual=false)
|
||||
protectedMethod() via the proxy : task-4 (virtual=false)
|
||||
callProtectedInternally() : main (virtual=false)
|
||||
```
|
||||
|
||||
Two things in that transcript are worth separating, because they look the same and are not:
|
||||
|
||||
- `finalMethod()` ran on `main` because **CGLIB could not override it**. Calling it from another
|
||||
bean would not help.
|
||||
- `callProtectedInternally()` ran on `main` because it is a **self-invocation** (chapter 2).
|
||||
`protectedMethod()` itself is perfectly proxyable, and running on `task-4` when the test calls
|
||||
it through the proxy proves it.
|
||||
|
||||
So "it ran on the caller's thread" has at least two distinct causes, and the fix differs. The
|
||||
test in `VisibilityTest` can call a protected method at all only because it lives in the same
|
||||
package as the service.
|
||||
|
||||
## The `final` class case
|
||||
|
||||
A `final` class cannot be subclassed either, so CGLIB cannot proxy it at all. That one *does*
|
||||
fail loudly — context startup throws — which makes it much less dangerous than a `final` method.
|
||||
|
||||
## Kotlin
|
||||
|
||||
Kotlin classes and members are `final` unless declared `open`. A Kotlin service with `@Async` and
|
||||
no `open` keyword, and no `kotlin-spring` compiler plugin, is the `final` case above. The
|
||||
`kotlin-spring` plugin exists to open Spring-annotated classes automatically; it does not open
|
||||
methods annotated only with `@Async` unless the class-level rule already applies.
|
||||
|
||||
next: [Return types and exceptions](04-return-types-and-exceptions.md)
|
||||
61
async/docs/04-return-types-and-exceptions.md
Normal file
61
async/docs/04-return-types-and-exceptions.md
Normal file
@@ -0,0 +1,61 @@
|
||||
prev: [What the proxy cannot see](03-what-the-proxy-cannot-see.md) · [README](../README.md) · next: [Pool sizing](05-pool-sizing.md)
|
||||
|
||||
# 4. Return types, and where the exceptions go
|
||||
|
||||
The proxy must return something to the caller before your method has run. That limits what it
|
||||
can return, and `AsyncExecutionAspectSupport.doSubmit` enumerates the cases:
|
||||
|
||||
| declared return type | what the caller gets |
|
||||
|---|---|
|
||||
| `CompletableFuture<T>` | the future from `executor.submitCompletable(task)` |
|
||||
| `Future<T>` | the future from `executor.submit(task)` |
|
||||
| `void` (or Kotlin `Unit`) | `null`, after submitting |
|
||||
| anything else | **`IllegalArgumentException`, thrown at the call site** |
|
||||
|
||||
That last row is worth stating plainly, because a great deal of writing on `@Async` claims such a
|
||||
method "returns null". It does not. From [`docs/output/return-types.txt`](output/return-types.txt):
|
||||
|
||||
```
|
||||
plainString() : java.lang.IllegalArgumentException: Invalid return type for async method
|
||||
(only Future and void supported): class java.lang.String
|
||||
```
|
||||
|
||||
The method body never runs. Nothing rejects the signature at startup, so this is a runtime
|
||||
failure on whichever code path reaches it first — which may be a rarely exercised one.
|
||||
|
||||
## Exceptions
|
||||
|
||||
An exception from a `Future`-returning method is delivered through the future. The caller sees it
|
||||
if the caller calls `get()` or `join()`, and never sees it otherwise:
|
||||
|
||||
```
|
||||
futureThatThrows().get() : IllegalStateException: thrown from a CompletableFuture @Async method
|
||||
```
|
||||
|
||||
An exception from a `void` method has nowhere to go. It is handed to the configured
|
||||
`AsyncUncaughtExceptionHandler`; the default is `SimpleAsyncUncaughtExceptionHandler`, which logs
|
||||
it at `ERROR` and discards it:
|
||||
|
||||
```
|
||||
ERROR ... o.s.a.i.SimpleAsyncUncaughtExceptionHandler :
|
||||
Unexpected exception occurred invoking async method:
|
||||
public void com.ankurm.async.ReturnTypeService.voidThatThrows()
|
||||
java.lang.IllegalStateException: thrown from a void @Async method
|
||||
```
|
||||
|
||||
That log line is the entire error handling of a `void @Async` method in a stock application. It
|
||||
does not increment a metric, it does not fail a health check, and it does not reach the caller.
|
||||
|
||||
Replace it by implementing `AsyncConfigurer` and returning your own handler from
|
||||
`getAsyncUncaughtExceptionHandler()`. Bear in mind that only one `AsyncConfigurer` may exist, and
|
||||
in Boot 4.1 the auto-configuration already contributes one — so supplying your own also takes
|
||||
over executor selection unless you return the auto-configured executor from
|
||||
`getAsyncExecutor()`.
|
||||
|
||||
## The practical rule
|
||||
|
||||
Return `CompletableFuture<T>` even when the caller ignores the value, and make sure something
|
||||
eventually calls `.exceptionally(...)` or `whenComplete(...)`. `void` is for fire-and-forget work
|
||||
whose failure genuinely does not matter, and there is much less of that than people assume.
|
||||
|
||||
next: [Pool sizing](05-pool-sizing.md)
|
||||
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)
|
||||
57
async/docs/06-context-propagation.md
Normal file
57
async/docs/06-context-propagation.md
Normal file
@@ -0,0 +1,57 @@
|
||||
prev: [Pool sizing](05-pool-sizing.md) · [README](../README.md) · next: [Which executor runs it](07-which-executor-runs-it.md)
|
||||
|
||||
# 6. `spring.task.execution.propagate-context` — new in Boot 4.1
|
||||
|
||||
Handing a `Callable` to another thread leaves every `ThreadLocal` behind. That is why the async
|
||||
thread in a stock application sees no MDC entries, no `RequestAttributes`, and no
|
||||
`SecurityContext` — and why "the trace id disappears in the async part" is such a common report.
|
||||
|
||||
Spring Boot 4.1.0 added a property for it:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
task:
|
||||
execution:
|
||||
propagate-context: true
|
||||
```
|
||||
|
||||
It decorates the auto-configured executor with `ContextPropagatingTaskDecorator`, which takes a
|
||||
snapshot of everything registered with micrometer's `ContextRegistry` at submission time and
|
||||
restores it around the task on the executing thread.
|
||||
|
||||
## How new is it, exactly
|
||||
|
||||
New in **4.1.0**. It is absent from `TaskExecutionProperties` in 4.0.8 and in 3.5.9 — checked by
|
||||
extracting `TaskExecutionProperties.class` from each `spring-boot-autoconfigure` jar and running
|
||||
`javap` over it, rather than by reading release notes. The accessor pair
|
||||
`getPropagateContext()`/`setPropagateContext(boolean)` appears first in the 4.1.0 jar.
|
||||
|
||||
Note also what it is *not*: there is no matching `spring.task.scheduling.propagate-context`. The
|
||||
scheduler is not covered.
|
||||
|
||||
## Measured
|
||||
|
||||
`RequestId` in this module is a `ThreadLocal<String>` with a `ThreadLocalAccessor` registered
|
||||
against `ContextRegistry`. Two tests differ only in the property:
|
||||
|
||||
- `ContextNotPropagatedTest` — the async thread reads `null`.
|
||||
- `ContextPropagatedTest` — the async thread reads `req-4711`
|
||||
([output](output/context-propagation.txt)).
|
||||
|
||||
## The three things that make it silently do nothing
|
||||
|
||||
1. **micrometer's `context-propagation` is not on the classpath.** The property is still bound
|
||||
and still accepted; nothing decorates the executor. This module declares
|
||||
`io.micrometer:context-propagation` explicitly for that reason.
|
||||
2. **Nothing registered an accessor.** The snapshot only carries what `ContextRegistry` knows
|
||||
about. Libraries that ship accessors (Micrometer tracing, Reactor) register their own; a
|
||||
`ThreadLocal` of your own does not register itself.
|
||||
3. **The executor is not the auto-configured one.** The decorator is applied by Boot's
|
||||
auto-configuration. Declare your own `Executor` bean (chapter 7) and you have opted out of
|
||||
the property along with everything else Boot was doing.
|
||||
|
||||
`SecurityContext` propagation is a related but separate mechanism, with its own set of ways to
|
||||
get it wrong; that is covered in
|
||||
[Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/).
|
||||
|
||||
next: [Which executor runs it](07-which-executor-runs-it.md)
|
||||
67
async/docs/07-which-executor-runs-it.md
Normal file
67
async/docs/07-which-executor-runs-it.md
Normal 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)
|
||||
70
async/docs/08-virtual-threads-and-pinning.md
Normal file
70
async/docs/08-virtual-threads-and-pinning.md
Normal file
@@ -0,0 +1,70 @@
|
||||
prev: [Which executor runs it](07-which-executor-runs-it.md) · [README](../README.md)
|
||||
|
||||
# 8. Virtual threads, and the pinning advice that expired
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
`applicationTaskExecutor` becomes a `SimpleAsyncTaskExecutor` over virtual threads. From
|
||||
[`docs/output/virtual-threads.txt`](output/virtual-threads.txt):
|
||||
|
||||
```
|
||||
applicationTaskExecutor : org.springframework.core.task.SimpleAsyncTaskExecutor
|
||||
@Async ran on : task-1 (virtual=true)
|
||||
```
|
||||
|
||||
Note that the thread-name prefix is unchanged, so `task-1` alone does not tell you which world
|
||||
you are in. `Thread.currentThread().isVirtual()` does.
|
||||
|
||||
## What you gave up
|
||||
|
||||
The pool properties are still bound and now mean nothing. Boot's own metadata says so for each
|
||||
one: *"Doesn't have an effect if virtual threads are enabled."* `core-size`, `max-size`,
|
||||
`queue-capacity`, `keep-alive` — all inert. The test in `VirtualThreadsTest` sets `core-size=4`
|
||||
and `max-size=12` precisely to show that they are accepted and ignored.
|
||||
|
||||
More importantly, a `SimpleAsyncTaskExecutor` has **no queue and, by default, no concurrency
|
||||
limit**. Cheap threads are not free downstream capacity: ten thousand concurrent `@Async` calls
|
||||
to a service with a twenty-connection pool is ten thousand threads queueing on a semaphore. If
|
||||
you want a bound, set `spring.task.execution.simple.concurrency-limit`, and decide whether
|
||||
`spring.task.execution.simple.reject-tasks-when-limit-reached` should be `true` (fail fast) or
|
||||
left `false` (block the caller).
|
||||
|
||||
## The pinning advice
|
||||
|
||||
Nearly everything written about virtual threads before 2025 tells you to avoid `synchronized`,
|
||||
because a virtual thread that blocks while holding a monitor pins its carrier. JEP 491, delivered
|
||||
in **JDK 24**, removed that.
|
||||
|
||||
`PinningProbe` runs 32 virtual threads, each sleeping 300 ms, with the scheduler limited to two
|
||||
carrier threads — once with the sleep inside a `synchronized` block on an uncontended private
|
||||
monitor, once without. If pinning happens, the guarded run must take about 32 / 2 × 300 ms =
|
||||
4800 ms. The same class file, run on both JVMs
|
||||
([`docs/output/pinning-probe.txt`](output/pinning-probe.txt)):
|
||||
|
||||
```
|
||||
java.version : 21.0.12.1
|
||||
no monitor held : 313 ms
|
||||
blocked inside synchronized: 4806 ms
|
||||
|
||||
java.version : 25.0.4.1
|
||||
no monitor held : 312 ms
|
||||
blocked inside synchronized: 301 ms
|
||||
```
|
||||
|
||||
4806 ms against the 4800 ms the arithmetic predicts, then the whole effect gone.
|
||||
|
||||
**What has not changed:** a virtual thread still pins its carrier while executing a native frame
|
||||
or inside a class initialiser. And `synchronized` is still a mutual-exclusion lock, so a
|
||||
*contended* monitor still serialises your work — JEP 491 removed the carrier-thread cost, not the
|
||||
lock. `ReentrantLock` remains preferable where you want fairness, timeouts or `tryLock`; it is no
|
||||
longer required merely to avoid pinning.
|
||||
|
||||
If you are on JDK 21 — still an LTS, still perfectly reasonable — the old advice is your advice,
|
||||
and the 4806 ms above is what it is protecting you from.
|
||||
|
||||
[README](../README.md)
|
||||
6
async/docs/output/context-propagation.txt
Normal file
6
async/docs/output/context-propagation.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
== spring.task.execution.propagate-context (new in Boot 4.1.0) ==
|
||||
|
||||
caller thread : main (virtual=false)
|
||||
RequestId set : req-4711
|
||||
@Async thread saw: req-4711
|
||||
|
||||
14
async/docs/output/executor-force-mode.txt
Normal file
14
async/docs/output/executor-force-mode.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
== Two custom Executor beans plus spring.task.execution.mode=force ==
|
||||
|
||||
Executor beans in this context: 3
|
||||
reportsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
|
||||
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=reports-
|
||||
emailsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
|
||||
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=emails-
|
||||
applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
|
||||
corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
|
||||
Bean named 'taskExecutor' present: false
|
||||
Bean named 'applicationTaskExecutor' present: true
|
||||
|
||||
@Async ran on : task-1 (virtual=false)
|
||||
|
||||
10
async/docs/output/executor-one-custom.txt
Normal file
10
async/docs/output/executor-one-custom.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
== A single custom Executor bean ==
|
||||
|
||||
Executor beans in this context: 1
|
||||
myExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
|
||||
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=mine-
|
||||
Bean named 'taskExecutor' present: false
|
||||
Bean named 'applicationTaskExecutor' present: false
|
||||
|
||||
@Async ran on : mine-1 (virtual=false)
|
||||
|
||||
8
async/docs/output/executor-report.txt
Normal file
8
async/docs/output/executor-report.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
== Stock Spring Boot 4.1.1 context, nothing configured ==
|
||||
|
||||
Executor beans in this context: 1
|
||||
applicationTaskExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
|
||||
corePoolSize=8 maxPoolSize=2147483647 queueCapacity=2147483647 threadNamePrefix=task-
|
||||
Bean named 'taskExecutor' present: false
|
||||
Bean named 'applicationTaskExecutor' present: true
|
||||
|
||||
12
async/docs/output/executor-two-custom.txt
Normal file
12
async/docs/output/executor-two-custom.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
== Two custom Executor beans, no 'taskExecutor' ==
|
||||
|
||||
Executor beans in this context: 2
|
||||
reportsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
|
||||
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=reports-
|
||||
emailsExecutor -> org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor [TaskExecutor]
|
||||
corePoolSize=2 maxPoolSize=2 queueCapacity=2147483647 threadNamePrefix=emails-
|
||||
Bean named 'taskExecutor' present: false
|
||||
Bean named 'applicationTaskExecutor' present: false
|
||||
|
||||
@Async ran on : SimpleAsyncTaskExecutor-1 (virtual=false)
|
||||
|
||||
20
async/docs/output/pinning-probe.txt
Normal file
20
async/docs/output/pinning-probe.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
---------------------------------------------------------------
|
||||
java.version : 21.0.12.1
|
||||
jdk.virtualThreadScheduler.parallelism: 2
|
||||
tasks=32 sleep=300ms
|
||||
|
||||
no monitor held : 313 ms
|
||||
blocked inside synchronized: 4806 ms
|
||||
|
||||
Pinned would be about 4800 ms for the synchronized run (32 tasks / 2 carriers x 300 ms).
|
||||
Not pinned is about 300 ms for both.
|
||||
---------------------------------------------------------------
|
||||
java.version : 25.0.4.1
|
||||
jdk.virtualThreadScheduler.parallelism: 2
|
||||
tasks=32 sleep=300ms
|
||||
|
||||
no monitor held : 312 ms
|
||||
blocked inside synchronized: 301 ms
|
||||
|
||||
Pinned would be about 4800 ms for the synchronized run (32 tasks / 2 carriers x 300 ms).
|
||||
Not pinned is about 300 ms for both.
|
||||
6
async/docs/output/pool-bounded-queue.txt
Normal file
6
async/docs/output/pool-bounded-queue.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
== core-size=4, max-size=12, queue-capacity=4, 16 blocking tasks ==
|
||||
|
||||
distinct threads that ran a task : 12
|
||||
thread names : [task-6, task-7, task-11, task-12, task-10, task-8, task-9, task-1, task-2, task-3, task-4, task-5]
|
||||
4 core threads, 4 tasks queued, 8 more threads created up to max-size.
|
||||
|
||||
6
async/docs/output/pool-unbounded-queue.txt
Normal file
6
async/docs/output/pool-unbounded-queue.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
== core-size=4, max-size=12, queue-capacity=<unbounded default>, 16 blocking tasks ==
|
||||
|
||||
distinct threads that ran a task : 4
|
||||
thread names : [task-1, task-2, task-3, task-4]
|
||||
max-size had no effect: the queue never refused a task.
|
||||
|
||||
11
async/docs/output/return-types.txt
Normal file
11
async/docs/output/return-types.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
== What each @Async return type hands back ==
|
||||
|
||||
completableFuture().get() : task-5 (virtual=false)
|
||||
plainString() : java.lang.IllegalArgumentException: Invalid return type for async method (only Future and void supported): class java.lang.String
|
||||
futureThatThrows().get() : IllegalStateException: thrown from a CompletableFuture @Async method
|
||||
voidThatThrows() : returned normally. The exception went to
|
||||
SimpleAsyncUncaughtExceptionHandler, which logs it at
|
||||
ERROR under the logger
|
||||
o.s.a.i.SimpleAsyncUncaughtExceptionHandler and
|
||||
discards it. The caller is never told.
|
||||
|
||||
7
async/docs/output/self-invocation.txt
Normal file
7
async/docs/output/self-invocation.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
== Which thread each call actually ran on (Boot 4.1.1, JDK 25) ==
|
||||
|
||||
caller thread : main (virtual=false)
|
||||
service.annotated() : task-7 (virtual=false)
|
||||
service.viaSelfInvocation() : main (virtual=false)
|
||||
service.viaSelfReference() : task-8 (virtual=false)
|
||||
|
||||
26
async/docs/output/tests.txt
Normal file
26
async/docs/output/tests.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
[INFO] Running com.ankurm.async.ContextNotPropagatedTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.804 s -- in com.ankurm.async.ContextNotPropagatedTest
|
||||
[INFO] Running com.ankurm.async.VisibilityTest
|
||||
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.068 s -- in com.ankurm.async.VisibilityTest
|
||||
[INFO] Running com.ankurm.async.VirtualThreadsTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.204 s -- in com.ankurm.async.VirtualThreadsTest
|
||||
[INFO] Running com.ankurm.async.ExecutorReportTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.015 s -- in com.ankurm.async.ExecutorReportTest
|
||||
[INFO] Running com.ankurm.async.BoundedQueueTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.612 s -- in com.ankurm.async.BoundedQueueTest
|
||||
[INFO] Running com.ankurm.async.OwnExecutorTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.102 s -- in com.ankurm.async.OwnExecutorTest
|
||||
[INFO] Running com.ankurm.async.UnboundedQueueTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.624 s -- in com.ankurm.async.UnboundedQueueTest
|
||||
[INFO] Running com.ankurm.async.ForceModeTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.126 s -- in com.ankurm.async.ForceModeTest
|
||||
[INFO] Running com.ankurm.async.SelfInvocationTest
|
||||
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.030 s -- in com.ankurm.async.SelfInvocationTest
|
||||
[INFO] Running com.ankurm.async.TwoExecutorsTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.116 s -- in com.ankurm.async.TwoExecutorsTest
|
||||
[INFO] Running com.ankurm.async.ReturnTypeTest
|
||||
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.025 s -- in com.ankurm.async.ReturnTypeTest
|
||||
[INFO] Running com.ankurm.async.ContextPropagatedTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.096 s -- in com.ankurm.async.ContextPropagatedTest
|
||||
[INFO] Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
10
async/docs/output/virtual-threads.txt
Normal file
10
async/docs/output/virtual-threads.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
== spring.threads.virtual.enabled=true, with pool properties still set ==
|
||||
|
||||
applicationTaskExecutor : org.springframework.core.task.SimpleAsyncTaskExecutor
|
||||
@Async ran on : task-1 (virtual=true)
|
||||
|
||||
Executor beans in this context: 1
|
||||
applicationTaskExecutor -> org.springframework.core.task.SimpleAsyncTaskExecutor [TaskExecutor]
|
||||
Bean named 'taskExecutor' present: false
|
||||
Bean named 'applicationTaskExecutor' present: true
|
||||
|
||||
9
async/docs/output/visibility.txt
Normal file
9
async/docs/output/visibility.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
== @Async against final, protected and private methods ==
|
||||
|
||||
proxy class : com.ankurm.async.VisibilityService$$SpringCGLIB$$0
|
||||
isCglibProxy : true
|
||||
publicMethod() : task-4 (virtual=false)
|
||||
finalMethod() : main (virtual=false)
|
||||
protectedMethod() via the proxy : task-5 (virtual=false)
|
||||
callProtectedInternally() : main (virtual=false)
|
||||
|
||||
Reference in New Issue
Block a user