Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Remove queue-subdispatching pattern to the ThreadPool global queue - #131177

Merged
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp
Aug 7, 2026
Merged

Remove queue-subdispatching pattern to the ThreadPool global queue #131177
VSadov merged 4 commits into
dotnet:mainfrom
VSadov:subDisp

Conversation

@VSadov

@VSadovVSadov commented Jul 22, 2026

Copy link
Copy Markdown
Member

To avoid allocating a workitem per every socket/IO event and reduce the number of enqueues to the global queue we use the following pattern:

  • Socket/IO engine places the events (a struct) into a concurrent queue, where the storage is naturally reused.
  • In order to get the event executed, we post a self-replicating workitem into a global queue and that workitem fetches the events and executes them.
  • There is also a heuristic that if the queue runs dry or after some time duration (and in Windows case also in a presence of local workitems) the task stops "pumping" of the events would not starve other workitems.

=== The pattern has some issues that get worse on big core counts:

  • The self-replication of the event queue task places copies of itself into the global queue every time a worker starts "pumping" and sees more items in the queue. We must do this for correctness - to make sure that remaining queued events will be eventually picked up even if the current thread blocks.
    This self-requeuing can put a lot of stress on the global queue.
    In a case of assignable queues, the re-queued item ends up in the assignable queue, which are local to a subset of workers, thus may impact p99 latencies.

  • When a worker stops dispatching from the event queue (due to time limit, for example) and then picks up an event queue task again, it may be a queue from a different IO engine.
    This randomizes both the assignment of the event queues to workers and the order of event execution.
    It is possible that multiple workers would "pump" events from the same queue, while other queues are neglected (and keep growing if events are arriving).
    A queue that is overshared may cause contentions, spinning/sleeping of workers.

  • the event queue is basically a memory cache with no upper bounds. We do not know how big the event queue can get if the enqueuing outruns dequeuing for some nontrivial time.
    We do not report the size of this queue in the ThreadPool Queue Length or any other counter even though these are technically threadpool workitems.

=== What we do instead in this PR:
At the high level instead of relying on a queue to reduce the number of global enqueues (temporal batching), we explicitly combine multiple events into batches of predictable and bounded size and enqueue entire batches to the global queue.

Details:

  • IO engine fills actual event workitems (in IThreadPoolWorkItem sense) with data.

  • The workitems are reused via a pool that has an upper bound.
    (in an unlikely case that the limit is reached we will allocate/GC the workitems).

  • To avoid stressing the global queues, the workitems are packed into balanced binary trees, so that we could submit the entire batch for execution in one enqueue.

  • Worker threads, upon executing the parent, place the children into the local queue.
    The current worker is likely still the best worker to execute the children, but if other workers need work they can steal whole subtrees thus divide-and-conquering the remaining work.

  • as a general approach if execution of an event results in more than one task, we place the additional tasks into the local queue - to relieve the stress on the global queue, improve the locality of execution and statistically reduce the max workqueue lengths.

  • the size of a batch is bounded to make sure that large batches do not impact p99 latency.

=== Perf diffs:

JSON benchmarks

 x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%

The change appears to erase most of the gap with net10 on JSON benchmark in high core configuration, thus:
Fixes: #127484

@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

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @karelz, @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

@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.

CopilotAI 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.

Pull request overview

This PR changes how socket / IO completion events are dispatched to the thread pool, replacing the prior “queue + pumping work item” pattern with pooled per-event work items that are submitted in batches via a balanced binary tree to reduce global-queue pressure and bound memory growth.

Changes:

  • Remove the Windows-only ThreadPoolTypedWorkItemQueue sub-dispatcher from ThreadPoolWorkQueue.
  • Windows IOCP: pool IOCompletionPoller.Event instances and batch-submit completions as a balanced tree, fanning out to local queues as nodes execute.
  • Unix sockets: pool SocketIOEvent instances and similarly batch-submit async socket events as a balanced tree; adjust some preferLocal scheduling.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.csRemoves the Windows-only typed work item queue implementation previously used for IO sub-dispatch.
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.csIntroduces pooled Event work items and balanced-tree batching for IOCP completions; changes queueing behavior.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.csReworks async socket event dispatch to use pooled SocketIOEvent work items and balanced-tree batching.
src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncContext.Unix.csSwitches some thread-pool queueing to preferLocal: true for async operation processing/cancellation callbacks.

