A semaphore represents a fixed number of permits. At most N callers may hold one at the same time, which makes the primitive useful for bounded concurrency rather than single-owner exclusion. SemaphoreSlim is the usual in-process choice in .NET because waiting can be asynchronous through WaitAsync. The operating-system-backed Semaphore exists for synchronous and named cross-process coordination.
How It Works
A wait consumes one permit. Release puts it back:
Semaphore:WaitOneconsumes one permit.SemaphoreSlim:Wait/WaitAsyncconsumes one permit.- With no permits available, another caller waits.
- A release allows one waiter to compete for the returned permit.
System.Threading.Semaphorecan be named for cross-process coordination.SemaphoreSlimstays inside one process. Named semaphores are Windows-only, and construction with a name throwsPlatformNotSupportedExceptionon Linux and macOS.
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
- A leaked permit shrinks capacity. If an exception path skips
Release, the semaphore permanently admits fewer callers. With four permits, four leaks stop all future work. Acquisition and release belong in onetry/finallyscope. - An extra release expands capacity. A
SemaphoreSlimcreated without an explicitmaxCountcan grow beyond its intended limit. SettingmaxCountturns the mistake intoSemaphoreFullExceptioninstead of a silent throttle failure. - Waiters are not guaranteed FIFO order. A later caller may acquire before an earlier one. A bounded channel is a better model when queue order belongs to the contract.
- There is no owner or recursion count. Unlike Mutex or lock/Monitor, any code path may call
Release. A method that holds the only permit and then waits on the same semaphore blocks itself. One boundary should own the acquire/release pair. - Contention creates async waiter state. An immediately available permit is cheap. A blocked
WaitAsyncmust enqueue state for later completion. A bounded channel can combine the throttle with the queue when producer/consumer flow is the real problem.
Tradeoffs
SemaphoreSlimsupports asynchronous waiting in one process.Semaphoreis operating-system-backed and can be named on Windows.- A semaphore models capacity. A mutex or lock models one owner.
Task.WhenAllonly composes the tasks supplied to it. If callers eagerly create an unbounded set of async operations, all of them may reach the dependency beforeWhenAllobserves completion. Acquiring a semaphore inside each operation limits how many enter the protected work at once.
SemaphoreSlim is the direct choice for an in-process async throttle. A named Semaphore only earns its heavier boundary when Windows processes on the same machine must share the count. Ordering or buffering turns the problem into a bounded channel rather than a bare permit counter.
Questions
When is
SemaphoreSlima better fit thanlock?
SemaphoreSlimfits asynchronous work or a resource that may allow more than one operation at a time.WaitAsynclets a caller wait for a permit without blocking a thread, and the permit can be released after anawait. Alockis better for a short synchronous critical section that allows only one thread at a time. Because a semaphore has no owner, its acquire and release still need one cleartry/finallyboundary.