📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a Semaphore in .NET, and how does it differ from a Mutex?

Counting semaphores, System.Threading.Semaphore as a kernel object, named semaphores and platform limits, no thread affinity, SemaphoreFullException and use cases.

Mid-Level Theory Intermediate kernel-objects semaphore synchronization throttling

🎯 Key points

  • A semaphore lets up to N holders in at once
  • WaitOne takes a slot; Release returns one
  • No ownership: any thread can release; not re-entrant
  • Releasing past the maximum throws SemaphoreFullException
  • Named Semaphore is cross-process on Windows only
  • Semaphore is a WaitHandle and works with WaitAny/WaitAll
  • Use SemaphoreSlim for in-process and async throttling

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"A semaphore manages capacity: it holds a count of available slots. WaitOne takes a slot, blocking when the count is zero, and Release gives 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 a Mutex is 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 throws SemaphoreFullException. System.Threading.Semaphore is kernel-backed and can be named for cross-process limits, but only on Windows. For anything in-process, especially async code, I use SemaphoreSlim, which is cheaper and supports WaitAsync."


1. How a counting semaphore works

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

2. Semaphore vs Mutex

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

3. Semaphore vs SemaphoreSlim

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

4. Consequences of "no owner"

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

5. Choosing

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

Production example

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.

🔍 What the interviewer is checking

Checks understanding of counting semaphores and ownership.

Green flags

  • Explains counts and maximum.
  • Contrasts ownership and re-entrancy with Mutex.
  • Knows the Windows-only named limitation.
  • Recommends SemaphoreSlim for async.

Red flags

  • Thinks a semaphore has an owner.
  • Uses kernel semaphores for request throttling.

📚 Learn in depth

1. Counting semantics, step by step

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

2. Initial vs maximum count

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.

3. WaitHandle integration

WaitHandle[] 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.

4. Platform notes

  • Windows: named semaphores are kernel objects; Global\ / Local\ prefixes control session visibility, like mutexes.
  • Linux / macOS: new Semaphore(..., name: "x") throws PlatformNotSupportedException (unnamed semaphores work). Named mutexes are emulated on Unix, named semaphores are not.
  • Containers don't share kernel objects with each other by default anyway.

5. Leak safety

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).

6. Cost comparison (order of magnitude)

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.

7. Version notes

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

8. Try it yourself

  1. Run the code example and confirm at most two jobs run at once; trigger SemaphoreFullException with an extra release.
  2. Create new Semaphore(0, 5) and use it as a signal: a producer releases once per item and a consumer waits once per item.
  3. On Linux, try to create a named semaphore and observe PlatformNotSupportedException.
  4. On Windows, run two processes sharing a named semaphore of count 1; kill one while it holds the slot and observe the other blocking forever.
  5. Benchmark Semaphore vs SemaphoreSlim wait/release pairs with BenchmarkDotNet.
  6. Use WaitHandle.WaitAny with a semaphore and a shutdown ManualResetEvent.

💻 Code

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");
}

⚠ Common mistakes

1. Kernel Semaphore for in-process throttling

What 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.

2. Releasing without a successful wait

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.

3. Double release on error paths

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.

4. Expecting named semaphores on Linux

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.

5. Treating count-1 semaphore as a mutex

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.

6. Ignoring leaked slots after crashes

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.

7. Waiting without timeouts

What happens: WaitOne() everywhere. Why it is wrong: leaked slots turn into silent hangs. Do instead: timeouts with logging and a clear failure path.

8. Using a semaphore where a queue is needed

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>.

9. Assuming fairness

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.

10. Confusing initial and maximum counts

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate contrasts ownership with Mutex Why: Checks Mutex behaviour
    How to answer

    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.

  2. When: Candidate recommends SemaphoreSlim for async Why: Probes async throttling
    How to answer

    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.

🛠 Real-world problems: how you handle them

Limiting GPU jobs across worker processes
Situation
A [Windows render node] ran [four] worker processes that each started GPU-heavy jobs.
Signals
GPU out-of-memory crashes when more than [two] jobs ran together.
Investigation
Job logs showed overlaps from different processes; in-process `SemaphoreSlim` limits could not see the other processes.
Resolution
Introduced a named `Global\` semaphore with count [2] shared by all worker processes, released in `finally`.
Prevention
Added a watchdog to restart the node service if slots leaked after a crash, and a GPU memory alert.

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.

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me