CopilotAI review requested due to automatic review settings July 23, 2026 22:06

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • Using ConcurrentQueue<T>.Count in the hot path (SocketIOEvent.Execute) can be quite expensive under contention (Count is not O(1) for ConcurrentQueue<T>). Since this runs per completion, it risks regressing throughput and adding cross-thread cache contention. Consider tracking pool size with an explicit int counter (Interlocked increment/decrement on enqueue/dequeue) instead of calling Count here.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • Using ConcurrentQueue<T>.Count in Event.Execute is a potentially expensive operation under contention (Count is not O(1) for ConcurrentQueue<T>). Since this executes per IO completion, it can add avoidable overhead. Consider tracking pool size with an explicit Interlocked counter on borrow/return instead of querying Count here.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 23, 2026 22:30

CopilotAI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:394

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it for every completion work item defeats some of the intended perf/throughput wins.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:377

  • ConcurrentQueue<T>.Count can be relatively expensive under contention (it may spin and can take the cross-segment lock when multiple segments are present; see ConcurrentQueue<T>.Count implementation). Calling it on every async socket event adds overhead on the hot path.

Consider tracking an approximate pool size with an int updated via Interlocked alongside the queue, and using that for the max-pool bound check instead of Count.

 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:102

  • The comment above _eventQueue says it’s a queue of events generated by EventLoop, but in this revision _eventQueue is used as a pool of reusable SocketIOEvent instances (see RentEvent and SocketIOEvent.Execute). Updating the comment would prevent future confusion about its semantics and why it’s bounded.
 //
// Queue of events generated by EventLoop() that would be processed by the thread pool
//
private readonly ConcurrentQueue<SocketIOEvent> _eventQueue = new ConcurrentQueue<SocketIOEvent>();

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:512

  • Event pooling uses ConcurrentQueue.Count on every completion to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock (see ConcurrentQueue.cs), so calling it in this hot path can add contention/latency and offset the intended allocation savings. Consider using an Interlocked-based approximate pool size counter (increment on Enqueue, decrement on Dequeue) instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:527

  • SocketIOEvent pooling uses ConcurrentQueue.Count on every event execution to enforce MaxEventPoolCount. ConcurrentQueue.Count can spin and may take the cross-segment lock, so this can be a measurable hot-path cost under high IO rates. Consider tracking pool size via an Interlocked counter (increment on Enqueue, decrement on RentEvent dequeue success) instead of Count.
 if (_queue.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_queue.Enqueue(this);
}

CopilotAI 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.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:509

  • Using ConcurrentQueue.Count on every IO completion to enforce the pool bound is potentially expensive. ConcurrentQueue.Count can spin and may take _crossSegmentLock when there are multiple segments (see System.Collections.Concurrent.ConcurrentQueue.Count implementation), which can become a noticeable overhead at high IO rates. Consider tracking pool size with an Interlocked counter (increment on Enqueue, decrement on successful TryDequeue) to keep the bound check O(1) without extra locking.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:533

  • SocketIOEvent.Execute() uses ConcurrentQueue.Count to enforce the pool bound. ConcurrentQueue.Count can spin and may take the cross-segment lock when multiple segments exist, so doing this per event can add measurable overhead under heavy IO. Consider maintaining an Interlocked pool-size counter instead of calling Count in the hot path.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

CopilotAI review requested due to automatic review settings July 31, 2026 06:06

CopilotAI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue<T>.Count can take _crossSegmentLock (and spin/retry) when the queue has multiple segments, so calling _pool.Count on every completion is a potentially expensive hot-path operation and undermines the goal of reducing overhead. Consider tracking the pool size with an int updated via Interlocked on enqueue/dequeue (best-effort is fine) and use that value for the MaxEventPoolCount check instead of Count.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • _pool.Count is queried for every SocketIOEvent execution. ConcurrentQueue<T>.Count is not a cheap counter; it may spin and can take _crossSegmentLock when multiple segments exist, so this can become a measurable overhead under high event rates. Consider maintaining an int pool size updated via Interlocked on enqueue/dequeue and using that for the MaxEventPoolCount gate instead of Count.
 SocketAsyncContext context = _context!;
