3.7 KiB
ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
- Status: Accepted
- Date: 2026-08-25
- Scope:
adapter:inbound:web—advanced.virtualthread,advanced.blockingbridge
Context
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge for WebFlux.
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a side effect of the pool size.
Switching to virtual threads deletes that limit without deleting anything that depended on it.
Decision
An explicit admission limit is required when virtual threads are enabled.
VirtualThreadProfile refuses construction without one. Without it the deployment accepts every
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
had it been refused. The load that used to be shed at the front door is shed at the back, after the
cost of accepting it.
The limit bounds concurrent use cases, not threads. VirtualThreadAdmissionGuard is a fair
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
The downstream budgets are carried in the profile and stated as unchanged. The whole point is
that they did not grow. admissionFitsDownstreamBudgets() reports when the admission limit exceeds
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
all database-bound, and that should be a choice rather than an accident.
Blocking offloads are registered, bounded and timed out. boundedElastic() is available from
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
application into an unbounded pool. BlockingBridgeProfile names the operations permitted to
offload; BlockingBridgeBudget bounds the concurrency and refuses a caller that cannot get a slot
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
dependencies starve behind them.
Pinning is observed, not assumed away. VirtualThreadProfile.requiredObservations() lists what
has to be watched — jdk.VirtualThreadPinned above all. A synchronized block held across a blocking
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
a deadlock a thread dump does not obviously show.
Consequences
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile will not let it be one.
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for the client than the same request accepted and timed out thirty seconds later behind a full pool. An operator seeing 503s climb should read them as the limit working.
VirtualThreadAdmissionGuard.peakActive()exists so a load test can assert the limit was applied. It is invisible from throughput, which is why a load test that only measures throughput would pass with the guard removed.
Alternatives considered
- Enable virtual threads and raise the downstream budgets to match. Rejected: the budgets are sized to what the dependencies can serve, not to what the web tier can accept.
- Bound the virtual threads themselves with a fixed-size executor. Rejected: that is a platform thread pool with extra steps.
- Let controllers call
boundedElastic()directly. Rejected: every such call site is invisible until the pool is the thing consuming the heap.