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. Named (cross-process) semaphores are Windows-only — constructing one on Linux/macOS throwsPlatformNotSupportedException, so don’t rely on them for cross-platform IPC (same caveat as namedMutex).
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, use a bounded channel instead. - No ownership tracking, and not reentrant — unlike Mutex or Monitor, semaphores have no thread affinity: any code path can
Release, even without a matchingWait, which makes leaks hard to trace (instrumentWait/Releasein production throttling code). The flip side is there is no recursion count — a method holding the only permit that calls another method which alsoWaitAsyncs the same semaphore self-deadlocks. Take the permit once at the top of the call chain. WaitAsyncallocates under contention — an immediately-available permit is cheap, but when callers have to wait,WaitAsyncenqueues an async waiter (aTask/state object) per caller. On a very hot throttle this allocation shows up; for high-throughput producer/consumer flows a bounded channel (which also gives true FIFO) is often the better primitive.
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.
Default to SemaphoreSlim for anything in-process — it is the only one of these that supports await. Reach for the named Semaphore only when the count has to be shared across processes on one machine, and then only on Windows. If you also need ordering or buffering rather than just a count, a bounded channel is the better primitive.
Questions
When should you choose
SemaphoreSlimoverlock?Choose
SemaphoreSlimwhen critical sections includeawaitand you need asynchronous waiting.lockcannot containawaitsafely.
Why is a semaphore useful for fan-out HTTP calls?
It limits in-flight requests, protecting downstream dependencies and your own resources from overload while preserving concurrency.
What bug pattern most often breaks semaphore-based code?
Missing or unbalanced
Releasecalls. The safe pattern is acquire thentry/finallyrelease in the same method scope.
References
- Semaphore class (Microsoft Learn) — the OS-backed, nameable variant; the ctor overloads and the Windows-only constraint on named semaphores.
- SemaphoreSlim class (Microsoft Learn) — the in-process async variant:
WaitAsyncoverloads,CurrentCount, and theSemaphoreFullExceptioncontract on over-release. - Overview of synchronization primitives (Microsoft Learn) — where the semaphore sits among
lock,Mutex, and the rest, with a decision table. - Threading in C#: Event wait handles, mutexes, and semaphores (Joe Albahari) — mechanism-level treatment of
WaitHandle-based waiting and the slim vs OS-backed split.