Add virtual-threads-benchmark-webflux: the WebFlux leg of the three-way benchmark

Fixes found during self-correction before publishing:
- /stream used Flux.interval(), which ticks on its own wall-clock schedule
  independent of downstream demand and threw OverflowException under a slow
  subscriber; switched to Flux.range(), which has no independent production
  schedule and can never outrun demand.
- Single-trial HTTP load tests on this shared sandbox swung by more than 50%
  run to run (795ms-1247ms observed on the identical /io endpoint back to
  back) -- large enough to flip which threading model looked faster. Fixed
  by taking the median of 5 independent trials for the I/O-bound benchmark
  and the median of 3 for the event-loop-starvation benchmark, rather than
  reporting a single noisy run as if it were precise.
- The event-loop-starvation test's first cut used only 8 concurrent /cpu
  requests as background load, which drained through the 4 event-loop
  threads well inside the /io measurement window and produced an
  inconsistent, sometimes-inverted result across runs; raising to 60 fixed
  the under-loading problem but still flaked once during verification
  (372ms vs 374ms p99, a real tie). Final fix: 150 concurrent requests plus
  the median-of-3 trials above.

Also adds StreamBackpressureTest, a StepVerifier proof that the /stream
endpoint never emits ahead of its subscriber's outstanding requests, and
updates the module's docs to report the de-noised numbers with an explicit
methodology note on how they compare to the single-trial platform/virtual-
thread numbers reused from a different post.
This commit is contained in:
Claude
2026-09-19 09:34:18 +00:00
parent f506b01389
commit 09631dcaab
19 changed files with 975 additions and 0 deletions
+9
View File
@@ -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 | | [`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 | | [`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 ## 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 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. 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 ## Licence
MIT — see [LICENSE](LICENSE). MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,4 @@
target/
*.class
.idea/
*.iml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,65 @@
# 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).
@@ -0,0 +1,187 @@
# 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).
@@ -0,0 +1,12 @@
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.
@@ -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=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.
@@ -0,0 +1,19 @@
/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.
@@ -0,0 +1,13 @@
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.
+60
View File
@@ -0,0 +1,60 @@
<?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>
+10
View File
@@ -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/
+7
View File
@@ -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
@@ -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);
}
}
}
@@ -0,0 +1,75 @@
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);
}
}
@@ -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);
}
}
@@ -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
@@ -0,0 +1,50 @@
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();
}
}
@@ -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);
}
}
}
@@ -0,0 +1,319 @@
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();
}
}