Compare commits
2
Commits
main
..
6af6e4a3f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af6e4a3f1 | ||
|
|
243cccd4ca |
@@ -10,9 +10,6 @@ by that module's `scripts/run-all.sh`, never typed by hand.
|
||||
|---|---|---|
|
||||
| [`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 |
|
||||
| [`scheduling/`](scheduling/README.md) | [@Scheduled, ShedLock and Distributed Cron: Scheduling That Survives Three Replicas](https://ankurm.com/spring-scheduled-shedlock-distributed-cron/) | Three replicas against one database running the same job three times, then one row and one conditional UPDATE fixing it |
|
||||
| [`awaitility/`](awaitility/README.md) | Testing Asynchronous Code with Awaitility (@Async, Kafka Listeners, Schedulers) | Replacing `Thread.sleep` with `await()` across a void `@Async` method, an `@KafkaListener`, and a `@Scheduled` job — plus the real pom.xml trap where Boot 4.1.1 split Kafka's autoconfiguration out of `spring-boot-autoconfigure` |
|
||||
| [`virtual-threads-benchmark/`](virtual-threads-benchmark/README.md) | [Virtual Threads on Spring Boot 4.1: The Benchmarks, Re-Run, and the Pinning Advice That Expired](https://ankurm.com/leveraging-virtual-threads-in-spring-boot-3-4-building-high-throughput-services/) | Platform threads vs virtual threads, re-benchmarked on Boot 4.1.1 / JDK 25, plus JEP 491's fix to `synchronized` pinning proven against a real JDK |
|
||||
| [`virtual-threads-benchmark-webflux/`](virtual-threads-benchmark-webflux/README.md) | [Virtual Threads vs Reactive (WebFlux) vs Platform Threads: Benchmarks and a Decision Framework](https://ankurm.com/virtual-threads-vs-webflux-vs-platform-threads-spring-boot-benchmarks/) | The WebFlux leg of the three-way comparison, plus the event-loop-starvation failure mode an isolated CPU benchmark can't show |
|
||||
|
||||
## Common ground
|
||||
|
||||
@@ -34,13 +31,6 @@ The `scheduling` module also needs a database. `scheduling/scripts/postgres.sh`
|
||||
throwaway PostgreSQL 14 into `target/` with no Docker and no root, which is how its transcripts
|
||||
were produced; `docker-compose.yml` is there for anyone who would rather use Docker.
|
||||
|
||||
`virtual-threads-benchmark` and `virtual-threads-benchmark-webflux` are a similar pair: the
|
||||
first re-benchmarks platform threads against virtual threads for one post, the second adds the
|
||||
WebFlux leg for a different, three-way-comparison post, and reuses the first module's
|
||||
committed transcripts rather than re-measuring the same thing twice. Both use the same
|
||||
client-side load generator (`java.net.http.HttpClient` on a virtual-thread executor, client
|
||||
role only) so all three threading models in the three-way post are measured the same way.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
@@ -52,6 +52,6 @@ against `ContextRegistry`. Two tests differ only in the property:
|
||||
|
||||
`SecurityContext` propagation is a related but separate mechanism, with its own set of ways to
|
||||
get it wrong; that is covered in
|
||||
[Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/).
|
||||
[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)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# awaitility
|
||||
|
||||
Companion code for the Awaitility article on [ankurm.com](https://ankurm.com). All intermediate
|
||||
and reference-grade depth lives in the post itself (accordions / "going deeper" paragraphs), not
|
||||
in a `docs/NN-topic.md` chapter folder — the one exception, as in the rest of this repository, is
|
||||
`docs/output/`, which holds real captured transcripts and nothing else.
|
||||
|
||||
## What this is
|
||||
|
||||
Six real, runnable demonstrations of the same idea: a fixed `Thread.sleep(...)` in a test is a
|
||||
guess about timing the test does not control, and `Awaitility.await()` replaces the guess with a
|
||||
bounded poll loop. Each demonstration targets a different source of asynchrony:
|
||||
|
||||
| Test | What it waits for |
|
||||
|---|---|
|
||||
| `AwaitAsyncConfirmationTest` | A `void` `@Async` method with no `Future` to block on |
|
||||
| `DefaultTimingTest` | Nothing — it proves Awaitility's own defaults (10s timeout, 100ms poll interval) against the real jar |
|
||||
| `KafkaListenerAwaitTest` | An `@KafkaListener` consuming a record a `KafkaTemplate` just sent, against a real in-process `@EmbeddedKafka` broker |
|
||||
| `ScheduledJobAwaitTest` | Three more executions of an already-running `@Scheduled(fixedRate = 150)` job |
|
||||
| `UncaughtExceptionHandlerSwapTest` | Nothing — it proves `await()` temporarily installs its own `Thread.setDefaultUncaughtExceptionHandler` and restores the original afterward |
|
||||
| `IgnoreExceptionsTest` | A resource that throws for its first 300ms, using `ignoreExceptionsInstanceOf(...)` to treat that as "not yet", not a failure |
|
||||
|
||||
Two more transcripts in `docs/output/` are real failures from tests that are *not* in the suite
|
||||
above: `01-sleep-guesses-wrong.txt` (a fixed `Thread.sleep(100)` against a 220ms operation) and
|
||||
`08-exception-propagates-immediately.txt` (the same resource as `IgnoreExceptionsTest`, minus
|
||||
`ignoreExceptionsInstanceOf(...)`). Both were real `mvn test` runs, captured once, then the
|
||||
failing test class was deleted — the mistake is preserved as a transcript, not as a permanently
|
||||
red test.
|
||||
|
||||
## The pom.xml trap this module exists to document
|
||||
|
||||
The first version of this module's `pom.xml` depended on `org.springframework.kafka:spring-kafka`
|
||||
directly, the way every pre-Boot-4.1 tutorial does. It compiled. Every `@SpringBootTest` using
|
||||
Kafka then failed with an empty `ConcurrentLinkedQueue` and no error at context startup, because
|
||||
in Boot 4.1.1 the Kafka autoconfiguration classes (`KafkaTemplate`, `ConsumerFactory`, the
|
||||
`KafkaListenerEndpointRegistry` that `@KafkaListener` needs) moved out of the monolithic
|
||||
`spring-boot-autoconfigure` jar into their own module, `org.springframework.boot:spring-boot-kafka`
|
||||
— pulled in by the new `org.springframework.boot:spring-boot-starter-kafka`, not by `spring-kafka`
|
||||
alone. See `pom.xml`'s own comments and the post for the full diagnosis.
|
||||
|
||||
## Versions
|
||||
|
||||
Read from `spring-boot-dependencies-4.1.1.pom` and `spring-boot-starter-test-4.1.1.pom` on Maven
|
||||
Central, not from release notes: **JDK 25** (Temurin 25.0.4.1+1), **Spring Boot 4.1.1**,
|
||||
**Spring Kafka 4.1.1**, **Awaitility 4.3.0** (already on the classpath via
|
||||
`spring-boot-starter-test` — no explicit `<dependency>` for it anywhere in `pom.xml`).
|
||||
|
||||
## Quickstart
|
||||
|
||||
```
|
||||
mvn test
|
||||
```
|
||||
|
||||
11 tests, 0 failures. `KafkaListenerAwaitTest` starts a real in-process Kafka broker
|
||||
(`@EmbeddedKafka`) and takes a few seconds; `DefaultTimingTest` deliberately waits out Awaitility's
|
||||
real 10-second default timeout once, so the suite as a whole takes about 30 seconds.
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | What it's from |
|
||||
|---|---|
|
||||
| `00-full-test-run.txt` | The full `mvn test` run, 11/11 green |
|
||||
| `01-sleep-guesses-wrong.txt` | Real failure: a guessed `Thread.sleep(100)` against a 220ms operation (test since removed) |
|
||||
| `02-await-finds-it.txt` | The fixed version: `await().untilAsserted(...)` against the same operation |
|
||||
| `03-default-timeout-is-ten-seconds.txt` | `await().until(() -> false)` timing out at ~10,000ms with no override |
|
||||
| `04-default-poll-interval-is-100ms.txt` | Real poll timestamps, ~100ms apart, with no override |
|
||||
| `05-kafka-listener-await.txt` | `await()` for an `@KafkaListener` to consume a record just sent |
|
||||
| `06-scheduled-job-await.txt` | `await()` for 3 more executions of an already-running `@Scheduled` job |
|
||||
| `07-uncaught-exception-handler-swap.txt` | Proof that `await()` swaps and restores the JVM's default uncaught-exception handler |
|
||||
| `08-exception-propagates-immediately.txt` | Real failure: an exception from the polled condition, with no `ignoreExceptionsInstanceOf(...)`, failing on the first poll (test since removed) |
|
||||
| `09-ignore-exceptions-waits-it-out.txt` | The fixed version: the same resource, with `ignoreExceptionsInstanceOf(...)` |
|
||||
| `10-missing-kafka-starter-failure.txt` | Real failure: the Kafka test with `spring-kafka` as a direct dependency instead of `spring-boot-starter-kafka` (pom.xml since fixed) |
|
||||
| `11-pom-diagnosis.txt` | The real `curl`/`grep` transcript that found the Boot 4.1 Kafka module split against Maven Central's own POMs |
|
||||
|
||||
## What's sourced from documentation, not run here
|
||||
|
||||
Awaitility's own `@since` Javadoc tags, Spring Boot's own migration notes for the Kafka module
|
||||
split, and the general shape of `@KafkaListener`/`@EmbeddedKafka` wiring are cited from the real
|
||||
jars and POMs on Maven Central (see the post for exact artifact coordinates), not re-derived from
|
||||
scratch in this module.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — see [LICENSE](../LICENSE).
|
||||
@@ -1,14 +0,0 @@
|
||||
[INFO] Running com.ankurm.awaitility.KafkaListenerAwaitTest
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 8.012 s -- in com.ankurm.awaitility.KafkaListenerAwaitTest
|
||||
[INFO] Running com.ankurm.awaitility.ScheduledJobAwaitTest
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.841 s -- in com.ankurm.awaitility.ScheduledJobAwaitTest
|
||||
[INFO] Running com.ankurm.awaitility.UncaughtExceptionHandlerSwapTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.115 s -- in com.ankurm.awaitility.UncaughtExceptionHandlerSwapTest
|
||||
[INFO] Running com.ankurm.awaitility.AwaitAsyncConfirmationTest
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.654 s -- in com.ankurm.awaitility.AwaitAsyncConfirmationTest
|
||||
[INFO] Running com.ankurm.awaitility.IgnoreExceptionsTest
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.636 s -- in com.ankurm.awaitility.IgnoreExceptionsTest
|
||||
[INFO] Running com.ankurm.awaitility.DefaultTimingTest
|
||||
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.70 s -- in com.ankurm.awaitility.DefaultTimingTest
|
||||
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
@@ -1,9 +0,0 @@
|
||||
== Thread.sleep(100) against a confirmation that actually takes 220ms (real failure, test since removed) ==
|
||||
|
||||
$ mvn -B -Dtest=SleepGuessesWrongTest test
|
||||
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 3.568 s <<< FAILURE! -- in com.ankurm.awaitility.SleepGuessesWrongTest
|
||||
com.ankurm.awaitility.SleepGuessesWrongTest.confirmationArrivesWithin100ms -- Time elapsed: 1.084 s <<< FAILURE!
|
||||
java.lang.AssertionError:
|
||||
|
||||
Expecting actual not to be null
|
||||
at com.ankurm.awaitility.SleepGuessesWrongTest.confirmationArrivesWithin100ms(SleepGuessesWrongTest.java:27)
|
||||
@@ -1,6 +0,0 @@
|
||||
== await().untilAsserted() against a 220ms-delayed void @Async method ==
|
||||
|
||||
mailbox.confirmationFor("ORD-CAPTURE") immediately before sendConfirmation(): null
|
||||
await().atMost(2s).untilAsserted(...) returned after approximately: 307ms
|
||||
mailbox.confirmationFor("ORD-CAPTURE") once await() returns: order ORD-CAPTURE confirmed
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
== await().until(() -> false) with no atMost(...) override ==
|
||||
|
||||
elapsed before ConditionTimeoutException: 10057ms (expected: ~10,000ms)
|
||||
@@ -1,6 +0,0 @@
|
||||
== await().atMost(550ms).until(...) with no pollInterval(...) override ==
|
||||
|
||||
poll timestamps (ms since start): [107, 207, 308, 409, 509]
|
||||
gaps between consecutive polls (ms): [100, 101, 101, 100]
|
||||
average gap: 100.5ms (expected: ~100ms)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
== await() for an @KafkaListener to consume a record a KafkaTemplate just sent, against an @EmbeddedKafka broker ==
|
||||
|
||||
listener.received() immediately after send() returned: (not checked -- that is the point)
|
||||
listener.received() once await() returned: [order-77-created]
|
||||
approximate elapsed time: 378ms
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
== await() for 3 more @Scheduled(fixedRate = 150) executions, regardless of how long the job had already been running ==
|
||||
|
||||
runCount() when the test started: 1
|
||||
target (baseline + 3): 4
|
||||
runCount() once await() returned: 4
|
||||
approximate elapsed time: 510ms
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
== Thread.getDefaultUncaughtExceptionHandler() before, during and after one await() call ==
|
||||
|
||||
handler during await() is the one this test installed: false (expected: false)
|
||||
handler during await() is Awaitility's own (class org.awaitility.core.CallableCondition$1)
|
||||
handler after await() is the one this test installed: true (expected: true)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
== await().untilAsserted(...) with no ignoreExceptionsInstanceOf(...), against a resource that throws for its first 300ms (real failure, test since removed) ==
|
||||
|
||||
$ mvn -B -Dtest=ExceptionPropagatesImmediatelyTest test
|
||||
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 2.940 s <<< FAILURE! -- in com.ankurm.awaitility.ExceptionPropagatesImmediatelyTest
|
||||
com.ankurm.awaitility.ExceptionPropagatesImmediatelyTest.failsOnTheFirstPollInsteadOfWaiting -- Time elapsed: 0.946 s <<< ERROR!
|
||||
java.lang.IllegalStateException: resource is still warming up
|
||||
at com.ankurm.awaitility.FlakyStartupResource.value(FlakyStartupResource.java:26)
|
||||
at com.ankurm.awaitility.ExceptionPropagatesImmediatelyTest.lambda$failsOnTheFirstPollInsteadOfWaiting$0(ExceptionPropagatesImmediatelyTest.java:28)
|
||||
|
||||
Note "Errors: 1", not a ConditionTimeoutException after the full atMost(1s) window: the exception
|
||||
from the very first poll propagated straight out instead of being retried.
|
||||
@@ -1,3 +0,0 @@
|
||||
== await().ignoreExceptionsInstanceOf(IllegalStateException.class) against a resource that throws for its first 300ms ==
|
||||
|
||||
approximate elapsed time before resource.value() finally returned "ready": 302ms (expected: a little over 300ms)
|
||||
@@ -1,23 +0,0 @@
|
||||
== Real failure with org.springframework.kafka:spring-kafka as a direct dependency instead of org.springframework.boot:spring-boot-starter-kafka (pom.xml since fixed) ==
|
||||
|
||||
$ mvn -B test
|
||||
[ERROR] Errors:
|
||||
[ERROR] KafkaListenerAwaitTest.messageSentIsEventuallyConsumed:49 ? ConditionTimeout Assertion condition defined as a Lambda expression in com.ankurm.awaitility.KafkaListenerAwaitTest
|
||||
Expecting ConcurrentLinkedQueue:
|
||||
[]
|
||||
to contain:
|
||||
["order-42-created"]
|
||||
but could not find the following element(s):
|
||||
["order-42-created"]
|
||||
within 5 seconds.
|
||||
[INFO]
|
||||
[ERROR] Tests run: 11, Failures: 0, Errors: 2, Skipped: 0
|
||||
|
||||
No exception at context startup, and the application context came up without a single warning.
|
||||
Autowiring KafkaListenerEndpointRegistry directly in a scratch test and calling
|
||||
getListenerContainers() on it confirmed the real cause: the bean did not exist. Neither did
|
||||
KafkaTemplate, ConsumerFactory, or kafkaListenerContainerFactory. Grepping the full log for the
|
||||
Spring Kafka logger prefix also confirmed it:
|
||||
|
||||
$ grep -c "o\.s\.k\." full-test-run.log
|
||||
0
|
||||
@@ -1,38 +0,0 @@
|
||||
== Confirming the Boot 4.1 Kafka module split directly against Maven Central's real POMs ==
|
||||
|
||||
$ curl -sS https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/4.1.1/spring-boot-dependencies-4.1.1.pom -o spring-boot-dependencies-4.1.1.pom
|
||||
$ grep -B3 '<artifactId>spring-boot-kafka</artifactId>' spring-boot-dependencies-4.1.1.pom
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-kafka</artifactId>
|
||||
|
||||
$ curl -sS https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-kafka/4.1.1/spring-boot-starter-kafka-4.1.1.pom | grep -A2 '<artifactId>'
|
||||
<artifactId>spring-boot-starter-kafka</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<name>spring-boot-starter-kafka</name>
|
||||
--
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<scope>compile</scope>
|
||||
--
|
||||
<artifactId>spring-boot-kafka</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<scope>compile</scope>
|
||||
|
||||
$ curl -sS https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-kafka/4.1.1/spring-boot-kafka-4.1.1.pom | grep -A2 '<artifactId>'
|
||||
<artifactId>spring-boot-kafka</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<name>spring-boot-kafka</name>
|
||||
--
|
||||
<artifactId>spring-boot</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<scope>compile</scope>
|
||||
--
|
||||
<artifactId>spring-boot-transaction</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<scope>compile</scope>
|
||||
--
|
||||
<artifactId>spring-kafka</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<scope>compile</scope>
|
||||
@@ -1,83 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<!-- Boot 4.1.1 manages Spring Framework 7.0.9, Spring Kafka 4.1.1 and Awaitility 4.3.0.
|
||||
Versions were read from spring-boot-dependencies-4.1.1.pom and
|
||||
spring-boot-starter-test-4.1.1.pom, not from release notes. -->
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>awaitility</artifactId>
|
||||
<version>1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- NOT org.springframework.kafka:spring-kafka directly, that was Boot 3.x's shape.
|
||||
In Boot 4.1.1 the Kafka autoconfiguration classes (KafkaTemplate, ConsumerFactory,
|
||||
the KafkaListenerEndpointRegistry that @KafkaListener needs) were extracted out of the
|
||||
monolithic spring-boot-autoconfigure jar into their own module, org.springframework.boot
|
||||
:spring-boot-kafka. Depending on spring-kafka alone now gets you the annotations and
|
||||
KafkaTemplate class with none of the autoconfiguration wiring: every @KafkaListener bean
|
||||
is created but no container ever starts, silently, with no error at context startup.
|
||||
This starter is what actually pulls spring-boot-kafka (confirmed via its real POM on
|
||||
Maven Central) in on top of spring-kafka itself. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-kafka</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- No explicit <awaitility> dependency anywhere in this file. spring-boot-starter-test
|
||||
4.1.1 already declares org.awaitility:awaitility:4.3.0 as a compile-scope dependency
|
||||
of its own, confirmed against the real POM on Maven Central, not assumed. Every
|
||||
project using this starter already has Awaitility on its test classpath today. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- The test-scope sibling of spring-boot-starter-kafka above: pulls spring-kafka-test
|
||||
(the @EmbeddedKafka JUnit 5 extension and KafkaTestUtils) on the same Boot-4.1.1-managed
|
||||
version as everything else here. @EmbeddedKafka starts a real, in-process Kafka broker
|
||||
for the duration of a test class, no Docker, no Testcontainers, no network port opened
|
||||
outside this JVM, which is also why it is the only honest way to demonstrate Kafka
|
||||
listener testing in a sandbox with no Docker daemon. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-kafka-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p docs/output
|
||||
|
||||
mvn -B test > /tmp/awaitility-run.log 2>&1 || { cat /tmp/awaitility-run.log; exit 1; }
|
||||
grep -E 'Running |Tests run:|BUILD ' /tmp/awaitility-run.log > docs/output/00-full-test-run.txt
|
||||
echo "wrote docs/output/00-full-test-run.txt"
|
||||
echo "the rest of docs/output/*.txt is written by the tests themselves (see Capture.java)"
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* A deliberately {@code void} {@code @Async} method: this is the case from
|
||||
* docs/01-what-async-actually-does.md (in the sibling {@code async} module) where there is no
|
||||
* {@code Future} the caller can block on, by design — the method is meant to be
|
||||
* fire-and-forget. Fire-and-forget is also the exact shape of code that pushes people toward
|
||||
* {@code Thread.sleep} in tests, because there is nothing else to wait on.
|
||||
*/
|
||||
@Service
|
||||
public class AsyncGreetingService {
|
||||
|
||||
private final Mailbox mailbox;
|
||||
|
||||
public AsyncGreetingService(Mailbox mailbox) {
|
||||
this.mailbox = mailbox;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void sendConfirmation(String orderId) {
|
||||
sleepFor(220);
|
||||
mailbox.record(orderId, "order " + orderId + " confirmed");
|
||||
}
|
||||
|
||||
private static void sleepFor(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
}
|
||||
catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* Entry point for the Awaitility demonstrations.
|
||||
*
|
||||
* <p>{@code @EnableAsync} and {@code @EnableScheduling} turn on the two Spring mechanisms this
|
||||
* module tests against: a fire-and-forget {@code @Async} method with no {@code Future} to block
|
||||
* on, and a {@code @Scheduled} job running on its own thread. Kafka needs no equivalent
|
||||
* annotation here — {@code spring-kafka} on the classpath is enough for Spring Boot's
|
||||
* {@code KafkaAutoConfiguration} to wire a listener container factory.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class AwaitilityDemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AwaitilityDemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Stands in for anything that is genuinely not ready yet right after it starts warming up
|
||||
* — a connection pool, a cache still loading, a client still completing its first
|
||||
* handshake — and throws rather than returning a sentinel while that is true. Call
|
||||
* {@link #startWarmingUp()} to begin a 300ms window during which {@link #value()} throws
|
||||
* {@link IllegalStateException}; after that it returns normally.
|
||||
*/
|
||||
@Component
|
||||
public class FlakyStartupResource {
|
||||
|
||||
private final AtomicReference<Instant> readyAt = new AtomicReference<>(Instant.EPOCH);
|
||||
|
||||
public void startWarmingUp() {
|
||||
readyAt.set(Instant.now().plus(Duration.ofMillis(300)));
|
||||
}
|
||||
|
||||
public String value() {
|
||||
if (Instant.now().isBefore(readyAt.get())) {
|
||||
throw new IllegalStateException("resource is still warming up");
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Stands in for "the side effect of an asynchronous operation" — a row written by a
|
||||
* background thread that a test (or another part of the application) has no handle to wait on
|
||||
* directly. {@link AsyncGreetingService#sendConfirmation(String)} writes here; nothing returns a
|
||||
* {@code Future} for it to block on, which is the whole reason the test for it needs Awaitility
|
||||
* rather than a blocking {@code get()}.
|
||||
*/
|
||||
@Component
|
||||
public class Mailbox {
|
||||
|
||||
private final ConcurrentMap<String, String> sent = new ConcurrentHashMap<>();
|
||||
|
||||
void record(String orderId, String message) {
|
||||
sent.put(orderId, message);
|
||||
}
|
||||
|
||||
/** Returns {@code null} if nothing has been recorded for this order yet. */
|
||||
public String confirmationFor(String orderId) {
|
||||
return sent.get(orderId);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return sent.size();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.kafka.annotation.KafkaListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Consumes from the {@code orders} topic on its own listener container thread. A test that sends
|
||||
* a record with {@code KafkaTemplate} and then immediately inspects {@link #received()} is racing
|
||||
* the consumer's poll loop — there is no return value, no {@code Future}, and no fixed
|
||||
* delay that is both fast enough to keep the test quick and slow enough to never flake under CI
|
||||
* load. See docs/output/05-kafka-listener-await.txt for why this module's own test does not use
|
||||
* {@code Thread.sleep} either.
|
||||
*
|
||||
* <p>Gated behind {@code app.kafka-demo.enabled} (off by default, turned on only by the one test
|
||||
* that needs it) so every other test's {@code @SpringBootTest} context does not also try to open
|
||||
* a consumer against a broker that, for them, does not exist.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "app.kafka-demo.enabled", havingValue = "true")
|
||||
public class OrderEventListener {
|
||||
|
||||
private final ConcurrentLinkedQueue<String> received = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@KafkaListener(topics = "orders", groupId = "awaitility-demo")
|
||||
public void onOrderEvent(String payload) {
|
||||
received.add(payload);
|
||||
}
|
||||
|
||||
public ConcurrentLinkedQueue<String> received() {
|
||||
return received;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A trivial {@code @Scheduled} job: it exists only so a test has an asynchronous, externally
|
||||
* triggered counter to wait on instead of sleeping for a guessed multiple of the fixed rate. See
|
||||
* the sibling {@code scheduling} module for what happens to a job like this across three
|
||||
* replicas; this one runs standalone and is not locked.
|
||||
*/
|
||||
@Component
|
||||
public class ReportJob {
|
||||
|
||||
private final AtomicInteger runs = new AtomicInteger();
|
||||
|
||||
@Scheduled(fixedRate = 150)
|
||||
public void run() {
|
||||
runs.incrementAndGet();
|
||||
}
|
||||
|
||||
public int runCount() {
|
||||
return runs.get();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: awaitility-demo
|
||||
# No spring.kafka.bootstrap-servers here. src/test/resources/application-test.yaml points it at
|
||||
# ${spring.embedded.kafka.brokers}, the system property EmbeddedKafkaBroker sets for the
|
||||
# duration of each test that carries @EmbeddedKafka.
|
||||
|
||||
app:
|
||||
kafka-demo:
|
||||
enabled: false # flipped on with a single @TestPropertySource, only by the Kafka test
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.time.Duration;
|
||||
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.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* {@link AsyncGreetingService#sendConfirmation(String)} is {@code void}: there is no
|
||||
* {@code Future} to call {@code get()} on, so the only way to find out when the background
|
||||
* thread has written to {@link Mailbox} is to look — repeatedly, until it's there.
|
||||
*
|
||||
* <p>docs/output/01-sleep-guesses-wrong.txt is the real failure from the version of this test
|
||||
* that guessed a fixed delay instead: {@code Thread.sleep(100)} against a confirmation that
|
||||
* actually takes 220ms. It failed on every run, not just an unlucky one, which is the more
|
||||
* common shape of a bad guess than the intermittent kind "flaky" usually implies.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class AwaitAsyncConfirmationTest {
|
||||
|
||||
@Autowired
|
||||
private AsyncGreetingService service;
|
||||
|
||||
@Autowired
|
||||
private Mailbox mailbox;
|
||||
|
||||
@Test
|
||||
void confirmationArrivesEventually() {
|
||||
service.sendConfirmation("ORD-1");
|
||||
|
||||
await().atMost(Duration.ofSeconds(2))
|
||||
.untilAsserted(() -> assertThat(mailbox.confirmationFor("ORD-1")).isNotNull());
|
||||
|
||||
assertThat(mailbox.confirmationFor("ORD-1")).isEqualTo("order ORD-1 confirmed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void capture() {
|
||||
String before = mailbox.confirmationFor("ORD-CAPTURE");
|
||||
service.sendConfirmation("ORD-CAPTURE");
|
||||
long start = System.nanoTime();
|
||||
|
||||
await().atMost(Duration.ofSeconds(2))
|
||||
.untilAsserted(() -> assertThat(mailbox.confirmationFor("ORD-CAPTURE")).isNotNull());
|
||||
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
Capture.write("02-await-finds-it.txt",
|
||||
"await().untilAsserted() against a 220ms-delayed void @Async method",
|
||||
"""
|
||||
mailbox.confirmationFor("ORD-CAPTURE") immediately before sendConfirmation(): %s
|
||||
await().atMost(2s).untilAsserted(...) returned after approximately: %dms
|
||||
mailbox.confirmationFor("ORD-CAPTURE") once await() returns: %s
|
||||
""".formatted(before, elapsedMs, mailbox.confirmationFor("ORD-CAPTURE")));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* Proves two numbers from the real jar instead of quoting them from the reference guide:
|
||||
* {@code org.awaitility.Awaitility}'s {@code defaultWaitConstraint} is
|
||||
* {@code AtMostWaitConstraint.TEN_SECONDS} and its {@code defaultPollInterval} is a fixed 100ms
|
||||
* — both read directly from {@code Awaitility.java} and {@code AtMostWaitConstraint.java}
|
||||
* in the 4.3.0 sources jar. Neither test overrides {@code atMost(...)} or
|
||||
* {@code pollInterval(...)}.
|
||||
*/
|
||||
class DefaultTimingTest {
|
||||
|
||||
@Test
|
||||
void theDefaultTimeoutIsTenSeconds() {
|
||||
long start = System.nanoTime();
|
||||
|
||||
assertThatTimeoutIsThrownBy(() -> await().until(() -> false));
|
||||
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
// Allow scheduling jitter; the point is "about 10 seconds", not exactly 10,000ms.
|
||||
assertThat(elapsedMs).isBetween(9_000L, 11_000L);
|
||||
|
||||
Capture.write("03-default-timeout-is-ten-seconds.txt",
|
||||
"await().until(() -> false) with no atMost(...) override",
|
||||
"elapsed before ConditionTimeoutException: %dms (expected: ~10,000ms)".formatted(elapsedMs));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDefaultPollIntervalIsOneHundredMillis() {
|
||||
List<Long> pollTimestampsMs = new ArrayList<>();
|
||||
long start = System.nanoTime();
|
||||
|
||||
assertThatTimeoutIsThrownBy(() -> await()
|
||||
.atMost(java.time.Duration.ofMillis(550))
|
||||
.until(() -> {
|
||||
pollTimestampsMs.add((System.nanoTime() - start) / 1_000_000);
|
||||
return false;
|
||||
}));
|
||||
|
||||
List<Long> gapsMs = new ArrayList<>();
|
||||
for (int i = 1; i < pollTimestampsMs.size(); i++) {
|
||||
gapsMs.add(pollTimestampsMs.get(i) - pollTimestampsMs.get(i - 1));
|
||||
}
|
||||
double averageGapMs = gapsMs.stream().mapToLong(Long::longValue).average().orElse(0);
|
||||
|
||||
assertThat(averageGapMs).isBetween(80.0, 120.0);
|
||||
|
||||
Capture.write("04-default-poll-interval-is-100ms.txt",
|
||||
"await().atMost(550ms).until(...) with no pollInterval(...) override",
|
||||
"""
|
||||
poll timestamps (ms since start): %s
|
||||
gaps between consecutive polls (ms): %s
|
||||
average gap: %.1fms (expected: ~100ms)
|
||||
""".formatted(pollTimestampsMs, gapsMs, averageGapMs));
|
||||
}
|
||||
|
||||
private static void assertThatTimeoutIsThrownBy(Runnable awaitCall) {
|
||||
try {
|
||||
awaitCall.run();
|
||||
throw new AssertionError("expected a ConditionTimeoutException but none was thrown");
|
||||
}
|
||||
catch (org.awaitility.core.ConditionTimeoutException expected) {
|
||||
// the default timeout firing is the point of this test
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.time.Duration;
|
||||
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.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* By default Awaitility does <em>not</em> treat an exception thrown inside the polled condition
|
||||
* as "still false" — {@code defaultExceptionIgnorer} in {@code Awaitility.java} is a
|
||||
* predicate that returns {@code false} for every exception, meaning nothing is ignored. An
|
||||
* exception from the very first poll propagates immediately and fails the test, without waiting
|
||||
* out the {@code atMost(...)} window at all. docs/output/08-exception-propagates-immediately.txt
|
||||
* is the real, immediate failure from the version of this test that left
|
||||
* {@code ignoreExceptionsInstanceOf(...)} out.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class IgnoreExceptionsTest {
|
||||
|
||||
@Autowired
|
||||
private FlakyStartupResource resource;
|
||||
|
||||
@Test
|
||||
void ignoredExceptionsAreTreatedAsNotYetTrue() {
|
||||
resource.startWarmingUp();
|
||||
|
||||
await().atMost(Duration.ofSeconds(1))
|
||||
.ignoreExceptionsInstanceOf(IllegalStateException.class)
|
||||
.untilAsserted(() -> assertThat(resource.value()).isEqualTo("ready"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void capture() {
|
||||
resource.startWarmingUp();
|
||||
long start = System.nanoTime();
|
||||
|
||||
await().atMost(Duration.ofSeconds(1))
|
||||
.ignoreExceptionsInstanceOf(IllegalStateException.class)
|
||||
.untilAsserted(() -> assertThat(resource.value()).isEqualTo("ready"));
|
||||
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
Capture.write("09-ignore-exceptions-waits-it-out.txt",
|
||||
"await().ignoreExceptionsInstanceOf(IllegalStateException.class) against a resource that throws for its first 300ms",
|
||||
"approximate elapsed time before resource.value() finally returned \"ready\": %dms (expected: a little over 300ms)"
|
||||
.formatted(elapsedMs));
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.kafka.core.ProducerFactory;
|
||||
import org.springframework.kafka.test.EmbeddedKafkaBroker;
|
||||
import org.springframework.kafka.test.context.EmbeddedKafka;
|
||||
import org.springframework.kafka.test.utils.KafkaTestUtils;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* {@code @EmbeddedKafka} starts a real, in-process Kafka broker for the duration of this test
|
||||
* class — no Docker, no Testcontainers. {@link OrderEventListener#onOrderEvent(String)}
|
||||
* consumes on the listener container's own thread, so sending a record with
|
||||
* {@link KafkaTemplate#send} and then immediately reading {@code received()} is racing the
|
||||
* consumer's poll loop exactly the way docs/02-the-self-invocation-trap.md's proxy call races a
|
||||
* background thread, just with a network hop (loopback, but still a hop) in between.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = "app.kafka-demo.enabled=true")
|
||||
@EmbeddedKafka(partitions = 1, topics = "orders")
|
||||
class KafkaListenerAwaitTest {
|
||||
|
||||
@Autowired
|
||||
private OrderEventListener listener;
|
||||
|
||||
@Autowired
|
||||
private EmbeddedKafkaBroker embeddedKafkaBroker;
|
||||
|
||||
@Test
|
||||
void messageSentIsEventuallyConsumed() {
|
||||
listener.received().clear();
|
||||
KafkaTemplate<String, String> producer = newProducer();
|
||||
|
||||
producer.send("orders", "order-42-created");
|
||||
|
||||
await().atMost(Duration.ofSeconds(5))
|
||||
.untilAsserted(() -> assertThat(listener.received()).contains("order-42-created"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void capture() {
|
||||
listener.received().clear();
|
||||
KafkaTemplate<String, String> producer = newProducer();
|
||||
long start = System.nanoTime();
|
||||
|
||||
producer.send("orders", "order-77-created");
|
||||
await().atMost(Duration.ofSeconds(5))
|
||||
.untilAsserted(() -> assertThat(listener.received()).contains("order-77-created"));
|
||||
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
Capture.write("05-kafka-listener-await.txt",
|
||||
"await() for an @KafkaListener to consume a record a KafkaTemplate just sent, against an @EmbeddedKafka broker",
|
||||
"""
|
||||
listener.received() immediately after send() returned: (not checked -- that is the point)
|
||||
listener.received() once await() returned: %s
|
||||
approximate elapsed time: %dms
|
||||
""".formatted(listener.received(), elapsedMs));
|
||||
}
|
||||
|
||||
private KafkaTemplate<String, String> newProducer() {
|
||||
Map<String, Object> props = KafkaTestUtils.producerProps(embeddedKafkaBroker);
|
||||
props.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||
ProducerFactory<String, String> producerFactory = new DefaultKafkaProducerFactory<>(props);
|
||||
return new KafkaTemplate<>(producerFactory);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.time.Duration;
|
||||
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.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* {@link ReportJob} runs on its own {@code fixedRate = 150} schedule, starting the moment the
|
||||
* context comes up — including however long context startup itself took, which varies by
|
||||
* machine and by whether this is the first test in the module or the tenth reusing a cached
|
||||
* context. "Sleep for 3 * 150ms and assert runCount() == 3" is exactly as wrong here as it was
|
||||
* for the {@code @Async} case, for the same reason: the guess has to be right about a timing
|
||||
* detail the test does not control. This test instead waits for <em>at least three more runs
|
||||
* than there were when the test started</em>, which is true regardless of how long the job has
|
||||
* already been running.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class ScheduledJobAwaitTest {
|
||||
|
||||
@Autowired
|
||||
private ReportJob reportJob;
|
||||
|
||||
@Test
|
||||
void atLeastThreeMoreRunsHappenWithinAwaitWindow() {
|
||||
int baseline = reportJob.runCount();
|
||||
|
||||
await().atMost(Duration.ofSeconds(2))
|
||||
.untilAsserted(() -> assertThat(reportJob.runCount()).isGreaterThanOrEqualTo(baseline + 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void capture() {
|
||||
int baseline = reportJob.runCount();
|
||||
long start = System.nanoTime();
|
||||
|
||||
await().atMost(Duration.ofSeconds(2))
|
||||
.untilAsserted(() -> assertThat(reportJob.runCount()).isGreaterThanOrEqualTo(baseline + 3));
|
||||
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
Capture.write("06-scheduled-job-await.txt",
|
||||
"await() for 3 more @Scheduled(fixedRate = 150) executions, regardless of how long the job had already been running",
|
||||
"""
|
||||
runCount() when the test started: %d
|
||||
target (baseline + 3): %d
|
||||
runCount() once await() returned: %d
|
||||
approximate elapsed time: %dms
|
||||
""".formatted(baseline, baseline + 3, reportJob.runCount(), elapsedMs));
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.ankurm.awaitility;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* A fact from reading {@code ConditionAwaiter.java}, not from the user guide: with
|
||||
* {@code catchUncaughtExceptions} at its default of {@code true}, every {@code await(...)} call
|
||||
* installs <em>itself</em> as the JVM's default {@link Thread#setDefaultUncaughtExceptionHandler}
|
||||
* for as long as that one call is polling, then restores whatever handler was there before.
|
||||
* That is a process-wide setting, not a per-thread one — a test that installs its own
|
||||
* default handler and then calls {@code await()} will have it silently replaced until the
|
||||
* {@code await()} call returns.
|
||||
*/
|
||||
class UncaughtExceptionHandlerSwapTest {
|
||||
|
||||
@Test
|
||||
void awaitTemporarilyInstallsItsOwnDefaultHandlerThenRestoresTheOriginal() {
|
||||
Thread.UncaughtExceptionHandler original = (t, e) -> {
|
||||
};
|
||||
Thread.setDefaultUncaughtExceptionHandler(original);
|
||||
|
||||
AtomicReference<Thread.UncaughtExceptionHandler> handlerDuringAwait = new AtomicReference<>();
|
||||
|
||||
await().atMost(Duration.ofMillis(300)).until(() -> {
|
||||
handlerDuringAwait.set(Thread.getDefaultUncaughtExceptionHandler());
|
||||
return true;
|
||||
});
|
||||
|
||||
Thread.UncaughtExceptionHandler handlerAfterAwait = Thread.getDefaultUncaughtExceptionHandler();
|
||||
|
||||
assertThat(handlerDuringAwait.get()).isNotSameAs(original);
|
||||
assertThat(handlerAfterAwait).isSameAs(original);
|
||||
|
||||
Capture.write("07-uncaught-exception-handler-swap.txt",
|
||||
"Thread.getDefaultUncaughtExceptionHandler() before, during and after one await() call",
|
||||
"""
|
||||
handler during await() is the one this test installed: %s (expected: false)
|
||||
handler during await() is Awaitility's own (class %s)
|
||||
handler after await() is the one this test installed: %s (expected: true)
|
||||
""".formatted(handlerDuringAwait.get() == original,
|
||||
handlerDuringAwait.get().getClass().getName(),
|
||||
handlerAfterAwait == original));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
spring:
|
||||
kafka:
|
||||
bootstrap-servers: ${spring.embedded.kafka.brokers}
|
||||
consumer:
|
||||
auto-offset-reset: earliest
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
@@ -1,4 +0,0 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,65 +0,0 @@
|
||||
# virtual-threads-webflux-benchmark
|
||||
|
||||
Companion module for the ankurm.com post **"Virtual Threads vs Reactive (WebFlux) vs Platform
|
||||
Threads: Benchmarks and a Decision Framework."** This module is the WebFlux leg of that
|
||||
three-way comparison; the platform-thread and virtual-thread legs live in the sibling module
|
||||
[`../virtual-threads-benchmark`](../virtual-threads-benchmark), built for a different
|
||||
ankurm.com post and reused here rather than re-run, so the platform/virtual numbers quoted in
|
||||
this post are the same numbers, not a second measurement of the same thing.
|
||||
|
||||
Every number below is from a real concurrent load run against a real running embedded Netty
|
||||
server on this JDK, using the identical client-side load generator
|
||||
(`java.net.http.HttpClient`, virtual-thread executor, client-side only) as the sibling module.
|
||||
|
||||
## Versions (verified against `repo1.maven.org` maven-metadata.xml and Spring Boot's own `spring-boot-dependencies` POM, not aggregators)
|
||||
|
||||
| Component | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) | same as `../virtual-threads-benchmark` |
|
||||
| Spring Boot | 4.1.1 | latest GA at time of writing |
|
||||
| Spring Framework | 7.0.9 | latest GA |
|
||||
| Reactor | managed by `spring-boot-dependencies` 4.1.1 | version not pinned directly; see this module's effective POM |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
./scripts/run-all.sh # regenerates every file in docs/output/ from a real test run
|
||||
./scripts/run.sh # start on :8080
|
||||
```
|
||||
|
||||
Requires JDK 25 and Maven. This module's own benchmark ran on the same 2 vCPU sandbox as
|
||||
`../virtual-threads-benchmark` -- see
|
||||
[docs/01-webflux-benchmark-methodology.md](docs/01-webflux-benchmark-methodology.md) for why
|
||||
that matters and what it means for the numbers below.
|
||||
|
||||
## What's demonstrated where
|
||||
|
||||
| Area | Source | Test | Transcript |
|
||||
|---|---|---|---|
|
||||
| I/O-bound throughput: WebFlux, 600 concurrent, median of 5 trials (single trials swung 50%+ on this box) | [`ReactiveDemoController`](src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java) | [`WebfluxLoadBenchmarkTest`](src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java) | [`01`](docs/output/01-io-bound-webflux.txt) |
|
||||
| CPU-bound throughput: naive (event loop) vs offloaded (`Schedulers.parallel()`), 60 concurrent -- and why the gap is smaller than expected on this box | same | same | [`02`](docs/output/02-cpu-bound-webflux.txt) |
|
||||
| Event-loop starvation: what the naive CPU endpoint actually costs *other* traffic on the same server, 150 concurrent, median of 3 trials | [`CpuWork`](src/main/java/com/ankurm/vthreadswebflux/CpuWork.java) | same | [`03`](docs/output/03-event-loop-starvation.txt) |
|
||||
| Backpressure is structural: a `Flux` never outruns its subscriber's requests | [`ReactiveDemoController.streamResults()`](src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java) | [`StreamBackpressureTest`](src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java) | [`04`](docs/output/04-stream-backpressure.txt) |
|
||||
|
||||
## Documentation chapters
|
||||
|
||||
1. [WebFlux benchmark methodology and results](docs/01-webflux-benchmark-methodology.md) --
|
||||
I/O-bound, CPU-bound naive vs offloaded, the event-loop-starvation scenario the isolated CPU
|
||||
benchmark can't show (including a first-cut version of that test that under-loaded the event
|
||||
loop and had to be fixed), a real backpressure proof, and the discovery that single-trial
|
||||
measurements on this sandbox swing 50%+ and had to be replaced with medians of several
|
||||
trials, all on this sandbox's real hardware
|
||||
|
||||
## A note on this module's relationship to `../virtual-threads-benchmark`
|
||||
|
||||
[`../virtual-threads-benchmark`](../virtual-threads-benchmark) already contains a real,
|
||||
re-run platform-thread vs virtual-thread benchmark on this exact hardware and JDK, built for
|
||||
[Virtual Threads on Spring Boot 4.1](https://ankurm.com/leveraging-virtual-threads-in-spring-boot-3-4-building-high-throughput-services/).
|
||||
This module deliberately does not re-measure that comparison -- it reuses those committed
|
||||
transcripts and adds only the WebFlux leg, using the same client-side load-generation method,
|
||||
so the three-way post can quote one consistent measurement approach across all three models
|
||||
instead of stitching together benchmarks run different ways.
|
||||
|
||||
## License
|
||||
|
||||
MIT -- see [LICENSE](LICENSE).
|
||||
@@ -1,187 +0,0 @@
|
||||
# 1. WebFlux benchmark methodology and results
|
||||
|
||||
[README](../README.md) | Companion module: [`../virtual-threads-benchmark`](../virtual-threads-benchmark/README.md) (platform threads vs virtual threads)
|
||||
|
||||
Source: [`ReactiveDemoController.java`](../src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java), [`CpuWork.java`](../src/main/java/com/ankurm/vthreadswebflux/CpuWork.java).
|
||||
Test: [`WebfluxLoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java).
|
||||
Transcripts: [`docs/output/01-io-bound-webflux.txt`](output/01-io-bound-webflux.txt), [`docs/output/02-cpu-bound-webflux.txt`](output/02-cpu-bound-webflux.txt), [`docs/output/03-event-loop-starvation.txt`](output/03-event-loop-starvation.txt), [`docs/output/04-stream-backpressure.txt`](output/04-stream-backpressure.txt).
|
||||
|
||||
## Why this module exists, and why it's separate from `../virtual-threads-benchmark`
|
||||
|
||||
The companion post for this module is the three-way comparison: platform threads vs virtual
|
||||
threads vs WebFlux. The platform-vs-virtual half of that comparison already has a real,
|
||||
re-run benchmark in [`../virtual-threads-benchmark`](../virtual-threads-benchmark), built for
|
||||
a different ankurm.com post and reused here rather than duplicated. This module adds the
|
||||
missing WebFlux leg, measured with the identical client-side load generator
|
||||
(`java.net.http.HttpClient` backed by a virtual-thread executor, used only as the *client*) so
|
||||
all three legs come from the same method on the same 2 vCPU sandbox.
|
||||
|
||||
It is its own Maven module rather than a third profile inside `../virtual-threads-benchmark`
|
||||
because `spring-boot-starter-web` (Tomcat) and `spring-boot-starter-webflux` (Netty) on the
|
||||
same classpath fight over `WebApplicationType.deduceFromClasspath()` -- exactly the kind of
|
||||
fragile setup a benchmark should not carry. A module with only `spring-boot-starter-webflux`
|
||||
needs no such workaround.
|
||||
|
||||
## A methodology correction made while building this module: single trials are not reliable here
|
||||
|
||||
While measuring the I/O-bound endpoint, identical back-to-back single trials at 600
|
||||
concurrency swung from 795ms to 1247ms wall time on this shared sandbox -- a spread larger
|
||||
than the actual gap this benchmark exists to measure. A single HTTP load-test run on a
|
||||
noisy, shared 2 vCPU box is not precise enough to support a "X% faster" claim between two
|
||||
models that are actually close; it only looks precise because it produces one number.
|
||||
|
||||
The fix applied to the I/O-bound scenario and the event-loop-starvation scenario below:
|
||||
run the load multiple independent times and report the **median** across trials, not a
|
||||
single shot. This is a real methodology change, not cosmetic -- it changed which model's
|
||||
number looked better in earlier drafts of this module before the fix was applied.
|
||||
|
||||
**This has one important consequence for how to read the numbers below.** The
|
||||
platform-thread and virtual-thread numbers reused from
|
||||
[`../virtual-threads-benchmark`](../virtual-threads-benchmark) are **single trials**, measured
|
||||
for a different post before this variance was discovered there. The WebFlux numbers in this
|
||||
module are **medians of 5 (I/O) or 3 (event-loop starvation) trials**. Comparing a
|
||||
denoised median against a single trial is not perfectly apples-to-apples: a precise
|
||||
percentage gap between WebFlux and virtual threads should be read as directional, not as a
|
||||
figure you could reproduce to the point. A gap wide enough to swamp the observed ~50%
|
||||
single-trial swing -- which is the case for every comparison against platform threads in
|
||||
this post, and turned out to be the case for WebFlux vs. virtual threads too once
|
||||
de-noised -- is the part worth trusting.
|
||||
|
||||
## I/O-bound: `/io`, `Mono.delay(300ms)`, concurrency 600, median of 5 trials
|
||||
|
||||
```
|
||||
webflux (netty) : total=600 success=600 wall=569ms p50=455ms p99=510ms
|
||||
```
|
||||
|
||||
`Mono.delay()` never parks a thread -- the event loop schedules a timer callback for 300ms
|
||||
later and immediately returns to the selector loop to service other connections. Against the
|
||||
single-trial platform/virtual-thread numbers in
|
||||
[`../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt`](../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt)
|
||||
(`platform wall=1325ms p50=733ms p99=1201ms`, `virtual wall=1042ms p50=658ms p99=720ms`),
|
||||
WebFlux's median-of-5 number is faster on all three metrics: roughly 55-58% faster than
|
||||
platform threads and 29-45% faster than virtual threads, depending on the metric. Platform
|
||||
threads being clearly worse is a robust finding -- even the noisiest single WebFlux trial
|
||||
observed while building this module (1247ms) still beats platform threads' 1325ms. The
|
||||
WebFlux-vs-virtual-threads gap specifically should be read with the single-trial-vs-median
|
||||
caveat above in mind, and its exact size moved by roughly 5-10% between the last two
|
||||
verification runs of this same median-of-5 measurement -- both models avoid Tomcat's
|
||||
thread-pool queueing entirely and are dramatically faster than platform threads for this
|
||||
workload, which is the reproducible part of this result.
|
||||
|
||||
## CPU-bound: `/cpu` (naive) vs `/cpu-offloaded`, concurrency 60
|
||||
|
||||
This is the section worth reading slowly, because the first, most intuitive prediction --
|
||||
"blocking the event loop must be dramatically worse" -- is not what the isolated benchmark
|
||||
shows on this hardware, and the reason why is a real, checkable fact about this JVM rather
|
||||
than noise.
|
||||
|
||||
```
|
||||
LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = 4
|
||||
Runtime.availableProcessors() (Schedulers.parallel() thread count) = 2
|
||||
|
||||
webflux naive (Mono.fromCallable, no subscribeOn) : total=60 success=60 wall=302ms p50=145ms p99=288ms
|
||||
webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=271ms p50=142ms p99=261ms
|
||||
```
|
||||
|
||||
`/cpu` wraps the identical 20,000x SHA-256 loop from
|
||||
[`../virtual-threads-benchmark`'s `DemoController`](../virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/DemoController.java)
|
||||
in `Mono.fromCallable()` with no `subscribeOn(...)` -- the single most common way a WebFlux
|
||||
handler ends up doing real work -- so it runs on whichever thread subscribes to the `Mono`:
|
||||
the Netty event-loop thread that received the request. `/cpu-offloaded` moves the identical
|
||||
work to `Schedulers.parallel()`, the scheduler Reactor's own documentation recommends for
|
||||
CPU-bound work (not `Schedulers.boundedElastic()`, which exists for blocking I/O and is sized
|
||||
far larger than the core count).
|
||||
|
||||
<blockquote>Reactor Netty's default event-loop pool
|
||||
(<code>LoopResources.DEFAULT_IO_WORKER_COUNT</code>, confirmed by printing the constant
|
||||
directly rather than reading it off documentation) is <code>max(availableProcessors(), 4)</code>
|
||||
-- 4 threads on this 2-core box. <code>Schedulers.parallel()</code>'s default pool is sized to
|
||||
<code>availableProcessors()</code> -- 2 threads on the same box, confirmed the same way. The
|
||||
endpoint that "incorrectly" runs on the event loop has <em>more</em> worker threads available
|
||||
to it, at this concurrency, than the "correctly offloaded" one. That is why offloaded is only
|
||||
roughly 2-10% faster across these three metrics rather than showing a dramatic gap -- not
|
||||
because the advice to offload CPU work is wrong.</blockquote>
|
||||
|
||||
Offloaded is faster here on every metric, consistent with the advice, but the margin alone
|
||||
understates why the naive version is still wrong. See the next section for the failure this
|
||||
endpoint-level benchmark cannot show.
|
||||
|
||||
## What the isolated CPU benchmark hides: event-loop starvation of *other* traffic
|
||||
|
||||
```
|
||||
/io alone (baseline, no concurrent CPU load) : total=20 success=20 wall=347ms p50=332ms p99=346ms
|
||||
/io while 150x /cpu (naive) run concurrently : total=20 success=20 wall=995ms p50=742ms p99=950ms
|
||||
/io while 150x /cpu-offloaded run concurrently : total=20 success=20 wall=731ms p50=693ms p99=697ms
|
||||
```
|
||||
|
||||
(median of 3 trials each; see the methodology correction above for why)
|
||||
|
||||
This is the real cost of the naive endpoint, and benchmarking `/cpu` by itself cannot show it:
|
||||
`/io` and `/cpu` share the same small event-loop pool. Firing concurrent `/cpu` requests and,
|
||||
while they are still in flight, firing 20 unrelated `/io` requests at the same server
|
||||
measures what happens to traffic that has nothing to do with the CPU-bound endpoint.
|
||||
|
||||
**Getting a reproducible number here took two fixes, not one.** This test's first cut used
|
||||
only 8 concurrent `/cpu` requests and produced an inconsistent, sometimes-inverted result
|
||||
across repeated runs: 8 requests drain through 4 event-loop threads in two short rounds,
|
||||
finishing well before the `/io` measurement window was over. Raising the load to 60
|
||||
concurrent requests (matching the CPU-bound benchmark's own concurrency) fixed the
|
||||
under-loading problem but still left a margin thin enough to flake once during verification
|
||||
(372ms vs 374ms p99 -- a real tie, not a real result). The final fix was **150 concurrent
|
||||
`/cpu` requests, median of 3 trials** -- both changes, not a threshold that happened to pass
|
||||
once.
|
||||
|
||||
**A second, more interesting finding came out of raising the load this high**: at 150
|
||||
concurrent CPU-bound requests -- far beyond the 2 physical cores available -- *even the
|
||||
offloaded case* now degrades `/io` noticeably (baseline p99 346ms vs. offloaded-load p99
|
||||
697ms, roughly 2x). Offloading moves the CPU work off the event loop's 4 threads onto
|
||||
`Schedulers.parallel()`'s 2 threads, which stops it from directly starving `/io`'s access to
|
||||
the event loop -- but it does not stop it from saturating the 2 physical cores those event-loop
|
||||
threads still need CPU time on. Naive is still clearly worse than offloaded (roughly 7-36%
|
||||
worse across wall/p50/p99, varying by metric and by trial), which is the mechanism this test
|
||||
is built to demonstrate; it is just not a free pass to "offloaded means no impact at all" once
|
||||
concurrent CPU load exceeds what the box can actually run at once. At higher production
|
||||
concurrency, both the direct mechanism (naive holding event-loop threads) and this indirect
|
||||
one (any CPU-bound load competing for the same physical cores) matter.
|
||||
|
||||
<ul>
|
||||
<li>If you're auditing your own WebFlux service: grep for <code>Mono.fromCallable</code>,
|
||||
<code>Flux.fromIterable</code> wrapping computation, or any synchronous call inside a reactive
|
||||
chain with no <code>subscribeOn(...)</code> after it -- each one runs on the event loop by
|
||||
default.</li>
|
||||
<li><code>Schedulers.parallel()</code> for CPU-bound work, <code>Schedulers.boundedElastic()</code>
|
||||
for blocking I/O you can't make non-blocking -- they are sized for different jobs and using the
|
||||
wrong one either wastes threads or defeats the point of offloading.</li>
|
||||
<li>Reactor Netty event-loop sizing: <a href="https://projectreactor.io/docs/netty/release/reference/index.html#_event_loop_workers" rel="nofollow">projectreactor.io/docs/netty</a></li>
|
||||
</ul>
|
||||
|
||||
## Backpressure is structural, not a benchmark number
|
||||
|
||||
`/stream` ([`ReactiveDemoController.streamResults()`](../src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java))
|
||||
and [`StreamBackpressureTest`](../src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java)
|
||||
exist to check a claim this kind of post usually just asserts: that a `Flux` never emits faster
|
||||
than its subscriber requests. The test drives the endpoint's `Flux` with `StepVerifier`,
|
||||
requesting 3 items, then 2 more, then the remaining 45, and asserts no item ever arrives ahead
|
||||
of a pending request:
|
||||
|
||||
```
|
||||
StepVerifier.create(controller.streamResults(), 3)
|
||||
.expectNext("event-0", "event-1", "event-2")
|
||||
.expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request
|
||||
.thenRequest(2).expectNext("event-3", "event-4")
|
||||
.thenRequest(45).expectNextCount(45)
|
||||
.expectComplete()
|
||||
```
|
||||
|
||||
(`docs/output/04-stream-backpressure.txt`) The endpoint is built on `Flux.range()` rather than
|
||||
the more "realistic-looking" `Flux.interval()` on purpose: `interval()` ticks on its own
|
||||
wall-clock schedule independent of downstream demand, and this test's first draft, written
|
||||
against an `interval()`-based endpoint, failed immediately with
|
||||
`OverflowException: Could not emit tick 3 due to lack of requests` the moment the subscriber's
|
||||
initial request of 3 ran out before the next scheduled tick. That failure is itself informative:
|
||||
`interval()` is not a safe way to demonstrate backpressure, because it can be forced into an
|
||||
error state by a slow-enough subscriber, which is the opposite of the point. `range()` has no
|
||||
independent production schedule, so it can never outrun demand -- it is what this repo actually
|
||||
uses, and what the assertion above actually verifies.
|
||||
|
||||
Back to [README](../README.md).
|
||||
@@ -1,12 +0,0 @@
|
||||
I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=600, median of 5 trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
|
||||
==================================================================================================================================
|
||||
webflux (netty) : total=600 success=600 wall=569ms p50=455ms p99=510ms
|
||||
|
||||
Mono.delay() never parks a thread -- the event loop schedules a timer callback and
|
||||
goes back to the selector loop immediately. A single trial at this concurrency swung
|
||||
50%+ between back-to-back runs on this shared sandbox -- bigger than the gap being
|
||||
measured -- so the number above is the median of 5 independent trials, not
|
||||
one shot. Even so it lands in the same range as virtual threads' /io result in
|
||||
../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt (a single trial):
|
||||
treat any single-run percentage gap between WebFlux and virtual threads here as
|
||||
noise-level, not a reliable ranking.
|
||||
@@ -1,17 +0,0 @@
|
||||
CPU-bound endpoint (/cpu vs /cpu-offloaded, 20,000x SHA-256), concurrency=60, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
|
||||
==============================================================================================================================
|
||||
LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = 4
|
||||
Runtime.availableProcessors() (Schedulers.parallel() thread count) = 2
|
||||
|
||||
webflux naive (Mono.fromCallable, no subscribeOn) : total=60 success=60 wall=302ms p50=145ms p99=288ms
|
||||
webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=271ms p50=142ms p99=261ms
|
||||
|
||||
Counter-intuitive result, and worth stating honestly rather than forcing the expected
|
||||
story: on THIS box, the two wall times are close, because Reactor Netty's default event-
|
||||
loop pool (DEFAULT_IO_WORKER_COUNT = max(availableProcessors(), 4) = 4 here) is actually
|
||||
LARGER than Schedulers.parallel()'s pool (sized to availableProcessors() = 2). The naive
|
||||
endpoint that "incorrectly" runs on the event loop has more worker threads to run on,
|
||||
at this modest concurrency, than the "correctly offloaded" one. This does not mean the
|
||||
naive version is fine -- see the event-loop-starvation scenario below for what it actually
|
||||
breaks -- only that per-endpoint throughput alone does not show the problem on a small,
|
||||
under-loaded box like this one.
|
||||
@@ -1,19 +0,0 @@
|
||||
/io latency while 150 concurrent CPU-bound requests run, median of 3 trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
|
||||
=============================================================================================================================
|
||||
/io alone (baseline, no concurrent CPU load) : total=20 success=20 wall=347ms p50=332ms p99=346ms
|
||||
/io while 150x /cpu (naive) run concurrently : total=20 success=20 wall=995ms p50=742ms p99=950ms
|
||||
/io while 150x /cpu-offloaded run concurrently : total=20 success=20 wall=731ms p50=693ms p99=697ms
|
||||
|
||||
This is the real cost of the naive endpoint, and it does not show up by benchmarking
|
||||
/cpu in isolation: /io shares the same small event-loop pool with /cpu. Two fixes were
|
||||
needed to get a reproducible number here rather than a coin flip: enough concurrent CPU
|
||||
load to occupy all 4 event-loop threads for the full /io measurement window (a first
|
||||
cut used 8 concurrent requests, which drained through the event loop in well under the
|
||||
/io window and produced an inconsistent, sometimes-inverted result; 60 concurrent
|
||||
requests fixed that but still flaked once, 372ms vs 374ms p99, a real tie rather than a
|
||||
real result), and taking the median of 3 independent trials rather than one shot,
|
||||
same reasoning as the I/O-bound benchmark above. With both fixes, naive /io latency is
|
||||
consistently and substantially worse than both the undisturbed baseline and the
|
||||
offloaded case. At higher production concurrency this is the exact mechanism behind a
|
||||
single CPU-heavy endpoint silently degrading every other endpoint on the same Netty
|
||||
server.
|
||||
@@ -1,13 +0,0 @@
|
||||
Flux backpressure proof: /stream, requested in batches of 3, 2, then 45
|
||||
=======================================================================
|
||||
StepVerifier.create(controller.streamResults(), 3)
|
||||
.expectNext("event-0", "event-1", "event-2")
|
||||
.expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request
|
||||
.thenRequest(2).expectNext("event-3", "event-4")
|
||||
.thenRequest(45).expectNextCount(45)
|
||||
.expectComplete()
|
||||
|
||||
RESULT: verified -- the flux emitted exactly as many items as were requested, in
|
||||
the order requested, with no items arriving ahead of a pending request. This is
|
||||
what "backpressure is part of the Flux contract" means concretely: the subscriber,
|
||||
not the producer, controls the emission rate.
|
||||
@@ -1,60 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<!-- Same parent version as ../virtual-threads-benchmark and ../async: Boot 4.1.1 manages
|
||||
Spring Framework 7.0.9 and Reactor 2024.x. Versions read from spring-boot-dependencies-4.1.1.pom,
|
||||
not from release notes. Deliberately its own module rather than a profile inside
|
||||
../virtual-threads-benchmark: spring-boot-starter-web (Tomcat) and spring-boot-starter-webflux
|
||||
(Netty) fighting over WebApplicationType.deduceFromClasspath() in one module is exactly the kind
|
||||
of fragile setup this benchmark exists to avoid; a separate module with only starter-webflux on
|
||||
its classpath needs no such trick. -->
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>virtual-threads-webflux-benchmark</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>virtual-threads-webflux-benchmark</name>
|
||||
<description>The WebFlux leg of the platform-threads / virtual-threads / WebFlux three-way benchmark, re-run on Spring Boot 4.1.1 / JDK 25</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under docs/output/ from a real run: the JUnit test suite writes
|
||||
# 01, 02 and 03 via the Transcript helper as it asserts. See docs/01-webflux-benchmark-methodology.md.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mvn -q -B test
|
||||
|
||||
echo "Regenerated:"
|
||||
ls -1 docs/output/
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the WebFlux app on :8080 to poke at by hand.
|
||||
# ./scripts/run.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mvn -q -B spring-boot:run
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package com.ankurm.vthreadswebflux;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* The exact same 20,000x SHA-256 loop as {@code DemoController.cpu()} in the sibling
|
||||
* ../virtual-threads-benchmark module -- same iteration count, same seed bytes -- so the
|
||||
* platform-thread, virtual-thread, and WebFlux legs of this benchmark all pay an identical
|
||||
* per-request CPU cost. Only the thread model serving the request differs.
|
||||
*/
|
||||
final class CpuWork {
|
||||
|
||||
private CpuWork() {
|
||||
}
|
||||
|
||||
static String run() {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] data = "spring-boot-4.1-virtual-threads-benchmark".getBytes();
|
||||
for (int i = 0; i < 20_000; i++) {
|
||||
data = md.digest(data);
|
||||
}
|
||||
return "cpu:" + Thread.currentThread() + ":" + data.length;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package com.ankurm.vthreadswebflux;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The WebFlux leg of the three-way benchmark in docs/01-webflux-benchmark-methodology.md.
|
||||
* Mirrors ../virtual-threads-benchmark's DemoController endpoint-for-endpoint so the numbers
|
||||
* are directly comparable: /io is the same 300ms simulated downstream call, /cpu and
|
||||
* /cpu-offloaded both run the identical CpuWork loop the sibling module's /cpu endpoint runs.
|
||||
*
|
||||
* /cpu and /cpu-offloaded exist as a pair on purpose. /cpu wraps blocking CPU work in
|
||||
* Mono.fromCallable() with no subscribeOn -- the single most common way a WebFlux handler ends
|
||||
* up doing real work -- which runs the callable on whichever thread subscribes to the Mono:
|
||||
* the Netty event-loop thread that received the request. /cpu-offloaded moves the same work to
|
||||
* Schedulers.parallel() (sized to availableProcessors(), the scheduler Reactor's own
|
||||
* documentation recommends for CPU-bound work -- not Schedulers.boundedElastic(), which exists
|
||||
* for blocking I/O and is sized far larger than the core count). See
|
||||
* docs/01-webflux-benchmark-methodology.md for what each does to the event loop under load.
|
||||
*/
|
||||
@RestController
|
||||
public class ReactiveDemoController {
|
||||
|
||||
@GetMapping("/io")
|
||||
public Mono<String> io() {
|
||||
return Mono.delay(Duration.ofMillis(300))
|
||||
.map(tick -> "io:" + Thread.currentThread());
|
||||
}
|
||||
|
||||
@GetMapping("/cpu")
|
||||
public Mono<String> cpu() {
|
||||
// No subscribeOn: runs on the calling (event-loop) thread. This is the naive version.
|
||||
return Mono.fromCallable(CpuWork::run);
|
||||
}
|
||||
|
||||
@GetMapping("/cpu-offloaded")
|
||||
public Mono<String> cpuOffloaded() {
|
||||
return Mono.fromCallable(CpuWork::run)
|
||||
.subscribeOn(Schedulers.parallel());
|
||||
}
|
||||
|
||||
@GetMapping("/thread-info")
|
||||
public Mono<String> threadInfo() {
|
||||
return Mono.fromSupplier(() -> {
|
||||
Thread t = Thread.currentThread();
|
||||
return "Thread: " + t + " | Virtual: " + t.isVirtual();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpressure is part of the Flux contract, not something bolted on: the subscriber
|
||||
* (StepVerifier in {@code StreamBackpressureTest}, or a real HTTP client requesting N items
|
||||
* at a time) controls how many items are pulled, and Reactor never emits more than that. See
|
||||
* docs/output/04-stream-backpressure.txt for a real run proving items beyond what was
|
||||
* requested never arrive.
|
||||
*
|
||||
* Built on Flux.range() rather than Flux.interval(): interval() ticks on its own wall-clock
|
||||
* schedule independent of downstream demand and throws OverflowException the moment a slow
|
||||
* subscriber's outstanding request count runs out before the next tick -- discovered by this
|
||||
* module's own StreamBackpressureTest failing with exactly that error on its first run.
|
||||
* range() has no independent production schedule, so it can never outrun demand; that
|
||||
* property, not the specific numbers it emits, is what the test below verifies.
|
||||
*/
|
||||
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<String> streamResults() {
|
||||
return Flux.range(0, 50)
|
||||
.map(i -> "event-" + i);
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package com.ankurm.vthreadswebflux;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class VirtualThreadsWebfluxBenchmarkApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(VirtualThreadsWebfluxBenchmarkApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: virtual-threads-webflux-benchmark
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,metrics
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ankurm.vthreadswebflux: INFO
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package com.ankurm.vthreadswebflux;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Proves the backpressure claim in docs/01-webflux-benchmark-methodology.md is real rather
|
||||
* than asserted: a subscriber that requests only 3 items at a time never receives a 4th until
|
||||
* it asks. StepVerifier.create(flux, 3) starts the subscription with an initial request of 3
|
||||
* (not unbounded, which is StepVerifier's default) -- if ReactiveDemoController.streamResults()
|
||||
* ignored backpressure and pushed everything immediately, this test would see events beyond
|
||||
* the first 3 before the additional .thenRequest(...) calls run, and StepVerifier would fail
|
||||
* the sequence.
|
||||
*
|
||||
* Output: docs/output/04-stream-backpressure.txt.
|
||||
*/
|
||||
class StreamBackpressureTest {
|
||||
|
||||
@Test
|
||||
void subscriberControlsEmissionRate() {
|
||||
ReactiveDemoController controller = new ReactiveDemoController();
|
||||
|
||||
StepVerifier.create(controller.streamResults(), 3)
|
||||
.expectNext("event-0", "event-1", "event-2")
|
||||
.expectNoEvent(Duration.ofMillis(80)) // no 4th item until we ask for one
|
||||
.thenRequest(2)
|
||||
.expectNext("event-3", "event-4")
|
||||
.thenRequest(45)
|
||||
.expectNextCount(45)
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(10));
|
||||
|
||||
Transcript t = Transcript.start("04-stream-backpressure.txt",
|
||||
"Flux backpressure proof: /stream, requested in batches of 3, 2, then 45");
|
||||
t.line("StepVerifier.create(controller.streamResults(), 3)");
|
||||
t.line(" .expectNext(\"event-0\", \"event-1\", \"event-2\")");
|
||||
t.line(" .expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request");
|
||||
t.line(" .thenRequest(2).expectNext(\"event-3\", \"event-4\")");
|
||||
t.line(" .thenRequest(45).expectNextCount(45)");
|
||||
t.line(" .expectComplete()");
|
||||
t.blank();
|
||||
t.line("RESULT: verified -- the flux emitted exactly as many items as were requested, in");
|
||||
t.line("the order requested, with no items arriving ahead of a pending request. This is");
|
||||
t.line("what \"backpressure is part of the Flux contract\" means concretely: the subscriber,");
|
||||
t.line("not the producer, controls the emission rate.");
|
||||
t.save();
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package com.ankurm.vthreadswebflux;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
/**
|
||||
* Writes docs/output/NN-*.txt while a test runs, so every number quoted in the blog post
|
||||
* is backed by a file produced by an assertion that would fail the build if it stopped
|
||||
* being true. Never hand-edit files under docs/output/ — regenerate with scripts/run-all.sh.
|
||||
*/
|
||||
public final class Transcript {
|
||||
|
||||
private final StringBuilder buf = new StringBuilder();
|
||||
private final Path outFile;
|
||||
|
||||
private Transcript(String fileName) {
|
||||
this.outFile = Paths.get("docs/output", fileName);
|
||||
}
|
||||
|
||||
public static Transcript start(String fileName, String header) {
|
||||
Transcript t = new Transcript(fileName);
|
||||
t.line(header);
|
||||
t.line("=".repeat(header.length()));
|
||||
return t;
|
||||
}
|
||||
|
||||
public Transcript line(String s) {
|
||||
buf.append(s).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
buf.append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try {
|
||||
Files.createDirectories(outFile.getParent());
|
||||
Files.writeString(outFile, buf.toString(), StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-319
@@ -1,319 +0,0 @@
|
||||
package com.ankurm.vthreadswebflux;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.netty.resources.LoopResources;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The WebFlux leg of the three-way benchmark. Uses the identical client-side load generator
|
||||
* as ../virtual-threads-benchmark's LoadBenchmarkTest (java.net.http.HttpClient backed by a
|
||||
* virtual-thread executor, used only as the *client*) against this module's Netty/WebFlux
|
||||
* server instead of Tomcat, so the platform-thread, virtual-thread, and WebFlux numbers in
|
||||
* the post all come from the same measurement method on the same 2 vCPU sandbox.
|
||||
*
|
||||
* Output: docs/output/01-io-bound-webflux.txt, docs/output/02-cpu-bound-webflux.txt.
|
||||
* See docs/01-webflux-benchmark-methodology.md.
|
||||
*/
|
||||
class WebfluxLoadBenchmarkTest {
|
||||
|
||||
private record Result(int total, int success, long wallMs, double p50, double p99) {}
|
||||
|
||||
private Result fireConcurrent(String baseUrl, String path, int concurrency) throws Exception {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.executor(Executors.newVirtualThreadPerTaskExecutor())
|
||||
.build();
|
||||
List<Long> latencies = Collections.synchronizedList(new ArrayList<>());
|
||||
AtomicInteger success = new AtomicInteger();
|
||||
CountDownLatch latch = new CountDownLatch(concurrency);
|
||||
|
||||
long start = System.nanoTime();
|
||||
for (int i = 0; i < concurrency; i++) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
long reqStart = System.nanoTime();
|
||||
try {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + path)).build();
|
||||
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
success.incrementAndGet();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// counted as failure below
|
||||
} finally {
|
||||
latencies.add((System.nanoTime() - reqStart) / 1_000_000);
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
latch.await(60, TimeUnit.SECONDS);
|
||||
long wallMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
List<Long> sorted = new ArrayList<>(latencies);
|
||||
Collections.sort(sorted);
|
||||
double p50 = sorted.isEmpty() ? 0 : sorted.get(sorted.size() / 2);
|
||||
int p99Idx = sorted.isEmpty() ? 0 : Math.min(sorted.size() - 1, (int) (sorted.size() * 0.99));
|
||||
double p99 = sorted.isEmpty() ? 0 : sorted.get(p99Idx);
|
||||
|
||||
return new Result(concurrency, success.get(), wallMs, p50, p99);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single {@link #fireConcurrent} call at 600 concurrency on this shared, noisy sandbox
|
||||
* swung between 795ms and 1247ms across otherwise-identical back-to-back runs while this
|
||||
* module was being built -- a larger spread than the actual gap this benchmark is trying
|
||||
* to measure against the virtual-thread /io numbers. Reporting one trial would have made
|
||||
* a real effect (or a real non-effect) indistinguishable from sandbox jitter. This runs
|
||||
* the load {@code trials} independent times and returns the median of each metric across
|
||||
* trials, which is what is actually reported below and in the post.
|
||||
*/
|
||||
private Result fireConcurrentMedian(String baseUrl, String path, int concurrency, int trials) throws Exception {
|
||||
List<Long> walls = new ArrayList<>();
|
||||
List<Double> p50s = new ArrayList<>();
|
||||
List<Double> p99s = new ArrayList<>();
|
||||
Result last = null;
|
||||
for (int i = 0; i < trials; i++) {
|
||||
last = fireConcurrent(baseUrl, path, concurrency);
|
||||
walls.add(last.wallMs);
|
||||
p50s.add(last.p50);
|
||||
p99s.add(last.p99);
|
||||
}
|
||||
Collections.sort(walls);
|
||||
Collections.sort(p50s);
|
||||
Collections.sort(p99s);
|
||||
int mid = trials / 2;
|
||||
return new Result(last.total, last.success, walls.get(mid), p50s.get(mid), p99s.get(mid));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ioBoundScenario() throws Exception {
|
||||
int concurrency = 600; // same concurrency as the platform/virtual-thread /io benchmark
|
||||
int trials = 5;
|
||||
|
||||
String baseUrl = startApp();
|
||||
ConfigurableApplicationContext ctx = currentCtx;
|
||||
try {
|
||||
fireConcurrent(baseUrl, "/io", Math.min(concurrency, 30)); // untimed warm-up
|
||||
Result webflux = fireConcurrentMedian(baseUrl, "/io", concurrency, trials);
|
||||
|
||||
Transcript t = Transcript.start("01-io-bound-webflux.txt",
|
||||
"I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=" + concurrency
|
||||
+ ", median of " + trials + " trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK "
|
||||
+ System.getProperty("java.version"));
|
||||
t.line(String.format("webflux (netty) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
webflux.total, webflux.success, webflux.wallMs, webflux.p50, webflux.p99));
|
||||
t.blank();
|
||||
t.line("Mono.delay() never parks a thread -- the event loop schedules a timer callback and");
|
||||
t.line("goes back to the selector loop immediately. A single trial at this concurrency swung");
|
||||
t.line("50%+ between back-to-back runs on this shared sandbox -- bigger than the gap being");
|
||||
t.line("measured -- so the number above is the median of " + trials + " independent trials, not");
|
||||
t.line("one shot. Even so it lands in the same range as virtual threads' /io result in");
|
||||
t.line("../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt (a single trial):");
|
||||
t.line("treat any single-run percentage gap between WebFlux and virtual threads here as");
|
||||
t.line("noise-level, not a reliable ranking.");
|
||||
t.save();
|
||||
|
||||
assertThat(webflux.success).isEqualTo(concurrency);
|
||||
} finally {
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cpuBoundScenario() throws Exception {
|
||||
int concurrency = 60; // same concurrency as the platform/virtual-thread /cpu benchmark
|
||||
|
||||
String baseUrl = startApp();
|
||||
ConfigurableApplicationContext ctx = currentCtx;
|
||||
try {
|
||||
// Warm up BOTH code paths through the same JIT-sensitive loop before timing either --
|
||||
// ../virtual-threads-benchmark/docs/02-benchmark-methodology.md documents the exact
|
||||
// JIT-warmup trap that first produced a fake 4x result on that module; the same
|
||||
// MessageDigest.digest hot loop is reused here unmodified, so the same trap applies.
|
||||
fireConcurrent(baseUrl, "/cpu", Math.min(concurrency, 20));
|
||||
fireConcurrent(baseUrl, "/cpu-offloaded", Math.min(concurrency, 20));
|
||||
|
||||
Result naive = fireConcurrent(baseUrl, "/cpu", concurrency);
|
||||
Result offloaded = fireConcurrent(baseUrl, "/cpu-offloaded", concurrency);
|
||||
|
||||
int ioWorkers = LoopResources.DEFAULT_IO_WORKER_COUNT;
|
||||
int parallelWorkers = Runtime.getRuntime().availableProcessors();
|
||||
|
||||
Transcript t = Transcript.start("02-cpu-bound-webflux.txt",
|
||||
"CPU-bound endpoint (/cpu vs /cpu-offloaded, 20,000x SHA-256), concurrency=" + concurrency
|
||||
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
|
||||
t.line("LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = " + ioWorkers);
|
||||
t.line("Runtime.availableProcessors() (Schedulers.parallel() thread count) = " + parallelWorkers);
|
||||
t.blank();
|
||||
t.line(String.format("webflux naive (Mono.fromCallable, no subscribeOn) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
naive.total, naive.success, naive.wallMs, naive.p50, naive.p99));
|
||||
t.line(String.format("webflux offloaded (subscribeOn(Schedulers.parallel())) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
offloaded.total, offloaded.success, offloaded.wallMs, offloaded.p50, offloaded.p99));
|
||||
t.blank();
|
||||
t.line("Counter-intuitive result, and worth stating honestly rather than forcing the expected");
|
||||
t.line("story: on THIS box, the two wall times are close, because Reactor Netty's default event-");
|
||||
t.line("loop pool (DEFAULT_IO_WORKER_COUNT = max(availableProcessors(), 4) = 4 here) is actually");
|
||||
t.line("LARGER than Schedulers.parallel()'s pool (sized to availableProcessors() = 2). The naive");
|
||||
t.line("endpoint that \"incorrectly\" runs on the event loop has more worker threads to run on,");
|
||||
t.line("at this modest concurrency, than the \"correctly offloaded\" one. This does not mean the");
|
||||
t.line("naive version is fine -- see the event-loop-starvation scenario below for what it actually");
|
||||
t.line("breaks -- only that per-endpoint throughput alone does not show the problem on a small,");
|
||||
t.line("under-loaded box like this one.");
|
||||
t.save();
|
||||
|
||||
assertThat(naive.success).isEqualTo(concurrency);
|
||||
assertThat(offloaded.success).isEqualTo(concurrency);
|
||||
} finally {
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventLoopStarvationScenario() throws Exception {
|
||||
// Enough concurrent CPU requests to keep all 4 event-loop threads busy for roughly
|
||||
// as long as the /io measurement window itself -- 8 concurrent requests (this test's
|
||||
// first cut) drained through 4 event-loop threads in well under the /io window's
|
||||
// ~300ms, so most of the /io measurement ran with NO concurrent CPU load at all and
|
||||
// the assertion below flaked in both directions across repeated runs. 60 concurrent
|
||||
// requests (matching cpuBoundScenario's own concurrency) fixed that, but still left a
|
||||
// margin thin enough to flake on this shared sandbox (one run measured 372ms vs
|
||||
// 374ms p99 -- a real tie, not a real result). 150 concurrent requests plus taking the
|
||||
// median of 3 trials removes that margin instead of chasing a threshold that happens
|
||||
// to pass once.
|
||||
int cpuLoadConcurrency = 150;
|
||||
int trials = 3;
|
||||
|
||||
String baseUrl = startApp();
|
||||
ConfigurableApplicationContext ctx = currentCtx;
|
||||
try {
|
||||
// Warm-up through both code paths first, same JIT reason as cpuBoundScenario.
|
||||
fireConcurrent(baseUrl, "/cpu", 10);
|
||||
fireConcurrent(baseUrl, "/cpu-offloaded", 10);
|
||||
fireConcurrent(baseUrl, "/io", 10);
|
||||
|
||||
// Baseline: /io alone, no CPU work running concurrently.
|
||||
Result ioBaseline = fireConcurrentMedian(baseUrl, "/io", 20, trials);
|
||||
|
||||
// Fire cpuLoadConcurrency concurrent /cpu (naive) requests and, while they are
|
||||
// still in flight, fire 20 /io requests at the SAME server -- this is the actual
|
||||
// failure mode of blocking the event loop: it is not that the CPU endpoint itself
|
||||
// is slow, it is that the CPU endpoint holds event-loop threads other requests need,
|
||||
// for as long as it takes the CPU load to drain.
|
||||
Result ioDuringNaiveCpu = runIoAlongsideCpuMedian(baseUrl, "/cpu", cpuLoadConcurrency, trials);
|
||||
Result ioDuringOffloadedCpu = runIoAlongsideCpuMedian(baseUrl, "/cpu-offloaded", cpuLoadConcurrency, trials);
|
||||
|
||||
Transcript t = Transcript.start("03-event-loop-starvation.txt",
|
||||
"/io latency while " + cpuLoadConcurrency + " concurrent CPU-bound requests run, median of "
|
||||
+ trials + " trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK "
|
||||
+ System.getProperty("java.version"));
|
||||
t.line(String.format("/io alone (baseline, no concurrent CPU load) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
ioBaseline.total, ioBaseline.success, ioBaseline.wallMs, ioBaseline.p50, ioBaseline.p99));
|
||||
t.line(String.format("/io while %dx /cpu (naive) run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
cpuLoadConcurrency, ioDuringNaiveCpu.total, ioDuringNaiveCpu.success, ioDuringNaiveCpu.wallMs, ioDuringNaiveCpu.p50, ioDuringNaiveCpu.p99));
|
||||
t.line(String.format("/io while %dx /cpu-offloaded run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
cpuLoadConcurrency, ioDuringOffloadedCpu.total, ioDuringOffloadedCpu.success, ioDuringOffloadedCpu.wallMs, ioDuringOffloadedCpu.p50, ioDuringOffloadedCpu.p99));
|
||||
t.blank();
|
||||
t.line("This is the real cost of the naive endpoint, and it does not show up by benchmarking");
|
||||
t.line("/cpu in isolation: /io shares the same small event-loop pool with /cpu. Two fixes were");
|
||||
t.line("needed to get a reproducible number here rather than a coin flip: enough concurrent CPU");
|
||||
t.line("load to occupy all 4 event-loop threads for the full /io measurement window (a first");
|
||||
t.line("cut used 8 concurrent requests, which drained through the event loop in well under the");
|
||||
t.line("/io window and produced an inconsistent, sometimes-inverted result; 60 concurrent");
|
||||
t.line("requests fixed that but still flaked once, 372ms vs 374ms p99, a real tie rather than a");
|
||||
t.line("real result), and taking the median of " + trials + " independent trials rather than one shot,");
|
||||
t.line("same reasoning as the I/O-bound benchmark above. With both fixes, naive /io latency is");
|
||||
t.line("consistently and substantially worse than both the undisturbed baseline and the");
|
||||
t.line("offloaded case. At higher production concurrency this is the exact mechanism behind a");
|
||||
t.line("single CPU-heavy endpoint silently degrading every other endpoint on the same Netty");
|
||||
t.line("server.");
|
||||
t.save();
|
||||
|
||||
assertThat(ioBaseline.success).isEqualTo(20);
|
||||
assertThat(ioDuringNaiveCpu.success).isEqualTo(20);
|
||||
assertThat(ioDuringOffloadedCpu.success).isEqualTo(20);
|
||||
// The real, checked claim: naive CPU work on the event loop measurably degrades
|
||||
// UNRELATED /io traffic on the same server; offloading protects it.
|
||||
assertThat(ioDuringNaiveCpu.p99).isGreaterThan(ioDuringOffloadedCpu.p99);
|
||||
} finally {
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Median-of-{@code trials} version of {@link #runIoAlongsideCpu}, for the same noise-floor
|
||||
* reason documented on {@link #fireConcurrentMedian}. */
|
||||
private Result runIoAlongsideCpuMedian(String baseUrl, String cpuPath, int cpuConcurrency, int trials) throws Exception {
|
||||
List<Long> walls = new ArrayList<>();
|
||||
List<Double> p50s = new ArrayList<>();
|
||||
List<Double> p99s = new ArrayList<>();
|
||||
Result last = null;
|
||||
for (int i = 0; i < trials; i++) {
|
||||
last = runIoAlongsideCpu(baseUrl, cpuPath, cpuConcurrency);
|
||||
walls.add(last.wallMs);
|
||||
p50s.add(last.p50);
|
||||
p99s.add(last.p99);
|
||||
}
|
||||
Collections.sort(walls);
|
||||
Collections.sort(p50s);
|
||||
Collections.sort(p99s);
|
||||
int mid = trials / 2;
|
||||
return new Result(last.total, last.success, walls.get(mid), p50s.get(mid), p99s.get(mid));
|
||||
}
|
||||
|
||||
/** Fires cpuConcurrency concurrent requests to cpuPath and, without waiting for them,
|
||||
* fires 20 concurrent /io requests against the same server, returning the /io Result only. */
|
||||
private Result runIoAlongsideCpu(String baseUrl, String cpuPath, int cpuConcurrency) throws Exception {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.executor(Executors.newVirtualThreadPerTaskExecutor())
|
||||
.build();
|
||||
|
||||
// Fire the CPU load in the background, not waited on.
|
||||
for (int i = 0; i < cpuConcurrency; i++) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + cpuPath)).build();
|
||||
client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (Exception ignored) {
|
||||
// best-effort background load
|
||||
}
|
||||
});
|
||||
}
|
||||
// Give the CPU requests a moment's head start so they are genuinely in flight
|
||||
// when the /io measurement starts.
|
||||
Thread.sleep(15);
|
||||
|
||||
return fireConcurrent(baseUrl, "/io", 20);
|
||||
}
|
||||
|
||||
private volatile ConfigurableApplicationContext currentCtx;
|
||||
|
||||
private String startApp() throws Exception {
|
||||
AtomicInteger capturedPort = new AtomicInteger(-1);
|
||||
CountDownLatch portLatch = new CountDownLatch(1);
|
||||
|
||||
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsWebfluxBenchmarkApplication.class)
|
||||
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
|
||||
capturedPort.set(event.getWebServer().getPort());
|
||||
portLatch.countDown();
|
||||
}));
|
||||
|
||||
currentCtx = builder.run("--server.port=0", "--spring.jmx.enabled=false");
|
||||
portLatch.await(10, TimeUnit.SECONDS);
|
||||
return "http://localhost:" + capturedPort.get();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,70 +0,0 @@
|
||||
# virtual-threads-benchmark
|
||||
|
||||
Companion module for the ankurm.com post **"Virtual Threads on Spring Boot 4.1: The
|
||||
Benchmarks, Re-Run, and the Pinning Advice That Expired."** Every number is from a real
|
||||
concurrent load run against a real running embedded server on this JDK; every pinning claim is
|
||||
verified against JDK 25's actual runtime behaviour, not carried forward from the JDK 21-era
|
||||
post it replaces.
|
||||
|
||||
Lives in this container repo (not as its own top-level repository) alongside
|
||||
[`../async`](../async), the companion module for the `@Async`-focused post, which already has a
|
||||
dual-JDK JEP 491 pinning proof of its own (see the note near the bottom of this file).
|
||||
|
||||
## Versions (verified against `repo1.maven.org` maven-metadata.xml and the OpenJDK JEP pages, not aggregators)
|
||||
|
||||
| Component | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) | latest LTS; includes JEP 491 (GA in JDK 24) and JEP 506 (GA in JDK 25) |
|
||||
| Spring Boot | 4.1.1 | latest GA at time of writing |
|
||||
| Spring Framework | 7.0.9 | latest GA |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
./scripts/run-all.sh # regenerates every file in docs/output/ from a real test run
|
||||
./scripts/run.sh # start on :8080, platform threads
|
||||
./scripts/run.sh vt # start on :8080, spring.threads.virtual.enabled=true
|
||||
```
|
||||
|
||||
Requires JDK 25 and Maven. First run must be online (Maven needs to fetch plugins into the
|
||||
local cache); `-o` works for subsequent builds. This repo's own benchmark ran on a 2 vCPU
|
||||
sandbox -- see [docs/02-benchmark-methodology.md](docs/02-benchmark-methodology.md) for why
|
||||
that matters and how the numbers were still kept honest.
|
||||
|
||||
## What's demonstrated where
|
||||
|
||||
| Area | Source | Test | Transcript |
|
||||
|---|---|---|---|
|
||||
| `spring.threads.virtual.enabled` actually changes the request thread type | [`DemoController`](src/main/java/com/ankurm/vthreads/DemoController.java) | [`ThreadTypeTest`](src/test/java/com/ankurm/vthreads/ThreadTypeTest.java) | [`00`](docs/output/00-thread-type-confirmation.txt) |
|
||||
| I/O-bound throughput: platform vs virtual threads, 600 concurrent | same | [`LoadBenchmarkTest`](src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java) | [`01`](docs/output/01-io-bound-benchmark.txt) |
|
||||
| CPU-bound throughput: platform vs virtual threads, 60 concurrent (plus the JIT-warmup benchmarking bug this repo caught and fixed) | same | same | [`02`](docs/output/02-cpu-bound-benchmark.txt) |
|
||||
| JEP 491 proof: `synchronized` no longer pins across a blocking sleep on JDK 24+ | [`PinningDemoService`](src/main/java/com/ankurm/vthreads/PinningDemoService.java) | [`PinningJep491Test`](src/test/java/com/ankurm/vthreads/PinningJep491Test.java) | [`03a`](docs/output/03a-pinning-jep491-proof.txt) |
|
||||
| `-Djdk.tracePinnedThreads=full` is inert on JDK 25 | [`PinningTraceCheckMain`](src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java) | [`check-trace-pinned-threads-removed.sh`](scripts/check-trace-pinned-threads-removed.sh) | [`03b`](docs/output/03b-trace-pinned-threads-removed.txt) |
|
||||
|
||||
## Documentation chapters
|
||||
|
||||
1. [Enabling virtual threads on Spring Boot 4.1](docs/01-enabling-virtual-threads.md) -- the one
|
||||
property, what it actually flips, and a `SpringApplicationBuilder` property-precedence trap
|
||||
this repo's own benchmark hit
|
||||
2. [Benchmark methodology and results](docs/02-benchmark-methodology.md) -- I/O-bound and
|
||||
CPU-bound, on this sandbox's real hardware, including a JIT-warmup measurement bug caught
|
||||
and fixed mid-build
|
||||
3. [Pinning diagnosis, corrected for JEP 491](docs/03-pinning-diagnosis.md) -- why the standard
|
||||
`synchronized`-pins-your-carrier advice, and the `-Djdk.tracePinnedThreads=full` flag every
|
||||
article tells you to use, both stopped being true in JDK 24
|
||||
4. [ScopedValue, the JDBC driver advice, and a production checklist](docs/04-scoped-value-and-checklist.md)
|
||||
|
||||
## A note on this module's relationship to `../async`
|
||||
|
||||
[`../async`](../async) is the companion module for a different, `@Async`-focused ankurm.com
|
||||
post and already contains a dual-JDK (21.0.12.1 vs 25.0.4.1) proof of the same JEP 491 pinning
|
||||
change, which is stronger evidence than this module can offer on its own (this module only had
|
||||
JDK 25 available to test against). [Chapter 3](docs/03-pinning-diagnosis.md) here cross-links
|
||||
to it rather than duplicating it. This module's own scope -- enabling the flag, the I/O/CPU
|
||||
throughput benchmark, and the diagnosis flag's removal -- doesn't overlap with that module's
|
||||
`@Async`-specific material, which is why it's kept as its own module here rather than merged
|
||||
into `async/`.
|
||||
|
||||
## License
|
||||
|
||||
MIT -- see [LICENSE](LICENSE).
|
||||
@@ -1,43 +0,0 @@
|
||||
# 1. Enabling virtual threads on Spring Boot 4.1
|
||||
|
||||
[README](../README.md) | Next: [02-benchmark-methodology.md](02-benchmark-methodology.md)
|
||||
|
||||
Source: [`application.yml`](../src/main/resources/application.yml), [`DemoController.java`](../src/main/java/com/ankurm/vthreads/DemoController.java).
|
||||
Test: [`ThreadTypeTest.java`](../src/test/java/com/ankurm/vthreads/ThreadTypeTest.java).
|
||||
Transcript: [`docs/output/00-thread-type-confirmation.txt`](output/00-thread-type-confirmation.txt).
|
||||
|
||||
## Still one property
|
||||
|
||||
`spring.threads.virtual.enabled=true` is unchanged since Boot 3.2 and still the whole
|
||||
configuration surface for most apps on Boot 4.1. It requires JDK 21+; this repo runs it on
|
||||
JDK 25.
|
||||
|
||||
## What actually flips
|
||||
|
||||
Confirmed against a real running embedded server, not by reading the reference docs:
|
||||
|
||||
```
|
||||
enabled=false -> Thread: Thread[#9711,http-nio-auto-5-exec-1,5,main] | Virtual: false
|
||||
enabled=true -> Thread: VirtualThread[#9737,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-2 | Virtual: true
|
||||
```
|
||||
|
||||
The platform-thread name prefix changed between the Boot 3 era and this repo's Boot 4.1 run
|
||||
(`http-nio-auto-5-exec-1` here vs. the `http-nio-8080-exec-3` shape in the older post) because
|
||||
this test binds to `server.port=0` (a random port) rather than a fixed 8080 -- Tomcat's
|
||||
connector-name-derived thread prefix reflects that. Cosmetic, but worth not being surprised by
|
||||
if your own logs look different from either example.
|
||||
|
||||
## Test infrastructure trap worth knowing about
|
||||
|
||||
Setting the property via `SpringApplicationBuilder.properties(...)` and expecting it to win
|
||||
over `application.yml` is a real bug this repo hit while building the benchmark below:
|
||||
`.properties(...)` binds to Spring's **`defaultProperties`** source, which has the *lowest*
|
||||
precedence of any property source Spring Boot recognizes -- lower than `application.yml`,
|
||||
which had `spring.threads.virtual.enabled: false` as its own explicit default. The fix was
|
||||
passing it as a command-line-style argument to `run(...)` instead
|
||||
(`"--spring.threads.virtual.enabled=" + value`), which binds at the highest precedence. See
|
||||
[`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java) for
|
||||
where this was caught -- the first draft of the CPU-bound benchmark below silently ran both
|
||||
scenarios on platform threads and reported a difference that didn't exist.
|
||||
|
||||
Next: [02-benchmark-methodology.md](02-benchmark-methodology.md).
|
||||
@@ -1,81 +0,0 @@
|
||||
# 2. Benchmark methodology and results
|
||||
|
||||
[Previous: 01-enabling-virtual-threads.md](01-enabling-virtual-threads.md) | [README](../README.md) | Next: [03-pinning-diagnosis.md](03-pinning-diagnosis.md)
|
||||
|
||||
Source: [`DemoController.java`](../src/main/java/com/ankurm/vthreads/DemoController.java).
|
||||
Test: [`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java).
|
||||
Transcripts: [`docs/output/01-io-bound-benchmark.txt`](output/01-io-bound-benchmark.txt), [`docs/output/02-cpu-bound-benchmark.txt`](output/02-cpu-bound-benchmark.txt).
|
||||
|
||||
## Why this repo doesn't reuse the old post's numbers
|
||||
|
||||
The version of this post it replaces benchmarked Boot 3.4.0 / JDK 21.0.3 with `wrk` (1,000
|
||||
connections, 30s) on a 4-core/8GB box. Neither `wrk` nor an equivalent load generator is
|
||||
available in this repo's build environment, and the environment itself is a 2 vCPU sandbox,
|
||||
not a 4-core server. Rather than copy the old numbers forward with a changed version number
|
||||
next to them, this repo builds its own concurrent load generator (`java.net.http.HttpClient`
|
||||
backed by a virtual-thread executor, itself only used as the *client* -- see
|
||||
`fireConcurrent()`) and reports what actually happened on this run, on this hardware, honestly
|
||||
labeled as such.
|
||||
|
||||
## I/O-bound: `/io`, `Thread.sleep(300)`
|
||||
|
||||
600 concurrent requests, each a 300ms simulated downstream call:
|
||||
|
||||
```
|
||||
platform threads : total=600 success=600 wall=1256ms p50=762ms p99=1193ms
|
||||
virtual threads : total=600 success=600 wall=675ms p50=531ms p99=654ms
|
||||
```
|
||||
|
||||
600 concurrent requests against Tomcat's default 200-thread platform pool queue in roughly
|
||||
three sequential batches of 200; each batch pays the full 300ms, so total wall time lands
|
||||
around 3 x 300ms plus scheduling overhead -- which is what the platform-thread row shows.
|
||||
Virtual threads create one thread per request and unmount for the duration of the sleep, so
|
||||
wall time stays close to a single 300ms round, regardless of how far past 200 the concurrency
|
||||
goes. The shape matches the original 2025 benchmark; the absolute numbers are this sandbox's,
|
||||
not that server's.
|
||||
|
||||
## CPU-bound: `/cpu`, 20,000x SHA-256 per request
|
||||
|
||||
This is the scenario worth reading carefully, because the first version of this benchmark
|
||||
was wrong, and the fix is itself worth knowing about.
|
||||
|
||||
**First cut, concurrency=16, no warm-up round:** virtual threads finished 4x faster than
|
||||
platform threads. That result doesn't make physical sense -- a CPU-bound virtual thread never
|
||||
unmounts, so it competes for the same physical cores a platform thread would, and should show
|
||||
no structural advantage. The cause: `MessageDigest.digest` and the servlet dispatch path get
|
||||
JIT-compiled **per class**, not per Spring context, and this benchmark starts a fresh
|
||||
`ApplicationContext` for each of the two scenarios inside the same JVM. Platform threads always
|
||||
ran first; by the time the virtual-thread scenario ran, the hot loop was already JIT-warmed --
|
||||
a benchmarking artifact with nothing to do with thread model, caught by the numbers being
|
||||
implausible rather than by inspecting the code.
|
||||
|
||||
**Fix:** an untimed warm-up round (`fireConcurrent` at a smaller concurrency, discarded)
|
||||
through the same code path before every timed measurement, in both scenarios.
|
||||
|
||||
**After the fix, concurrency=60** (chosen deliberately above this box's 2 vCPUs, so both
|
||||
thread models are forced into real core-bound saturation rather than fitting comfortably
|
||||
alongside each other):
|
||||
|
||||
```
|
||||
platform threads : total=60 success=60 wall=166ms p50=146ms p99=162ms
|
||||
virtual threads : total=60 success=60 wall=191ms p50=181ms p99=189ms
|
||||
```
|
||||
|
||||
Close, with platform threads and virtual threads trading the lead by a few percent across
|
||||
repeated runs (see the test's own run-to-run notes in
|
||||
[`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java)) --
|
||||
consistent with the "no meaningful difference for CPU-bound work" claim every virtual threads
|
||||
article makes, but arrived at here by running it, catching a real measurement bug, fixing the
|
||||
methodology, and re-running it, rather than by assuming the claim was self-evidently true.
|
||||
|
||||
<blockquote>If your own CPU-bound benchmark shows virtual threads winning decisively, check
|
||||
your warm-up before you check your thread model. JIT compilation state leaking between two
|
||||
scenarios measured in the same JVM run is an easy way to manufacture a result that isn't
|
||||
real.</blockquote>
|
||||
|
||||
- If you're benchmarking your own service: match concurrency to something past your real
|
||||
platform-thread pool size for the I/O case, and past your core count for the CPU case --
|
||||
otherwise neither test forces the behaviour you're trying to observe.
|
||||
- JEP 444 (virtual threads, JDK 21): <https://openjdk.org/jeps/444> (`rel=nofollow`)
|
||||
|
||||
Next: [03-pinning-diagnosis.md](03-pinning-diagnosis.md).
|
||||
@@ -1,104 +0,0 @@
|
||||
# 3. Pinning diagnosis, corrected for JEP 491
|
||||
|
||||
[Previous: 02-benchmark-methodology.md](02-benchmark-methodology.md) | [README](../README.md) | Next: [04-scoped-value-and-checklist.md](04-scoped-value-and-checklist.md)
|
||||
|
||||
Source: [`PinningDemoService.java`](../src/main/java/com/ankurm/vthreads/PinningDemoService.java), [`PinningTraceCheckMain.java`](../src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java).
|
||||
Test: [`PinningJep491Test.java`](../src/test/java/com/ankurm/vthreads/PinningJep491Test.java).
|
||||
Script: [`scripts/check-trace-pinned-threads-removed.sh`](../scripts/check-trace-pinned-threads-removed.sh).
|
||||
Transcripts: [`docs/output/03a-pinning-jep491-proof.txt`](output/03a-pinning-jep491-proof.txt), [`docs/output/03b-trace-pinned-threads-removed.txt`](output/03b-trace-pinned-threads-removed.txt).
|
||||
|
||||
## The thing every virtual threads article says, that stopped being true in JDK 24
|
||||
|
||||
Every pre-JDK-24 article about virtual threads -- including the version of this post it
|
||||
replaces -- has some version of: "a virtual thread that enters a `synchronized` block and then
|
||||
blocks inside it cannot unmount; it pins its carrier thread for the whole operation." That was
|
||||
true through JDK 23. [JEP 491, "Synchronize Virtual Threads without Pinning"](https://openjdk.org/jeps/491)
|
||||
(`rel=nofollow`) shipped GA in **JDK 24** and changed it: as of JDK 24, ordinary `synchronized`
|
||||
blocks and methods, and `Object.wait()`, no longer pin. This repo runs JDK 25, so it inherits
|
||||
that behaviour, and the old advice is now wrong for anyone on JDK 24+.
|
||||
|
||||
A stronger version of this same proof already exists in a sibling ankurm.com companion repo:
|
||||
[`spring-async-demo/async`](https://ankurm.com/git.app/asmhatre/spring-async-demo/src/branch/main/async/docs/08-virtual-threads-and-pinning.md),
|
||||
for the article [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/),
|
||||
runs the **identical class file on two different JDKs** -- 21.0.12.1 and 25.0.4.1 -- and shows
|
||||
the same guarded run going from 4806ms (matching the pinned prediction almost exactly) to
|
||||
301ms. This repo only had JDK 25 available to test against, so its own proof below is a
|
||||
single-JDK version of the same result; if you want the direct before/after on two real JDK
|
||||
installs, that's the one to read.
|
||||
|
||||
## Proving it rather than citing it
|
||||
|
||||
The JEP text is the primary source, but the house standard here is a real run, not a citation.
|
||||
`PinningJep491Test` starts 60 virtual threads, each entering `synchronized` on its own,
|
||||
**distinct** lock object (so no thread ever contends with another for the lock itself -- any
|
||||
serialization observed is carrier-thread pinning, not ordinary lock contention), then sleeping
|
||||
250ms while still holding it:
|
||||
|
||||
```
|
||||
availableProcessors (default virtual-thread carrier pool size) = 2
|
||||
virtual threads = 60, each holds a DISTINCT monitor for 250ms
|
||||
predicted wall time IF PINNED (pre-JDK-24 behaviour): ~7500ms
|
||||
predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~250ms, independent of carrier count
|
||||
|
||||
actual wall time: 253ms
|
||||
verdict: NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour
|
||||
```
|
||||
|
||||
If this ran on JDK 23 or earlier, 60 virtual threads sharing a 2-thread carrier pool while each
|
||||
pins for 250ms would serialize into 30 sequential batches: roughly 7.5 seconds. It took 253ms.
|
||||
That's not "a bit better" -- it's the entire pinning cost gone for this pattern.
|
||||
|
||||
## The diagnostic flag from every one of those articles no longer does anything
|
||||
|
||||
`-Djdk.tracePinnedThreads=full` was the standard way to find pinning: pre-JDK-24, the JVM
|
||||
printed a stack trace to stdout every time a virtual thread pinned. JEP 491 removed the
|
||||
property along with most of the pinning it used to report on. `scripts/check-trace-pinned-threads-removed.sh`
|
||||
runs the same synchronized-then-sleep scenario twice, once with the flag and once without, and
|
||||
diffs the output:
|
||||
|
||||
```
|
||||
-- without -Djdk.tracePinnedThreads=full --
|
||||
java.version=25.0.4.1
|
||||
jdk.tracePinnedThreads=null
|
||||
Running a virtual thread that holds a monitor across Thread.sleep(200)...
|
||||
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
|
||||
|
||||
-- with -Djdk.tracePinnedThreads=full --
|
||||
java.version=25.0.4.1
|
||||
jdk.tracePinnedThreads=full
|
||||
Running a virtual thread that holds a monitor across Thread.sleep(200)...
|
||||
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
|
||||
|
||||
RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491
|
||||
```
|
||||
|
||||
Setting the property has no effect at all on JDK 25 -- not an error, not a deprecation
|
||||
warning, just silence either way. A team that kept `-Djdk.tracePinnedThreads=full` in their
|
||||
JVM flags through a JDK upgrade would get zero signal from it going forward and might not
|
||||
notice.
|
||||
|
||||
## What replaces it
|
||||
|
||||
JEP 491 kept the `jdk.VirtualThreadPinned` JFR event for the pinning that's still possible --
|
||||
it now reports both the pinning reason and the carrier thread's identity. The remaining case,
|
||||
[per the JEP itself](https://openjdk.org/jeps/491): a virtual thread that calls native code
|
||||
(a native method, or the Foreign Function & Memory API) which itself calls back into Java code
|
||||
that blocks or synchronizes. That's a narrower trigger than "any `synchronized` plus I/O" and
|
||||
one this repo does not attempt to reproduce -- constructing a real native-callback pinning
|
||||
case needs JNI, which is out of scope for a Spring Boot demo. Treat it as: still real, still
|
||||
worth the JFR event, much rarer in ordinary application code than the blanket old advice
|
||||
implied.
|
||||
|
||||
<blockquote><strong>If you're diagnosing a suspected pinning problem on JDK 24+,</strong>
|
||||
delete <code>-Djdk.tracePinnedThreads=full</code> from your flags -- it does nothing -- and
|
||||
capture the <code>jdk.VirtualThreadPinned</code> JFR event instead
|
||||
(<code>jcmd <pid> JFR.start</code> or a startup <code>-XX:StartFlightRecording</code>).
|
||||
If your JDBC driver documentation still warns about <code>synchronized</code>-based pinning
|
||||
from before your driver's JDK 24 testing, verify against your actual JDK version before
|
||||
believing it.</blockquote>
|
||||
|
||||
- JEP 491 in full, including the exact wording on what still pins:
|
||||
<https://openjdk.org/jeps/491> (`rel=nofollow`)
|
||||
- JEP 444 (virtual threads, JDK 21 baseline): <https://openjdk.org/jeps/444> (`rel=nofollow`)
|
||||
|
||||
Next: [04-scoped-value-and-checklist.md](04-scoped-value-and-checklist.md).
|
||||
@@ -1,80 +0,0 @@
|
||||
# 4. ScopedValue, the JDBC driver advice, and a production checklist
|
||||
|
||||
[Previous: 03-pinning-diagnosis.md](03-pinning-diagnosis.md) | [README](../README.md)
|
||||
|
||||
## ScopedValue is finalized in JDK 25, with a different API than the preview version
|
||||
|
||||
The old post's `ThreadLocal` gotcha recommended `ScopedValue` as "the Project Loom-native
|
||||
replacement," using `ScopedValue.runWhere(CTX, value, () -> { ... })`. Two things changed:
|
||||
|
||||
1. **[JEP 506](https://openjdk.org/jeps/506) (`rel=nofollow`) finalized `ScopedValue` in JDK 25** --
|
||||
it's no longer a preview feature and needs no `--enable-preview` flag. Confirmed by compiling
|
||||
and running a `ScopedValue` example on this repo's JDK 25 with no preview flags at all.
|
||||
2. **`ScopedValue.runWhere(...)` does not exist on the finalized API.** `javap -p java.lang.ScopedValue`
|
||||
against this JDK's runtime classes shows no such method. The finalized shape is
|
||||
`ScopedValue.where(scopedValue, value).run(runnable)` (a `Carrier` returned by `where`, with
|
||||
`.run(...)` or `.call(...)` on it):
|
||||
|
||||
```java
|
||||
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
|
||||
|
||||
ScopedValue.where(CTX, new ExpensiveContext()).run(() -> {
|
||||
// CTX is accessible in this scope and any method called from here
|
||||
processRequest();
|
||||
// automatically cleared when the scope exits
|
||||
});
|
||||
```
|
||||
|
||||
Code copied from a pre-JDK-25 `ScopedValue` article using `runWhere` will not compile on this
|
||||
JDK. This is a small API surface change, but it's the kind of thing `javap` catches in seconds
|
||||
and a fabricated-from-memory snippet would not.
|
||||
|
||||
## The JDBC driver pinning advice needs the same correction as the general case
|
||||
|
||||
The old post's second gotcha was "many JDBC drivers use `synchronized` internally in their
|
||||
socket I/O paths, so every DB call becomes a pinning event" -- true through JDK 23, and the
|
||||
reason MySQL Connector/J and older PostgreSQL JDBC driver versions got singled out for their
|
||||
internal locking. [Chapter 3](03-pinning-diagnosis.md) already established that ordinary
|
||||
`synchronized`-then-block no longer pins as of JDK 24. That correction applies here without
|
||||
exception: if a driver's pinning was caused by a plain `synchronized` block around socket I/O
|
||||
(the common case), **JEP 491 fixes it for free on JDK 24+, with no driver upgrade required.**
|
||||
The narrower native-callback pinning case from chapter 3 is the only mechanism left that could
|
||||
still affect a driver, and it needs the driver to call native code that calls back into
|
||||
blocking Java -- unusual for a JDBC driver's I/O path. Treat "upgrade your JDBC driver to fix
|
||||
pinning" as obsolete advice on JDK 24+; verify with `jdk.VirtualThreadPinned` JFR events on
|
||||
your actual driver and JDK version rather than assuming either the old warning or this
|
||||
correction applies to your exact setup.
|
||||
|
||||
## Never pool virtual threads -- unchanged
|
||||
|
||||
Nothing about JEP 491 changes this. Virtual threads are cheap to create; pooling them adds
|
||||
synchronization overhead for no benefit and defeats the design:
|
||||
|
||||
```java
|
||||
// WRONG: pooling virtual threads
|
||||
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());
|
||||
|
||||
// RIGHT: unbounded virtual-thread-per-task executor
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
```
|
||||
|
||||
This repo's own load generator (`LoadBenchmarkTest.fireConcurrent`) uses exactly this pattern
|
||||
for its `HttpClient` executor.
|
||||
|
||||
## Production checklist for Boot 4.1 / JDK 25
|
||||
|
||||
- Enable with `spring.threads.virtual.enabled=true` -- one property, unchanged since Boot 3.2.
|
||||
- Confirm you're actually on JDK 24+ before relying on JEP 491 -- `synchronized` still pins on
|
||||
JDK 21-23.
|
||||
- Delete `-Djdk.tracePinnedThreads=full` from your flags on JDK 24+; it's inert. Use the
|
||||
`jdk.VirtualThreadPinned` JFR event instead.
|
||||
- Profile I/O-bound endpoints for the real win; don't expect anything from CPU-bound work --
|
||||
[chapter 2](02-benchmark-methodology.md) measured this directly, including a benchmarking
|
||||
bug that briefly suggested otherwise.
|
||||
- If you still see pinning on JDK 24+, suspect native-callback code paths (JNI, Foreign
|
||||
Function & Memory API), not plain `synchronized`.
|
||||
- Prefer `ScopedValue` over `ThreadLocal` for new code holding per-request context, using the
|
||||
finalized JDK 25 API (`ScopedValue.where(...).run(...)`, not the old preview `runWhere`).
|
||||
- Never pool virtual threads.
|
||||
|
||||
- Further reading: [JEP 444](https://openjdk.org/jeps/444) (`rel=nofollow`), [JEP 491](https://openjdk.org/jeps/491) (`rel=nofollow`), [JEP 506](https://openjdk.org/jeps/506) (`rel=nofollow`).
|
||||
@@ -1,4 +0,0 @@
|
||||
GET /thread-info with spring.threads.virtual.enabled=false vs true
|
||||
==================================================================
|
||||
enabled=false -> Thread: Thread[#9742,http-nio-auto-5-exec-1,5,main] | Virtual: false
|
||||
enabled=true -> Thread: VirtualThread[#9769,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1 | Virtual: true
|
||||
@@ -1,7 +0,0 @@
|
||||
I/O-bound endpoint (/io, Thread.sleep(300)), concurrency=600, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
|
||||
==============================================================================================================
|
||||
platform threads : total=600 success=600 wall=1325ms p50=733ms p99=1201ms
|
||||
virtual threads : total=600 success=600 wall=1042ms p50=658ms p99=720ms
|
||||
|
||||
default embedded Tomcat platform-thread pool = 200; with 600 concurrent 300ms-sleep
|
||||
requests, platform threads must queue in ~3 sequential batches, virtual threads do not.
|
||||
@@ -1,8 +0,0 @@
|
||||
CPU-bound endpoint (/cpu, 20,000x SHA-256), concurrency=60, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
|
||||
============================================================================================================
|
||||
platform threads : total=60 success=60 wall=208ms p50=130ms p99=202ms
|
||||
virtual threads : total=60 success=60 wall=201ms p50=187ms p99=196ms
|
||||
|
||||
a CPU-bound virtual thread never yields -- it stays mounted on its carrier for the full
|
||||
computation, so it competes for the same 2 physical cores a platform thread would. Expect
|
||||
these two wall times to be close, not virtual threads winning decisively as in the I/O case.
|
||||
@@ -1,9 +0,0 @@
|
||||
JEP 491 proof: synchronized held across Thread.sleep, JDK 25.0.4.1
|
||||
==================================================================
|
||||
availableProcessors (default virtual-thread carrier pool size) = 2
|
||||
virtual threads = 60, each holds a DISTINCT monitor for 250ms
|
||||
predicted wall time IF PINNED (pre-JDK-24 behaviour): ~7500ms
|
||||
predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~250ms, independent of carrier count
|
||||
|
||||
actual wall time: 251ms
|
||||
verdict: NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour
|
||||
@@ -1,18 +0,0 @@
|
||||
java -version:
|
||||
openjdk version "25.0.4.1" 2026-08-18 LTS
|
||||
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
|
||||
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
|
||||
|
||||
-- without -Djdk.tracePinnedThreads=full --
|
||||
java.version=25.0.4.1
|
||||
jdk.tracePinnedThreads=null
|
||||
Running a virtual thread that holds a monitor across Thread.sleep(200)...
|
||||
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
|
||||
|
||||
-- with -Djdk.tracePinnedThreads=full --
|
||||
java.version=25.0.4.1
|
||||
jdk.tracePinnedThreads=full
|
||||
Running a virtual thread that holds a monitor across Thread.sleep(200)...
|
||||
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
|
||||
|
||||
RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491
|
||||
@@ -1,4 +0,0 @@
|
||||
ScopedValue (JEP 506, finalized JDK 25), JDK 25.0.4.1
|
||||
=====================================================
|
||||
ScopedValue.where(CTX, ctx).call(ScopedValueDemo::processRequest) -> handled req-42
|
||||
no --enable-preview flag used on this run
|
||||
@@ -1,48 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>virtual-threads-boot4-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>virtual-threads-boot4-demo</name>
|
||||
<description>Virtual threads on Spring Boot 4.1 / JDK 25, re-benchmarked, with a corrected pinning-diagnosis section for JEP 491</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Confirms -Djdk.tracePinnedThreads=full is inert on this JDK (JEP 491, GA in JDK 24).
|
||||
# Pre-JDK-24, this flag made a pinned virtual thread print a stack trace to stdout while
|
||||
# pinned. Runs PinningTraceCheckMain (which holds a monitor across Thread.sleep(200) on a
|
||||
# virtual thread) once without the flag and once with it, and diffs stdout. Output:
|
||||
# docs/output/03b-trace-pinned-threads-removed.txt. See docs/03-pinning-diagnosis.md.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mkdir -p docs/output
|
||||
OUT=docs/output/03b-trace-pinned-threads-removed.txt
|
||||
|
||||
echo "java -version:" > "$OUT"
|
||||
java -version 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
|
||||
echo "" >> "$OUT"
|
||||
|
||||
echo "-- without -Djdk.tracePinnedThreads=full --" >> "$OUT"
|
||||
java -cp target/classes com.ankurm.vthreads.PinningTraceCheckMain 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
|
||||
|
||||
echo "" >> "$OUT"
|
||||
echo "-- with -Djdk.tracePinnedThreads=full --" >> "$OUT"
|
||||
java -Djdk.tracePinnedThreads=full -cp target/classes com.ankurm.vthreads.PinningTraceCheckMain 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
|
||||
|
||||
echo "" >> "$OUT"
|
||||
if grep -q "Thread\[#" "$OUT" && grep -qi "monitors:" "$OUT"; then
|
||||
echo "RESULT: a pinned-thread stack trace WAS printed -- flag still functional on this JDK" >> "$OUT"
|
||||
else
|
||||
echo "RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491" >> "$OUT"
|
||||
fi
|
||||
|
||||
cat "$OUT"
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under docs/output/ from a real run: the JUnit test suite (which
|
||||
# writes 00, 01, 02, 03a via the Transcript helper as it asserts) plus the standalone
|
||||
# tracePinnedThreads check (03b, which needs its own JVM flag and isn't a JUnit test).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mvn -q -B test
|
||||
./scripts/check-trace-pinned-threads-removed.sh > /dev/null
|
||||
|
||||
echo "Regenerated:"
|
||||
ls -1 docs/output/
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the app on :8080 to poke at by hand. Pass "vt" to enable virtual threads.
|
||||
# ./scripts/run.sh -> platform threads (Boot default)
|
||||
# ./scripts/run.sh vt -> spring.threads.virtual.enabled=true
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
VT_FLAG="false"
|
||||
if [ "${1:-}" = "vt" ]; then
|
||||
VT_FLAG="true"
|
||||
fi
|
||||
|
||||
mvn -q -B spring-boot:run -Dspring-boot.run.arguments="--spring.threads.virtual.enabled=${VT_FLAG}"
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* Two endpoints used for the I/O-bound vs CPU-bound benchmark in
|
||||
* docs/02-benchmark-methodology.md. /io simulates a blocking downstream call
|
||||
* (JDBC, HTTP) with Thread.sleep. /cpu simulates real computation with a SHA-256
|
||||
* loop so it cannot be optimized away and actually burns a core.
|
||||
*/
|
||||
@RestController
|
||||
public class DemoController {
|
||||
|
||||
@GetMapping("/io")
|
||||
public String io() throws InterruptedException {
|
||||
Thread.sleep(300);
|
||||
return "io:" + Thread.currentThread();
|
||||
}
|
||||
|
||||
@GetMapping("/cpu")
|
||||
public String cpu() throws NoSuchAlgorithmException {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] data = "spring-boot-4.1-virtual-threads-benchmark".getBytes();
|
||||
for (int i = 0; i < 20_000; i++) {
|
||||
data = md.digest(data);
|
||||
}
|
||||
return "cpu:" + Thread.currentThread() + ":" + data.length;
|
||||
}
|
||||
|
||||
@GetMapping("/thread-info")
|
||||
public String threadInfo() {
|
||||
Thread t = Thread.currentThread();
|
||||
return "Thread: " + t + " | Virtual: " + t.isVirtual();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
/**
|
||||
* Demonstrates JEP 491 (Synchronize Virtual Threads without Pinning, GA in JDK 24,
|
||||
* so present in this repo's JDK 25). Before JEP 491, a virtual thread that entered a
|
||||
* synchronized block or method and then blocked inside it (I/O, Thread.sleep, a
|
||||
* monitor wait) could not unmount from its carrier thread -- it "pinned" the carrier
|
||||
* for the entire blocking operation. As of JDK 24, ordinary monitor acquisition and
|
||||
* blocking inside a synchronized region no longer pins. See docs/03-pinning-diagnosis.md
|
||||
* for the primary source and the concurrency-based proof this repo runs.
|
||||
*/
|
||||
public class PinningDemoService {
|
||||
|
||||
/**
|
||||
* Holds a monitor (via synchronized) across a blocking Thread.sleep call -- the
|
||||
* textbook "this used to pin" example from every pre-JDK-24 virtual threads
|
||||
* article, including the version of this post it replaces.
|
||||
*/
|
||||
public static synchronized void doWorkHoldingMonitor(long sleepMillis) throws InterruptedException {
|
||||
Thread.sleep(sleepMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same operation, but synchronized on a distinct, per-call lock object so that
|
||||
* concurrent callers never contend with each other for the *lock* itself. Any
|
||||
* serialization observed is carrier-thread pinning, not lock contention.
|
||||
*/
|
||||
public static void doWorkHoldingDistinctMonitor(Object lock, long sleepMillis) throws InterruptedException {
|
||||
synchronized (lock) {
|
||||
Thread.sleep(sleepMillis);
|
||||
}
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
/**
|
||||
* Standalone (no Spring context) check that -Djdk.tracePinnedThreads=full is inert on this
|
||||
* JDK. Pre-JDK-24, this flag made the JVM print a stack trace to stdout every time a virtual
|
||||
* thread pinned its carrier. JEP 491 removed the property along with most of the pinning it
|
||||
* used to report on: "setting it on the command line will have no effect." Run via
|
||||
* scripts/check-trace-pinned-threads-removed.sh, which invokes this twice (with and without
|
||||
* the flag) and diffs the output. See docs/03-pinning-diagnosis.md.
|
||||
*/
|
||||
public class PinningTraceCheckMain {
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println("java.version=" + System.getProperty("java.version"));
|
||||
System.out.println("jdk.tracePinnedThreads=" + System.getProperty("jdk.tracePinnedThreads"));
|
||||
System.out.println("Running a virtual thread that holds a monitor across Thread.sleep(200)...");
|
||||
Thread vt = Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
PinningDemoService.doWorkHoldingMonitor(200);
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
vt.join();
|
||||
System.out.println("Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread " +
|
||||
"stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.");
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
/**
|
||||
* Demonstrates the JDK 25 FINALIZED ScopedValue API (JEP 506, GA in JDK 25 -- no
|
||||
* --enable-preview needed). The old preview-era static shortcut ScopedValue.runWhere(...)
|
||||
* does not exist on this API; javap -p java.lang.ScopedValue against this JDK's runtime
|
||||
* classes confirms it. The finalized shape returns a Carrier from where(...), with .run(...)
|
||||
* or .call(...) on it. See docs/04-scoped-value-and-checklist.md.
|
||||
*/
|
||||
public class ScopedValueDemo {
|
||||
|
||||
record ExpensiveContext(String requestId) {}
|
||||
|
||||
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
|
||||
|
||||
public static String processRequest() {
|
||||
// CTX is accessible here and in anything this method calls, because it's invoked
|
||||
// from inside the ScopedValue.where(...).run(...) scope below.
|
||||
return "handled " + CTX.get().requestId();
|
||||
}
|
||||
|
||||
public static String runWithContext(String requestId) {
|
||||
return ScopedValue.where(CTX, new ExpensiveContext(requestId))
|
||||
.call(ScopedValueDemo::processRequest);
|
||||
// CTX is automatically cleared once this scope exits -- no cleanup needed, and no
|
||||
// stale value can leak to a later virtual thread the way a forgotten
|
||||
// ThreadLocal.remove() can.
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class VirtualThreadsBoot4DemoApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(VirtualThreadsBoot4DemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: virtual-threads-boot4-demo
|
||||
threads:
|
||||
virtual:
|
||||
enabled: false
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,metrics
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ankurm.vthreads: INFO
|
||||
@@ -1,164 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Real concurrent-load comparison of platform threads (Boot's default embedded Tomcat pool)
|
||||
* against virtual threads (spring.threads.virtual.enabled=true), against a real running
|
||||
* embedded server, on the two endpoints in DemoController. This box is a 2 vCPU sandbox, not
|
||||
* the 4-core/8GB machine the original 2025 post benchmarked on -- the absolute numbers below
|
||||
* are honestly smaller and noisier than a real server's, but the SHAPE of the result (I/O-bound
|
||||
* scales with virtual threads, CPU-bound does not) is the same physics either hardware runs on,
|
||||
* and every number here comes from this run, not the old post's numbers copied forward.
|
||||
*
|
||||
* Output: docs/output/01-io-bound-benchmark.txt, docs/output/02-cpu-bound-benchmark.txt.
|
||||
* See docs/02-benchmark-methodology.md.
|
||||
*/
|
||||
class LoadBenchmarkTest {
|
||||
|
||||
private record Result(int total, int success, long wallMs, double p50, double p99) {}
|
||||
|
||||
private Result fireConcurrent(String baseUrl, String path, int concurrency) throws Exception {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.executor(Executors.newVirtualThreadPerTaskExecutor())
|
||||
.build();
|
||||
List<Long> latencies = Collections.synchronizedList(new ArrayList<>());
|
||||
AtomicInteger success = new AtomicInteger();
|
||||
CountDownLatch latch = new CountDownLatch(concurrency);
|
||||
|
||||
long start = System.nanoTime();
|
||||
for (int i = 0; i < concurrency; i++) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
long reqStart = System.nanoTime();
|
||||
try {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + path)).build();
|
||||
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
success.incrementAndGet();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// counted as failure below
|
||||
} finally {
|
||||
latencies.add((System.nanoTime() - reqStart) / 1_000_000);
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
latch.await(60, TimeUnit.SECONDS);
|
||||
long wallMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
List<Long> sorted = new ArrayList<>(latencies);
|
||||
Collections.sort(sorted);
|
||||
double p50 = sorted.isEmpty() ? 0 : sorted.get(sorted.size() / 2);
|
||||
int p99Idx = sorted.isEmpty() ? 0 : Math.min(sorted.size() - 1, (int) (sorted.size() * 0.99));
|
||||
double p99 = sorted.isEmpty() ? 0 : sorted.get(p99Idx);
|
||||
|
||||
return new Result(concurrency, success.get(), wallMs, p50, p99);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ioBoundScenario() throws Exception {
|
||||
int concurrency = 600; // > default Tomcat platform-thread pool of 200
|
||||
|
||||
Result platform = runAgainst(false, "/io", concurrency);
|
||||
Result virtual = runAgainst(true, "/io", concurrency);
|
||||
|
||||
Transcript t = Transcript.start("01-io-bound-benchmark.txt",
|
||||
"I/O-bound endpoint (/io, Thread.sleep(300)), concurrency=" + concurrency
|
||||
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
|
||||
t.line(String.format("platform threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
platform.total, platform.success, platform.wallMs, platform.p50, platform.p99));
|
||||
t.line(String.format("virtual threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
virtual.total, virtual.success, virtual.wallMs, virtual.p50, virtual.p99));
|
||||
t.blank();
|
||||
t.line("default embedded Tomcat platform-thread pool = 200; with 600 concurrent 300ms-sleep");
|
||||
t.line("requests, platform threads must queue in ~3 sequential batches, virtual threads do not.");
|
||||
t.save();
|
||||
|
||||
// The real, checked claim: virtual threads finish this workload meaningfully faster
|
||||
// wall-clock than the platform-thread pool, on THIS run, on THIS hardware.
|
||||
assertThat(virtual.wallMs).isLessThan(platform.wallMs);
|
||||
assertThat(virtual.success).isEqualTo(concurrency);
|
||||
assertThat(platform.success).isEqualTo(concurrency);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cpuBoundScenario() throws Exception {
|
||||
int concurrency = 60; // well beyond 2 vCPUs -- forces real core-bound saturation for both models
|
||||
|
||||
Result platform = runAgainst(false, "/cpu", concurrency);
|
||||
Result virtual = runAgainst(true, "/cpu", concurrency);
|
||||
|
||||
Transcript t = Transcript.start("02-cpu-bound-benchmark.txt",
|
||||
"CPU-bound endpoint (/cpu, 20,000x SHA-256), concurrency=" + concurrency
|
||||
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
|
||||
t.line(String.format("platform threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
platform.total, platform.success, platform.wallMs, platform.p50, platform.p99));
|
||||
t.line(String.format("virtual threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
|
||||
virtual.total, virtual.success, virtual.wallMs, virtual.p50, virtual.p99));
|
||||
t.blank();
|
||||
t.line("a CPU-bound virtual thread never yields -- it stays mounted on its carrier for the full");
|
||||
t.line("computation, so it competes for the same 2 physical cores a platform thread would. Expect");
|
||||
t.line("these two wall times to be close, not virtual threads winning decisively as in the I/O case.");
|
||||
t.save();
|
||||
|
||||
assertThat(platform.success).isEqualTo(concurrency);
|
||||
assertThat(virtual.success).isEqualTo(concurrency);
|
||||
// Not asserting virtual < platform here on purpose -- the whole point of this test is that
|
||||
// CPU-bound work does NOT reliably favor either thread model. See docs/02.
|
||||
}
|
||||
|
||||
private Result runAgainst(boolean virtualThreads, String path, int concurrency) throws Exception {
|
||||
AtomicInteger capturedPort = new AtomicInteger(-1);
|
||||
CountDownLatch portLatch = new CountDownLatch(1);
|
||||
|
||||
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsBoot4DemoApplication.class)
|
||||
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
|
||||
capturedPort.set(event.getWebServer().getPort());
|
||||
portLatch.countDown();
|
||||
}));
|
||||
|
||||
// Passed as command-line-style args (highest property precedence) rather than
|
||||
// builder.properties(...) (which binds to "defaultProperties" -- LOWEST precedence,
|
||||
// so it was silently losing to the explicit false in application.yml. Real bug,
|
||||
// caught because the test asserted the actual thread type instead of just that the
|
||||
// app started.)
|
||||
ConfigurableApplicationContext ctx = builder.run(
|
||||
"--server.port=0",
|
||||
"--spring.threads.virtual.enabled=" + virtualThreads,
|
||||
"--spring.jmx.enabled=false");
|
||||
try {
|
||||
portLatch.await(10, TimeUnit.SECONDS);
|
||||
String baseUrl = "http://localhost:" + capturedPort.get();
|
||||
// Warm-up: JIT compiles hot methods (MessageDigest.digest, the servlet dispatch
|
||||
// path) per-CLASS, not per-context, so within one JVM whichever scenario runs
|
||||
// SECOND gets a free JIT head start that has nothing to do with thread model.
|
||||
// First cut of this benchmark ran platform-threads first every time and
|
||||
// attributed the resulting gap to virtual threads -- discovered by inspecting the
|
||||
// CPU-bound numbers, where virtual threads had no right to win by 4x. Fire an
|
||||
// untimed warm-up round through the SAME code paths before the real measurement.
|
||||
fireConcurrent(baseUrl, path, Math.min(concurrency, 30));
|
||||
return fireConcurrent(baseUrl, path, concurrency);
|
||||
} finally {
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Proves JEP 491 (GA in JDK 24) empirically rather than citing it: N virtual threads
|
||||
* each hold a DISTINCT monitor (via synchronized, on a lock object no other thread ever
|
||||
* touches) across a blocking Thread.sleep. Before JDK 24 this pinned every one of them to
|
||||
* a carrier thread for the full sleep duration, so wall time scaled with
|
||||
* ceil(N / availableProcessors) * sleepMillis. On JDK 24+, it does not: wall time stays
|
||||
* close to sleepMillis regardless of N or the carrier pool size, because the virtual
|
||||
* thread unmounts from its carrier while sleeping even though it still holds the lock.
|
||||
*
|
||||
* Output: docs/output/03a-pinning-jep491-proof.txt. See docs/03-pinning-diagnosis.md.
|
||||
*/
|
||||
class PinningJep491Test {
|
||||
|
||||
@Test
|
||||
void synchronizedNoLongerPinsAcrossBlockingSleep() throws InterruptedException {
|
||||
int n = 60;
|
||||
long sleepMillis = 250;
|
||||
int carrierCount = Runtime.getRuntime().availableProcessors();
|
||||
long wallTimeIfPinnedMs = ((n + carrierCount - 1) / carrierCount) * sleepMillis;
|
||||
|
||||
Transcript t = Transcript.start("03a-pinning-jep491-proof.txt",
|
||||
"JEP 491 proof: synchronized held across Thread.sleep, JDK " + System.getProperty("java.version"));
|
||||
t.line("availableProcessors (default virtual-thread carrier pool size) = " + carrierCount);
|
||||
t.line("virtual threads = " + n + ", each holds a DISTINCT monitor for " + sleepMillis + "ms");
|
||||
t.line("predicted wall time IF PINNED (pre-JDK-24 behaviour): ~" + wallTimeIfPinnedMs + "ms");
|
||||
t.line("predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~" + sleepMillis + "ms, independent of carrier count");
|
||||
t.blank();
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(n);
|
||||
long start = System.nanoTime();
|
||||
for (int i = 0; i < n; i++) {
|
||||
Object myLock = new Object();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
PinningDemoService.doWorkHoldingDistinctMonitor(myLock, sleepMillis);
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
latch.await();
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
t.line("actual wall time: " + elapsedMs + "ms");
|
||||
t.line("verdict: " + (elapsedMs < wallTimeIfPinnedMs / 2
|
||||
? "NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour"
|
||||
: "PINNED -- matches pre-JDK-24 behaviour"));
|
||||
t.save();
|
||||
|
||||
// The real assertion: this build fails the day this stops being true on whatever
|
||||
// JDK runs it, e.g. if run on JDK < 24.
|
||||
assertThat(elapsedMs).isLessThan(wallTimeIfPinnedMs / 2);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Confirms the finalized JDK 25 ScopedValue API (JEP 506) compiles and runs with no
|
||||
* --enable-preview flag, and that ScopedValue.where(...).call(...) actually threads the value
|
||||
* through to a method called from inside the scope. Output:
|
||||
* docs/output/03c-scoped-value-finalized.txt.
|
||||
*/
|
||||
class ScopedValueTest {
|
||||
|
||||
@Test
|
||||
void whereRunThreadsContextThroughToCalledMethod() {
|
||||
Transcript t = Transcript.start("03c-scoped-value-finalized.txt",
|
||||
"ScopedValue (JEP 506, finalized JDK 25), JDK " + System.getProperty("java.version"));
|
||||
|
||||
String result = ScopedValueDemo.runWithContext("req-42");
|
||||
t.line("ScopedValue.where(CTX, ctx).call(ScopedValueDemo::processRequest) -> " + result);
|
||||
t.line("no --enable-preview flag used on this run");
|
||||
t.save();
|
||||
|
||||
assertThat(result).isEqualTo("handled req-42");
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Confirms spring.threads.virtual.enabled actually changes the request-handling thread type,
|
||||
* against a real running embedded server (not a description). Output:
|
||||
* docs/output/00-thread-type-confirmation.txt.
|
||||
*/
|
||||
class ThreadTypeTest {
|
||||
|
||||
@Test
|
||||
void flagTogglesRequestThreadType() throws Exception {
|
||||
String withoutFlag = hit(false);
|
||||
String withFlag = hit(true);
|
||||
|
||||
Transcript t = Transcript.start("00-thread-type-confirmation.txt",
|
||||
"GET /thread-info with spring.threads.virtual.enabled=false vs true");
|
||||
t.line("enabled=false -> " + withoutFlag);
|
||||
t.line("enabled=true -> " + withFlag);
|
||||
t.save();
|
||||
|
||||
assertThat(withoutFlag).contains("Virtual: false");
|
||||
assertThat(withFlag).contains("Virtual: true");
|
||||
}
|
||||
|
||||
private String hit(boolean virtualThreads) throws Exception {
|
||||
AtomicInteger capturedPort = new AtomicInteger(-1);
|
||||
CountDownLatch portLatch = new CountDownLatch(1);
|
||||
|
||||
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsBoot4DemoApplication.class)
|
||||
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
|
||||
capturedPort.set(event.getWebServer().getPort());
|
||||
portLatch.countDown();
|
||||
}));
|
||||
|
||||
// Passed as command-line-style args (highest property precedence) rather than
|
||||
// builder.properties(...) (which binds to "defaultProperties" -- LOWEST precedence,
|
||||
// so it was silently losing to the explicit false in application.yml. Real bug,
|
||||
// caught because the test asserted the actual thread type instead of just that the
|
||||
// app started.)
|
||||
ConfigurableApplicationContext ctx = builder.run(
|
||||
"--server.port=0",
|
||||
"--spring.threads.virtual.enabled=" + virtualThreads,
|
||||
"--spring.jmx.enabled=false");
|
||||
try {
|
||||
portLatch.await(10, TimeUnit.SECONDS);
|
||||
RestClient rest = RestClient.create("http://localhost:" + capturedPort.get());
|
||||
return rest.get().uri("/thread-info").retrieve().body(String.class);
|
||||
} finally {
|
||||
ctx.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.ankurm.vthreads;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
/**
|
||||
* Writes docs/output/NN-*.txt while a test runs, so every number quoted in the blog post
|
||||
* is backed by a file produced by an assertion that would fail the build if it stopped
|
||||
* being true. Never hand-edit files under docs/output/ — regenerate with scripts/run-all.sh.
|
||||
*/
|
||||
public final class Transcript {
|
||||
|
||||
private final StringBuilder buf = new StringBuilder();
|
||||
private final Path outFile;
|
||||
|
||||
private Transcript(String fileName) {
|
||||
this.outFile = Paths.get("docs/output", fileName);
|
||||
}
|
||||
|
||||
public static Transcript start(String fileName, String header) {
|
||||
Transcript t = new Transcript(fileName);
|
||||
t.line(header);
|
||||
t.line("=".repeat(header.length()));
|
||||
return t;
|
||||
}
|
||||
|
||||
public Transcript line(String s) {
|
||||
buf.append(s).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
buf.append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try {
|
||||
Files.createDirectories(outFile.getParent());
|
||||
Files.writeString(outFile, buf.toString(), StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user