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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
*.log
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Ankur Mhatre
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
25
README.md
Normal file
25
README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# spring-async-demo
|
||||||
|
|
||||||
|
Companion code for the asynchronous execution series on [ankurm.com](https://ankurm.com). Each
|
||||||
|
directory is a self-contained Maven project for one article, with its own `pom.xml`, its own
|
||||||
|
numbered documentation chapters, and its own captured output under `docs/output/` — regenerated
|
||||||
|
by that module's `scripts/run-all.sh`, never typed by hand.
|
||||||
|
|
||||||
|
| Module | Article | What it demonstrates |
|
||||||
|
|---|---|---|
|
||||||
|
| [`async/`](async/README.md) | [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/) | Which thread a method actually ran on, in every case where the answer is not the one you expect |
|
||||||
|
|
||||||
|
## Common ground
|
||||||
|
|
||||||
|
All modules target the same verified stack: **JDK 25** (Temurin 25.0.4.1+1), **Spring Boot
|
||||||
|
4.1.1**, **Spring Framework 7.0.9**. Versions were read from `maven-metadata.xml` on Maven Central
|
||||||
|
and from Boot's own `spring-boot-dependencies` POM, rather than from release announcements.
|
||||||
|
|
||||||
|
Everything is asserted by a test and captured to a file. The measurement is nearly always the same
|
||||||
|
one: the name of the thread that ran the work, returned by the code itself. Timing cannot tell a
|
||||||
|
fast synchronous call from an asynchronous one, which is why `@Async` failures survive so long in
|
||||||
|
production.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
64
async/README.md
Normal file
64
async/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 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.
|
||||||
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
|
||||||
|
[Virtual Threads and SecurityContext Propagation](https://ankurm.com/spring-security-virtual-threads-context-propagation/).
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
52
async/pom.xml
Normal file
52
async/pom.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<!-- Boot 4.1.1 manages Spring Framework 7.0.9 and micrometer-context-propagation.
|
||||||
|
Versions were read from spring-boot-dependencies-4.1.1.pom, not from release notes. -->
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>async</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- spring.task.execution.propagate-context (new in Boot 4.1.0) wires a
|
||||||
|
ContextPropagatingTaskDecorator, which needs micrometer's context-propagation on the
|
||||||
|
classpath. Without this dependency the property is accepted and silently does nothing.
|
||||||
|
See docs/06-context-propagation.md. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>context-propagation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
24
async/scripts/pinning-probe.sh
Executable file
24
async/scripts/pinning-probe.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs PinningProbe with the virtual-thread scheduler limited to two carrier threads, so that
|
||||||
|
# pinning (if it happened) would serialise the work into visible seconds.
|
||||||
|
#
|
||||||
|
# The probe is compiled on its own with --release 21 rather than as part of the module, so the
|
||||||
|
# same class file can be run on a JDK 21 and a JDK 25 JVM. That comparison is the whole point:
|
||||||
|
# JEP 491 landed in JDK 24, and the difference between the two runs is the feature.
|
||||||
|
#
|
||||||
|
# Usage: scripts/pinning-probe.sh <path-to-javac> <path-to-java> [more java binaries...]
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
JAVAC_BIN="${1:-javac}"
|
||||||
|
shift || true
|
||||||
|
OUT=target/probe-classes
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
"$JAVAC_BIN" --release 21 -d "$OUT" src/main/java/com/ankurm/async/PinningProbe.java
|
||||||
|
|
||||||
|
for JAVA_BIN in "${@:-java}"; do
|
||||||
|
echo "---------------------------------------------------------------"
|
||||||
|
"$JAVA_BIN" \
|
||||||
|
-Djdk.virtualThreadScheduler.parallelism=2 \
|
||||||
|
-Djdk.virtualThreadScheduler.maxPoolSize=2 \
|
||||||
|
-cp "$OUT" com.ankurm.async.PinningProbe
|
||||||
|
done
|
||||||
21
async/scripts/run-all.sh
Executable file
21
async/scripts/run-all.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file under docs/output/. Nothing in this module's documentation, or in the
|
||||||
|
# article it accompanies, is typed by hand.
|
||||||
|
#
|
||||||
|
# scripts/run-all.sh # tests only
|
||||||
|
# scripts/run-all.sh /path/to/jdk21/bin/java # tests plus the JDK 21 vs JDK 25 pinning run
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mkdir -p docs/output
|
||||||
|
|
||||||
|
mvn -B test 2>&1 | grep -E 'Running |Tests run:|BUILD ' > docs/output/tests.txt
|
||||||
|
echo "wrote docs/output/tests.txt"
|
||||||
|
|
||||||
|
JAVA_BIN="$(command -v java)"
|
||||||
|
JAVAC_BIN="$(command -v javac)"
|
||||||
|
if [ $# -ge 1 ]; then
|
||||||
|
./scripts/pinning-probe.sh "$JAVAC_BIN" "$1" "$JAVA_BIN" > docs/output/pinning-probe.txt
|
||||||
|
else
|
||||||
|
./scripts/pinning-probe.sh "$JAVAC_BIN" "$JAVA_BIN" > docs/output/pinning-probe.txt
|
||||||
|
fi
|
||||||
|
echo "wrote docs/output/pinning-probe.txt"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for the @Async demonstrations.
|
||||||
|
*
|
||||||
|
* <p>{@code @EnableAsync} is what installs {@code AsyncAnnotationBeanPostProcessor}. Spring Boot
|
||||||
|
* does <em>not</em> turn it on for you: without this annotation every {@code @Async} method in
|
||||||
|
* this project runs on the caller's thread and nothing warns you. That is the first and most
|
||||||
|
* boring way to lose a day — see docs/01-what-async-actually-does.md.
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncDemoApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AsyncDemoApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
async/src/main/java/com/ankurm/async/ContextService.java
Normal file
16
async/src/main/java/com/ankurm/async/ContextService.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Reports what the async thread can see of the caller's {@link RequestId}. */
|
||||||
|
@Service
|
||||||
|
public class ContextService {
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public CompletableFuture<String> readRequestId() {
|
||||||
|
return CompletableFuture.completedFuture(String.valueOf(RequestId.get()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.core.task.TaskExecutor;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the executors that actually exist in this context, rather than the ones you believe
|
||||||
|
* exist. Runtime state here is otherwise invisible: an {@code @Async} call that silently landed
|
||||||
|
* on {@code SimpleAsyncTaskExecutor} looks exactly like one that landed on a tuned pool until
|
||||||
|
* you read a thread name under load.
|
||||||
|
*
|
||||||
|
* <p>Delete this from anything you ship — it is a diagnostic, not a feature.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ExecutorDiagnostics {
|
||||||
|
|
||||||
|
private final ApplicationContext context;
|
||||||
|
|
||||||
|
public ExecutorDiagnostics(ApplicationContext context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String report() {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
Map<String, Executor> executors = this.context.getBeansOfType(Executor.class);
|
||||||
|
out.append("Executor beans in this context: ").append(executors.size()).append('\n');
|
||||||
|
executors.forEach((name, executor) -> {
|
||||||
|
out.append(" ").append(name)
|
||||||
|
.append(" -> ").append(executor.getClass().getName())
|
||||||
|
.append(executor instanceof TaskExecutor ? " [TaskExecutor]" : "")
|
||||||
|
.append('\n');
|
||||||
|
if (executor instanceof ThreadPoolTaskExecutor pool) {
|
||||||
|
out.append(" corePoolSize=").append(pool.getCorePoolSize())
|
||||||
|
.append(" maxPoolSize=").append(pool.getMaxPoolSize())
|
||||||
|
.append(" queueCapacity=").append(pool.getQueueCapacity())
|
||||||
|
.append(" threadNamePrefix=").append(pool.getThreadNamePrefix())
|
||||||
|
.append('\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out.append("Bean named 'taskExecutor' present: ")
|
||||||
|
.append(this.context.containsBean("taskExecutor")).append('\n');
|
||||||
|
out.append("Bean named 'applicationTaskExecutor' present: ")
|
||||||
|
.append(this.context.containsBean("applicationTaskExecutor")).append('\n');
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three ways to have an {@code Executor} bean, with three different outcomes for {@code @Async}.
|
||||||
|
*
|
||||||
|
* <p>Boot's {@code applicationTaskExecutor} is conditional on there being no {@code Executor}
|
||||||
|
* bean already (see {@code TaskExecutorConfigurations.OnExecutorCondition}). Declaring one of
|
||||||
|
* your own therefore removes Boot's, and what {@code @Async} does next depends entirely on how
|
||||||
|
* many candidates are left.
|
||||||
|
*
|
||||||
|
* <p>See docs/07-which-executor-runs-it.md.
|
||||||
|
*/
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
public class ExecutorSelectionConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One custom executor. Boot backs off, this is the only candidate, {@code @Async} uses it.
|
||||||
|
* Threads are named {@code mine-N}.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("ownexecutor")
|
||||||
|
public Executor myExecutor() {
|
||||||
|
return build("mine-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two custom executors and neither is called {@code taskExecutor}. Boot still backs off, and
|
||||||
|
* {@code AsyncExecutionAspectSupport} cannot pick between them: it catches the
|
||||||
|
* {@code NoUniqueBeanDefinitionException}, looks for a bean literally named
|
||||||
|
* {@code taskExecutor}, does not find one, and falls back to a plain
|
||||||
|
* {@code SimpleAsyncTaskExecutor} — a new unpooled thread for every call, forever.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("twoexecutors")
|
||||||
|
public Executor reportsExecutor() {
|
||||||
|
return build("reports-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Profile("twoexecutors")
|
||||||
|
public Executor emailsExecutor() {
|
||||||
|
return build("emails-");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThreadPoolTaskExecutor build(String prefix) {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
executor.setMaxPoolSize(2);
|
||||||
|
executor.setThreadNamePrefix(prefix);
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
async/src/main/java/com/ankurm/async/PinningProbe.java
Normal file
77
async/src/main/java/com/ankurm/async/PinningProbe.java
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures whether blocking inside a {@code synchronized} block pins the carrier thread.
|
||||||
|
*
|
||||||
|
* <p>Every guide written before JDK 24 tells you not to combine virtual threads with
|
||||||
|
* {@code synchronized}, because a virtual thread that blocks while holding a monitor pins its
|
||||||
|
* carrier and the carrier cannot run anything else. JEP 491 (JDK 24) removed that. This probe is
|
||||||
|
* how you check rather than believe: it runs the same 32 tasks twice, once inside a
|
||||||
|
* {@code synchronized} block on a private monitor and once without, with the scheduler limited
|
||||||
|
* to two carrier threads.
|
||||||
|
*
|
||||||
|
* <p>If pinning happens, the synchronized run takes roughly {@code tasks / carriers * sleep}.
|
||||||
|
* If it does not, both runs take roughly {@code sleep}.
|
||||||
|
*
|
||||||
|
* <p>Run it with scripts/pinning-probe.sh, which sets the carrier count. See
|
||||||
|
* docs/08-virtual-threads-and-pinning.md.
|
||||||
|
*/
|
||||||
|
public final class PinningProbe {
|
||||||
|
|
||||||
|
private static final int TASKS = 32;
|
||||||
|
|
||||||
|
private static final Duration SLEEP = Duration.ofMillis(300);
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
System.out.println("java.version : " + System.getProperty("java.version"));
|
||||||
|
System.out.println("jdk.virtualThreadScheduler.parallelism: "
|
||||||
|
+ System.getProperty("jdk.virtualThreadScheduler.parallelism", "<unset>"));
|
||||||
|
System.out.println("tasks=" + TASKS + " sleep=" + SLEEP.toMillis() + "ms");
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
long plain = run(false);
|
||||||
|
long guarded = run(true);
|
||||||
|
|
||||||
|
System.out.printf("no monitor held : %5d ms%n", plain);
|
||||||
|
System.out.printf("blocked inside synchronized: %5d ms%n", guarded);
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("Pinned would be about " + (TASKS / 2) * SLEEP.toMillis()
|
||||||
|
+ " ms for the synchronized run (32 tasks / 2 carriers x 300 ms).");
|
||||||
|
System.out.println("Not pinned is about " + SLEEP.toMillis() + " ms for both.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long run(boolean holdMonitor) throws InterruptedException {
|
||||||
|
List<Thread> threads = new ArrayList<>();
|
||||||
|
long start = System.nanoTime();
|
||||||
|
for (int i = 0; i < TASKS; i++) {
|
||||||
|
threads.add(Thread.ofVirtual().start(() -> task(holdMonitor)));
|
||||||
|
}
|
||||||
|
for (Thread t : threads) {
|
||||||
|
t.join();
|
||||||
|
}
|
||||||
|
return Duration.ofNanos(System.nanoTime() - start).toMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void task(boolean holdMonitor) {
|
||||||
|
// A monitor per task, so nothing here is contended. The question is only whether the
|
||||||
|
// carrier thread is released while this virtual thread sleeps.
|
||||||
|
Object monitor = new Object();
|
||||||
|
try {
|
||||||
|
if (holdMonitor) {
|
||||||
|
synchronized (monitor) {
|
||||||
|
Thread.sleep(SLEEP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Thread.sleep(SLEEP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
async/src/main/java/com/ankurm/async/RequestId.java
Normal file
69
async/src/main/java/com/ankurm/async/RequestId.java
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import io.micrometer.context.ContextRegistry;
|
||||||
|
import io.micrometer.context.ThreadLocalAccessor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ThreadLocal that survives the hop onto an {@code @Async} thread — but only because it
|
||||||
|
* is registered with micrometer's {@link ContextRegistry} and only when
|
||||||
|
* {@code spring.task.execution.propagate-context} is on.
|
||||||
|
*
|
||||||
|
* <p>{@code propagate-context} is new in Spring Boot 4.1.0. It was not present in 4.0.8 (checked
|
||||||
|
* by decompiling {@code TaskExecutionProperties} from both jars). It makes Boot decorate the
|
||||||
|
* auto-configured executor with {@code ContextPropagatingTaskDecorator}, which captures whatever
|
||||||
|
* is registered here at submission time and restores it around the task.
|
||||||
|
*
|
||||||
|
* <p>See docs/06-context-propagation.md.
|
||||||
|
*/
|
||||||
|
public final class RequestId {
|
||||||
|
|
||||||
|
public static final String KEY = "requestId";
|
||||||
|
|
||||||
|
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
ContextRegistry.getInstance().registerThreadLocalAccessor(new Accessor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private RequestId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void set(String value) {
|
||||||
|
HOLDER.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String get() {
|
||||||
|
return HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Touching this class runs the static initialiser that registers the accessor. */
|
||||||
|
public static void register() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Accessor implements ThreadLocalAccessor<String> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object key() {
|
||||||
|
return KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getValue() {
|
||||||
|
return HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setValue(String value) {
|
||||||
|
HOLDER.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setValue() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
async/src/main/java/com/ankurm/async/ReturnTypeService.java
Normal file
51
async/src/main/java/com/ankurm/async/ReturnTypeService.java
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return types {@code @Async} supports, and the one that quietly returns null.
|
||||||
|
*
|
||||||
|
* <p>{@code AsyncExecutionAspectSupport.doSubmit} branches on the declared return type.
|
||||||
|
* {@code CompletableFuture} and {@code Future} are submitted and the handle is returned;
|
||||||
|
* {@code void} is submitted and {@code null} returned. Anything else — including a plain
|
||||||
|
* {@code String} — falls into the final {@code else} and throws
|
||||||
|
* {@code IllegalArgumentException: Invalid return type for async method (only Future and void
|
||||||
|
* supported)}. It is thrown to the caller, at call time, on every invocation. The widely
|
||||||
|
* repeated claim that such a method “returns null” is wrong; the method body never
|
||||||
|
* runs at all.
|
||||||
|
*
|
||||||
|
* <p>See docs/04-return-types-and-exceptions.md.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ReturnTypeService {
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public CompletableFuture<String> completableFuture() {
|
||||||
|
return CompletableFuture.completedFuture(Threads.describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles cleanly and throws {@code IllegalArgumentException} on every call. Nothing about
|
||||||
|
* the signature is rejected at startup, so this is a runtime failure in whichever code path
|
||||||
|
* happens to reach it first.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public String plainString() {
|
||||||
|
return Threads.describe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exception from a void method: the caller cannot see it. */
|
||||||
|
@Async
|
||||||
|
public void voidThatThrows() {
|
||||||
|
throw new IllegalStateException("thrown from a void @Async method");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exception from a future-returning method: the caller can see it, if it looks. */
|
||||||
|
@Async
|
||||||
|
public CompletableFuture<String> futureThatThrows() {
|
||||||
|
throw new IllegalStateException("thrown from a CompletableFuture @Async method");
|
||||||
|
}
|
||||||
|
}
|
||||||
51
async/src/main/java/com/ankurm/async/SaturationService.java
Normal file
51
async/src/main/java/com/ankurm/async/SaturationService.java
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentSkipListSet;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the pool actually grows, and where it does not.
|
||||||
|
*
|
||||||
|
* <p>{@code ThreadPoolTaskExecutor} wraps a {@code ThreadPoolExecutor}, and {@code
|
||||||
|
* ThreadPoolExecutor} only creates a thread beyond the core size when the queue refuses a task.
|
||||||
|
* Spring Boot's default queue capacity is unbounded, so it never refuses, so the pool never grows
|
||||||
|
* past {@code spring.task.execution.pool.core-size} — whatever {@code max-size} says.
|
||||||
|
*
|
||||||
|
* <p>Each method records the distinct thread names it ran on. Counting them is the measurement.
|
||||||
|
*
|
||||||
|
* <p>See docs/05-pool-sizing.md.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SaturationService {
|
||||||
|
|
||||||
|
private final Set<String> threadsSeen = new ConcurrentSkipListSet<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks until released, so every concurrently running task holds its thread. That is what
|
||||||
|
* makes the number of distinct threads equal to the pool's real concurrency.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void occupyAThread(CountDownLatch arrived, CountDownLatch release) {
|
||||||
|
threadsSeen.add(Threads.name());
|
||||||
|
arrived.countDown();
|
||||||
|
try {
|
||||||
|
release.await(30, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> threadsSeen() {
|
||||||
|
return Set.copyOf(this.threadsSeen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset() {
|
||||||
|
this.threadsSeen.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The self-invocation trap, and the two ways out of it.
|
||||||
|
*
|
||||||
|
* <p>{@code @Async} is implemented by a proxy that wraps this bean. Every call that arrives from
|
||||||
|
* outside goes through the proxy and gets handed to an executor. Every call that starts
|
||||||
|
* <em>inside</em> this class is a plain {@code this.method()} invocation on the target object, so
|
||||||
|
* the proxy is never consulted and the method runs on the caller's thread. Nothing is logged.
|
||||||
|
*
|
||||||
|
* <p>See docs/02-the-self-invocation-trap.md.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SelfInvocationService {
|
||||||
|
|
||||||
|
private final ObjectProvider<SelfInvocationService> self;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@code ObjectProvider} rather than a constructor-injected {@code SelfInvocationService}:
|
||||||
|
* the latter is a self-reference that Spring resolves, but it hands you the proxy only
|
||||||
|
* because of a special case, and it makes the cycle explicit in a way reviewers argue about.
|
||||||
|
* {@code ObjectProvider} is lazy, so there is no cycle to resolve at construction time.
|
||||||
|
*/
|
||||||
|
public SelfInvocationService(ObjectProvider<SelfInvocationService> self) {
|
||||||
|
this.self = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The annotated method. Reached through the proxy, it runs on an executor thread.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public CompletableFuture<String> annotated() {
|
||||||
|
return CompletableFuture.completedFuture(Threads.describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trap. {@code annotated()} here is {@code this.annotated()}, which never touches the
|
||||||
|
* proxy, so the work runs on whatever thread called {@code viaSelfInvocation()} and the
|
||||||
|
* returned future is already complete.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<String> viaSelfInvocation() {
|
||||||
|
return annotated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fix that keeps the code in one class: go out through the proxy deliberately.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<String> viaSelfReference() {
|
||||||
|
return self.getObject().annotated();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
async/src/main/java/com/ankurm/async/Threads.java
Normal file
26
async/src/main/java/com/ankurm/async/Threads.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread description used by every transcript in docs/output/.
|
||||||
|
*
|
||||||
|
* <p>The whole project is built on one idea: instead of asserting which executor Spring picked,
|
||||||
|
* print the name of the thread the method actually ran on. Thread names are the only honest
|
||||||
|
* evidence, because {@code @Async} failing silently and {@code @Async} working look identical
|
||||||
|
* from the outside.
|
||||||
|
*/
|
||||||
|
public final class Threads {
|
||||||
|
|
||||||
|
private Threads() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. {@code task-1 (virtual=false)} or {@code VirtualThread[#42]/runnable (virtual=true)}. */
|
||||||
|
public static String describe() {
|
||||||
|
Thread t = Thread.currentThread();
|
||||||
|
return t.getName() + " (virtual=" + t.isVirtual() + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just the name, for assertions that care about the executor's thread-name prefix. */
|
||||||
|
public static String name() {
|
||||||
|
return Thread.currentThread().getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
async/src/main/java/com/ankurm/async/VisibilityService.java
Normal file
49
async/src/main/java/com/ankurm/async/VisibilityService.java
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What CGLIB can and cannot override.
|
||||||
|
*
|
||||||
|
* <p>Spring Boot proxies with CGLIB by default ({@code spring.aop.proxy-target-class} is true),
|
||||||
|
* which means the proxy is a generated <em>subclass</em>. A subclass can override a public or
|
||||||
|
* protected method. It cannot override a {@code final} one, and it cannot see a private one.
|
||||||
|
* Both of those {@code @Async} annotations are therefore inert, and neither produces a warning.
|
||||||
|
*
|
||||||
|
* <p>See docs/03-what-the-proxy-cannot-see.md.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class VisibilityService {
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public CompletableFuture<String> publicMethod() {
|
||||||
|
return CompletableFuture.completedFuture(Threads.describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cannot be overridden by the CGLIB subclass, so the advice never runs. */
|
||||||
|
@Async
|
||||||
|
public final CompletableFuture<String> finalMethod() {
|
||||||
|
return CompletableFuture.completedFuture(Threads.describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protected methods <em>can</em> be overridden by a subclass, so this one is advised. */
|
||||||
|
@Async
|
||||||
|
protected CompletableFuture<String> protectedMethod() {
|
||||||
|
return CompletableFuture.completedFuture(Threads.describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachable only from inside the class, so it is a self-invocation as well as private. */
|
||||||
|
@Async
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private CompletableFuture<String> privateMethod() {
|
||||||
|
return CompletableFuture.completedFuture(Threads.describe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls the protected one from outside its own {@code this}, via the caller's proxy. */
|
||||||
|
public CompletableFuture<String> callProtectedInternally() {
|
||||||
|
return protectedMethod();
|
||||||
|
}
|
||||||
|
}
|
||||||
6
async/src/main/resources/application.yaml
Normal file
6
async/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: async
|
||||||
|
# Nothing is set here on purpose. Every number in docs/output/executor-report.txt is a Spring
|
||||||
|
# Boot default, printed from the live bean rather than copied out of the reference guide.
|
||||||
|
# The profiles that change behaviour are in src/test/resources/ and in scripts/.
|
||||||
43
async/src/test/java/com/ankurm/async/BoundedQueueTest.java
Normal file
43
async/src/test/java/com/ankurm/async/BoundedQueueTest.java
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** The same pool with a bounded queue. One property changes; the concurrency triples. */
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"spring.task.execution.pool.core-size=4",
|
||||||
|
"spring.task.execution.pool.max-size=12",
|
||||||
|
"spring.task.execution.pool.queue-capacity=4" })
|
||||||
|
class BoundedQueueTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SaturationService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aBoundedQueueIsWhatLetsThePoolGrow() throws Exception {
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
CountDownLatch arrived = new CountDownLatch(12);
|
||||||
|
service.reset();
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
service.occupyAThread(arrived, release);
|
||||||
|
}
|
||||||
|
assertThat(arrived.await(10, TimeUnit.SECONDS)).isTrue();
|
||||||
|
Thread.sleep(500);
|
||||||
|
int threads = service.threadsSeen().size();
|
||||||
|
release.countDown();
|
||||||
|
|
||||||
|
assertThat(threads).isEqualTo(12);
|
||||||
|
Capture.write("pool-bounded-queue.txt",
|
||||||
|
"core-size=4, max-size=12, queue-capacity=4, 16 blocking tasks",
|
||||||
|
"distinct threads that ran a task : " + threads + "\n"
|
||||||
|
+ "thread names : " + service.threadsSeen() + "\n"
|
||||||
|
+ "4 core threads, 4 tasks queued, 8 more threads created up to max-size.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
23
async/src/test/java/com/ankurm/async/Capture.java
Normal file
23
async/src/test/java/com/ankurm/async/Capture.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/** Writes a transcript under docs/output/ so every number in the article has a file behind it. */
|
||||||
|
final class Capture {
|
||||||
|
|
||||||
|
private Capture() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static void write(String fileName, String heading, String body) {
|
||||||
|
Path dir = Path.of(System.getProperty("user.dir"), "docs", "output");
|
||||||
|
try {
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Files.writeString(dir.resolve(fileName), "== " + heading + " ==\n\n" + body + "\n");
|
||||||
|
}
|
||||||
|
catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("could not write " + fileName, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** The default: a ThreadLocal set by the caller is not visible to the @Async thread. */
|
||||||
|
@SpringBootTest
|
||||||
|
class ContextNotPropagatedTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ContextService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theAsyncThreadSeesNothing() throws Exception {
|
||||||
|
RequestId.set("req-4711");
|
||||||
|
try {
|
||||||
|
assertThat(service.readRequestId().get()).isEqualTo("null");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
RequestId.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code spring.task.execution.propagate-context=true} — new in Spring Boot 4.1.0 —
|
||||||
|
* decorates the auto-configured executor so registered ThreadLocals survive the hop.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.task.execution.propagate-context=true")
|
||||||
|
class ContextPropagatedTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ContextService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onePropertyCarriesTheThreadLocalAcross() throws Exception {
|
||||||
|
RequestId.register();
|
||||||
|
RequestId.set("req-4711");
|
||||||
|
try {
|
||||||
|
String seen = service.readRequestId().get();
|
||||||
|
assertThat(seen).isEqualTo("req-4711");
|
||||||
|
Capture.write("context-propagation.txt",
|
||||||
|
"spring.task.execution.propagate-context (new in Boot 4.1.0)",
|
||||||
|
"caller thread : " + Threads.describe() + "\n"
|
||||||
|
+ "RequestId set : req-4711\n"
|
||||||
|
+ "@Async thread saw: " + seen + "\n");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
RequestId.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
async/src/test/java/com/ankurm/async/ExecutorReportTest.java
Normal file
20
async/src/test/java/com/ankurm/async/ExecutorReportTest.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
/** Prints the stock context so the article's "defaults" table has a file behind it. */
|
||||||
|
@SpringBootTest
|
||||||
|
class ExecutorReportTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ExecutorDiagnostics diagnostics;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void printStockContext() {
|
||||||
|
Capture.write("executor-report.txt",
|
||||||
|
"Stock Spring Boot 4.1.1 context, nothing configured", this.diagnostics.report());
|
||||||
|
}
|
||||||
|
}
|
||||||
33
async/src/test/java/com/ankurm/async/ForceModeTest.java
Normal file
33
async/src/test/java/com/ankurm/async/ForceModeTest.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same two custom executors, plus {@code spring.task.execution.mode=force}. Boot creates
|
||||||
|
* {@code applicationTaskExecutor} anyway and marks it as the one @Async should use.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.task.execution.mode=force")
|
||||||
|
@ActiveProfiles("twoexecutors")
|
||||||
|
class ForceModeTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ReturnTypeService service;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ExecutorDiagnostics diagnostics;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forceRestoresTheApplicationTaskExecutor() throws Exception {
|
||||||
|
String ranOn = service.completableFuture().get();
|
||||||
|
assertThat(ranOn).startsWith("task-");
|
||||||
|
Capture.write("executor-force-mode.txt",
|
||||||
|
"Two custom Executor beans plus spring.task.execution.mode=force",
|
||||||
|
this.diagnostics.report() + "\n@Async ran on : " + ranOn + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
29
async/src/test/java/com/ankurm/async/OwnExecutorTest.java
Normal file
29
async/src/test/java/com/ankurm/async/OwnExecutorTest.java
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** One Executor bean of your own: Boot backs off and @Async uses yours. */
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("ownexecutor")
|
||||||
|
class OwnExecutorTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ReturnTypeService service;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ExecutorDiagnostics diagnostics;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void yourExecutorWins() throws Exception {
|
||||||
|
String ranOn = service.completableFuture().get();
|
||||||
|
assertThat(ranOn).startsWith("mine-");
|
||||||
|
Capture.write("executor-one-custom.txt", "A single custom Executor bean",
|
||||||
|
this.diagnostics.report() + "\n@Async ran on : " + ranOn + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
81
async/src/test/java/com/ankurm/async/ReturnTypeTest.java
Normal file
81
async/src/test/java/com/ankurm/async/ReturnTypeTest.java
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return types, and where the exceptions go.
|
||||||
|
*
|
||||||
|
* <p>The interesting assertion is the first one. A very common claim is that an {@code @Async}
|
||||||
|
* method with a plain return type quietly returns {@code null}; on Spring Framework 7.0.9 it
|
||||||
|
* throws, at the call site, before the body has run.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
class ReturnTypeTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ReturnTypeService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPlainReturnTypeThrowsAtTheCallSite() {
|
||||||
|
assertThatThrownBy(() -> service.plainString())
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessage("Invalid return type for async method (only Future and void supported): "
|
||||||
|
+ "class java.lang.String");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFutureCarriesTheException() {
|
||||||
|
CompletableFuture<String> future = service.futureThatThrows();
|
||||||
|
assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS))
|
||||||
|
.isInstanceOf(ExecutionException.class)
|
||||||
|
.hasCauseInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aVoidMethodThrowsNothingTheCallerCanSee() {
|
||||||
|
assertThatCode(() -> service.voidThatThrows()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capture() throws Exception {
|
||||||
|
String plain;
|
||||||
|
try {
|
||||||
|
plain = String.valueOf(service.plainString());
|
||||||
|
}
|
||||||
|
catch (IllegalArgumentException ex) {
|
||||||
|
plain = ex.getClass().getName() + ": " + ex.getMessage();
|
||||||
|
}
|
||||||
|
String futureOutcome;
|
||||||
|
try {
|
||||||
|
service.futureThatThrows().get(5, TimeUnit.SECONDS);
|
||||||
|
futureOutcome = "completed normally";
|
||||||
|
}
|
||||||
|
catch (ExecutionException ex) {
|
||||||
|
futureOutcome = ex.getCause().getClass().getSimpleName() + ": " + ex.getCause().getMessage();
|
||||||
|
}
|
||||||
|
service.voidThatThrows();
|
||||||
|
String body = """
|
||||||
|
completableFuture().get() : %s
|
||||||
|
plainString() : %s
|
||||||
|
futureThatThrows().get() : %s
|
||||||
|
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.
|
||||||
|
""".formatted(service.completableFuture().get(), plain, futureOutcome);
|
||||||
|
Capture.write("return-types.txt", "What each @Async return type hands back", body);
|
||||||
|
assertThat(plain).contains("IllegalArgumentException");
|
||||||
|
}
|
||||||
|
}
|
||||||
54
async/src/test/java/com/ankurm/async/SelfInvocationTest.java
Normal file
54
async/src/test/java/com/ankurm/async/SelfInvocationTest.java
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proves the self-invocation trap by thread name rather than by timing, because a task that runs
|
||||||
|
* on the caller's thread and a task that runs quickly on a pool thread are indistinguishable by
|
||||||
|
* clock.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
class SelfInvocationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SelfInvocationService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void throughTheProxyItIsAsynchronous() throws Exception {
|
||||||
|
assertThat(service.annotated().get()).startsWith("task-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selfInvocationSilentlyRunsOnTheCallerThread() throws ExecutionException, InterruptedException {
|
||||||
|
String callerThread = Threads.name();
|
||||||
|
String ranOn = service.viaSelfInvocation().get();
|
||||||
|
|
||||||
|
assertThat(ranOn).startsWith(callerThread);
|
||||||
|
assertThat(ranOn).doesNotStartWith("task-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goingBackOutThroughTheProxyRestoresIt() throws Exception {
|
||||||
|
assertThat(service.viaSelfReference().get()).startsWith("task-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capture() throws Exception {
|
||||||
|
String body = """
|
||||||
|
caller thread : %s
|
||||||
|
service.annotated() : %s
|
||||||
|
service.viaSelfInvocation() : %s
|
||||||
|
service.viaSelfReference() : %s
|
||||||
|
""".formatted(Threads.describe(), service.annotated().get(),
|
||||||
|
service.viaSelfInvocation().get(), service.viaSelfReference().get());
|
||||||
|
Capture.write("self-invocation.txt",
|
||||||
|
"Which thread each call actually ran on (Boot 4.1.1, JDK 25)", body);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
async/src/test/java/com/ankurm/async/TwoExecutorsTest.java
Normal file
35
async/src/test/java/com/ankurm/async/TwoExecutorsTest.java
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two Executor beans and no bean named {@code taskExecutor}. Boot's applicationTaskExecutor is
|
||||||
|
* gone, neither of yours can be chosen, and @Async falls back to an unpooled
|
||||||
|
* SimpleAsyncTaskExecutor without saying so at any log level.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("twoexecutors")
|
||||||
|
class TwoExecutorsTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ReturnTypeService service;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ExecutorDiagnostics diagnostics;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neitherOfYoursIsUsed() throws Exception {
|
||||||
|
String ranOn = service.completableFuture().get();
|
||||||
|
assertThat(ranOn).doesNotStartWith("reports-");
|
||||||
|
assertThat(ranOn).doesNotStartWith("emails-");
|
||||||
|
assertThat(ranOn).doesNotStartWith("task-");
|
||||||
|
Capture.write("executor-two-custom.txt", "Two custom Executor beans, no 'taskExecutor'",
|
||||||
|
this.diagnostics.report() + "\n@Async ran on : " + ranOn + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
46
async/src/test/java/com/ankurm/async/UnboundedQueueTest.java
Normal file
46
async/src/test/java/com/ankurm/async/UnboundedQueueTest.java
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* core-size 4, max-size 12, and the Boot default unbounded queue. Sixteen blocking tasks are
|
||||||
|
* submitted; four threads run them, because an unbounded queue never refuses a task and
|
||||||
|
* {@code ThreadPoolExecutor} only creates a non-core thread when the queue refuses one.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"spring.task.execution.pool.core-size=4",
|
||||||
|
"spring.task.execution.pool.max-size=12" })
|
||||||
|
class UnboundedQueueTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SaturationService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void maxSizeIsIgnoredWhenTheQueueIsUnbounded() throws Exception {
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
CountDownLatch arrived = new CountDownLatch(4);
|
||||||
|
service.reset();
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
service.occupyAThread(arrived, release);
|
||||||
|
}
|
||||||
|
assertThat(arrived.await(10, TimeUnit.SECONDS)).isTrue();
|
||||||
|
Thread.sleep(500);
|
||||||
|
int threads = service.threadsSeen().size();
|
||||||
|
release.countDown();
|
||||||
|
|
||||||
|
assertThat(threads).isEqualTo(4);
|
||||||
|
Capture.write("pool-unbounded-queue.txt",
|
||||||
|
"core-size=4, max-size=12, queue-capacity=<unbounded default>, 16 blocking tasks",
|
||||||
|
"distinct threads that ran a task : " + threads + "\n"
|
||||||
|
+ "thread names : " + service.threadsSeen() + "\n"
|
||||||
|
+ "max-size had no effect: the queue never refused a task.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
48
async/src/test/java/com/ankurm/async/VirtualThreadsTest.java
Normal file
48
async/src/test/java/com/ankurm/async/VirtualThreadsTest.java
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.core.task.SimpleAsyncTaskExecutor;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With virtual threads on, {@code applicationTaskExecutor} is a {@code SimpleAsyncTaskExecutor}
|
||||||
|
* over virtual threads. Note what that costs: the pool properties are still accepted and now
|
||||||
|
* mean nothing, and a {@code SimpleAsyncTaskExecutor} has no queue and, by default, no
|
||||||
|
* concurrency limit at all.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"spring.threads.virtual.enabled=true",
|
||||||
|
"spring.task.execution.pool.core-size=4",
|
||||||
|
"spring.task.execution.pool.max-size=12" })
|
||||||
|
class VirtualThreadsTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ReturnTypeService service;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
@Qualifier("applicationTaskExecutor")
|
||||||
|
private Executor applicationTaskExecutor;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ExecutorDiagnostics diagnostics;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void asyncRunsOnAVirtualThread() throws Exception {
|
||||||
|
String ranOn = service.completableFuture().get();
|
||||||
|
assertThat(ranOn).contains("virtual=true");
|
||||||
|
assertThat(this.applicationTaskExecutor).isInstanceOf(SimpleAsyncTaskExecutor.class);
|
||||||
|
|
||||||
|
Capture.write("virtual-threads.txt",
|
||||||
|
"spring.threads.virtual.enabled=true, with pool properties still set",
|
||||||
|
"applicationTaskExecutor : " + this.applicationTaskExecutor.getClass().getName() + "\n"
|
||||||
|
+ "@Async ran on : " + ranOn + "\n\n"
|
||||||
|
+ this.diagnostics.report());
|
||||||
|
}
|
||||||
|
}
|
||||||
53
async/src/test/java/com/ankurm/async/VisibilityTest.java
Normal file
53
async/src/test/java/com/ankurm/async/VisibilityTest.java
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package com.ankurm.async;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.aop.support.AopUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** What the CGLIB subclass can and cannot override, measured. */
|
||||||
|
@SpringBootTest
|
||||||
|
class VisibilityTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VisibilityService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theBeanIsACglibProxy() {
|
||||||
|
assertThat(AopUtils.isCglibProxy(this.service)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicMethodIsAdvised() throws Exception {
|
||||||
|
assertThat(service.publicMethod().get()).startsWith("task-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finalMethodIsSilentlySynchronous() throws Exception {
|
||||||
|
assertThat(service.finalMethod().get()).doesNotStartWith("task-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectedMethodIsAdvisedWhenCalledThroughTheProxy() throws Exception {
|
||||||
|
// Same package as the service, so the test can reach a protected method on the proxy.
|
||||||
|
assertThat(service.protectedMethod().get()).startsWith("task-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capture() throws Exception {
|
||||||
|
String body = """
|
||||||
|
proxy class : %s
|
||||||
|
isCglibProxy : %s
|
||||||
|
publicMethod() : %s
|
||||||
|
finalMethod() : %s
|
||||||
|
protectedMethod() via the proxy : %s
|
||||||
|
callProtectedInternally() : %s
|
||||||
|
""".formatted(service.getClass().getName(), AopUtils.isCglibProxy(this.service),
|
||||||
|
service.publicMethod().get(), service.finalMethod().get(),
|
||||||
|
service.protectedMethod().get(), service.callProtectedInternally().get());
|
||||||
|
Capture.write("visibility.txt", "@Async against final, protected and private methods", body);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user