threading: lock-free fast path for SemaphoreSlim.WaitAsync#125452
Open
thomhurst wants to merge 16 commits intodotnet:mainfrom
Open
threading: lock-free fast path for SemaphoreSlim.WaitAsync#125452thomhurst wants to merge 16 commits intodotnet:mainfrom
thomhurst wants to merge 16 commits intodotnet:mainfrom
Conversation
Author
|
@EgorBot -intel -amd -arm using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class SemaphoreSlimUncontended
{
private SemaphoreSlim _sem = new SemaphoreSlim(1, 1);
[Benchmark]
public async Task WaitAsync_Release()
{
await _sem.WaitAsync();
_sem.Release();
}
} |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces a lock-free fast path in SemaphoreSlim.WaitAsync that attempts to acquire an available permit via CAS, avoiding taking m_lockObjAndDisposed when uncontended.
Changes:
- Added a CAS-based fast path to decrement
m_currentCountwhen a permit appears immediately available. - Added special-case handling to keep
AvailableWaitHandlestate consistent if it’s initialized concurrently during the fast-path acquire.
Member
|
Doesn't lock itself has fast paths for that? |
Author
|
@EgorBot -intel -amd -arm using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class SemaphoreSlimUncontended
{
private SemaphoreSlim _sem = new SemaphoreSlim(1, 1);
[Benchmark]
public async Task WaitAsync_Release()
{
await _sem.WaitAsync();
_sem.Release();
}
} |
Author
|
@EgorBo I think just by not entering the lock we can save some time: EgorBot/Benchmarks#31 |
Use Interlocked.Add to apply a relative delta to m_currentCount rather than writing back an absolute snapshot-derived value, so concurrent lock-free decrements from the WaitAsync fast path are not overwritten.
Replace plain --m_currentCount with a CAS loop to prevent a double grant when the lock-free WaitAsync fast path decrements m_currentCount between the > 0 check and the decrement in the slow path. WaitCore is safe because m_waitCount++ on lock entry blocks the CAS guard for its entire critical section. WaitAsyncCore has no such protection.
Apply the same CAS-loop pattern to WaitCore's m_currentCount decrement that was applied to WaitAsyncCore in the previous commit. A fast-path thread that read m_waitCount = 0 before WaitCore's m_waitCount++ can still race with WaitCore's check-at-404 / decrement-at-407 sequence. The CAS loop serializes both operations on m_currentCount atomically.
fb8aeb0 to
419dbb0
Compare
… stress test The assert !waitSuccessful || m_currentCount > 0 in WaitCore could fire spuriously in Debug builds: the lock-free WaitAsync fast path runs outside the lock, so it can decrement m_currentCount to 0 between WaitUntilCountOrTimeout returning and the assert executing. Adds a stress test that races AvailableWaitHandle lazy initialization against WaitAsync fast-path acquires and verifies the handle is never signaled when CurrentCount == 0.
…t WaitAsync fast path
…phoreSlim Also fix CS0420 in Release(): Volatile.Read(ref volatile_field) triggers a compiler error in the coreclr project build; replaced with a plain field read (already volatile) so the testhost can be rebuilt with the fixed implementation.
…cessor task is always cancelled
- Extract duplicated CAS-decrement loop into TryDecrementCount() with AggressiveInlining, replacing inline copies in WaitCore and WaitAsyncCore - Strengthen Assert.InRange to Assert.Equal in NeverUnderflows test - Add bulk Release(2) concurrent stress test for Interlocked.Add delta math - Add cancellation-during-fast-path stress test for count integrity - Use m_currentCount (post-Add) instead of netCount for m_waitHandle.Set() - Add UncontendedSync and MixedSyncAsync benchmarks for sync path coverage
Prevents the background task from pegging a CPU core in CI while still exercising concurrent lazy initialization of the wait handle.
Author
|
@EgorBot -intel -amd -arm using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class SemaphoreSlimUncontended
{
private SemaphoreSlim _sem = new SemaphoreSlim(1, 1);
[Benchmark]
public async Task WaitAsync_Release()
{
await _sem.WaitAsync();
_sem.Release();
}
} |
Member
|
@thomhurst, please address the copilot feedback. |
… file WaitAsync(CancellationToken) returns Task, not Task<bool>; the prior declaration didn't compile. Removed PerformanceTests/SemaphoreSlimBenchmarks.cs since it had no csproj and wasn't wired into any project; runtime perf benchmarks live in dotnet/performance, and the EgorBot inline benchmarks posted on the PR cover the relevant scenarios.
Author
|
@JulieLeeMSFT Pushed 526f1c4 to address the remaining Copilot threads |
The comment narrated what the next several lines do; the variable name and surrounding structure already convey it.
Address review hazards in the WaitAsync CAS fast path: - Make m_waitCount and m_asyncHead volatile. The fast path reads them without the lock; sync-waiter writes inside the lock must publish via release semantics rather than depending on the lock release that the fast path bypasses. Without this, ARM64 can let the fast path observe m_waitCount == 0 while a sync waiter is parked, stealing the slot and leaving the waiter blocked. - Restructure AvailableWaitHandle init to publish-then-reflect: publish the handle unsignaled, full barrier, then conditionally Set based on the post-publish count read. Closes the race where ManualResetEvent allocation overlapped a fast-path CAS, leaving the handle Set with count == 0. - WaitCore: loop instead of falling through with a stale waitSuccessful when TryDecrementCount loses to a fast-path acquirer. Fixes the case where Wait(Infinite) could return without owning a permit (silently dropped by the void overload, lying about acquisition for bool overloads). - Release: use Interlocked.Add's return value for the MRE.Set sentinel so a fast-path decrement racing between the Add and the re-read doesn't mask the 0 -> positive transition. - Strengthen the AvailableWaitHandle init test: allocate a fresh SemaphoreSlim per iteration so each iteration is a real attempt at the race. With a single semaphore the race only fires on the first AvailableWaitHandle access.
Defensive: the prior placement was correct because a thrown OCE always short-circuits before re-entry, but moving 'oce' (and 'timedOut', already loop-local) inside makes the freshness invariant explicit and robust to future edits.
Author
|
@EgorBot -intel -amd -arm using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class SemaphoreSlimUncontended
{
private SemaphoreSlim _sem = new SemaphoreSlim(1, 1);
[Benchmark]
public async Task WaitAsync_Release()
{
await _sem.WaitAsync();
_sem.Release();
}
} |
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.
Use a lock-free CAS fast path in SemaphoreSlim.WaitAsync to skip the Monitor lock when a permit is immediately available, improving uncontended throughput