Repository navigation
Conversation
When a permit is available and nobody is waiting, WaitAsync now acquires it with a single CAS on m_currentCount instead of taking the Monitor lock. The lock path is kept out of line (NoInlining WaitAsyncCoreSlow) so the fast path has no EH and the JIT emits it without frame setup or lock bookkeeping. Because the fast path decrements m_currentCount without the lock: - m_waitCount and m_asyncHead are volatile so the fast path's waiter checks are acquire-ordered. - Locked decrements (WaitCore, WaitAsyncCoreLocked) use a CAS loop (TryDecrementCount), and WaitCore retries if it loses the race. - Release validates m_maxCount against a re-read snapshot and applies only the net delta with Interlocked.Add, so it never overwrites a concurrent fast-path decrement or exposes permits reserved for waiters it is releasing. - AvailableWaitHandle publishes the handle before reading the count, and the fast path resets it if it raced with initialization. Only the multithreaded implementation gets the fast path; the single-threaded implementation gains a plain TryDecrementCount for the shared locked path. Supersedes dotnet#125452, re-applied on top of the MultiThreading/SingleThreading split from dotnet#135903.
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
thomhurst
marked this pull request as draft
October 29, 2026 01:04
Contributor
|
Tagging subscribers to this area: @JulieLeeMSFT, @VSadov |
This was referenced Oct 29, 2026
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #125452. That PR no longer applied after #135903 split
SemaphoreSlimintoSemaphoreSlim.MultiThreading.cs/SemaphoreSlim.SingleThreading.cs, so this re-applies the same change on top of the new layout as a single commit (plus one codegen improvement, below).Summary
SemaphoreSlim.WaitAsyncalways takes theMonitorlock, even when a permit is available and nobody is waiting. This adds a lock-free fast path: whenm_waitHandle is null, the count is positive and there are no sync or async waiters, the permit is taken with a single CAS onm_currentCountand the cachedTask<bool>is returned.Only the multithreaded implementation gets the fast path. The single-threaded implementation is unchanged apart from a plain
TryDecrementCountused by the shared locked path.Correctness
Because the fast path decrements
m_currentCountwithout the lock:m_waitCountandm_asyncHeadarevolatile, so the fast path's waiter checks are acquire-ordered.WaitCore,WaitAsyncCoreLocked) use a CAS loop (TryDecrementCount).WaitCoreretries if the fast path wins the race between its wait and its decrement.Releasenever stores an absolute count. It validatesm_maxCountagainst a re-read snapshot (a racing fast-path decrement can only lower the count, so this can't throwSemaphoreFullExceptionspuriously). It then applies only the net delta, after removing the permits it handed directly to async waiters, withInterlocked.Add. So it can't overwrite a concurrent fast-path decrement, and the fast path can't take a permit that was reserved for a waiter.AvailableWaitHandlepublishes the handle unsignaled and then reads the count after a full barrier. A fast path that races with initialization resets the handle under the lock.Stress tests in
SemaphoreSlimTestscover concurrent fast path vs.WaitCore, bulkRelease, async-waiter handoff vs.m_maxCount, cancellation racing acquire, and concurrentAvailableWaitHandleinitialization.Codegen
The fast path stays in
WaitAsyncCore, and thelockmoves to aNoInliningWaitAsyncCoreSlow(the rare wait-handle reset moves toResetWaitHandleIfEmpty). With no try/finally in the method, the JIT emits the fast path without an rbp frame, callee-saved pushes orlockTakenzero-initialization, and tail-calls the slow path on a miss. On x64 Tier1,WaitAsync()is 134 bytes, down from 393 when the fast path and the lock share one method.Performance
Measured on x64 Windows (22 logical cores), .NET 10. The implementations were copied into a standalone BenchmarkDotNet project so baseline and patched code run side by side in the same process.
Uncontended
WaitAsync()+Release(), 6 launches, no allocations:Contended, one permit, N async workers looping
await WaitAsync(); Release();(5 launches):The 2-worker case is unresolved. It flips between a mode where the fast path keeps succeeding and a convoy mode where every acquire is handed to a queued waiter. The mean depends on how often a run falls into the convoy, so results vary widely between runs. Across BenchmarkDotNet runs this PR came out between 1.09× and 1.36× of main, always with large error bars. A 3-second throughput loop showed parity (main 493 ns/op, this PR 481 ns/op). I can't rule out a small regression at 2 workers from local data, so the EgorBot runs below include that case.
For the contended case generally (the question raised on #125452): with 8 workers,
SemaphoreSlim's own code is under 5% of trace samples. The cost is dominated by the thread-pool hop for each FIFO handoff to a queued async waiter, and queued waiters still take the existing locked path, which this PR doesn't change.New EgorBot runs (Intel/AMD/ARM64) to follow in the comments.