Skip to content

threading: lock-free fast path for SemaphoreSlim.WaitAsync - #136616

Draft
thomhurst wants to merge 1 commit into
dotnet:mainfrom
thomhurst:semaphoreslim-fastpath
Draft

thomhurst wants to merge 1 commit into
dotnet:mainfrom
thomhurst:semaphoreslim-fastpath

Conversation

@thomhurst

Copy link
Copy Markdown
Contributor

Supersedes #125452. That PR no longer applied after #135903 split SemaphoreSlim into SemaphoreSlim.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.WaitAsync always takes the Monitor lock, even when a permit is available and nobody is waiting. This adds a lock-free fast path: when m_waitHandle is null, the count is positive and there are no sync or async waiters, the permit is taken with a single CAS on m_currentCount and the cached Task<bool> is returned.

Only the multithreaded implementation gets the fast path. The single-threaded implementation is unchanged apart from a plain TryDecrementCount used by the shared locked path.

Correctness

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.
  • Decrements under the lock (WaitCore, WaitAsyncCoreLocked) use a CAS loop (TryDecrementCount). WaitCore retries if the fast path wins the race between its wait and its decrement.
  • Release never stores an absolute count. It validates m_maxCount against a re-read snapshot (a racing fast-path decrement can only lower the count, so this can't throw SemaphoreFullException spuriously). It then applies only the net delta, after removing the permits it handed directly to async waiters, with Interlocked.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.
  • AvailableWaitHandle publishes 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 SemaphoreSlimTests cover concurrent fast path vs. WaitCore, bulk Release, async-waiter handoff vs. m_maxCount, cancellation racing acquire, and concurrent AvailableWaitHandle initialization.

Codegen

The fast path stays in WaitAsyncCore, and the lock moves to a NoInlining WaitAsyncCoreSlow (the rare wait-handle reset moves to ResetWaitHandleIfEmpty). With no try/finally in the method, the JIT emits the fast path without an rbp frame, callee-saved pushes or lockTaken zero-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:

Mean Ratio
main 35.6 ns 1.00
this PR 18.6 ns 0.54

Contended, one permit, N async workers looping await WaitAsync(); Release(); (5 launches):

Workers main this PR
2 466 ± 127 ns 583 ± 186 ns (bimodal, see below)
4 727 ns 583 ns (0.82)
8 585 ns 600 ns (1.03, within noise)

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.

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.
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Oct 29, 2026
@azure-pipelines

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

@thomhurst
thomhurst marked this pull request as draft October 29, 2026 01:04
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

This branch has not been deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant