From 243cccd4ca9b138086970e14e70d54a2d07d55f5 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Tue, 1 Sep 2026 23:27:47 +0530 Subject: [PATCH] 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. --- .gitignore | 2 + LICENSE | 21 +++++ README.md | 25 ++++++ async/README.md | 64 +++++++++++++++ async/docs/01-what-async-actually-does.md | 50 ++++++++++++ async/docs/02-the-self-invocation-trap.md | 63 +++++++++++++++ async/docs/03-what-the-proxy-cannot-see.md | 48 +++++++++++ async/docs/04-return-types-and-exceptions.md | 61 ++++++++++++++ async/docs/05-pool-sizing.md | 57 +++++++++++++ async/docs/06-context-propagation.md | 57 +++++++++++++ async/docs/07-which-executor-runs-it.md | 67 +++++++++++++++ async/docs/08-virtual-threads-and-pinning.md | 70 ++++++++++++++++ async/docs/output/context-propagation.txt | 6 ++ async/docs/output/executor-force-mode.txt | 14 ++++ async/docs/output/executor-one-custom.txt | 10 +++ async/docs/output/executor-report.txt | 8 ++ async/docs/output/executor-two-custom.txt | 12 +++ async/docs/output/pinning-probe.txt | 20 +++++ async/docs/output/pool-bounded-queue.txt | 6 ++ async/docs/output/pool-unbounded-queue.txt | 6 ++ async/docs/output/return-types.txt | 11 +++ async/docs/output/self-invocation.txt | 7 ++ async/docs/output/tests.txt | 26 ++++++ async/docs/output/virtual-threads.txt | 10 +++ async/docs/output/visibility.txt | 9 +++ async/pom.xml | 52 ++++++++++++ async/scripts/pinning-probe.sh | 24 ++++++ async/scripts/run-all.sh | 21 +++++ .../ankurm/async/AsyncDemoApplication.java | 22 +++++ .../java/com/ankurm/async/ContextService.java | 16 ++++ .../com/ankurm/async/ExecutorDiagnostics.java | 51 ++++++++++++ .../ankurm/async/ExecutorSelectionConfig.java | 60 ++++++++++++++ .../java/com/ankurm/async/PinningProbe.java | 77 ++++++++++++++++++ .../main/java/com/ankurm/async/RequestId.java | 69 ++++++++++++++++ .../com/ankurm/async/ReturnTypeService.java | 51 ++++++++++++ .../com/ankurm/async/SaturationService.java | 51 ++++++++++++ .../ankurm/async/SelfInvocationService.java | 57 +++++++++++++ .../main/java/com/ankurm/async/Threads.java | 26 ++++++ .../com/ankurm/async/VisibilityService.java | 49 +++++++++++ async/src/main/resources/application.yaml | 6 ++ .../com/ankurm/async/BoundedQueueTest.java | 43 ++++++++++ .../test/java/com/ankurm/async/Capture.java | 23 ++++++ .../async/ContextNotPropagatedTest.java | 27 +++++++ .../ankurm/async/ContextPropagatedTest.java | 37 +++++++++ .../com/ankurm/async/ExecutorReportTest.java | 20 +++++ .../java/com/ankurm/async/ForceModeTest.java | 33 ++++++++ .../com/ankurm/async/OwnExecutorTest.java | 29 +++++++ .../java/com/ankurm/async/ReturnTypeTest.java | 81 +++++++++++++++++++ .../com/ankurm/async/SelfInvocationTest.java | 54 +++++++++++++ .../com/ankurm/async/TwoExecutorsTest.java | 35 ++++++++ .../com/ankurm/async/UnboundedQueueTest.java | 46 +++++++++++ .../com/ankurm/async/VirtualThreadsTest.java | 48 +++++++++++ .../java/com/ankurm/async/VisibilityTest.java | 53 ++++++++++++ 53 files changed, 1891 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 async/README.md create mode 100644 async/docs/01-what-async-actually-does.md create mode 100644 async/docs/02-the-self-invocation-trap.md create mode 100644 async/docs/03-what-the-proxy-cannot-see.md create mode 100644 async/docs/04-return-types-and-exceptions.md create mode 100644 async/docs/05-pool-sizing.md create mode 100644 async/docs/06-context-propagation.md create mode 100644 async/docs/07-which-executor-runs-it.md create mode 100644 async/docs/08-virtual-threads-and-pinning.md create mode 100644 async/docs/output/context-propagation.txt create mode 100644 async/docs/output/executor-force-mode.txt create mode 100644 async/docs/output/executor-one-custom.txt create mode 100644 async/docs/output/executor-report.txt create mode 100644 async/docs/output/executor-two-custom.txt create mode 100644 async/docs/output/pinning-probe.txt create mode 100644 async/docs/output/pool-bounded-queue.txt create mode 100644 async/docs/output/pool-unbounded-queue.txt create mode 100644 async/docs/output/return-types.txt create mode 100644 async/docs/output/self-invocation.txt create mode 100644 async/docs/output/tests.txt create mode 100644 async/docs/output/virtual-threads.txt create mode 100644 async/docs/output/visibility.txt create mode 100644 async/pom.xml create mode 100755 async/scripts/pinning-probe.sh create mode 100755 async/scripts/run-all.sh create mode 100644 async/src/main/java/com/ankurm/async/AsyncDemoApplication.java create mode 100644 async/src/main/java/com/ankurm/async/ContextService.java create mode 100644 async/src/main/java/com/ankurm/async/ExecutorDiagnostics.java create mode 100644 async/src/main/java/com/ankurm/async/ExecutorSelectionConfig.java create mode 100644 async/src/main/java/com/ankurm/async/PinningProbe.java create mode 100644 async/src/main/java/com/ankurm/async/RequestId.java create mode 100644 async/src/main/java/com/ankurm/async/ReturnTypeService.java create mode 100644 async/src/main/java/com/ankurm/async/SaturationService.java create mode 100644 async/src/main/java/com/ankurm/async/SelfInvocationService.java create mode 100644 async/src/main/java/com/ankurm/async/Threads.java create mode 100644 async/src/main/java/com/ankurm/async/VisibilityService.java create mode 100644 async/src/main/resources/application.yaml create mode 100644 async/src/test/java/com/ankurm/async/BoundedQueueTest.java create mode 100644 async/src/test/java/com/ankurm/async/Capture.java create mode 100644 async/src/test/java/com/ankurm/async/ContextNotPropagatedTest.java create mode 100644 async/src/test/java/com/ankurm/async/ContextPropagatedTest.java create mode 100644 async/src/test/java/com/ankurm/async/ExecutorReportTest.java create mode 100644 async/src/test/java/com/ankurm/async/ForceModeTest.java create mode 100644 async/src/test/java/com/ankurm/async/OwnExecutorTest.java create mode 100644 async/src/test/java/com/ankurm/async/ReturnTypeTest.java create mode 100644 async/src/test/java/com/ankurm/async/SelfInvocationTest.java create mode 100644 async/src/test/java/com/ankurm/async/TwoExecutorsTest.java create mode 100644 async/src/test/java/com/ankurm/async/UnboundedQueueTest.java create mode 100644 async/src/test/java/com/ankurm/async/VirtualThreadsTest.java create mode 100644 async/src/test/java/com/ankurm/async/VisibilityTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1c13f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..78580c9 --- /dev/null +++ b/README.md @@ -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). diff --git a/async/README.md b/async/README.md new file mode 100644 index 0000000..9783b70 --- /dev/null +++ b/async/README.md @@ -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. diff --git a/async/docs/01-what-async-actually-does.md b/async/docs/01-what-async-actually-does.md new file mode 100644 index 0000000..b7c66d7 --- /dev/null +++ b/async/docs/01-what-async-actually-does.md @@ -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) diff --git a/async/docs/02-the-self-invocation-trap.md b/async/docs/02-the-self-invocation-trap.md new file mode 100644 index 0000000..ee3b8dd --- /dev/null +++ b/async/docs/02-the-self-invocation-trap.md @@ -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 annotated() { ... } + +public CompletableFuture 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`: + +```java +public CompletableFuture 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) diff --git a/async/docs/03-what-the-proxy-cannot-see.md b/async/docs/03-what-the-proxy-cannot-see.md new file mode 100644 index 0000000..1f21343 --- /dev/null +++ b/async/docs/03-what-the-proxy-cannot-see.md @@ -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) diff --git a/async/docs/04-return-types-and-exceptions.md b/async/docs/04-return-types-and-exceptions.md new file mode 100644 index 0000000..2a480c0 --- /dev/null +++ b/async/docs/04-return-types-and-exceptions.md @@ -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` | the future from `executor.submitCompletable(task)` | +| `Future` | 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` 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) diff --git a/async/docs/05-pool-sizing.md b/async/docs/05-pool-sizing.md new file mode 100644 index 0000000..04e757e --- /dev/null +++ b/async/docs/05-pool-sizing.md @@ -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) diff --git a/async/docs/06-context-propagation.md b/async/docs/06-context-propagation.md new file mode 100644 index 0000000..f695cbe --- /dev/null +++ b/async/docs/06-context-propagation.md @@ -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` 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) diff --git a/async/docs/07-which-executor-runs-it.md b/async/docs/07-which-executor-runs-it.md new file mode 100644 index 0000000..b3a334f --- /dev/null +++ b/async/docs/07-which-executor-runs-it.md @@ -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) diff --git a/async/docs/08-virtual-threads-and-pinning.md b/async/docs/08-virtual-threads-and-pinning.md new file mode 100644 index 0000000..46b1dff --- /dev/null +++ b/async/docs/08-virtual-threads-and-pinning.md @@ -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) diff --git a/async/docs/output/context-propagation.txt b/async/docs/output/context-propagation.txt new file mode 100644 index 0000000..cd75002 --- /dev/null +++ b/async/docs/output/context-propagation.txt @@ -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 + diff --git a/async/docs/output/executor-force-mode.txt b/async/docs/output/executor-force-mode.txt new file mode 100644 index 0000000..673994d --- /dev/null +++ b/async/docs/output/executor-force-mode.txt @@ -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) + diff --git a/async/docs/output/executor-one-custom.txt b/async/docs/output/executor-one-custom.txt new file mode 100644 index 0000000..71f9434 --- /dev/null +++ b/async/docs/output/executor-one-custom.txt @@ -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) + diff --git a/async/docs/output/executor-report.txt b/async/docs/output/executor-report.txt new file mode 100644 index 0000000..cc435e3 --- /dev/null +++ b/async/docs/output/executor-report.txt @@ -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 + diff --git a/async/docs/output/executor-two-custom.txt b/async/docs/output/executor-two-custom.txt new file mode 100644 index 0000000..1319010 --- /dev/null +++ b/async/docs/output/executor-two-custom.txt @@ -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) + diff --git a/async/docs/output/pinning-probe.txt b/async/docs/output/pinning-probe.txt new file mode 100644 index 0000000..d45332d --- /dev/null +++ b/async/docs/output/pinning-probe.txt @@ -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. diff --git a/async/docs/output/pool-bounded-queue.txt b/async/docs/output/pool-bounded-queue.txt new file mode 100644 index 0000000..3fc1c13 --- /dev/null +++ b/async/docs/output/pool-bounded-queue.txt @@ -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. + diff --git a/async/docs/output/pool-unbounded-queue.txt b/async/docs/output/pool-unbounded-queue.txt new file mode 100644 index 0000000..ea34dbf --- /dev/null +++ b/async/docs/output/pool-unbounded-queue.txt @@ -0,0 +1,6 @@ +== core-size=4, max-size=12, queue-capacity=, 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. + diff --git a/async/docs/output/return-types.txt b/async/docs/output/return-types.txt new file mode 100644 index 0000000..1e46b21 --- /dev/null +++ b/async/docs/output/return-types.txt @@ -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. + diff --git a/async/docs/output/self-invocation.txt b/async/docs/output/self-invocation.txt new file mode 100644 index 0000000..27b2dd6 --- /dev/null +++ b/async/docs/output/self-invocation.txt @@ -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) + diff --git a/async/docs/output/tests.txt b/async/docs/output/tests.txt new file mode 100644 index 0000000..310ca0d --- /dev/null +++ b/async/docs/output/tests.txt @@ -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 diff --git a/async/docs/output/virtual-threads.txt b/async/docs/output/virtual-threads.txt new file mode 100644 index 0000000..c315658 --- /dev/null +++ b/async/docs/output/virtual-threads.txt @@ -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 + diff --git a/async/docs/output/visibility.txt b/async/docs/output/visibility.txt new file mode 100644 index 0000000..2ecf33b --- /dev/null +++ b/async/docs/output/visibility.txt @@ -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) + diff --git a/async/pom.xml b/async/pom.xml new file mode 100644 index 0000000..01d6603 --- /dev/null +++ b/async/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + async + 1.0 + jar + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter + + + + io.micrometer + context-propagation + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/async/scripts/pinning-probe.sh b/async/scripts/pinning-probe.sh new file mode 100755 index 0000000..a12ad50 --- /dev/null +++ b/async/scripts/pinning-probe.sh @@ -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 [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 diff --git a/async/scripts/run-all.sh b/async/scripts/run-all.sh new file mode 100755 index 0000000..7228cf1 --- /dev/null +++ b/async/scripts/run-all.sh @@ -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" diff --git a/async/src/main/java/com/ankurm/async/AsyncDemoApplication.java b/async/src/main/java/com/ankurm/async/AsyncDemoApplication.java new file mode 100644 index 0000000..7ae4964 --- /dev/null +++ b/async/src/main/java/com/ankurm/async/AsyncDemoApplication.java @@ -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. + * + *

{@code @EnableAsync} is what installs {@code AsyncAnnotationBeanPostProcessor}. Spring Boot + * does not 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); + } +} diff --git a/async/src/main/java/com/ankurm/async/ContextService.java b/async/src/main/java/com/ankurm/async/ContextService.java new file mode 100644 index 0000000..30a18ea --- /dev/null +++ b/async/src/main/java/com/ankurm/async/ContextService.java @@ -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 readRequestId() { + return CompletableFuture.completedFuture(String.valueOf(RequestId.get())); + } +} diff --git a/async/src/main/java/com/ankurm/async/ExecutorDiagnostics.java b/async/src/main/java/com/ankurm/async/ExecutorDiagnostics.java new file mode 100644 index 0000000..bacddc1 --- /dev/null +++ b/async/src/main/java/com/ankurm/async/ExecutorDiagnostics.java @@ -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. + * + *

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 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(); + } +} diff --git a/async/src/main/java/com/ankurm/async/ExecutorSelectionConfig.java b/async/src/main/java/com/ankurm/async/ExecutorSelectionConfig.java new file mode 100644 index 0000000..1e4a54e --- /dev/null +++ b/async/src/main/java/com/ankurm/async/ExecutorSelectionConfig.java @@ -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}. + * + *

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. + * + *

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; + } +} diff --git a/async/src/main/java/com/ankurm/async/PinningProbe.java b/async/src/main/java/com/ankurm/async/PinningProbe.java new file mode 100644 index 0000000..1efd06a --- /dev/null +++ b/async/src/main/java/com/ankurm/async/PinningProbe.java @@ -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. + * + *

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. + * + *

If pinning happens, the synchronized run takes roughly {@code tasks / carriers * sleep}. + * If it does not, both runs take roughly {@code sleep}. + * + *

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", "")); + 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 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(); + } + } +} diff --git a/async/src/main/java/com/ankurm/async/RequestId.java b/async/src/main/java/com/ankurm/async/RequestId.java new file mode 100644 index 0000000..9757538 --- /dev/null +++ b/async/src/main/java/com/ankurm/async/RequestId.java @@ -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. + * + *

{@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. + * + *

See docs/06-context-propagation.md. + */ +public final class RequestId { + + public static final String KEY = "requestId"; + + private static final ThreadLocal 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 { + + @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(); + } + } +} diff --git a/async/src/main/java/com/ankurm/async/ReturnTypeService.java b/async/src/main/java/com/ankurm/async/ReturnTypeService.java new file mode 100644 index 0000000..5e5d32c --- /dev/null +++ b/async/src/main/java/com/ankurm/async/ReturnTypeService.java @@ -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. + * + *

{@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. + * + *

See docs/04-return-types-and-exceptions.md. + */ +@Service +public class ReturnTypeService { + + @Async + public CompletableFuture 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 futureThatThrows() { + throw new IllegalStateException("thrown from a CompletableFuture @Async method"); + } +} diff --git a/async/src/main/java/com/ankurm/async/SaturationService.java b/async/src/main/java/com/ankurm/async/SaturationService.java new file mode 100644 index 0000000..2c9aa8a --- /dev/null +++ b/async/src/main/java/com/ankurm/async/SaturationService.java @@ -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. + * + *

{@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. + * + *

Each method records the distinct thread names it ran on. Counting them is the measurement. + * + *

See docs/05-pool-sizing.md. + */ +@Service +public class SaturationService { + + private final Set 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 threadsSeen() { + return Set.copyOf(this.threadsSeen); + } + + public void reset() { + this.threadsSeen.clear(); + } +} diff --git a/async/src/main/java/com/ankurm/async/SelfInvocationService.java b/async/src/main/java/com/ankurm/async/SelfInvocationService.java new file mode 100644 index 0000000..a53800e --- /dev/null +++ b/async/src/main/java/com/ankurm/async/SelfInvocationService.java @@ -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. + * + *

{@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 + * inside 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. + * + *

See docs/02-the-self-invocation-trap.md. + */ +@Service +public class SelfInvocationService { + + private final ObjectProvider 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 self) { + this.self = self; + } + + /** + * The annotated method. Reached through the proxy, it runs on an executor thread. + */ + @Async + public CompletableFuture 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 viaSelfInvocation() { + return annotated(); + } + + /** + * The fix that keeps the code in one class: go out through the proxy deliberately. + */ + public CompletableFuture viaSelfReference() { + return self.getObject().annotated(); + } +} diff --git a/async/src/main/java/com/ankurm/async/Threads.java b/async/src/main/java/com/ankurm/async/Threads.java new file mode 100644 index 0000000..ab8add7 --- /dev/null +++ b/async/src/main/java/com/ankurm/async/Threads.java @@ -0,0 +1,26 @@ +package com.ankurm.async; + +/** + * Thread description used by every transcript in docs/output/. + * + *

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(); + } +} diff --git a/async/src/main/java/com/ankurm/async/VisibilityService.java b/async/src/main/java/com/ankurm/async/VisibilityService.java new file mode 100644 index 0000000..c742a89 --- /dev/null +++ b/async/src/main/java/com/ankurm/async/VisibilityService.java @@ -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. + * + *

Spring Boot proxies with CGLIB by default ({@code spring.aop.proxy-target-class} is true), + * which means the proxy is a generated subclass. 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. + * + *

See docs/03-what-the-proxy-cannot-see.md. + */ +@Service +public class VisibilityService { + + @Async + public CompletableFuture publicMethod() { + return CompletableFuture.completedFuture(Threads.describe()); + } + + /** Cannot be overridden by the CGLIB subclass, so the advice never runs. */ + @Async + public final CompletableFuture finalMethod() { + return CompletableFuture.completedFuture(Threads.describe()); + } + + /** Protected methods can be overridden by a subclass, so this one is advised. */ + @Async + protected CompletableFuture 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 privateMethod() { + return CompletableFuture.completedFuture(Threads.describe()); + } + + /** Calls the protected one from outside its own {@code this}, via the caller's proxy. */ + public CompletableFuture callProtectedInternally() { + return protectedMethod(); + } +} diff --git a/async/src/main/resources/application.yaml b/async/src/main/resources/application.yaml new file mode 100644 index 0000000..842e307 --- /dev/null +++ b/async/src/main/resources/application.yaml @@ -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/. diff --git a/async/src/test/java/com/ankurm/async/BoundedQueueTest.java b/async/src/test/java/com/ankurm/async/BoundedQueueTest.java new file mode 100644 index 0000000..96196dd --- /dev/null +++ b/async/src/test/java/com/ankurm/async/BoundedQueueTest.java @@ -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"); + } +} diff --git a/async/src/test/java/com/ankurm/async/Capture.java b/async/src/test/java/com/ankurm/async/Capture.java new file mode 100644 index 0000000..8bd45ee --- /dev/null +++ b/async/src/test/java/com/ankurm/async/Capture.java @@ -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); + } + } +} diff --git a/async/src/test/java/com/ankurm/async/ContextNotPropagatedTest.java b/async/src/test/java/com/ankurm/async/ContextNotPropagatedTest.java new file mode 100644 index 0000000..a182677 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/ContextNotPropagatedTest.java @@ -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(); + } + } +} diff --git a/async/src/test/java/com/ankurm/async/ContextPropagatedTest.java b/async/src/test/java/com/ankurm/async/ContextPropagatedTest.java new file mode 100644 index 0000000..899fc80 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/ContextPropagatedTest.java @@ -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(); + } + } +} diff --git a/async/src/test/java/com/ankurm/async/ExecutorReportTest.java b/async/src/test/java/com/ankurm/async/ExecutorReportTest.java new file mode 100644 index 0000000..fa7ee05 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/ExecutorReportTest.java @@ -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()); + } +} diff --git a/async/src/test/java/com/ankurm/async/ForceModeTest.java b/async/src/test/java/com/ankurm/async/ForceModeTest.java new file mode 100644 index 0000000..075c061 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/ForceModeTest.java @@ -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"); + } +} diff --git a/async/src/test/java/com/ankurm/async/OwnExecutorTest.java b/async/src/test/java/com/ankurm/async/OwnExecutorTest.java new file mode 100644 index 0000000..e7396a4 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/OwnExecutorTest.java @@ -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"); + } +} diff --git a/async/src/test/java/com/ankurm/async/ReturnTypeTest.java b/async/src/test/java/com/ankurm/async/ReturnTypeTest.java new file mode 100644 index 0000000..306371e --- /dev/null +++ b/async/src/test/java/com/ankurm/async/ReturnTypeTest.java @@ -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. + * + *

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 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"); + } +} diff --git a/async/src/test/java/com/ankurm/async/SelfInvocationTest.java b/async/src/test/java/com/ankurm/async/SelfInvocationTest.java new file mode 100644 index 0000000..1b3df25 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/SelfInvocationTest.java @@ -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); + } +} diff --git a/async/src/test/java/com/ankurm/async/TwoExecutorsTest.java b/async/src/test/java/com/ankurm/async/TwoExecutorsTest.java new file mode 100644 index 0000000..a6f2e3a --- /dev/null +++ b/async/src/test/java/com/ankurm/async/TwoExecutorsTest.java @@ -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"); + } +} diff --git a/async/src/test/java/com/ankurm/async/UnboundedQueueTest.java b/async/src/test/java/com/ankurm/async/UnboundedQueueTest.java new file mode 100644 index 0000000..2c10777 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/UnboundedQueueTest.java @@ -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=, 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"); + } +} diff --git a/async/src/test/java/com/ankurm/async/VirtualThreadsTest.java b/async/src/test/java/com/ankurm/async/VirtualThreadsTest.java new file mode 100644 index 0000000..e2eb873 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/VirtualThreadsTest.java @@ -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()); + } +} diff --git a/async/src/test/java/com/ankurm/async/VisibilityTest.java b/async/src/test/java/com/ankurm/async/VisibilityTest.java new file mode 100644 index 0000000..39392d4 --- /dev/null +++ b/async/src/test/java/com/ankurm/async/VisibilityTest.java @@ -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); + } +}