A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@VSadov@stephentoub@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} 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

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

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

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

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

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@VSadov@stephentoub@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } 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

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

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

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

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

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

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

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy. - #123921

Merged
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo
Feb 15, 2026
Merged

A few fixes in the threadpool semaphore. Unify Windows/Unix implementation of LIFO policy.#123921
VSadov merged 25 commits into
dotnet:mainfrom
VSadov:lifo

Conversation

@VSadov

@VSadovVSadov commented Feb 2, 2026

Copy link
Copy Markdown
Member

Re: #123159

Changes:

  • Correctly handle Backoff.Exponential(0).

Embarrassing bug.
To get exponentially growing random spin count for an iteration we generate pseudorandom uint and do >> (32 - attempt). Since C# masks the shift operand with 31, when attempt==0 we end up not shifting at all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in scenarios that benefit from very long spins).

  • Unified implementation of LIFO policy with lightweight minimal implementation of LIFO waiting.

Once we are done spinning, we block threads and when workers are needed again wake them in LIFO order.

Unix WaitSubsystem is pretty heavy for these needs. It supports Interruptible waits, waiting on multiple objects, etc... None of that is interesting here. Most calls into the subsystem take a global process-wide lock which can contend under load with other uses, or a worker-waking threads may contend with the workers going to sleep, etc...

Windows used an opaque GetQueuedCompletionStatus for the side effect of releasing threads in LIFO order when completion is posted, with unknown overheads and interactions, even though typically it is more efficient than Unix WaitSubsystem.

The portable implementation seems to be faster than either of the platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive benchmarks).

The portable implementation is also easier to reason about and to debug anomalies.

  • Adaptive spinning in the threadpool based on estimates of CPU core availability.

Spinning in threadpool is very tricky and spinning benefits differ greatly between scenarios. For some scenarios the longer the spin the better. But there are scenarios that benefit when the threadpool releases cores quickly once it sees no work. No preset fixed spin count is going to be good for everything.

