Skip to content

Reduce unnecessary spinning after empty thread-pool dispatches - #136078

Open
hez2010 wants to merge 4 commits into
dotnet:mainfrom
hez2010:thread-pool
Open

hez2010 wants to merge 4 commits into
dotnet:mainfrom
hez2010:thread-pool

Conversation

@hez2010

@hez2010 hez2010 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

We are wasting a lot of time on spinning after empty thread-pool dispatches, which causes major performance regressions on runtime async resumption. This issue also regressed our workload where we are using runtime async to build the job scheduler of a computing system, where each job can run very short.

Instead of always spinning after empty thread-pool dispatches, we can track recent external worker requests (including coalesced requests), and when a dispatch finds no work, park without spinning if the last external request was over DispatchQuantumMs ago. We can reuse the timestamp already recorded for dispatch progress to avoid reading the clock again after an empty dispatch.

This reduces unnecessary polling for worker-generated workloads while keeping workers available for external bursts.

Benchmark .NET 10 .NET 11 RC1 .NET 11 RC2 PR
Synchronous baseline 0.332 ns 0.332 ns 0.333 ns 0.332 ns
Async, no suspension 6.466 ns 0.395 ns 0.393 ns 0.396 ns
Completed Task await 4.006 ns 0.333 ns 0.330 ns 0.335 ns
Completed ValueTask await 0.754 ns 0.333 ns 0.336 ns 0.333 ns
Task.Yield suspension 302.58 ns 35.73 ns 172.76 ns 37.39 ns
ThreadPool continuation 373.83 ns 84.19 ns 245.32 ns 87.89 ns
TaskCompletionSource continuation 499.52 ns 98.60 ns 361.80 ns 102.27 ns
Async state-machine chain 712.21 ns 77.56 ns 251.05 ns 76.57 ns

Resolves #135738

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Oct 19, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @VSadov
See info in area-owners.md if you want to be subscribed.


