SemaphoreSlim internals, WaitAsync with timeouts and cancellation, async mutual exclusion, throttling HTTP calls, non-reentrancy and release discipline.
SemaphoreSlim is a lightweight, in-process counting semaphore that supports WaitAsync, so it works with async/await. With a count of 1 it is an async lock: await gate.WaitAsync(ct); try { ... } finally { gate.Release(); }. With a count of N it limits how many async operations run at once, for example ten concurrent calls to a partner API. It is not re-entrant and has no owner, so calling it again from inside the protected section deadlocks, and every successful wait must be paired with exactly one Release in finally.
"
SemaphoreSlimis a lightweight, in-process counting semaphore withWaitAsync, so waiting doesn't block a thread. That gives it two everyday jobs. With a count of 1 it's an async lock, the replacement forlockwhen the critical section containsawait. With a count of N it's a throttle: at most N concurrent calls to a partner API, the database or the file system. The pattern is always the same:await gate.WaitAsync(ct), thentry { ... } finally { gate.Release(); }, with thetrystarting only after the wait succeeds. Two things catch people out. It's not re-entrant: if the same flow waits on it again while holding it, it deadlocks itself. And it has no owner, so every successful wait must be paired with exactly oneRelease. I share one instance per protected resource, always pass a cancellation token or timeout, and for simple loops I often preferParallel.ForEachAsyncwithMaxDegreeOfParallelism."
lock / Lock |
SemaphoreSlim |
Semaphore (kernel) |
|
|---|---|---|---|
await inside the protected section |
No | Yes | No |
| Async waiting | No | WaitAsync |
No |
| Count > 1 (throttling) | No | Yes | Yes |
| Re-entrant | Yes | No | No |
| Ownership / thread affinity | Yes | No | No |
| Cross-process | No | No | Named (Windows) |
| Cost | Lowest | Low (spins, then waits) | Kernel call |
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct); // may throw OperationCanceledException: nothing to release
try
{
await ReloadCacheAsync(ct);
}
finally
{
_gate.Release();
}
}
private static readonly SemaphoreSlim PartnerLimit = new(10, 10); // shared across requests
public async Task<Quote> GetQuoteAsync(string sku, CancellationToken ct)
{
if (!await PartnerLimit.WaitAsync(TimeSpan.FromSeconds(2), ct))
throw new TimeoutException("Partner API is saturated"); // fail fast rather than queue forever
try { return await _client.GetQuoteAsync(sku, ct); }
finally { PartnerLimit.Release(); }
}
| Member | Behaviour |
|---|---|
WaitAsync() |
Waits indefinitely |
WaitAsync(CancellationToken) |
Throws OperationCanceledException if cancelled while waiting |
WaitAsync(TimeSpan[, ct]) |
Returns bool: release only if true |
Release() / Release(n) |
Returns previous count; above maxCount → SemaphoreFullException |
CurrentCount |
Free slots: useful as a saturation metric |
AvailableWaitHandle |
Lazily created WaitHandle for interop |
| Ordering | Not guaranteed FIFO: don't depend on it |
Dispose |
Only when nobody can be waiting (usually long-lived; often never disposed) |
| Pattern | Best for |
|---|---|
Shared SemaphoreSlim |
Many code paths sharing one limit (all calls to one dependency) |
Parallel.ForEachAsync |
A loop over a known collection |
Channel<T> with N readers |
Long-running pipelines with backpressure |
System.Threading.RateLimiting / ASP.NET Core rate limiter |
Requests per time window, per-client limits, queueing policies |
Polly / Microsoft.Extensions.Http.Resilience bulkhead |
Concurrency limit + queue + rejection policy as part of a resilience pipeline |
Our [configuration cache] wrapped a refresh in lock and, to make that possible, called the config service with HttpClient.GetAsync(...).Result. Every [five minutes], when the cache expired, the API stalled: the ThreadPool queue length spiked and requests timed out. A dump showed dozens of threads blocked on the lock and the owner blocked inside .Result. I replaced it with a SemaphoreSlim(1, 1): WaitAsync with the request's token, a double-check after acquiring (so only one caller reloads), and serving the stale value to everyone else while the refresh ran. The stalls disappeared, and we added a load test around cache expiry plus an analyser rule against .Result.
Checks practical async synchronisation skills.
Green flags
WaitAsync/try/finally Release pattern correctly.Parallel.ForEachAsync as an alternative.Red flags
lock around awaits or .Result.SemaphoreSlim
├─ m_currentCount free slots
├─ m_maxCount
├─ m_lockObjAndDisposed internal Monitor for sync waiters
└─ m_asyncHead/m_asyncTail linked list of TaskNode (async waiters)
WaitAsync():
lock internal; if count > 0 -> count--, return completed task (no allocation in common case)
else -> append TaskNode (a Task<bool>) to the async list, register cancellation/timeout, return it
Release():
lock internal; if async waiters -> complete the head TaskNode (asynchronously) ; else count++
WaitAsync returns an already-completed cached task.Wait() spins briefly before blocking on the internal monitor.Release.public async Task SaveAsync()
{
await _gate.WaitAsync();
try { await ValidateAsync(); } // ValidateAsync also does await _gate.WaitAsync() -> waits forever
finally { _gate.Release(); }
}
Design rule: public entry points take the gate; private helpers assume it's held (document it, or pass a token/marker parameter).
// WRONG: Release runs even if WaitAsync threw (cancelled) -> count increases
try
{
await _gate.WaitAsync(ct);
await WorkAsync(ct);
}
finally { _gate.Release(); }
// RIGHT
await _gate.WaitAsync(ct);
try { await WorkAsync(ct); }
finally { _gate.Release(); }
Passing maxCount to the constructor turns an accidental extra release into a loud SemaphoreFullException instead of silently raising concurrency.
public static class SemaphoreExtensions
{
public static async ValueTask<Releaser> LockAsync(this SemaphoreSlim s, CancellationToken ct = default)
{
await s.WaitAsync(ct).ConfigureAwait(false);
return new Releaser(s);
}
public readonly struct Releaser(SemaphoreSlim s) : IDisposable
{
public void Dispose() => s.Release();
}
}
using (await _gate.LockAsync(ct))
{
await SaveAsync(ct);
}
To serialise work per entity (per customer id), people create ConcurrentDictionary<string, SemaphoreSlim>. The trap: entries are never removed and the dictionary grows forever. Use reference counting and removal, a striped approach (semaphores[hash % N]), or a library such as AsyncKeyedLock.
Export CurrentCount (or max - CurrentCount as "in use") and wait time as metrics. A throttle that's always at zero free slots means the dependency is the bottleneck, or the limit is too low.
| Version | Change |
|---|---|
| .NET Framework 4.0 | SemaphoreSlim |
| .NET Framework 4.5 | WaitAsync |
| .NET Core 2.x+ | Lower-allocation async waits |
| .NET 6 | Parallel.ForEachAsync as a loop alternative |
| .NET 7 | System.Threading.RateLimiting and ASP.NET Core rate limiting |
| .NET 10 | Same API |
RefreshingCache and count loads.WaitAsync inside the try, cancel some waits, and watch CurrentCount exceed the initial count (or throw with maxCount).Parallel.ForEachAsync(..., new ParallelOptions { MaxDegreeOfParallelism = 5 }, ...).LockAsync helper and use it with using.using System.Threading;
var limiter = new SemaphoreSlim(initialCount: 5, maxCount: 5);
int inside = 0, peak = 0;
var tasks = Enumerable.Range(1, 50).Select(async id =>
{
await limiter.WaitAsync();
try
{
int now = Interlocked.Increment(ref inside);
InterlockedMax(ref peak, now);
await Task.Delay(200); // stands in for an HTTP call
}
finally
{
Interlocked.Decrement(ref inside);
limiter.Release();
}
});
await Task.WhenAll(tasks);
Console.WriteLine($"Peak concurrency: {peak}"); // 5
var cache = new RefreshingCache();
await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => cache.GetAsync()));
Console.WriteLine($"Loads: {cache.Loads}"); // 1
static void InterlockedMax(ref int target, int value)
{
int current;
while (value > (current = Volatile.Read(ref target)) &&
Interlocked.CompareExchange(ref target, value, current) != current) { }
}
public sealed class RefreshingCache
{
private readonly SemaphoreSlim _gate = new(1, 1);
private string? _value;
public int Loads;
public async Task<string> GetAsync(CancellationToken ct = default)
{
if (_value is { } v) return v;
await _gate.WaitAsync(ct);
try
{
if (_value is { } again) return again; // double-check after acquiring
await Task.Delay(100, ct); // load once
Interlocked.Increment(ref Loads);
return _value = "loaded";
}
finally
{
_gate.Release();
}
}
}
What happens: WaitAsync is inside the try, or the bool result of a timed wait is ignored.
Why it is wrong: a slot that was never taken is returned: concurrency silently increases.
Do instead: start the try after a successful wait; release only when the timed wait returned true.
SemaphoreSlim(1, 1)What happens: a method holding the gate calls another method that waits on it. Why it is wrong: not re-entrant: self-deadlock. Do instead: acquire once at the public entry point; helpers assume it's held.
Wait() in async codeWhat happens: _gate.Wait() inside an async method.
Why it is wrong: blocks a pool thread; contributes to starvation.
Do instead: await _gate.WaitAsync(ct).
What happens: var limiter = new SemaphoreSlim(10); inside the handler.
Why it is wrong: every request has its own limit, so nothing is throttled.
Do instead: share one instance per protected resource (field, static or singleton).
What happens: await _gate.WaitAsync(); in request code.
Why it is wrong: requests queue forever when the dependency is slow; client disconnects don't free waiters.
Do instead: pass the request token and/or a timeout and fail fast.
What happens: logging, serialisation and other calls happen while holding a count-1 gate. Why it is wrong: lengthens hold time and reduces throughput. Do instead: keep the protected section minimal.
What happens: ConcurrentDictionary<string, SemaphoreSlim> grows with every key.
Why it is wrong: memory leak.
Do instead: reference-counted removal, striping, or a dedicated library.
What happens: SemaphoreSlim expected to limit work across pods.
Why it is wrong: in-process only.
Do instead: distributed limiter, queue with fixed consumers, or DB/Redis-based limits.
What happens: code assumes waiters are served in arrival order.
Why it is wrong: ordering isn't guaranteed.
Do instead: use a Channel<T> when order matters.
What happens: a shared semaphore is disposed during shutdown while requests wait.
Why it is wrong: waiters get ObjectDisposedException or hang.
Do instead: keep it alive for the app's lifetime; cancel waiters via tokens on shutdown.
SemaphoreSlim is the everyday async coordination tool. I keep the protected section minimal, always pass a cancellation token, and expose CurrentCount as a metric so saturation is visible.
| Use | Configuration |
|---|---|
| Async mutual exclusion | new SemaphoreSlim(1, 1) |
| Limit downstream calls | new SemaphoreSlim(n, n) shared |
| Cache stampede guard | Count 1 plus double-check |
| Per-key locks | Dictionary of semaphores with cleanup, or a library |
A semaphore holds a count of available slots: WaitOne takes a slot (blocking if the count is zero) and Release returns one, so up to N threads can be inside at once. System.Threading.Semaphore is the kernel-backed version; it can be named for cross-process use on Windows (named semaphores are not supported on Linux or macOS). Unlike a Mutex, it has no owner: any thread can release it, and it is not re-entrant. Releasing past the maximum throws SemaphoreFullException. For in-process and async throttling, use SemaphoreSlim instead.
lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.
A Task is an object that represents an operation that will complete in the future, with a status, an optional exception and the ability to attach continuations. It is not a thread. Some tasks run code on a pool thread (Task.Run, called delegate tasks); many represent work with no thread at all, such as a pending network read, a timer (Task.Delay) or a TaskCompletionSource (promise tasks). You compose tasks with await, Task.WhenAll, Task.WhenAny and WaitAsync, and avoid blocking on them.
How to tell it (STAR)
Every [five minutes] our API stalled when the config cache expired. The dump showed threads queued on a lock while one thread blocked on an HTTP call inside it. I switched to an async semaphore with a double-check and served stale data during refresh. The stalls disappeared.
Last updated 31 Oct 2026.