Adaptive approach appears to be necessary to improve some scenarios without regressing many others.
We can further improve the heuristic, if there are more ideas.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @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 addresses performance regressions in the threadpool semaphore (issue #123159) and unifies the Windows/Unix implementation of the LIFO (Last-In-First-Out) policy for threadpool worker thread management.

Changes:

  • Introduces a unified LowLevelThreadBlocker class that uses OS-provided compare-and-wait APIs (futex on Linux, WaitOnAddress on Windows) for efficient thread blocking, with a fallback to monitor-based implementation for other platforms
  • Refactors LowLevelLifoSemaphore to use the new blocker infrastructure, removes platform-specific Windows/Unix implementations, and improves spinning heuristics based on CPU availability
  • Adds native futex support for Linux through syscalls and Windows WaitOnAddress API interop

Reviewed changes

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

Show a summary per file
FileDescription
src/native/libs/System.Native/pal_threading.hAdds declarations for Linux futex operations
src/native/libs/System.Native/pal_threading.cImplements futex wait/wake operations for Linux using syscalls
src/native/libs/System.Native/entrypoints.cRegisters new futex entrypoints for Linux
src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.csFixes spelling, removes spin count configuration, passes active thread count to semaphore
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelThreadBlocker.csNew class providing portable thread blocking using futex/WaitOnAddress or monitor fallback
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.csMajor refactoring to use LowLevelThreadBlocker, implements LIFO queue with pending signals, improves spin heuristics
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Windows.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.Unix.csDeleted - functionality moved to unified implementation
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Windows.csNew file providing Windows WaitOnAddress API wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelFutex.Unix.csNew file providing Linux futex wrapper
src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.csModified to return spin count and skip spinning on first attempt
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitemsUpdates project to include new files and remove deleted platform-specific files
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.WaitOnAddress.csNew interop declarations for Windows WaitOnAddress and WakeByAddressSingle APIs
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CriticalSection.csAdds SuppressGCTransition attribute to LeaveCriticalSection
src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ConditionVariable.csAdds SuppressGCTransition attribute to WakeConditionVariable
src/libraries/Common/src/Interop/Unix/System.Native/Interop.LowLevelMonitor.csAdds SuppressGCTransition attributes to Release and Signal_Release
src/libraries/Common/src/Interop/Unix/System.Native/Interop.Futex.csNew interop declarations for Linux futex operations

Comment threadsrc/native/libs/System.Native/pal_threading.c
CopilotAI review requested due to automatic review settings February 3, 2026 00:16

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 17 out of 17 changed files in this pull request and generated 4 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c Outdated
CopilotAI review requested due to automatic review settings February 3, 2026 01:35

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 17 out of 17 changed files in this pull request and generated 6 comments.

Comment threadsrc/native/libs/System.Native/pal_threading.c
@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

One test that was affected by #123159 is
System.Buffers.Tests.RentReturnArrayPoolTests<Byte>.ProducerConsumer

The test involved one thread renting an array, mutating it, passing to another thread via one-element buffer, the other thread would inspect the buffer and release, and so on.
In particular there is a scenario where both sides wait synchronously on async result of a buffer operation. There is an occasional race condition when IsCompleted on the buffer operation returns false, but subsequent OnCompleted sees a completed async operation. Since it can`t attach continuation to already completed result, it posts a workitem to the threadpool and attaches continuation to that. As a result, once in a while one of the threads that plays buffer ping-pong effectively waits on the completion of such workitem.

Since the scenario needs to wait on a task only occasionally, depending on environment (CPU speed, memory speed, ...), it varies how frequently the need arises for such task, but generally the test is sensitive to threadpool spinning long enough to execute a task without waking a thread.

The results after this PR, vs baseline:

=== Linux x64
(azure VM, so it is what it is, but the test has little of guest/host interactions - not IO-heavy at all)

  • baseline:
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1.787 us0.0995 us0.1146 us1.800 us1.5178 us1.997 us0.005084 B
ProducerConsumer4096FalseFalseTrue1.836 us0.1398 us0.1610 us1.859 us1.3978 us2.057 us-82 B
ProducerConsumer4096FalseTrueFalse1.035 us0.0642 us0.0739 us1.052 us0.7293 us1.074 us--
ProducerConsumer4096FalseTrueTrue1.095 us0.0601 us0.0692 us1.122 us0.8213 us1.135 us--
ProducerConsumer4096TrueFalseFalse1.927 us0.0647 us0.0692 us1.939 us1.7744 us2.025 us-6 B
ProducerConsumer4096TrueFalseTrue1.952 us0.0584 us0.0672 us1.952 us1.7911 us2.045 us-2 B
ProducerConsumer4096TrueTrueFalse1.494 us0.0366 us0.0422 us1.491 us1.4217 us1.575 us--
ProducerConsumer4096TrueTrueTrue1.875 us0.0916 us0.1055 us1.879 us1.6830 us2.075 us--
  • after the PR:
    (mostly improvements, some scenarios really like long spins though....)
MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse1,706.1 ns122.59 ns141.18 ns1,709.4 ns1,486.7 ns1,921.7 ns0.005083 B
ProducerConsumer4096FalseFalseTrue1,737.3 ns134.77 ns155.20 ns1,723.8 ns1,558.4 ns2,107.2 ns-83 B
ProducerConsumer4096FalseTrueFalse855.6 ns25.55 ns28.40 ns860.4 ns740.1 ns869.3 ns--
ProducerConsumer4096FalseTrueTrue1,035.9 ns21.40 ns23.79 ns1,038.6 ns965.3 ns1,065.8 ns--
ProducerConsumer4096TrueFalseFalse1,450.8 ns53.79 ns57.55 ns1,446.4 ns1,326.9 ns1,572.2 ns-1 B
ProducerConsumer4096TrueFalseTrue2,383.5 ns261.28 ns300.89 ns2,273.3 ns2,037.4 ns3,040.7 ns-9 B
ProducerConsumer4096TrueTrueFalse1,503.2 ns57.55 ns66.28 ns1,512.2 ns1,375.6 ns1,584.2 ns--
ProducerConsumer4096TrueTrueTrue1,842.5 ns68.79 ns79.22 ns1,837.4 ns1,712.4 ns2,035.9 ns--

@VSadov

Copy link
Copy Markdown
MemberAuthor

Same tests on Windows:
(clearly an improvement)

BenchmarkDotNet v0.14.1-nightly.20250107.205, Windows 11 (10.0.26200.7623)
AMD Ryzen 9 7950X 4.50GHz, 1 CPU, 32 logical and 16 physical cores

=== baseline:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse552.4 ns23.25 ns25.84 ns549.7 ns514.5 ns601.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue734.0 ns26.71 ns29.69 ns730.6 ns671.1 ns795.4 ns0.002583 B
ProducerConsumer4096FalseTrueFalse325.9 ns13.82 ns14.19 ns325.1 ns304.5 ns358.8 ns--
ProducerConsumer4096FalseTrueTrue367.9 ns7.89 ns9.09 ns369.1 ns332.3 ns376.9 ns--
ProducerConsumer4096TrueFalseFalse1,390.5 ns447.74 ns515.62 ns1,050.2 ns959.7 ns2,085.7 ns-58 B
ProducerConsumer4096TrueFalseTrue1,380.9 ns32.70 ns37.65 ns1,386.5 ns1,286.9 ns1,437.0 ns-32 B
ProducerConsumer4096TrueTrueFalse886.0 ns17.08 ns18.28 ns889.2 ns852.0 ns922.1 ns--
ProducerConsumer4096TrueTrueTrue1,012.1 ns19.45 ns18.20 ns1,007.6 ns979.7 ns1,043.3 ns--

=== this PR:

MethodRentalSizeManipulateArrayAsyncUseSharedPoolMeanErrorStdDevMedianMinMaxGen0Allocated
ProducerConsumer4096FalseFalseFalse395.7 ns26.11 ns25.64 ns384.3 ns373.3 ns462.0 ns0.005084 B
ProducerConsumer4096FalseFalseTrue498.8 ns73.72 ns81.94 ns480.5 ns399.9 ns667.2 ns0.005083 B
ProducerConsumer4096FalseTrueFalse249.4 ns4.31 ns3.37 ns249.8 ns243.8 ns257.0 ns--
ProducerConsumer4096FalseTrueTrue321.5 ns5.30 ns4.42 ns319.3 ns317.4 ns330.1 ns--
ProducerConsumer4096TrueFalseFalse960.5 ns30.51 ns35.14 ns955.2 ns914.2 ns1,035.8 ns-5 B
ProducerConsumer4096TrueFalseTrue1,255.1 ns25.08 ns24.63 ns1,256.5 ns1,209.8 ns1,304.5 ns-30 B
ProducerConsumer4096TrueTrueFalse883.0 ns15.86 ns14.83 ns882.2 ns863.3 ns917.1 ns--
ProducerConsumer4096TrueTrueTrue1,056.4 ns19.67 ns18.40 ns1,059.9 ns1,014.1 ns1,087.3 ns--

@VSadov

VSadov commented Feb 3, 2026

Copy link
Copy Markdown
MemberAuthor

TE benchmarks seem to favor the change as well.

Unlike ProducerConsumer microbenchmark, TE does not like long threadpool spins, likely because there are non-threadpool threads like epoll threads.
This shows that threadpool heuristics can do the right adjustments.

Using command:

crank --config https://raw.githubusercontent.com/aspnet/Benchmarks/main/scenarios/json.benchmarks.yml --scenario json --profile aspnet-gold-lin --application.framework net11.0 --application.options.outputFiles <. . .>

=== Baseline:

| First Request (ms) | 172 |
| Requests/sec | 1,828,617 |
| Requests | 27,611,979 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 12.27 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 291.23 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

=== This PR:

| First Request (ms) | 171 |
| Requests/sec | 1,846,521 |
| Requests | 27,882,744 |
| Mean latency (ms) | 0.14 |
| Max latency (ms) | 7.00 |
| Bad responses | 0 |
| Socket errors | 0 |
| Read throughput (MB/s) | 294.08 |
| Latency 50th (ms) | 0.12 |
| Latency 75th (ms) | 0.16 |
| Latency 90th (ms) | 0.22 |
| Latency 99th (ms) | 0.37 |

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 17 out of 17 changed files in this pull request and generated no new comments.

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 17 out of 17 changed files in this pull request and generated 3 comments.

CopilotAI review requested due to automatic review settings February 14, 2026 22:22

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 21 out of 21 changed files in this pull request and generated no new comments.

@VSadov
VSadov merged commit fbf6be5 into dotnet:mainFeb 15, 2026
176 checks passed
@VSadov
VSadov deleted the lifo branch February 15, 2026 02:14
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!!!

iremyux pushed a commit to iremyux/dotnet-runtime that referenced this pull request Mar 2, 2026
…ation of LIFO policy. (dotnet#123921)
Re: dotnet#123159
Changes:
- Correctly handle `Backoff.Exponential(0)`. Embarrassing bug. To get exponentially growing random spin count for an iteration we
generate pseudorandom `uint` and do `>> (32 - attempt)`. Since C# masks
the shift operand with 31, when `attempt==0` we end up not shifting at
all, and the first iteration gets a large random spin count.
That caused many noisy results and interestingly some improvements (in
scenarios that benefit from very long spins).
- Unified implementation of LIFO policy with lightweight minimal
implementation of LIFO waiting.
Once we are done spinning, we block threads and when workers are needed
again wake them in LIFO order.
Unix WaitSubsystem is pretty heavy for these needs. It supports
Interruptible waits, waiting on multiple objects, etc... None of that is
interesting here. Most calls into the subsystem take a global
process-wide lock which can contend under load with other uses, or a
worker-waking threads may contend with the workers going to sleep,
etc...
Windows used an opaque `GetQueuedCompletionStatus` for the side effect
of releasing threads in LIFO order when completion is posted, with
unknown overheads and interactions, even though typically it is more
efficient than Unix WaitSubsystem.
The portable implementation seems to be faster than either of the
platform-specific ones.
(measured by disabling spinning and running a few latency-sensitive
benchmarks).
The portable implementation is also easier to reason about and to debug
anomalies.
- Adaptive spinning in the threadpool based on estimates of CPU core
availability.
Spinning in threadpool is very tricky and spinning benefits differ
greatly between scenarios. For some scenarios the longer the spin the
better. But there are scenarios that benefit when the threadpool
releases cores quickly once it sees no work. No preset fixed spin count
is going to be good for everything.
Adaptive approach appears to be necessary to improve some scenarios
without regressing many others.
We can further improve the heuristic, if there are more ideas.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
agocke added a commit that referenced this pull request Mar 5, 2026
…mplementation of LIFO policy." (#125193)
Reverts #123921
This change appears to have caused a large regression in NuGet restore
performance. Reverting is confirmed to produce a significant improvement
(10-15%).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Mar 17, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@VSadov@stephentoub@jkotas