Counting semaphores, System.Threading.Semaphore as a kernel object, named semaphores and platform limits, no thread affinity, SemaphoreFullException and use cases.
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.
"A semaphore manages capacity: it holds a count of available slots.
WaitOnetakes a slot, blocking when the count is zero, andReleasegives one back, so up to N threads can be inside at the same time. That makes it a throttle: 'at most 10 concurrent connections', 'at most 2 GPU jobs per machine'. The big difference from aMutexis ownership. A mutex is owned by a thread: only that thread can release it, it's re-entrant, and abandonment is detected. A semaphore has no owner: any thread can release, it isn't re-entrant (each wait consumes a slot), there's no abandonment detection, and releasing past the maximum throwsSemaphoreFullException.System.Threading.Semaphoreis kernel-backed and can be named for cross-process limits, but only on Windows. For anything in-process, especially async code, I useSemaphoreSlim, which is cheaper and supportsWaitAsync."
| Operation | Effect |
|---|---|
new Semaphore(initialCount: 3, maximumCount: 3) |
3 slots available, cap 3 |
WaitOne() |
If count > 0 → decrement and continue; else block |
WaitOne(timeout) |
Same, returns false on timeout |
Release() / Release(n) |
Increment by 1 / n, wake waiters; returns the previous count |
Release() beyond max |
SemaphoreFullException |
Mutex |
Semaphore |
|
|---|---|---|
| Holders at once | 1 | Up to N |
| Ownership | Owning thread | None |
| Re-entrant | Yes | No: a second wait takes another slot |
| Release from another thread | ApplicationException |
Allowed |
| Abandonment detection | AbandonedMutexException |
No: slots of a crashed holder are lost |
| Named / cross-process | Yes (Windows, Linux, macOS) | Windows only |
| Typical use | Exclusive access with ownership | Capacity limits, signalling between producer and consumer |
Semaphore |
SemaphoreSlim |
|
|---|---|---|
| Backing | Kernel object | User-mode (spins, then waits) |
| Cost | Kernel call per operation | Very cheap uncontended |
| Async | No | WaitAsync |
| Named / cross-process | Yes (Windows) | No |
WaitHandle (WaitAny/WaitAll) |
Yes | Only via AvailableWaitHandle |
| Use for | Cross-process limits on Windows | Everything in-process |
| Upside | Downside |
|---|---|
A producer can Release slots that consumers wait on (signalling) |
A double Release silently raises effective concurrency (until the max) |
Work can be released on a different thread (async-friendly in SemaphoreSlim) |
A crashed process holding slots of a named semaphore never gives them back |
| No re-entrancy: a method that waits twice can deadlock itself |
| Need | Tool |
|---|---|
| Limit async calls in one process | SemaphoreSlim |
| Limit work across processes on one Windows machine | Named Semaphore |
| Same on Linux/macOS | File-lock-based scheme, a local coordinator service, or a distributed limiter |
| Limit across servers | Distributed rate limiter / queue with fixed consumers |
| Exclusive access with ownership | lock / Mutex |
On our [Windows render farm], each node ran [four] worker processes that launched GPU-heavy jobs, and the GPU ran out of memory whenever more than [two] jobs overlapped. Each process already had an in-process SemaphoreSlim(2), but those couldn't see each other, so four processes could still run eight jobs. I introduced a named Global\Contoso.GpuJobs semaphore with count [2], shared by all worker processes and released in finally. Because a crashed worker would leak its slot, I added a node watchdog that restarted the worker service (resetting the semaphore) if slots stayed exhausted with no running jobs, plus a GPU-memory alert. The out-of-memory crashes stopped.
Checks understanding of counting semaphores and ownership.
Green flags
SemaphoreSlim for async.Red flags
new Semaphore(2, 2) count = 2
T1 WaitOne count = 1
T2 WaitOne count = 0
T3 WaitOne blocks
T1 Release -> returns 0 count = 1 -> T3 wakes, count = 0
T2 Release -> returns 0 count = 1
T3 Release -> returns 1 count = 2
extra Release SemaphoreFullException
new Semaphore(0, 10) starts with no slots: waiters block until someone releases. That turns a semaphore into a counting signal: each Release means "one more item is ready". This is how some producer/consumer queues are built; Channel<T> or BlockingCollection<T> are usually better.
WaitHandle integrationWaitHandle[] handles = [gpuSlots, shutdownEvent];
int which = WaitHandle.WaitAny(handles, TimeSpan.FromMinutes(1));
if (which == 0) { try { RunGpuJob(); } finally { gpuSlots.Release(); } }
Also usable with ThreadPool.RegisterWaitForSingleObject to run a callback when a slot becomes available without blocking a thread.
Global\ / Local\ prefixes control session visibility, like mutexes.new Semaphore(..., name: "x") throws PlatformNotSupportedException (unnamed semaphores work). Named mutexes are emulated on Unix, named semaphores are not.if (!slots.WaitOne(TimeSpan.FromSeconds(30)))
throw new TimeoutException("No GPU slot available");
try
{
RunJob();
}
finally
{
slots.Release(); // exactly once, only after a successful wait
}
Rules: release only if the wait succeeded, release exactly once, always in finally. For named semaphores, plan crash recovery (watchdog, service restart, or a lease-based design).
| Operation (uncontended) | Approximate cost |
|---|---|
SemaphoreSlim.Wait/Release |
Tens of nanoseconds |
lock |
Tens of nanoseconds |
Semaphore.WaitOne/Release |
Microseconds (kernel transitions) |
Measure on your hardware; the point is the order-of-magnitude difference.
| Version | Change |
|---|---|
| .NET Framework 2.0 | System.Threading.Semaphore |
| .NET Framework 4.0 | SemaphoreSlim |
| .NET Framework 4.5 | SemaphoreSlim.WaitAsync |
| .NET Core | Named semaphores supported on Windows only |
| .NET 10 | Unchanged |
SemaphoreFullException with an extra release.new Semaphore(0, 5) and use it as a signal: a producer releases once per item and a consumer waits once per item.PlatformNotSupportedException.Semaphore vs SemaphoreSlim wait/release pairs with BenchmarkDotNet.WaitHandle.WaitAny with a semaphore and a shutdown ManualResetEvent.using System.Threading;
using var gpuSlots = new Semaphore(initialCount: 2, maximumCount: 2);
// On Windows, a name makes it machine-wide: new Semaphore(2, 2, @"Global\Contoso.GpuJobs")
var workers = Enumerable.Range(1, 5).Select(id => new Thread(() =>
{
gpuSlots.WaitOne();
try
{
Console.WriteLine($"Job {id} started");
Thread.Sleep(500);
}
finally
{
gpuSlots.Release();
}
})).ToList();
workers.ForEach(t => t.Start());
workers.ForEach(t => t.Join());
try
{
gpuSlots.Release(); // count is already at the maximum
}
catch (SemaphoreFullException)
{
Console.WriteLine("Released too many times");
}
Semaphore for in-process throttlingWhat happens: new Semaphore(10, 10) limits outgoing HTTP calls in an API.
Why it is wrong: kernel call per operation and no async support.
Do instead: SemaphoreSlim with WaitAsync.
What happens: Release() in finally even when WaitOne(timeout) returned false.
Why it is wrong: the count grows above intended concurrency or throws SemaphoreFullException.
Do instead: release only after a successful wait.
What happens: both a catch and a finally call Release.
Why it is wrong: silently allows more concurrency than designed.
Do instead: exactly one Release in finally.
What happens: code ported from Windows uses a named semaphore in a Linux container.
Why it is wrong: PlatformNotSupportedException.
Do instead: another cross-process mechanism or a distributed limiter.
What happens: a Semaphore(1,1) is used where re-entrancy or ownership is assumed.
Why it is wrong: not re-entrant (self-deadlock) and no abandonment detection.
Do instead: Mutex/lock when ownership matters; SemaphoreSlim(1,1) for async mutual exclusion without re-entrancy.
What happens: a worker process dies holding a named semaphore slot. Why it is wrong: capacity is permanently reduced until restart. Do instead: watchdogs, restarts, or lease-based designs.
What happens: WaitOne() everywhere.
Why it is wrong: leaked slots turn into silent hangs.
Do instead: timeouts with logging and a clear failure path.
What happens: a semaphore counts "items available" while a separate list holds items.
Why it is wrong: two structures to keep consistent; easy to race.
Do instead: Channel<T> or BlockingCollection<T>.
What happens: code expects waiters to be served in order. Why it is wrong: no FIFO guarantee. Do instead: use a queue when order matters.
What happens: new Semaphore(0, 5) used as a throttle.
Why it is wrong: everyone blocks: there are no initial slots.
Do instead: new Semaphore(5, 5) for a throttle; (0, N) for signalling.
Semaphores are about capacity, not ownership. I use the kernel Semaphore only for named, Windows-only cross-process limits, and I treat crash recovery of slots as part of the design.
| Need | Primitive |
|---|---|
| Limit async calls in a process | SemaphoreSlim |
| Limit across processes on Windows | Named Semaphore |
| Limit across servers | Distributed rate limiter or queue |
| Exclusive with ownership | Mutex or lock |
A Mutex is an operating-system mutual-exclusion object. Unlike lock, it can be named and shared between processes on the same machine, which is its main use: single-instance applications or protecting a shared file. It is much slower than lock because every acquire involves the kernel, it is thread-affine (the owning thread must release it) and re-entrant. If the owner exits without releasing, the next waiter gets AbandonedMutexException, which tells you the protected state might be inconsistent. It is not suitable for async code.
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.
How to tell it (STAR)
Our [render nodes] crashed when too many GPU jobs overlapped. Each process had its own limit, so they could not coordinate. I used a named machine-wide semaphore with [two] slots and added a watchdog for leaked slots. The out-of-memory crashes stopped.
Last updated 31 Oct 2026.