AsyncSemaphore 2.3.0
dotnet add package AsyncSemaphore --version 2.3.0
NuGet\Install-Package AsyncSemaphore -Version 2.3.0
<PackageReference Include="AsyncSemaphore" Version="2.3.0" />
<PackageVersion Include="AsyncSemaphore" Version="2.3.0" />
<PackageReference Include="AsyncSemaphore" />
paket add AsyncSemaphore --version 2.3.0
#r "nuget: AsyncSemaphore, 2.3.0"
#:package AsyncSemaphore@2.3.0
#addin nuget:?package=AsyncSemaphore&version=2.3.0
#tool nuget:?package=AsyncSemaphore&version=2.3.0
AsyncSemaphore
An async semaphore featuring:
- Automatic releasing without try/finally blocks by utilising the IDisposable
usingpattern - At-most-once release per acquisition, even when a handle is copied, boxed, or disposed concurrently
- Pooled
IValueTaskSourcewaiters to reduce allocation during contention TryWaitand a blockingWaitalongsideWaitAsync, plus an opt-inUnpairedAsyncSemaphorefor signal-style use- Analyzers to help you implement the desired pattern
- An
IAsyncSemaphoreinterface for if you need to mock
Install
dotnet add package AsyncSemaphore
Usage
The library types are in the Semaphores namespace:
using Semaphores;
private readonly AsyncSemaphore _asyncSemaphore = new AsyncSemaphore(1);
public async Task MyMethod()
{
// Just assign the `IDisposable` returned from `WaitAsync` to a variable and use the using statement with it
using var lockHandle = await _asyncSemaphore.WaitAsync();
// Do whatever you want - Even if we throw exceptions, we'll release the semaphore once we leave this method's scope
await DoSomethingInsideLock();
}
or scoped:
private readonly AsyncSemaphore _asyncSemaphore = new AsyncSemaphore(1);
public async Task MyMethod()
{
// or create your own scope with {} braces - And after you leave that scope, your lock will be released
using (await _asyncSemaphore.WaitAsync())
{
await DoSomethingInsideLock();
}
await DoSomethingAfterLockReleased();
}
Try-acquire and blocking waits
Try-acquire
TryWait takes a permit only if one is available right now. It never blocks and never queues:
if (_asyncSemaphore.TryWait(out var lockHandle))
{
using (lockHandle)
{
DoSomethingInsideLock();
}
}
When TryWait returns true, dispose the returned handle with using to return the permit. When it returns false, no permit was taken and the returned handle is default; disposing it does nothing. Use TryWait instead of checking CurrentCount before a wait, because another caller can take the available permit between the check and the wait.
Blocking waits
Wait blocks the calling thread, for code paths where blocking is the intended behaviour. Prefer WaitAsync in async code. Calling Wait, or awaiting WaitAsync, gives you an AsyncSemaphoreReleaser that must be disposed with using. Blocking and async waiters share the same FIFO queue:
using var lockHandle = _asyncSemaphore.Wait(cancellationToken);
A blocked thread is woken directly by the thread that releases the permit, so it does not depend on the thread pool to make progress.
Timeouts and cancellation
Both Wait and WaitAsync accept a timeout and a cancellation token. For example, a synchronous operation can wait for up to five seconds:
public void MyBlockingMethod(CancellationToken cancellationToken)
{
using var lockHandle = _asyncSemaphore.Wait(TimeSpan.FromSeconds(5), cancellationToken);
DoSomethingInsideLock();
}
The async equivalent is using var lockHandle = await _asyncSemaphore.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);.
- A wait that times out throws
TimeoutException; it does not returnfalseas a timedSemaphoreSlim.Waitdoes. TimeSpan.Zeromakes a single immediate attempt and throwsTimeoutExceptionif no permit is available. UseTryWaitwhen you want a Boolean result instead.- Omitting the timeout, or passing
Timeout.InfiniteTimeSpan, waits without a time limit. - Cancellation throws
OperationCanceledException. A token that is already cancelled prevents acquisition even when a permit is available.
Timeouts and cancellation apply to acquiring the permit, not to the work inside the using scope. A failed wait gives you no handle to dispose. These timeout and cancellation rules also apply to UnpairedAsyncSemaphore.
Upgrading a hand-written IAsyncSemaphore
IAsyncSemaphore gained TryWait(out AsyncSemaphoreReleaser), Wait(CancellationToken) and Wait(TimeSpan, CancellationToken). This is a source and binary breaking change for hand-written implementations: a decorator or a fake must add all three members and be recompiled. Mocking libraries generate the members on their own. A decorator forwards each one to the semaphore it wraps:
public bool TryWait(out AsyncSemaphoreReleaser releaser) => _inner.TryWait(out releaser);
public AsyncSemaphoreReleaser Wait(CancellationToken cancellationToken = default) => _inner.Wait(cancellationToken);
public AsyncSemaphoreReleaser Wait(TimeSpan timeout, CancellationToken cancellationToken = default) => _inner.Wait(timeout, cancellationToken);
A fake that holds no real permits can return a default handle from both Wait overloads, because a default handle releases nothing. Its TryWait implementation must assign the out handle (which can also be default) and return a Boolean indicating the simulated acquisition result.
Releasing without a prior wait
AsyncSemaphore only hands out a release through the handle of a successful wait, which is what lets it guarantee one release per acquisition. When that pairing genuinely does not fit (a wake-up signal, or a permit broker whose ownership is tracked elsewhere), opt in to UnpairedAsyncSemaphore:
private readonly UnpairedAsyncSemaphore _signal = new UnpairedAsyncSemaphore(0);
// Called by the consumer. There is no release handle to dispose.
public async Task WaitForSignalAsync(CancellationToken cancellationToken)
{
await _signal.WaitAsync(cancellationToken);
}
// Called independently by the producer, whether or not it ever waited.
public void Signal() => _signal.Release();
UnpairedAsyncSemaphore runs on the same core, but its waits return no release handle:
| Member | Result | Behaviour |
|---|---|---|
WaitAsync |
ValueTask |
Asynchronously consumes one permit. |
Wait |
void |
Blocks the calling thread until it consumes one permit. |
TryWait() |
bool |
Consumes one permit immediately if available; otherwise returns false without queueing. |
Release() |
void |
Publishes one permit without requiring a prior wait. |
CurrentCount |
int |
Reports the number of currently available permits. |
Both WaitAsync and Wait have overloads taking an optional CancellationToken, or a TimeSpan timeout and an optional CancellationToken. For example, await _signal.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); consumes a signal without a using statement. UnpairedAsyncSemaphore implements IDisposable, but not IAsyncSemaphore, whose waits return release handles.
Unlike AsyncSemaphore, whose constructor requires a positive count, UnpairedAsyncSemaphore accepts an initial count of zero. Each Release() wakes one queued waiter in FIFO order, or leaves a permit available for a future wait. Signals therefore accumulate if the producer releases before the consumer waits. There is no configurable maximum count: it can grow beyond the initial count, up to int.MaxValue; releasing at that limit throws SemaphoreFullException. There is no Release(int) overload.
Its waits allocate no release handle, but contention, timeouts and cancellation can still allocate. Nothing stops a permit from being leaked or released twice, and the analyzers do not cover this type, so prefer AsyncSemaphore wherever the acquirer is also the releaser.
Analyzers
The AsyncSemaphore package includes Roslyn analyzers for AsyncSemaphore and IAsyncSemaphore usage. Wait gets the same handle-assignment and using checks as WaitAsync, without an await requirement. TryWait checks flag discarded or unused handles; callers must still ensure every successful acquisition is disposed.
See the analyzer guide for SEM0001–SEM0004, examples, and the limits of the TryWait checks.
Performance
All copies of a handle share the same atomic release decision. Default handles and repeated disposal are harmless; disposing a stale copy cannot release a later acquisition.
A gate with a single permit (new AsyncSemaphore(1)) holds that decision itself, as a 64-bit epoch that each release advances, so its acquisitions allocate nothing. A handle records the epoch it was acquired under and only that value can release, which keeps the guarantee for copies of any age. While blocking Wait callers are spinning on such a gate, and for a short while afterwards, its acquisitions fall back to the allocation below: a state private to the holder releases faster than a shared epoch next to readers that are hammering the count.
A gate with more than one permit can have several acquisitions outstanding at once, so each successful acquisition allocates one small release-state object (24 bytes).
Queued waiters are served in the order they arrived. On a single-permit gate, a caller that arrives while the permit is on its way to the next waiter may take it first. A waiter resumes on the thread pool, and until it does the gate is owned but idle. Behind a short critical section every caller that arrives in that gap would queue and pay the same hop in turn, so the waits convoy. A caller that is already running uses the gap instead. The waiter it overtook is served by its release, ahead of everything queued behind it, and a single wait is overtaken at most 16 times before the permit is handed to it directly. Waits with a timeout or a cancellable token, blocking waits, gates with more than one permit and UnpairedAsyncSemaphore are never overtaken.
Construction is cheap: the waiter queue is created on the first contended wait, so a gate that never contends (one per cache entry, stream, or tenant) does not pay for it. Waiter nodes that overflow the per-thread and per-instance slots go to one bounded pool shared by every semaphore in the process, so a short-lived gate builds no pool of its own and its nodes outlive it.
Contended waits reuse pooled IValueTaskSource nodes. Timed or cancellable waits may additionally allocate timers, registrations, and exceptions. The implementation is not allocation-free.
Run the benchmarks for your workload and runtime:
dotnet run --project AsyncSemaphore.Benchmark -c Release -- --filter "*Benchmarks*"
PairedAcquisitionBenchmarks tracks what a paired acquisition costs next to the SemaphoreSlim region it replaces, for a single permit and for several.
Earlier measurements of the unprotected struct releaser do not represent the copy-safe implementation.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Bcl.AsyncInterfaces (>= 6.0.0)
- System.Threading.Tasks.Extensions (>= 4.5.4)
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages (2)
Showing the top 2 NuGet packages that depend on AsyncSemaphore:
| Package | Downloads |
|---|---|
|
ModularPipelines
Write your pipelines in C#! |
|
|
DotnetModularPipelines
Write your pipelines in C#! |
GitHub repositories (1)
Showing the top 1 popular GitHub repositories that depend on AsyncSemaphore:
| Repository | Stars |
|---|---|
|
thomhurst/ModularPipelines
Write your pipelines in C# !
|
| Version | Downloads | Last Updated |
|---|---|---|
| 2.3.0 | 122 | 9/20/2026 |
| 2.2.0 | 97 | 9/19/2026 |
| 2.1.0 | 88 | 9/19/2026 |
| 2.0.0 | 3,625 | 9/1/2026 |
| 1.5.0 | 141,273 | 2/7/2026 |
| 1.4.6 | 150 | 2/7/2026 |
| 1.4.0 | 147 | 2/7/2026 |
| 1.3.273 | 133 | 2/7/2026 |
| 1.3.272 | 153 | 2/7/2026 |
| 1.3.0 | 89,168 | 12/1/2024 |
| 1.2.2 | 67,988 | 6/19/2024 |
| 1.2.1 | 237 | 6/19/2024 |
| 1.2.0 | 315 | 6/18/2024 |
| 1.1.0 | 239 | 6/18/2024 |
| 1.0.8 | 247 | 6/17/2024 |
| 1.0.6 | 216 | 6/17/2024 |
| 1.0.4 | 297 | 6/16/2024 |
| 1.0.3 | 326 | 6/16/2024 |
| 1.0.2 | 234 | 6/16/2024 |
| 1.0.0 | 259 | 6/16/2024 |