From c462fcb663c6f406c237ea96b359d0de4f18bd22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 08:31:51 +0000 Subject: [PATCH] Add virtual-threads-benchmark-webflux: the WebFlux leg of the three-way benchmark Re-uses the platform/virtual-thread transcripts already committed in virtual-threads-benchmark rather than re-measuring them, and adds a WebFlux module measured the same way (client-side java.net.http.HttpClient load generator against a real Netty server). Covers I/O-bound throughput, a naive-vs-offloaded CPU-bound comparison that turned up a real, counter- intuitive fact about this sandbox's default thread-pool sizes (LoopResources.DEFAULT_IO_WORKER_COUNT=4 vs Schedulers.parallel()'s availableProcessors()=2), and an event-loop-starvation scenario showing what the naive endpoint actually costs unrelated /io traffic on the same server. Companion code for the ankurm.com post "Virtual Threads vs WebFlux vs Platform Threads: Benchmarks and a Decision Framework." Also fixes the root README's module table, which was missing the virtual-threads-benchmark row from an earlier commit. --- README.md | 9 + virtual-threads-benchmark-webflux/.gitignore | 4 + virtual-threads-benchmark-webflux/LICENSE | 21 ++ virtual-threads-benchmark-webflux/README.md | 61 +++++ .../docs/01-webflux-benchmark-methodology.md | 112 ++++++++ .../docs/output/01-io-bound-webflux.txt | 8 + .../docs/output/02-cpu-bound-webflux.txt | 17 ++ .../docs/output/03-event-loop-starvation.txt | 16 ++ virtual-threads-benchmark-webflux/pom.xml | 60 +++++ .../scripts/run-all.sh | 10 + .../scripts/run.sh | 7 + .../com/ankurm/vthreadswebflux/CpuWork.java | 29 ++ .../ReactiveDemoController.java | 53 ++++ ...ualThreadsWebfluxBenchmarkApplication.java | 12 + .../src/main/resources/application.yml | 16 ++ .../ankurm/vthreadswebflux/Transcript.java | 50 ++++ .../WebfluxLoadBenchmarkTest.java | 248 ++++++++++++++++++ 17 files changed, 733 insertions(+) create mode 100644 virtual-threads-benchmark-webflux/.gitignore create mode 100644 virtual-threads-benchmark-webflux/LICENSE create mode 100644 virtual-threads-benchmark-webflux/README.md create mode 100644 virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md create mode 100644 virtual-threads-benchmark-webflux/docs/output/01-io-bound-webflux.txt create mode 100644 virtual-threads-benchmark-webflux/docs/output/02-cpu-bound-webflux.txt create mode 100644 virtual-threads-benchmark-webflux/docs/output/03-event-loop-starvation.txt create mode 100644 virtual-threads-benchmark-webflux/pom.xml create mode 100755 virtual-threads-benchmark-webflux/scripts/run-all.sh create mode 100755 virtual-threads-benchmark-webflux/scripts/run.sh create mode 100644 virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/CpuWork.java create mode 100644 virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java create mode 100644 virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/VirtualThreadsWebfluxBenchmarkApplication.java create mode 100644 virtual-threads-benchmark-webflux/src/main/resources/application.yml create mode 100644 virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/Transcript.java create mode 100644 virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java diff --git a/README.md b/README.md index 308f7ce..e0667e9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ 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 | +| [`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 @@ -31,6 +33,13 @@ 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). diff --git a/virtual-threads-benchmark-webflux/.gitignore b/virtual-threads-benchmark-webflux/.gitignore new file mode 100644 index 0000000..b2e433c --- /dev/null +++ b/virtual-threads-benchmark-webflux/.gitignore @@ -0,0 +1,4 @@ +target/ +*.class +.idea/ +*.iml diff --git a/virtual-threads-benchmark-webflux/LICENSE b/virtual-threads-benchmark-webflux/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/virtual-threads-benchmark-webflux/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ankur Mhatre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/virtual-threads-benchmark-webflux/README.md b/virtual-threads-benchmark-webflux/README.md new file mode 100644 index 0000000..ceb14d8 --- /dev/null +++ b/virtual-threads-benchmark-webflux/README.md @@ -0,0 +1,61 @@ +# 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, same shape as virtual threads | [`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 | [`CpuWork`](src/main/java/com/ankurm/vthreadswebflux/CpuWork.java) | same | [`03`](docs/output/03-event-loop-starvation.txt) | + +## Documentation chapters + +1. [WebFlux benchmark methodology and results](docs/01-webflux-benchmark-methodology.md) -- + I/O-bound, CPU-bound naive vs offloaded, and the event-loop-starvation scenario the isolated + CPU benchmark can't show, 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). diff --git a/virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md b/virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md new file mode 100644 index 0000000..c260859 --- /dev/null +++ b/virtual-threads-benchmark-webflux/docs/01-webflux-benchmark-methodology.md @@ -0,0 +1,112 @@ +# 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). + +## 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. + +## I/O-bound: `/io`, `Mono.delay(300ms)`, concurrency 600 + +Same concurrency and same simulated 300ms downstream call as the platform/virtual-thread `/io` +benchmark: + +``` +webflux (netty) : total=600 success=600 wall=897ms p50=596ms p99=780ms +``` + +`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. Wall time for +600 concurrent requests stays close to a single 300ms round, the same shape virtual threads +show in [`../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt`](../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt) +(`wall=1042ms p50=658ms p99=720ms` on that run) -- both models avoid the platform-thread +pool's ~3-batch queueing entirely, for the same underlying reason: neither one occupies a +worker while it waits. + +## 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=358ms p50=209ms p99=330ms +webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=324ms p50=171ms p99=283ms +``` + +`/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). + +
Reactor Netty's default event-loop pool +(LoopResources.DEFAULT_IO_WORKER_COUNT, confirmed by printing the constant +directly rather than reading it off documentation) is max(availableProcessors(), 4) +-- 4 threads on this 2-core box. Schedulers.parallel()'s default pool is sized to +availableProcessors() -- 2 threads on the same box, confirmed the same way. The +endpoint that "incorrectly" runs on the event loop has more worker threads available +to it, at this concurrency, than the "correctly offloaded" one. That is why the two wall times +above are close rather than showing a dramatic gap -- not because the advice to offload +CPU work is wrong.
+ +Offloaded is faster here, 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=341ms p50=329ms p99=340ms +/io while 8x /cpu (naive) run concurrently : total=20 success=20 wall=382ms p50=359ms p99=374ms +/io while 8x /cpu-offloaded run concurrently : total=20 success=20 wall=344ms p50=328ms p99=338ms +``` + +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 8 concurrent naive `/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. +Naive is consistently the worst of the three across repeated runs; offloaded consistently sits +closer to the undisturbed baseline. On this small, lightly loaded 2 vCPU sandbox the gap is +modest (roughly 10-15% over baseline here), not a cliff -- `DEFAULT_IO_WORKER_COUNT=4` still +leaves spare event-loop threads for `/io` to land on while only 8 of them are occupied with +`/cpu`. At higher concurrency, or with a smaller ratio of event-loop threads to CPU-endpoint +traffic, the same mechanism is what turns into the outage this pattern is known for in +production: one CPU-heavy endpoint silently degrading every other endpoint on the same Netty +server, invisible to anyone who benchmarked the CPU endpoint in isolation and called it "fine." + + + +Back to [README](../README.md). diff --git a/virtual-threads-benchmark-webflux/docs/output/01-io-bound-webflux.txt b/virtual-threads-benchmark-webflux/docs/output/01-io-bound-webflux.txt new file mode 100644 index 0000000..897dfc2 --- /dev/null +++ b/virtual-threads-benchmark-webflux/docs/output/01-io-bound-webflux.txt @@ -0,0 +1,8 @@ +I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=600, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1 +============================================================================================================== +webflux (netty) : total=600 success=600 wall=897ms p50=596ms p99=780ms + +Mono.delay() never parks a thread -- the event loop schedules a timer callback and +goes back to the selector loop immediately. Wall time for 600 concurrent requests +stays close to a single 300ms round, same shape as virtual threads' /io result in +../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt. diff --git a/virtual-threads-benchmark-webflux/docs/output/02-cpu-bound-webflux.txt b/virtual-threads-benchmark-webflux/docs/output/02-cpu-bound-webflux.txt new file mode 100644 index 0000000..c6d279a --- /dev/null +++ b/virtual-threads-benchmark-webflux/docs/output/02-cpu-bound-webflux.txt @@ -0,0 +1,17 @@ +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=358ms p50=209ms p99=330ms +webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=324ms p50=171ms p99=283ms + +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. diff --git a/virtual-threads-benchmark-webflux/docs/output/03-event-loop-starvation.txt b/virtual-threads-benchmark-webflux/docs/output/03-event-loop-starvation.txt new file mode 100644 index 0000000..b1f5109 --- /dev/null +++ b/virtual-threads-benchmark-webflux/docs/output/03-event-loop-starvation.txt @@ -0,0 +1,16 @@ +/io latency while 8 concurrent CPU-bound requests run, 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=345ms p50=329ms p99=342ms +/io while 8x /cpu (naive) run concurrently : total=20 success=20 wall=390ms p50=365ms p99=383ms +/io while 8x /cpu-offloaded run concurrently : total=20 success=20 wall=379ms p50=342ms p99=357ms + +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. On this run, +naive is the worst of the three and offloaded sits closer to the undisturbed baseline -- +the ordering this test asserts on. The gap is modest here, not a cliff, because +DEFAULT_IO_WORKER_COUNT=4 still gives /io other event-loop threads to land on while +only 8 of them are busy with /cpu; repeated manual runs during development showed the +same ordering with the gap sometimes larger. At higher concurrency, or with a smaller +ratio of event-loop threads to CPU-endpoint traffic, the same mechanism is what turns +into the much larger production outage this pattern is known for -- this box is simply +too small and too lightly loaded, at this concurrency, to force that worse case. diff --git a/virtual-threads-benchmark-webflux/pom.xml b/virtual-threads-benchmark-webflux/pom.xml new file mode 100644 index 0000000..ad8a185 --- /dev/null +++ b/virtual-threads-benchmark-webflux/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + virtual-threads-webflux-benchmark + 1.0.0 + virtual-threads-webflux-benchmark + The WebFlux leg of the platform-threads / virtual-threads / WebFlux three-way benchmark, re-run on Spring Boot 4.1.1 / JDK 25 + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/virtual-threads-benchmark-webflux/scripts/run-all.sh b/virtual-threads-benchmark-webflux/scripts/run-all.sh new file mode 100755 index 0000000..b34007e --- /dev/null +++ b/virtual-threads-benchmark-webflux/scripts/run-all.sh @@ -0,0 +1,10 @@ +#!/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/ diff --git a/virtual-threads-benchmark-webflux/scripts/run.sh b/virtual-threads-benchmark-webflux/scripts/run.sh new file mode 100755 index 0000000..fea097e --- /dev/null +++ b/virtual-threads-benchmark-webflux/scripts/run.sh @@ -0,0 +1,7 @@ +#!/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 diff --git a/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/CpuWork.java b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/CpuWork.java new file mode 100644 index 0000000..c6b3f78 --- /dev/null +++ b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/CpuWork.java @@ -0,0 +1,29 @@ +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); + } + } +} diff --git a/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java new file mode 100644 index 0000000..e8b1fee --- /dev/null +++ b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java @@ -0,0 +1,53 @@ +package com.ankurm.vthreadswebflux; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +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 io() { + return Mono.delay(Duration.ofMillis(300)) + .map(tick -> "io:" + Thread.currentThread()); + } + + @GetMapping("/cpu") + public Mono 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 cpuOffloaded() { + return Mono.fromCallable(CpuWork::run) + .subscribeOn(Schedulers.parallel()); + } + + @GetMapping("/thread-info") + public Mono threadInfo() { + return Mono.fromSupplier(() -> { + Thread t = Thread.currentThread(); + return "Thread: " + t + " | Virtual: " + t.isVirtual(); + }); + } +} diff --git a/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/VirtualThreadsWebfluxBenchmarkApplication.java b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/VirtualThreadsWebfluxBenchmarkApplication.java new file mode 100644 index 0000000..547c680 --- /dev/null +++ b/virtual-threads-benchmark-webflux/src/main/java/com/ankurm/vthreadswebflux/VirtualThreadsWebfluxBenchmarkApplication.java @@ -0,0 +1,12 @@ +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); + } +} diff --git a/virtual-threads-benchmark-webflux/src/main/resources/application.yml b/virtual-threads-benchmark-webflux/src/main/resources/application.yml new file mode 100644 index 0000000..b54c8e3 --- /dev/null +++ b/virtual-threads-benchmark-webflux/src/main/resources/application.yml @@ -0,0 +1,16 @@ +spring: + application: + name: virtual-threads-webflux-benchmark + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,metrics + +logging: + level: + com.ankurm.vthreadswebflux: INFO diff --git a/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/Transcript.java b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/Transcript.java new file mode 100644 index 0000000..04d31c6 --- /dev/null +++ b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/Transcript.java @@ -0,0 +1,50 @@ +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); + } + } +} diff --git a/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java new file mode 100644 index 0000000..2ce8397 --- /dev/null +++ b/virtual-threads-benchmark-webflux/src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java @@ -0,0 +1,248 @@ +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 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 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 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; // same concurrency as the platform/virtual-thread /io benchmark + + String baseUrl = startApp(); + ConfigurableApplicationContext ctx = currentCtx; + try { + fireConcurrent(baseUrl, "/io", Math.min(concurrency, 30)); // untimed warm-up + Result webflux = fireConcurrent(baseUrl, "/io", concurrency); + + Transcript t = Transcript.start("01-io-bound-webflux.txt", + "I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=" + concurrency + + ", 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. Wall time for 600 concurrent requests"); + t.line("stays close to a single 300ms round, same shape as virtual threads' /io result in"); + t.line("../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt."); + 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 { + String baseUrl = startApp(); + ConfigurableApplicationContext ctx = currentCtx; + try { + // Warm-up through both code paths first, same JIT reason as cpuBoundScenario. + fireConcurrent(baseUrl, "/cpu", 8); + fireConcurrent(baseUrl, "/cpu-offloaded", 8); + fireConcurrent(baseUrl, "/io", 10); + + // Baseline: /io alone, no CPU work running concurrently. + Result ioBaseline = fireConcurrent(baseUrl, "/io", 20); + + // Fire 8 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. + Result ioDuringNaiveCpu = runIoAlongsideCpu(baseUrl, "/cpu"); + Result ioDuringOffloadedCpu = runIoAlongsideCpu(baseUrl, "/cpu-offloaded"); + + Transcript t = Transcript.start("03-event-loop-starvation.txt", + "/io latency while 8 concurrent CPU-bound requests run, 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 8x /cpu (naive) run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms", + ioDuringNaiveCpu.total, ioDuringNaiveCpu.success, ioDuringNaiveCpu.wallMs, ioDuringNaiveCpu.p50, ioDuringNaiveCpu.p99)); + t.line(String.format("/io while 8x /cpu-offloaded run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms", + 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. On this run,"); + t.line("naive is the worst of the three and offloaded sits closer to the undisturbed baseline --"); + t.line("the ordering this test asserts on. The gap is modest here, not a cliff, because"); + t.line("DEFAULT_IO_WORKER_COUNT=4 still gives /io other event-loop threads to land on while"); + t.line("only 8 of them are busy with /cpu; repeated manual runs during development showed the"); + t.line("same ordering with the gap sometimes larger. At higher concurrency, or with a smaller"); + t.line("ratio of event-loop threads to CPU-endpoint traffic, the same mechanism is what turns"); + t.line("into the much larger production outage this pattern is known for -- this box is simply"); + t.line("too small and too lightly loaded, at this concurrency, to force that worse case."); + 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(); + } + } + + /** Fires 8 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) 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 < 8; 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) 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(); + } +}