Interop.Sys.SocketEvents events = _events;
if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Threading

@VSadov

Copy link
Copy Markdown
MemberAuthor

@MihuBot benchmark System.Buffers

@MihuBot

Copy link
Copy Markdown
System.Threading.Tests.Perf_Volatile
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Write_doubleMain0.9338 ns0.0040 ns1.00-NA
Write_doublePR0.9317 ns0.0029 ns1.00-NA
Read_doubleMain0.9485 ns0.0045 ns1.00-NA
Read_doublePR0.9459 ns0.0064 ns1.00-NA
System.Threading.Tests.Perf_Timer
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ShortScheduleAndDisposeMain74.99 ns0.395 ns1.00120 B1.00
ShortScheduleAndDisposePR75.45 ns0.697 ns1.01120 B1.00
LongScheduleAndDisposeMain75.44 ns0.651 ns1.00120 B1.00
LongScheduleAndDisposePR75.34 ns0.673 ns1.00120 B1.00
ScheduleManyThenDisposeManyMain223,379,838.13 ns1,965,673.532 ns1.00144001328 B1.00
ScheduleManyThenDisposeManyPR223,129,600.73 ns2,591,171.964 ns1.00144001328 B1.00
ShortScheduleAndDisposeWithFiringTimersMain80.28 ns1.599 ns1.00144 B1.00
ShortScheduleAndDisposeWithFiringTimersPR80.46 ns1.515 ns1.00144 B1.00
SynchronousContentionMain1,420,584,665.07 ns17,411,095.255 ns1.001152000760 B1.00
SynchronousContentionPR1,298,023,677.18 ns25,870,105.978 ns0.911152000760 B1.00
AsynchronousContentionMain1,128,918,393.85 ns36,890,792.880 ns1.001152002232 B1.00
AsynchronousContentionPR1,403,692,668.40 ns41,823,779.338 ns1.251152002232 B1.00
System.Threading.Tests.Perf_ThreadStatic
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
GetThreadStaticMain1.869 ns0.0084 ns1.00-NA
GetThreadStaticPR1.886 ns0.0068 ns1.01-NA
SetThreadStaticMain3.271 ns0.0107 ns1.00-NA
SetThreadStaticPR3.272 ns0.0150 ns1.00-NA
System.Threading.Tests.Perf_ThreadPool
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1 Gen0=38000.0000
MethodToolchainWorkItemsPerCoreMeanErrorRatioAllocatedAlloc Ratio
QueueUserWorkItem_WaitCallback_ThroughputMain200000001.980 s0.0144 s1.00610.35 MB1.00
QueueUserWorkItem_WaitCallback_ThroughputPR200000001.965 s0.0112 s0.99610.35 MB1.00
System.Threading.Tests.Perf_Thread
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
CurrentThreadMain1.895 ns0.0140 ns1.00-NA
CurrentThreadPR1.892 ns0.0078 ns1.00-NA
GetCurrentProcessorIdMain2.321 ns0.0074 ns1.00-NA
GetCurrentProcessorIdPR2.119 ns0.0111 ns0.91-NA
System.Threading.Tests.Perf_SpinLock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain10.560 ns0.0062 ns1.00-NA
EnterExitPR10.553 ns0.0124 ns1.00-NA
TryEnterExitMain10.564 ns0.0055 ns1.00-NA
TryEnterExitPR10.546 ns0.0044 ns1.00-NA
TryEnter_FailMain1.236 ns0.0054 ns1.00-NA
TryEnter_FailPR1.233 ns0.0050 ns1.00-NA
System.Threading.Tests.Perf_SemaphoreSlim
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReleaseWaitMain29.63 ns0.016 ns1.00-NA
ReleaseWaitPR29.65 ns0.014 ns1.00-NA
ReleaseWaitAsyncMain28.25 ns0.021 ns1.00-NA
ReleaseWaitAsyncPR28.26 ns0.011 ns1.00-NA
ReleaseWaitAsync_WithCancellationTokenMain317.66 ns20.687 ns1.00528 B1.00
ReleaseWaitAsync_WithCancellationTokenPR317.60 ns22.628 ns1.00528 B1.00
ReleaseWaitAsync_WithTimeoutMain337.80 ns17.205 ns1.00624 B1.00
ReleaseWaitAsync_WithTimeoutPR318.12 ns7.530 ns0.94624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutMain361.32 ns11.905 ns1.00624 B1.00
ReleaseWaitAsync_WithCancellationTokenAndTimeoutPR362.21 ns17.881 ns1.00624 B1.00
System.Threading.Tests.Perf_Monitor
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EnterExitMain13.55 ns0.008 ns1.00-NA
EnterExitPR13.55 ns0.006 ns1.00-NA
TryEnterExitMain13.55 ns0.009 ns1.00-NA
TryEnterExitPR13.54 ns0.007 ns1.00-NA
System.Threading.Tests.Perf_Lock
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
ReaderWriterLockSlimPerfMain13.55 ns0.008 ns1.00-NA
ReaderWriterLockSlimPerfPR13.55 ns0.034 ns1.00-NA
System.Threading.Tests.Perf_Interlocked
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Increment_intMain4.824 ns0.0021 ns1.00-NA
Increment_intPR4.820 ns0.0040 ns1.00-NA
Decrement_intMain4.829 ns0.0159 ns1.00-NA
Decrement_intPR4.819 ns0.0018 ns1.00-NA
Increment_longMain4.818 ns0.0025 ns1.00-NA
Increment_longPR4.820 ns0.0016 ns1.00-NA
Decrement_longMain4.815 ns0.0020 ns1.00-NA
Decrement_longPR4.820 ns0.0025 ns1.00-NA
Add_intMain4.815 ns0.0016 ns1.00-NA
Add_intPR4.816 ns0.0022 ns1.00-NA
Add_longMain4.817 ns0.0019 ns1.00-NA
Add_longPR4.819 ns0.0019 ns1.00-NA
Exchange_intMain4.819 ns0.0033 ns1.00-NA
Exchange_intPR4.819 ns0.0048 ns1.00-NA
Exchange_longMain4.817 ns0.0020 ns1.00-NA
Exchange_longPR4.820 ns0.0020 ns1.00-NA
CompareExchange_intMain4.829 ns0.0024 ns1.00-NA
CompareExchange_intPR4.830 ns0.0019 ns1.00-NA
CompareExchange_longMain4.832 ns0.0037 ns1.00-NA
CompareExchange_longPR4.831 ns0.0033 ns1.00-NA
CompareExchange_object_MatchMain5.509 ns0.0028 ns1.00-NA
CompareExchange_object_MatchPR5.508 ns0.0035 ns1.00-NA
CompareExchange_object_NoMatchMain5.510 ns0.0028 ns1.00-NA
CompareExchange_object_NoMatchPR5.508 ns0.0041 ns1.00-NA
System.Threading.Tests.Perf_EventWaitHandle
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Set_ResetMain42.75 ns0.027 ns1.00-NA
Set_ResetPR42.72 ns0.020 ns1.00-NA
System.Threading.Tests.Perf_CancellationToken
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NRQIIJ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-NGSIDY : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
RegisterAndUnregister_SerialMain18.912 ns0.0493 ns1.00-NA
RegisterAndUnregister_SerialPR18.946 ns0.1112 ns1.00-NA
CancelMain54.448 ns0.8583 ns1.00192 B1.00
CancelPR53.189 ns0.3285 ns0.98192 B1.00
CreateLinkedTokenSource1Main21.830 ns0.3112 ns1.0064 B1.00
CreateLinkedTokenSource1PR21.034 ns0.4128 ns0.9664 B1.00
CreateLinkedTokenSource2Main38.236 ns0.4997 ns1.0080 B1.00
CreateLinkedTokenSource2PR37.323 ns0.1491 ns0.9880 B1.00
CreateLinkedTokenSource3Main58.207 ns0.3793 ns1.00128 B1.00
CreateLinkedTokenSource3PR58.819 ns0.3601 ns1.01128 B1.00
CreateTokenDisposeMain5.448 ns0.0367 ns1.0048 B1.00
CreateTokenDisposePR5.462 ns0.0467 ns1.0048 B1.00
CreateRegisterDisposeMain32.044 ns0.3211 ns1.00192 B1.00
CreateRegisterDisposePR31.946 ns0.3375 ns1.00192 B1.00
CreateManyRegisterDisposeMain14.507 ns0.0215 ns1.00-NA
CreateManyRegisterDisposePR14.487 ns0.0178 ns1.00-NA
CreateManyRegisterMultipleDisposeMain80.629 ns0.1873 ns1.00-NA
CreateManyRegisterMultipleDisposePR81.699 ns0.1683 ns1.01-NA
CancelAfterMain51.286 ns0.3029 ns1.00144 B1.00
CancelAfterPR52.058 ns1.7330 ns1.02144 B1.00
System.Threading.Tasks.Tests.Perf_AsyncMethods
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
EmptyAsyncMethodInvocationMain3.731 ns0.0128 ns1.00-NA
EmptyAsyncMethodInvocationPR3.286 ns0.0038 ns0.88-NA
SingleYieldMethodInvocationMain102.221 ns5.3328 ns1.0096 B1.00
SingleYieldMethodInvocationPR100.185 ns4.8837 ns0.9896 B1.00
YieldMain30.483 ns0.5967 ns1.00-NA
YieldPR30.693 ns1.1908 ns1.01-NA
System.Threading.Tasks.ValueTaskPerfTest
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-UXTJFQ : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-WCARJH : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-XVCCJK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HMQJNI : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False PowerPlanMode= IterationTime=250ms
MaxIterationCount=20 MaxWarmupIterationCount=10 MinIterationCount=15
MinWarmupIterationCount=2 WarmupCount=-1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
Await_FromResultMain5.328 ns0.0110 ns1.00-NA
Await_FromResultPR5.446 ns0.0079 ns1.02-NA
Await_FromCompletedTaskMain9.748 ns0.1823 ns1.0072 B1.00
Await_FromCompletedTaskPR9.318 ns0.1712 ns0.9672 B1.00
Await_FromCompletedValueTaskSourceMain15.410 ns0.2158 ns1.0072 B1.00
Await_FromCompletedValueTaskSourcePR15.242 ns0.1697 ns0.9972 B1.00
CreateAndAwait_FromResultMain5.281 ns0.0154 ns1.00-NA
CreateAndAwait_FromResultPR5.294 ns0.0182 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitMain5.276 ns0.0160 ns1.00-NA
CreateAndAwait_FromResult_ConfigureAwaitPR5.312 ns0.0280 ns1.01-NA
CreateAndAwait_FromCompletedTaskMain5.774 ns0.0214 ns1.00-NA
CreateAndAwait_FromCompletedTaskPR5.914 ns0.0899 ns1.02-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitMain5.817 ns0.0137 ns1.00-NA
CreateAndAwait_FromCompletedTask_ConfigureAwaitPR6.211 ns0.0136 ns1.07-NA
CreateAndAwait_FromCompletedValueTaskSourceMain6.762 ns0.0108 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSourcePR6.319 ns0.0094 ns0.93-NA
CreateAndAwait_FromYieldingAsyncMethodMain204.260 ns9.2949 ns1.00207 B1.00
CreateAndAwait_FromYieldingAsyncMethodPR212.860 ns13.1086 ns1.04207 B1.00
CreateAndAwait_FromDelayedTCSMain77.672 ns0.3388 ns1.00216 B1.00
CreateAndAwait_FromDelayedTCSPR76.081 ns0.5506 ns0.98216 B1.00
Copy_PassAsArgumentAndReturn_FromResultMain2.842 ns0.0268 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromResultPR2.832 ns0.0272 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskMain5.853 ns0.0244 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromTaskPR6.085 ns0.0314 ns1.04-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourceMain8.025 ns0.0529 ns1.00-NA
Copy_PassAsArgumentAndReturn_FromValueTaskSourcePR7.993 ns0.0522 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitMain6.334 ns0.0582 ns1.00-NA
CreateAndAwait_FromCompletedValueTaskSource_ConfigureAwaitPR6.310 ns0.0456 ns1.00-NA
System.Threading.Channels.Tests.UnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain30.37 ns0.041 ns1.00-NA
TryWriteThenTryReadPR30.40 ns0.034 ns1.00-NA
WriteAsyncThenReadAsyncMain30.35 ns0.029 ns1.00-NA
WriteAsyncThenReadAsyncPR30.35 ns0.019 ns1.00-NA
ReadAsyncThenWriteAsyncMain49.04 ns0.016 ns1.00-NA
ReadAsyncThenWriteAsyncPR49.30 ns0.207 ns1.01-NA
PingPongMain2,627,902.03 ns111,019.119 ns1.00903 B1.00
PingPongPR2,658,820.31 ns145,066.288 ns1.01901 B1.00
System.Threading.Channels.Tests.SpscUnboundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain17.54 ns0.013 ns1.00-NA
TryWriteThenTryReadPR17.54 ns0.010 ns1.00-NA
WriteAsyncThenReadAsyncMain22.39 ns0.016 ns1.00-NA
WriteAsyncThenReadAsyncPR22.43 ns0.025 ns1.00-NA
ReadAsyncThenWriteAsyncMain46.94 ns0.020 ns1.00-NA
ReadAsyncThenWriteAsyncPR47.04 ns0.076 ns1.00-NA
PingPongMain2,511,563.26 ns93,531.184 ns1.00901 B1.00
PingPongPR2,627,531.08 ns129,689.322 ns1.05900 B1.00
System.Threading.Channels.Tests.BoundedChannelPerfTests
BenchmarkDotNet v0.16.0-nightly.20260703.576, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 1 CPU, 8 logical and 4 physical cores
Memory: 31.34 GB Total, 5.59 GB Available
Job-TPEJOW : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
Job-HKHXHK : .NET 11.0.0 (11.0.0-dev, 42.42.42.42424), X64 RyuJIT x86-64-v4
EvaluateOverhead=False OutlierMode=Default PowerPlanMode=
IterationTime=250ms MaxIterationCount=20 MemoryRandomization=Default
MinIterationCount=15 WarmupCount=1
MethodToolchainMeanErrorRatioAllocatedAlloc Ratio
TryWriteThenTryReadMain35.91 ns0.031 ns1.00-NA
TryWriteThenTryReadPR35.87 ns0.019 ns1.00-NA
WriteAsyncThenReadAsyncMain37.57 ns0.044 ns1.00-NA
WriteAsyncThenReadAsyncPR37.61 ns0.061 ns1.00-NA
ReadAsyncThenWriteAsyncMain47.84 ns0.043 ns1.00-NA
ReadAsyncThenWriteAsyncPR48.03 ns0.038 ns1.00-NA
PingPongMain2,893,666.76 ns138,789.838 ns1.00901 B1.00
PingPongPR2,847,279.72 ns148,677.365 ns0.99901 B1.00

