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
@@ -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();
}
}