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: WaitOne consumes one permit.
  • SemaphoreSlim: Wait/WaitAsync consumes one permit.
  • If no permits are available, callers wait.
  • Release returns a permit and wakes a waiter.
  • System.Threading.Semaphore can be named for cross-process coordination; SemaphoreSlim is in-process only. Named (cross-process) semaphores are Windows-only — constructing one on Linux/macOS throws PlatformNotSupportedException, so don’t rely on them for cross-platform IPC (same caveat as named Mutex).

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 Release in 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 in finally.
  • Over-release inflates concurrency — for SemaphoreSlim without an explicit maxCount, calling Release without a matching Wait silently increases the permit count beyond your intended limit. Your “max 10 concurrent” throttle quietly becomes 11, then 12. With explicit maxCount, over-release throws SemaphoreFullException — which is noisy but at least detectable. Always set maxCount and keep acquire/release symmetry in one scope.
  • No fairness guaranteeSemaphoreSlim does 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 matching Wait, which makes leaks hard to trace (instrument Wait/Release in production throttling code). The flip side is there is no recursion count — a method holding the only permit that calls another method which also WaitAsyncs the same semaphore self-deadlocks. Take the permit once at the top of the call chain.
  • WaitAsync allocates under contention — an immediately-available permit is cheap, but when callers have to wait, WaitAsync enqueues an async waiter (a Task/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

  • SemaphoreSlim vs Semaphore: SemaphoreSlim is lighter and async-friendly in-process; Semaphore supports 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

References