@MihuBot

Copy link
Copy Markdown

CopilotAI review requested due to automatic review settings August 4, 2026 22:17

CopilotAI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.IO.Windows.cs:408

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Calling it on every IO completion can become a noticeable CPU cost exactly when the pool is large. Consider enforcing MaxEventPoolCount with a separate Interlocked-updated counter (increment when returning an Event to the pool, decrement when renting) so the hot path avoids Count while still bounding growth.
 if (_pool.Count < MaxEventPoolCount)
{
this.nativeOverlapped = null;
this.bytesTransferred = 0;
_left = null;
_right = null;
_pool.Enqueue(this);
}

src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEngine.Unix.cs:421

  • ConcurrentQueue.Count is not a cheap constant-time read (it may spin and can take the cross-segment lock when the queue spans multiple segments). Checking it on every SocketIOEvent.Execute() adds avoidable overhead on a very hot path and becomes more expensive as the pool grows. Consider tracking an explicit pool size counter (Interlocked) so MaxEventPoolCount can be enforced without calling Count per completion.
 if (_pool.Count < MaxEventPoolCount)
{
_context = null;
_events = Interop.Sys.SocketEvents.None;
_left = null;
_right = null;
_pool.Enqueue(this);
}

@VSadov
VSadov marked this pull request as ready for review August 4, 2026 23:13
@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.

