Concurrency
Mutex vs semaphore: interview questions and how to answer them
A mutex has an owner and grants exclusive access; a semaphore is a counter of permits with no owner — which is why one can be reentrant and released only by its holder, and the other cannot.
Written and reviewed by Sahil Srivastav
What it actually is
The textbook answer is that a mutex allows one holder and a semaphore allows N, and a binary semaphore is therefore a mutex. That answer is wrong in the way that matters. The distinction is ownership: a mutex records which thread holds it, so it can detect reentrance, refuse release by a non-owner, and participate in priority inheritance. A semaphore records only a count, so any thread can release a permit it never acquired.
That single difference produces every practical consequence. A mutex can be recursive, because it knows the acquiring thread is already the holder. A semaphore cannot, because it has nothing to compare against — a second acquire from the same thread simply takes a second permit, and if only one exists, that thread deadlocks against itself. Conversely, a semaphore can be used for signalling between threads, where one thread releases what another acquires, which a mutex forbids.
So the choice is driven by the question you are answering. "Only one thread may be inside this section" is mutual exclusion — use a mutex or lock. "At most N of these operations may be in flight" is resource counting — use a counting semaphore. "Wake a waiter when work is available" is signalling — use a semaphore, a condition variable, or a blocking queue, never a mutex.
Why it matters in production
Because bounded concurrency is one of the highest-leverage reliability tools in a backend service, and a semaphore is how you implement it. A service calling a downstream that can handle 20 concurrent requests will, without a bound, send it 200 under a traffic spike and take it down — then time out, retry, and take it down again. A 20-permit semaphore in front of that call converts an outage into a queue and then into fast rejections, which is the bulkhead pattern.
Because misusing a binary semaphore as a mutex produces bugs that are hard to attribute. Nothing stops an unrelated code path from releasing a permit it never took, so the limit silently rises to two and the section is no longer exclusive. A mutex would have thrown `IllegalMonitorStateException`. Type safety here is real safety.
And because interviewers use the question to see whether you can go past the flashcard. Everyone knows "mutex is one, semaphore is many". The ownership answer, and the reentrance consequence that follows from it, is what separates a memorised comparison from understanding.
How it works
Ownership enables reentrance and release checks
A mutex stores the owning thread and a hold count. Reacquiring from the owner increments the count; releasing decrements it and only frees the mutex at zero. Release by a non-owner is an error the implementation can detect. A semaphore has neither piece of state, so both behaviours are impossible.
A counting semaphore is a permit pool, not a lock
Initialise it with N. `acquire` blocks while the count is zero; `release` increments. Because permits are fungible, the natural use is limiting how many of something can happen at once — concurrent downstream calls, in-flight uploads, connections to a legacy system that falls over above a threshold.
tryAcquire with a timeout is what makes it a bulkhead
Blocking indefinitely for a permit just moves the queue, and an unbounded queue of waiting threads is its own failure. `tryAcquire(0)` rejects immediately when saturated and `tryAcquire(50, MILLISECONDS)` allows a short wait. Fast rejection under saturation is what keeps a dependency's slowness from becoming your thread exhaustion.
Release must be in a finally block, without exception
A permit or lock not released on the exception path leaks, and the pool shrinks by one per failure until nothing can acquire. This is the single most common way a semaphore-limited path dies: it works for weeks and then a downstream starts throwing and the limiter drains to zero.
Semaphores can signal; mutexes cannot
Because release need not come from the acquirer, a semaphore expresses "producer signals, consumer waits". A mutex cannot: releasing one you do not hold is an error. For the producer-consumer case a `BlockingQueue` is usually better still, since it bounds the buffer and handles both directions.
Implementing it
Use a lock (`ReentrantLock`, `synchronized`, `std::mutex`) when the requirement is exclusivity over shared state, and let its ownership semantics catch your mistakes.
Use a counting semaphore for concurrency limits per dependency, sized to what the dependency can absorb rather than to what your service can generate. One semaphore per downstream, not one shared across all of them, or a slow dependency starves a fast one.
Always pair acquire with a `finally` release, and prefer a wrapper — a `withPermit` helper, try-with-resources, an RAII guard — so the pairing cannot be skipped by a new code path.
Do not size a semaphore by trial and error against your own latency. Apply Little's law: permits ≈ target throughput × expected latency, then verify the downstream can sustain it.
// Bulkhead: bound in-flight calls to one dependency, reject fast when saturated
private final Semaphore permits = new Semaphore(20);
Quote fetch(Request r) throws InterruptedException {
if (!permits.tryAcquire(50, TimeUnit.MILLISECONDS)) {
throw new DependencySaturatedException("pricing"); // shed, do not queue
}
try {
return pricingClient.call(r);
} finally {
permits.release(); // must be finally, or the pool drains on errors
}
}
// Ownership is the real difference
ReentrantLock lock = new ReentrantLock();
lock.unlock(); // IllegalMonitorStateException: not the owner
Semaphore s = new Semaphore(1);
s.release(); // permits == 2 now; exclusivity silently goneInterview questions and how to answer them
What is the difference between a mutex and a semaphore?
Ownership. A mutex records which thread holds it, so it can be reentrant, can reject an unlock from a non-owner, and can support priority inheritance. A semaphore is just a permit count with no notion of who took what, so any thread may release and reentrance is meaningless. The count difference — one versus N — falls out of their intended uses rather than being the definition: a binary semaphore allows one holder but is still not a mutex, because releasing it from the wrong thread is legal and silently breaks exclusivity.
You need to cap concurrent calls to a fragile downstream at 20. How?
A 20-permit semaphore around the call, acquired with a short timeout and released in a `finally`. The timeout is the important part: blocking forever on a permit turns the downstream's slowness into my thread pool filling with waiters, whereas failing fast after 50 ms sheds load and keeps my service responsive. I would size the permits from the downstream's measured capacity — Little's law gives permits ≈ throughput × latency — and expose the rejection count as a metric, because that number is the earliest signal that the dependency is degrading.
Can a semaphore be reentrant?
No, and the reason is structural: reentrance requires knowing that the thread asking is already the holder, and a semaphore stores only a count. A thread that acquires a one-permit semaphore twice blocks on its own permit, with no owner information for a detector to notice. That is precisely why a recursive code path must be guarded by a reentrant lock, not by a binary semaphore.
When is a semaphore the right tool but a lock is not?
Two cases. When the constraint is "at most N in flight" rather than "one at a time" — connection limits, upload slots, bounded fan-out. And when the acquire and the release happen on different threads, which a mutex forbids by design: a producer releasing a permit that a consumer acquires is a legitimate signalling pattern. For that second case I would usually reach for a `BlockingQueue` instead, since it bounds the buffer and handles both directions without hand-rolled counting.
What goes wrong if a permit is not released?
The pool shrinks permanently. Each leaked permit lowers the effective limit by one, so the symptom is a service that behaves correctly for a long time and then, after a burst of downstream errors, stops making that call at all — every acquire times out and the limiter reads as saturated with zero requests in flight. It is easy to misread as a downstream outage. The defences are a `finally` release, a helper that owns the pairing so no call site can get it wrong, and a gauge on `availablePermits()` so a monotonic decline is visible before it reaches zero.
Answers that lose the round
- Giving only "one versus many" as the difference and missing ownership, which is the property every practical consequence follows from
- Claiming a binary semaphore is a mutex: it has no owner, so it is not reentrant and any thread can release it
- Using a semaphore to guard shared state and letting an unrelated path release a permit, silently raising the limit
- Acquiring a permit outside a try and releasing inside it, so an exception between the two leaks a permit permanently
- Recursively acquiring a single-permit semaphore, which deadlocks the thread against itself with no detector to report it
- Blocking indefinitely on `acquire` under saturation, converting a downstream slowdown into total thread exhaustion
- Sharing one semaphore across several dependencies, so the slowest one consumes all the permits
FAQ
Is `synchronized` a mutex?
Yes — a reentrant one, owned by the entering thread, released automatically when the block exits, including on an exception. The trade-offs versus `ReentrantLock` are that you cannot time out, cannot interrupt a waiter, and cannot have multiple condition queues; what you gain is that release cannot be forgotten.
What is a condition variable and where does it fit?
It is the waiting mechanism that pairs with a mutex: you hold the lock, find the state is not yet what you need, and `await`, which atomically releases the lock and parks you until signalled. It is the right primitive for "wait until a predicate holds", which neither a mutex nor a bare semaphore expresses. The rule is always to wait in a loop that rechecks the predicate, because spurious wakeups are permitted.
Is a rate limiter the same as a semaphore?
No, and conflating them leads to the wrong limit. A semaphore bounds *concurrency* — how many are in flight at once. A rate limiter bounds *arrivals per unit time*. A downstream that dies above 20 simultaneous requests needs a semaphore; an API that allows 100 calls per second regardless of concurrency needs a token bucket.