Semaphore
Intro
Semaphore controls concurrent access by allowing up to N holders at once, unlike Mutex and lock which allow exactly one. This is the right primitive when you need bounded parallelism — for example, limiting an HTTP client to 10 concurrent outbound requests to avoid overwhelming a downstream API (which returns 429 at 15 concurrent connections), or capping database connection usage below the pool maximum during batch processing. In modern .NET, SemaphoreSlim is preferred for in-process async workflows because it supports WaitAsync and avoids kernel transitions.
How It Works
A semaphore tracks a permit count:
Semaphore:WaitOneconsumes one permit.SemaphoreSlim:Wait/WaitAsyncconsumes one permit.- If no permits are available, callers wait.
Releasereturns a permit and wakes a waiter.System.Threading.Semaphorecan be named for cross-process coordination;SemaphoreSlimis in-process only.
Example
using var gate = new SemaphoreSlim(initialCount: 4, maxCount: 4);
await gate.WaitAsync(cancellationToken);
try
{
await ProcessAsync(cancellationToken);
}
finally
{
gate.Release();
}
Named Semaphore for cross-process bounded access:
// Limit 3 concurrent processes accessing a shared resource
const string SemName = "MyApp.ResourceGate";
using var sem = new Semaphore(initialCount: 3, maximumCount: 3, name: SemName);
if (!sem.WaitOne(TimeSpan.FromSeconds(5)))
throw new TimeoutException("Could not acquire semaphore slot.");
try
{
AccessSharedResource();
}
finally
{
sem.Release();
}
Pitfalls
- Leaked permits stall all waiters — forgetting
Releasein an exception path permanently reduces available permits. With a maxCount of 4, one leaked permit drops throughput by 25%; four leaked permits deadlock the system. Always release infinally. - Over-release inflates concurrency — for
SemaphoreSlimwithout an explicitmaxCount, callingReleasewithout a matchingWaitsilently increases the permit count beyond your intended limit. Your "max 10 concurrent" throttle quietly becomes 11, then 12. With explicitmaxCount, over-release throwsSemaphoreFullException— which is noisy but at least detectable. Always setmaxCountand keep acquire/release symmetry in one scope. - No fairness guarantee —
SemaphoreSlimdoes not guarantee FIFO ordering under contention. A request that arrives later can acquire the permit before an earlier waiter, causing starvation in pathological cases. If ordering matters, useChannel<T>as a bounded queue. - No ownership tracking — unlike
Mutex, semaphores do not track which thread/task acquired a permit. Any code path can callRelease, even without a matchingWait. This makes debugging permit leaks harder — instrument with logging aroundWait/Releasein production throttling code.
Tradeoffs
SemaphoreSlimvsSemaphore:SemaphoreSlimis lighter and async-friendly in-process;Semaphoresupports named cross-process coordination.- Semaphore vs mutex/lock: semaphore allows bounded parallelism; mutex/lock allows only one owner at a time.
- Semaphore vs unbounded
Task.WhenAll: semaphore caps pressure on dependencies and connection pools at the cost of a little orchestration complexity.
Questions
SemaphoreSlim over lock?
Choose SemaphoreSlim when critical sections include await and you need asynchronous waiting. lock cannot contain await safely.
It limits in-flight requests, protecting downstream dependencies and your own resources from overload while preserving concurrency.
Missing or unbalanced Release calls. The safe pattern is acquire then try/finally release in the same method scope.
Links
- Semaphore class (Microsoft Learn)
- SemaphoreSlim class (Microsoft Learn)
- Overview of synchronization primitives (Microsoft Learn)
- Threading in C#: Event wait handles, mutexes, and semaphores (Joe Albahari)