internal void EnsureWorkerRequested()
{
if (ThreadPoolWorkQueueThreadLocals.threadLocals == null)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we are not using if (!Thread.CurrentThread.IsThreadPoolThread) because that would miss some external producers like the IO completion pollers.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The timing race needs correction, and the shared scheduling heuristic requires human validation of burst latency and multi-producer contention.

1 open issue
What changed in this PR

Reduces empty-dispatch spinning in the portable thread pool to address runtime-async performance regressions while preserving responsiveness to external bursts.

Changes:

  • Tracks external worker requests, including coalesced requests.
  • Skips spinning after empty dispatches when external activity is no longer recent.

Builds, tests, and benchmarks were not run.

File Description
src/​libraries/​System.Private.CoreLib/​src/​System/​Threading/​PortableThreadPool.WorkerThread.cs Uses external-request age to decide whether to skip spinning.
src/​libraries/​System.Private.CoreLib/​src/​System/​Threading/​PortableThreadPool.cs Records the latest external-request timestamp.

Review effort: Balanced

@VSadov

VSadov commented Oct 19, 2026

Copy link
Copy Markdown
Member

This may need quite a bit of testing.
The area is known for changes moving microbenchmarks one way and then moving more e2e scenarios the other.

@VSadov VSadov closed this Oct 19, 2026
@VSadov VSadov reopened this Oct 19, 2026
@VSadov

VSadov commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Sorry, closed by accident. (The page moved and a wrong button was clicked)

@hez2010

hez2010 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

This may need quite a bit of testing. The area is known for changes moving microbenchmarks one way and then moving more e2e scenarios the other.

Agreed. I have the benchmark numbers in the linked issue comments, and it doesn't seem to regress the workload the prior PR was addressing. But I think it still worth to run the tests in your end as I don't have the exact environment and workload.

// which can introduce another burst after the queues drain. For work produced entirely by
// workers, park empty dispatches promptly instead of competing with the productive workers.
return spurious && (numProcessingWork > SpuriousDispatchNoSpinThreshold ||
(Environment.TickCount - Volatile.Read(ref threadPoolInstance._separated.lastExternalWorkItemTimeMs))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming there won't be a case where no external producer updates lastExternalWorkItemTimeMs for more than Int32.MaxValue milliseconds.

@VSadov

VSadov commented Oct 20, 2026

Copy link
Copy Markdown
Member

113 complete A/B pairs across 41 comparisons, including the 12 additional valid pairs. Changes are PR/main paired geometric means. Higher throughput is better; lower CPU/op and p99 are better.

S = original server-only cohort; B = both server and client use main/main versus PR/PR; R = separate server-only retry cohort. S managed clients stay on stock .NET 11; HTTP load generation uses unchanged native wrk. Gold is x64; CB200 is ARM64.

  • Bold number: its individual, unadjusted 95% confidence interval excludes zero.
  • Non-bold numeric result: its interval includes zero—inconclusive, not proof of no effect.

Largest movements

At least one of throughput, server CPU/op, or p99 moved by 5% or more. Sorted by the largest absolute point estimate, not by confidence.

# Test Platform Cohort Pairs Throughput Δ Server CPU/op Δ p99 Δ
1 SignalR broadcast Gold Windows S 3 +89.76% −67.48% +545.84%
2 JSON-high CB200 S 4 −0.03% −0.79% +158.05%
3 JSON-low CB200 R 4 +1.58% −4.83% +143.38%
4 JSON-low CB200 S 4 +1.42% +1.75% −66.33%
5 SignalR broadcast CB200 S 4 −11.35% −48.35% +13.85%
6 Minimal JSON-high CB200 S 4 +0.17% +0.31% −47.57%
7 SignalR broadcast Gold Linux S 3 +7.89% −46.78% −0.81%
8 Minimal JSON-low CB200 S 4 −0.55% +0.79% −42.01%
9 WebSocket-high Gold Windows B 2 +0.48% −0.94% +40.84%
10 Minimal JSON-low Gold Windows S 3 +1.35% +1.93% +20.98%
11 WebSocket-high Gold Windows S 2 −0.17% −1.00% +16.33%
12 PlainText-low CB200 S 3 +0.36% +14.45% N/A
13 WebSocket-low CB200 S 3 −2.40% +10.90% +5.96%
14 JSON-high CB200 R 3 −1.63% +2.33% +9.45%
15 WebSocket-low CB200 B 3 −2.72% +8.50% +5.38%
16 WebSocket-high CB200 B 4 −4.14% −0.47% −5.41%
17 WebSocket-low Gold Windows S 3 +0.41% −0.88% +5.18%

Remaining results

All three primary point estimates moved by less than 5%.

# Test Platform Cohort Pairs Throughput Δ Server CPU/op Δ p99 Δ
18 Single query Gold Linux R 2 −1.50% +0.59% −4.49%
19 Single query Gold Windows R 2 +0.02% −1.71% −3.96%
20 Fortunes Dapper Gold Windows R 2 −0.99% −1.71% −3.18%
21 WebSocket-high CB200 S 2 −0.72% +2.61% −0.36%
22 Fortunes Dapper Gold Linux R 2 +0.25% +0.39% −2.19%
23 Fortunes Gold Windows S 3 +0.51% −0.07% −2.16%
24 Minimal JSON-low Gold Linux S 3 −0.23% +0.21% +1.78%
25 WebSocket-low Gold Windows B 3 −0.27% +1.04% +1.34%
26 WebSocket-low Gold Linux S 3 +0.27% +0.22% −1.28%
27 WebSocket-high Gold Linux S 2 −0.44% +0.72% −1.26%
28 Fortunes Gold Linux S 3 −0.47% +1.25% −0.18%
29 JSON-high Gold Linux S 2 +0.21% −0.12% −1.20%
30 Multiple queries Gold Linux R 2 −0.48% −1.15% +1.19%
31 Fortunes EF Gold Linux R 2 −1.18% +0.79% +0.55%
32 JSON-low Gold Linux R 3 −0.55% +0.55% +1.09%
33 JSON-low Gold Linux S 3 −0.13% +0.12% −0.79%
34 WebSocket-high Gold Linux B 2 +0.17% −0.20% −0.78%
35 WebSocket-low Gold Linux B 3 −0.68% +0.64% +0.71%
36 Fortunes EF Gold Windows S 2 −0.53% +0.46% +0.14%
37 PlainText-low Gold Linux S 3 −0.32% +0.42% N/A
38 JSON-high Gold Linux R 2 −0.41% +0.13% −0.17%
39 Updates Gold Linux R 2 −0.22% −0.32% +0.12%
40 PlainText-high Gold Linux S 2 −0.16% −0.05% +0.29%
41 Minimal JSON-high Gold Linux S 2 −0.22% +0.11% −0.27%

N/A means unusable latency percentiles, not measured zero latency. CPU/op is measured-window server CPU per request/message, not peak CPU. Percentile changes compare per-run p99 values; they are not pooled percentiles.

@VSadov

VSadov commented Oct 20, 2026

Copy link
Copy Markdown
Member

On crank benchmarks the change worked better than I expected.

Thread pool has conflicting requirements on whether to use more aggressive parking/spinning policy that improves latencies or more frugal regime that avoids waste.
It is not uncommon that frugality leads to having to block/wake workers, leading to longer latencies and in latency-gated scenarios (i.e. client-server ping pong) to lower throughput as well.

Whether requests are entering threadpool externally of from workers appears to be an interesting signal to consider.

There are some cases where latencies got much worse. Sometimes leading to higher throughput, sometimes not. It is interesting to find why - maybe the signal is still useful and just the response needs to be tuned a bit.

@hez2010

hez2010 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

It seems that the extra clock read after an empty dispatch was adding unnecessary overhead, so I updated the implementation to reuse the timestamp already recorded for dispatch progress instead of calling Environment.TickCount again.

Here are the new benchmark results against the same workloads. Runtime async is on Windows x64, and the network workloads are on Linux x64.

Runtime async:

Benchmark Without PR With PR Change
Synchronous baseline 0.332 ns 0.320 ns −3.6%
Async method, no suspension 0.382 ns 0.380 ns −0.4%
Completed Task await 0.322 ns 0.326 ns +1.1%
Completed ValueTask await 0.321 ns 0.324 ns +1.1%
Task.Yield suspension 184.86 ns 38.92 ns −78.9%
ThreadPool continuation 255.88 ns 86.53 ns −66.2%
TaskCompletionSource continuation 370.69 ns 101.13 ns −72.7%
Async state-machine chain 196.13 ns 50.36 ns −74.3%

HTTP and WebSocket results, shown as without PR → with PR (change). Low = 4 connections, high = 100 connections; WebSocket uses 1,000-byte echo with compression disabled. S = server updated, B = both server and client updated. The longer runs use a 15-second warmup, with 30 seconds measured for WebSocket and 45 seconds for JSON.

Workload Cohort Throughput (req/s) Server CPU/op (µs) p99 (ms)
JSON-low S 69,387 → 67,856 (−2.2%) 34.538 → 35.222 (+2.0%) 0.4060 → 0.4990 (+22.9%)
Minimal JSON-low S 64,141 → 64,813 (+1.0%) 36.963 → 36.643 (−0.9%) 0.4610 → 0.3170 (−31.2%)
WebSocket-low S 50,590 → 43,860 (−13.3%) 47.321 → 52.667 (+11.3%) 0.1877 → 0.1947 (+3.7%)
WebSocket-high S 140,814 → 140,471 (−0.2%) 23.108 → 23.307 (+0.9%) 3.1103 → 3.0670 (−1.4%)
JSON-high S 176,313 → 182,185 (+3.3%) 20.242 → 19.801 (−2.2%) 3.8250 → 3.6420 (−4.8%)
Minimal JSON-high S 179,974 → 182,588 (+1.5%) 21.141 → 21.177 (+0.2%) 3.8180 → 3.2910 (−13.8%)
PlainText-low S 720,187 → 716,461 (−0.5%) 3.882 → 3.863 (−0.5%) N/A
WebSocket-low B 43,523 → 46,892 (+7.7%) 58.159 → 50.569 (−13.1%) 0.2046 → 0.2094 (+2.3%)
WebSocket-high B 136,574 → 136,923 (+0.3%) 23.628 → 24.094 (+2.0%) 3.0996 → 3.1615 (+2.0%)
WebSocket-low (longer run 1) S 46,454 → 46,024 (−0.9%) 50.444 → 50.800 (+0.7%) 0.1779 → 0.1854 (+4.2%)
JSON-low (longer run 1) S 71,243 → 71,147 (−0.1%) 33.135 → 33.590 (+1.4%) 0.2970 → 0.1880 (−36.7%)
WebSocket-low (longer run 2) S 45,174 → 44,021 (−2.6%) 53.674 → 52.801 (−1.6%) 0.1945 → 0.1844 (−5.2%)
JSON-low (longer run 2) S 70,211 → 68,549 (−2.4%) 34.540 → 35.216 (+2.0%) 0.4150 → 0.4280 (+3.1%)
Workload Cohort Client CPU/op (µs) Combined CPU/op (µs)
WebSocket-low S 49.021 → 53.883 (+9.9%) 96.341 → 106.550 (+10.6%)
WebSocket-high S 25.864 → 25.783 (−0.3%) 48.972 → 49.090 (+0.2%)
WebSocket-low B 60.472 → 51.948 (−14.1%) 118.632 → 102.517 (−13.6%)
WebSocket-high B 26.425 → 25.812 (−2.3%) 50.053 → 49.906 (−0.3%)
WebSocket-low (longer run 1) S 48.615 → 50.619 (+4.1%) 99.059 → 101.418 (+2.4%)
WebSocket-low (longer run 2) S 53.319 → 52.437 (−1.7%) 106.993 → 105.238 (−1.6%)

SignalR broadcast diagnostic (100 connections, backpressure restored; overloaded, so this is not a reliable comparison of the original workload):

Metric Without PR With PR Change
Throughput (messages/s) 551,086 507,903 −7.8%
Server CPU/message (µs) 3.615 4.265 +18.0%
Client CPU/message (µs) 5.766 6.304 +9.3%
Combined CPU/message (µs) 9.381 10.569 +12.7%
p99 (ms) 13,915.69 14,054.73 +1.0%

It seems that with this approach the runtime async suspension cases take 66–79% less time, while the four synchronous/completed-await controls stay within 5%. The initial WebSocket-low throughput and CPU regression did not repeat in the two longer runs, where both metrics stayed within 5%. Latency remains variable.

@VSadov may I ask for another test run on your side so that we can know whether the previous regressions have been resolved or not?

@VSadov

VSadov commented Oct 23, 2026

Copy link
Copy Markdown
Member

@VSadov may I ask for another test run on your side so that we can know whether the previous regressions have been resolved or not?

There were some lab capacity issues. it is taking longer than expected.

This branch has not been deployed

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

Labels

area-System.Threading community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runtime async has major perf regression since .NET 11 RC2

4 participants