Skip to content

Fix ConcurrentBag yield spin loop with virtual threads#2399

Open
TusharSariya wants to merge 1 commit into
brettwooldridge:devfrom
TusharSariya:2398
Open

Fix ConcurrentBag yield spin loop with virtual threads#2399
TusharSariya wants to merge 1 commit into
brettwooldridge:devfrom
TusharSariya:2398

Conversation

@TusharSariya

@TusharSariya TusharSariya commented Apr 10, 2026

Copy link
Copy Markdown

Fixes #2398.

If a borrowing vt increments waiters but it is preempted before it can poll the handoff queue,
all requiter VTs can see there is a waiter and try to do an handoff, all the requiter VTs will hit
thread.yield(). This is a subtle scheduler race condition, it becomes visible with virtual threads
due to the number of threads that can be spawned.

Solution

Rather than a yield which keeps the thread runnable, use an interruptible park when doing an offer
to the handoff queue. This will park the requiter and allow any preempted borrowers to start polling
the handoff queue.

Tests:

I did not note any changes to performance or behaviour, tests are tricky and non deterministic.

Could not reproduce on JDK 25. Most likely cause is JDK-8319447, which integrated DelayScheduler
into ForkJoinPool and tightened virtual-thread timed-park entry. could not prove this.

@TusharSariya TusharSariya changed the title Fix ConcurrentBag return-path yield spin under virtual threads Fix ConcurrentBag yield spin loop with virtual threads Apr 11, 2026
@ittaigolde

Copy link
Copy Markdown
Contributor

Thanks for putting this together. I ran this PR through the same small harness I used on #2398, comparing dev vs this branch on JDK 21.0.11.

The good news: this does eliminate the virtual-thread Thread.yield() samples in the reproducer.

Results from the 60s VT repro:

dev HEAD this PR
Throughput 72,206,870 borrows / 60.039s (1.20M/s) 192,827,720 borrows / 60.038s (3.21M/s)
JFR Thread.yield samples 10 / 302 (3.3%) 0 / 1322 (0.0%)
JFR ConcurrentBag.requite samples 51 / 302 (16.9%) 296 / 1322 (22.4%)

However, I also ran the upstream HikariCP-benchmark JMH benchmarks with platform threads:

-f 5 -i 15 -wi 5 -t 8 -p pool=hikari

Benchmark dev HEAD this PR
ConnectionBench.cycleCnnection 39,866 +/- 4,479 ops/ms 13,924 +/- 903 ops/ms
StatementBench.cycleStatement 166,724 +/- 2,936 ops/ms 169,141 +/- 2,111 ops/ms

So this branch fixes the VT yield symptom, but in this run it regressed the platform-thread ConnectionBench by about 65%.

I think the likely reason is that this changes the handoff behavior for all callers by replacing the existing spin/yield path with timed SynchronousQueue.offer(..., 10, MICROSECONDS). That is good for virtual threads, but seems too expensive for the platform-thread connection cycle path.

A narrower alternative is to keep the existing platform-thread behavior and only route virtual-thread callers to the existing parkNanos(10us) path. That also removed Thread.yield() from the VT repro in my run, while preserving the platform-thread JMH results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HikariCP 7.0.2 — ConcurrentBag.requite() yield-spin still saturates all carrier threads under virtual thread load

2 participants