@eduardo-vpeduardo-vp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@VSadov
VSadov merged commit 7da460b into dotnet:mainAug 7, 2026
145 checks passed
@VSadov
VSadov deleted the subDisp branch August 7, 2026 19:09
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-rc1 milestone Aug 10, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Aug 11, 2026
…otnet#131177)
To avoid allocating a workitem per every socket/IO event and reduce the
number of enqueues to the global queue we use the following pattern:
- Socket/IO engine places the events (a struct) into a concurrent queue,
where the storage is naturally reused.
- In order to get the event executed, we post a self-replicating
workitem into a global queue and that workitem fetches the events and
executes them.
- There is also a heuristic that if the queue runs dry or after some
time duration (and in Windows case also in a presence of local
workitems) the task stops "pumping" of the events would not starve other
workitems.
=== The pattern has some issues that get worse on big core counts:
* The self-replication of the event queue task places copies of itself
into the global queue every time a worker starts "pumping" and sees more
items in the queue. We must do this for correctness - to make sure that
remaining queued events will be eventually picked up even if the current
thread blocks.
This self-requeuing can put a lot of stress on the global queue. In a case of assignable queues, the re-queued item ends up in the
assignable queue, which are local to a subset of workers, thus may
impact p99 latencies.
* When a worker stops dispatching from the event queue (due to time
limit, for example) and then picks up an event queue task again, it may
be a queue from a different IO engine.
This randomizes both the assignment of the event queues to workers and
the order of event execution.
It is possible that multiple workers would "pump" events from the same
queue, while other queues are neglected (and keep growing if events are
arriving).
A queue that is overshared may cause contentions, spinning/sleeping of
workers.
* the event queue is basically a memory cache with no upper bounds. We
do not know how big the event queue can get if the enqueuing outruns
dequeuing for some nontrivial time.
We do not report the size of this queue in the `ThreadPool Queue Length`
or any other counter even though these are technically threadpool
workitems.
=== What we do instead in this PR: At the high level instead of relying on a queue to reduce the number of
global enqueues (temporal batching), we explicitly combine multiple
events into batches of predictable and bounded size and enqueue entire
batches to the global queue.
Details: * IO engine fills actual event workitems (in `IThreadPoolWorkItem`
sense) with data.
* The workitems are reused via a pool that has an upper bound. (in an unlikely case that the limit is reached we will allocate/GC the
workitems).
* To avoid stressing the global queues, the workitems are packed into
balanced binary trees, so that we could submit the entire batch for
execution in one enqueue.
* Worker threads, upon executing the parent, place the children into the
local queue.
The current worker is likely still the best worker to execute the
children, but if other workers need work they can steal whole subtrees
thus divide-and-conquering the remaining work.
* as a general approach if execution of an event results in more than
one task, we place the additional tasks into the local queue - to
relieve the stress on the global queue, improve the locality of
execution and statistically reduce the max workqueue lengths.
* the size of a batch is bounded to make sure that large batches do not
impact p99 latency.
=== Perf diffs:
JSON benchmarks
```diff
x64 / HIGH load (56 cores, load 4096 connections, load threads=64)
- base RPS 2,528,080 p50 1.47 ms p99 3.79 ms
+ new RPS 2,580,722 p50 1.50 ms p99 3.13 ms
+2.1% +2.0% -17.4%
x64 / LOW load (56 cores, load 512 connections)
- base RPS 2,141,591 p50 0.21 ms p99 0.57 ms
+ new RPS 2,177,068 p50 0.21 ms p99 0.59 ms
+1.7% 0.0% +3.5%
arm64 / HIGH load (96 cores, load 4096 connections, load threads=64)
- base RPS 3,715,921 p50 0.57 ms p99 7.30 ms
+ new RPS 3,696,351 p50 0.56 ms p99 7.01 ms
-0.5% -1.8% -4.0%
arm64 / LOW load (96 cores, load 256 connections)
- base RPS 2,064,045 p50 0.25 ms p99 0.78 ms
+ new RPS 2,149,487 p50 0.25 ms p99 0.44 ms
+4.1% 0.0% -43.6%
```
The change appears to erase most of the gap with net10 on JSON benchmark
in high core configuration, thus:
Fixes: dotnet#127484
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET 11 ASP.NET Core throughput regression on ARM64 at high core counts (16+) (Kestrel JSON benchmark)

4 participants

@VSadov@MihuBot@eduardo-vp