The question this answers
When the two primitives look identical at N = 1, what actually differs, and which failures does each one make possible?
Two jobs in one service: guarding a shared in-memory index against concurrent mutation, and signalling from a background loader to a request handler that the index has finished loading.
The index structure itself, guarded in the first job. In the second job nothing is guarded — the shared thing is a *readiness condition*, and the two tasks are different tasks: one signals, the other waits.
For the index: at most one mutator inside the region, and the mutating task is the one that leaves. For readiness: a handler proceeds only after the loader has published a complete index — never before, and not indefinitely after.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Ownership is the difference everything else follows from
A mutex is *owned*. The runtime records which task holds it, and that task is the one required to release it. From that one property everything distinctive follows: reentrancy is definable (the owner may re-acquire, since it already excludes everyone else), priority inheritance is implementable (the scheduler can boost the owner because it knows who that is), release-by-a-non-owner is an error the implementation can detect, and deadlock detection can build a wait-for graph because every edge has a known endpoint.
A semaphore is *counted*. There is no owner, only an integer. Any task may release, including one that never acquired — and that is not a bug in the design, it is the feature that makes signalling possible. The loader releases a permit that the handler acquires; the two are different tasks and the count is the message.
So the practical rule is not about the value of N. It is about *shape*: use a mutex when the concept is "I am protecting a region and I will leave it myself"; use a semaphore when the concept is "there are N of something" or "someone else will tell me when". A binary semaphore used as a mutex works and quietly discards ownership, which means it also discards the error detection, the reentrancy and the priority handling that came with it.
| Property | Mutex | Counting semaphore | Why it matters |
|---|---|---|---|
| Ownership | the acquiring task owns it | none — just a count | Ownership is what makes the remaining rows possible or impossible. |
| Who may release | only the owner (others are an error or undefined behaviour) | anyone, including a task that never acquired | The mutex catches a bug; the semaphore allows a technique. Same behaviour, opposite intent. |
| Reentrancy | definable, and available in reentrant variants | never — a second acquire from the same task just consumes another permit | A recursive call under a binary semaphore self-deadlocks with no diagnostic. |
| Priority inheritance | possible — the scheduler knows whom to boost | impossible — there is nobody to boost | This is why real-time systems mandate mutexes for exclusion. See Priority Inversion. |
| Natural use | protect a critical section | limit concurrent access to N resources, or signal between tasks | Choose by the concept, not by the count. |
| Deadlock detection | tractable — a wait-for graph with known holders | much harder — a waiter cannot name who owes it a permit | A hung semaphore acquire tells you nothing about who to blame. |
| Typical failure | deadlock, convoy, forgotten release | leaked permit, double release, unbounded waiter queue | Double release silently raises the limit above the real resource capacity. |
| Cross-task signalling | no — the owner must release | yes — this is the point | The producer/consumer handoff needs a primitive with no owner. |
Signalling: the thing a mutex cannot do
The clearest way to see that these are different primitives is to write something a mutex cannot express. The index loader runs at startup; request handlers must wait until it is done. With a semaphore initialised to zero, each handler acquires — and blocks, because the count is zero — and the loader releases once loading completes. Task A waits, task B signals. There is no owner and there cannot be one, because the signaller was never inside anything.
Try the same with a mutex and it does not work: a mutex acquired by the loader and released by the loader is exclusion, not signalling, and a mutex acquired by the loader and released by a handler is an error in most implementations and undefined behaviour in C++. The primitive is the wrong shape for the job.
The schedule below traces both roles in one service to make the contrast concrete: a mutex protecting the index against two mutators, and a zero-initialised semaphore carrying readiness from the loader to a handler. Then it shows the failure that appears when a binary semaphore is used for the first job — a reentrant call that a mutex would have handled and a semaphore deadlocks on, permanently, on a single task.
| # | Loader task | Handler task | Mutator task (recursive path) | State |
|---|---|---|---|---|
| 1 | · | ready.acquire() — count is 0, so park | · | readyCount=0 indexLoaded=no |
| 2 | load index from disk (3 s) | · | · | readyCount=0 indexLoaded=no |
| 3 | ready.release() — count 0 → 1, wakes H | · | · | readyCount=1 indexLoaded=yes |
| 4 | · | resumes; count 1 → 0; serves the request | · | readyCount=0 indexLoaded=yes |
| 5 | · | · | indexLock.acquire() [MUTEX] — enters the mutation region | indexLock=held by M depth=1 |
| 6 | · | · | calls rebuildBucket(), which acquires indexLock again [REENTRANT MUTEX] | indexLock=held by M depth=2 |
| 7 | · | · | returns; depth 2 → 1; returns; releases | indexLock=free depth=0 |
| 8 | · | · | SWAP: same code with a binary SEMAPHORE. acquire() — count 1 → 0 | permits=0 depth=1 |
| 9 | · | · | rebuildBucket() calls acquire() again — count is 0, so M parks | permits=0 depth=1 ✕ M is waiting for a permit that only M can release, and M is parked. Self-deadlock on a single task, with no cycle, no second actor and no diagnostic — a wait-for graph cannot even be drawn, because a semaphore waiter cannot name who owes it a permit. |
What each language actually gives you
The API surface makes the distinction obvious in some languages and hides it in others. C++ separates them cleanly and documents the undefined behaviour. Python offers both plus an RLock for the reentrant case, and its documentation is explicit that a Semaphore may be released by any thread. JavaScript ships neither, because a single-threaded runtime has no need for exclusion within a synchronous block — so every JS "mutex" is a userland promise queue, and the distinction has to be maintained by convention.
The one place people get burned across all of them: a binary semaphore reads as a drop-in mutex and passes review. It compiles, it excludes, and it silently removes owner checking, reentrancy and priority inheritance. When the recursive path is added six months later, the process hangs with a single task blocked on a permit only it could return.
1#include <mutex>2#include <semaphore>3 4std::mutex index_mtx; // owned. not reentrant.5{ std::lock_guard g(index_mtx); mutate(); } // RAII: released on every path6// index_mtx.unlock() from another thread -> undefined behaviour7// index_mtx.lock() twice on one thread -> undefined behaviour8std::recursive_mutex rec_mtx; // the reentrant variant, explicitly9 10std::counting_semaphore<1> ready{0}; // no owner. starts empty.11void loader() { build_index(); ready.release(); } // signaller never acquired12void handler() { ready.acquire(); serve(); } // different task, legal13 14// std::binary_semaphore is counting_semaphore<1>. It is NOT a mutex:15// no ownership, no reentrancy, no priority inheritance, and no error16// if a thread that never acquired calls release().The cleanest separation of the four. std::mutex is explicitly non-reentrant and both misuse cases are undefined behaviour rather than exceptions, so the compiler will not save you.
1import threading2 3index_lock = threading.Lock() # owned-ish: release() from a non-owner raises4with index_lock: mutate() # scope-bound5 6rec_lock = threading.RLock() # reentrant: the SAME thread may re-acquire7with rec_lock:8 with rec_lock: # fine. a plain Lock() would deadlock here.9 mutate()10 11ready = threading.Semaphore(0) # no owner. any thread may release.12def loader(): build_index(); ready.release()13def handler(): ready.acquire(); serve()14 15# For readiness that must wake EVERY waiter, a semaphore is wrong:16ready_evt = threading.Event() # set() wakes all current and future waiters17def loader2(): build_index(); ready_evt.set()18def handler2(): ready_evt.wait(); serve()Python makes the three cases explicit: Lock for exclusion, RLock when the same thread re-enters, Semaphore for counting or signalling — and Event when readiness must reach all waiters rather than one.
1// No built-in mutex or semaphore, because a synchronous block already2// runs to completion. What you need is coordination ACROSS awaits.3 4// "Mutex": a promise chain. Ownership exists only by convention.5let tail = Promise.resolve()6function withLock(fn) {7 const run = tail.then(fn, fn)8 tail = run.catch(() => {}) // never let a rejection poison the chain9 return run10}11 12// Semaphore: a permit count plus a queue of resolvers.13class Semaphore {14 #n; #q = []15 constructor(n) { this.#n = n }16 acquire() { return this.#n-- > 0 ? Promise.resolve()17 : new Promise(r => this.#q.push(r)) }18 release() { this.#n++; this.#q.shift()?.() } // anyone may call this19}20 21// Readiness for many waiters is just a promise everyone awaits:22const ready = buildIndex() // one promise, resolved once23async function handler() { await ready; serve() }Neither primitive exists natively; both are userland. The promise-chain "mutex" has no owner at all, so reentrancy is a guaranteed self-deadlock and no runtime error will tell you.
1// Same runtime as JavaScript. Types can at least make the distinction2// visible at call sites, which is the only enforcement available.3 4interface Mutex { withLock<T>(fn: () => Promise<T>): Promise<T> } // no release()5interface Permits { acquire(): Promise<void>; release(): void } // release is public6 7// The Mutex interface deliberately offers no release(): the only way to8// leave the region is to return from the callback, so no path can forget9// it and no other task can call it. That is ownership, expressed the only10// way this runtime allows.11 12declare const indexLock: Mutex13await indexLock.withLock(async () => { mutate() })14 15declare const dbPermits: Permits // 20 permits; released by whoever holds one16try { await dbPermits.acquire(); await query() } finally { dbPermits.release() }The types cannot enforce ownership at runtime, but shaping the mutex API as a scope-bound callback with no public release makes both the forgotten-release and the released-by-another-task bugs unrepresentable at the call site.
- C++ separates them explicitly and makes both misuses undefined behaviour, so nothing at runtime will diagnose them.
- Python distinguishes
Lock,RLock,SemaphoreandEventas four different tools, which is the clearest mapping of concept to primitive of the four languages. - JavaScript and TypeScript have neither natively; both are userland constructions with no ownership at all, so reentrancy self-deadlocks silently.
- Only the semaphore can be released by a task that did not acquire it — which is exactly why it, and not a mutex, is the signalling primitive.
- One release wakes one waiter in every implementation. Readiness that must reach all waiters is a latch, an event or a resolved promise, never a semaphore.
Key points
- A mutex has an owner; a semaphore has a count. Every other difference follows from that.
- Only the owner may release a mutex. Anyone may release a semaphore — which is the feature that makes cross-task signalling possible.
- Reentrancy is definable only for an owned primitive. A recursive call under a binary semaphore self-deadlocks a single task with no cycle and no diagnostic.
- Priority inheritance requires an owner to boost, so real-time systems mandate mutexes for exclusion.
- Choose by concept, not by count: "I am protecting a region and will leave it myself" is a mutex; "there are N of these" or "someone will tell me when" is a semaphore.
- A semaphore release wakes exactly one waiter. Readiness that must reach every waiter is a latch, an event or a resolved promise.
- Deadlock involving a semaphore is harder to diagnose because a waiter cannot name who owes it a permit — there is no wait-for edge to draw.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Mutex: acquire records the calling task as owner and blocks others; release verifies (or assumes) the caller is the owner, clears ownership and wakes one waiter.
- • Reentrant mutex: acquire by the current owner increments a recursion count instead of blocking; release decrements and only frees the lock at zero.
- • Semaphore: acquire atomically decrements a count and parks if it would go negative; release atomically increments and wakes one waiter, with no check on who is calling.
- • Because a semaphore has no owner, its count can rise above its initial value through a stray release, permanently increasing the effective limit.
- • Because a mutex has an owner, the scheduler can implement priority inheritance and the runtime can build a wait-for graph for deadlock detection.
- • Mutex, two mutators: A acquires, mutates, releases; B blocks then proceeds. Exclusion holds and the leaving task is the entering task.
- • Reentrant mutex, recursive mutator: A acquires (depth 1), calls a helper that acquires (depth 2), unwinds to 0 and releases. Correct.
- • Binary semaphore, same recursive mutator: A acquires (count 0), the helper acquires and parks. A is waiting for a permit only A can return — self-deadlock on one task.
- • Semaphore signalling: handler acquires on a zero count and parks; loader releases; handler wakes. Two different tasks, no ownership, exactly the intended behaviour.
- • Semaphore signalling with two handlers: the loader releases once and one handler wakes; the second waits forever. The permit was consumed. Use a latch or an event.
- • Stray double release on a 20-permit semaphore: the count reaches 21 and the pool now exceeds the resource's real capacity, with errors surfacing at the database rather than in your queue.
- • Mutex guarantees mutual exclusion, an owner, and a release-to-acquire happens-before edge. Most implementations also guarantee an error (or explicitly, undefined behaviour) on release by a non-owner.
- • Mutex does NOT guarantee reentrancy unless it is a reentrant variant —
std::mutexandthreading.Lockdeadlock on re-acquisition by the same task. - • Semaphore guarantees at most N holders and the same memory-visibility edge. It does NOT guarantee an owner, reentrancy, or that the count stays at or below its initial value.
- • Neither guarantees fairness by default. A waiter can be overtaken indefinitely in typical implementations of both.
- • Neither guarantees anything across processes. Both are process-local unless you are using a named OS semaphore or an equivalent kernel object.
- • Both contend on a single atomic cell for the count or lock word; at very high acquisition rates that cache line is the bottleneck for either primitive.
- • A mutex serialises everything in the region, so contention is bounded by hold time × arrival rate. A semaphore with N permits allows N-way concurrency, so the same arrival rate produces roughly 1/N the queueing.
- • A binary semaphore used as a mutex has the same contention profile as a mutex and none of its diagnostic affordances, so it is strictly worse when contention becomes a problem.
- • Semaphore waiters are cheaper to reason about but easier to accumulate: the queue is unbounded by default and each waiter holds its own request state.
- • Self-deadlock from re-entry under a binary semaphore — one task, no cycle, no wait-for edge, and no runtime error.
- • Release by a non-owner on a mutex: an exception in Python, undefined behaviour in C++, and a silently broken invariant in a userland JavaScript lock.
- • Double release on a semaphore, raising the effective limit above the real resource capacity — the mirror of a permit leak and much harder to spot.
- • Priority inversion under a semaphore, unfixable because there is no owner to boost. See Priority Inversion.
- • Lost signal when a semaphore is used for readiness and the release happens before any handler waits — the permit is banked, so the first waiter proceeds and later ones block forever. See Lost Wakeups: The Notify That Arrived Before the Wait.
- • One-waiter-only signalling: a release wakes one task while the team assumed it woke all of them.
- • Mutex when the concept is a critical section with a single entering-and-leaving task — which is most exclusion in application code.
- • Reentrant mutex when a guarded operation may legitimately call another guarded operation on the same object.
- • Semaphore when the number is a real resource limit, or when the pattern is a handoff between distinct tasks.
- • Semaphore for producer/consumer counting, where the count of available items *is* the coordination. See Producer / Consumer.
- • A binary semaphore standing in for a mutex: it works until someone adds a recursive path, and it discards ownership checking, reentrancy and priority inheritance for nothing gained.
- • A mutex where signalling is needed — it cannot express "another task will tell me when", and forcing it produces a busy-wait or a release-by-non-owner bug.
- • A semaphore for broadcast readiness, where one release wakes one waiter and the rest hang.
- • Either primitive when the real constraint is on another machine; both are process-local. See A Mutex on Server A Does Nothing About Server B.
- • For a mutex: hold time and wait time at p99, plus contended-acquire fraction. Thread dumps name the owner directly, which is the diagnostic advantage of ownership.
- • For a semaphore: available permits as a gauge, plus acquire and release counts as separate counters — their divergence is a leak, and release exceeding acquire is a double release.
- • A hung task blocked on a semaphore acquire with no named holder is the signature of the ownership gap; if diagnosis matters, prefer the owned primitive.
- • In review, one question settles the choice: "who releases this, and is it always the same task that acquired it?" If the answer is "someone else", it must be a semaphore; if it is "the same task", it should be a mutex.
- • Two primitives with overlapping behaviour means every call site carries an implicit claim about which concept applies, and nothing enforces it.
- • Reentrancy has to be a deliberate choice — a reentrant mutex hides a design where a guarded operation calls another, which is worth knowing about even when it is safe.
- • The semaphore's permissive release is power that must be paired with discipline: scope-bound acquisition for the resource-limiting case, and a deliberate design for the signalling case.
- • In JavaScript and TypeScript both primitives are userland, so their semantics are whatever your utility implements, and reviewers must read that implementation rather than a specification.
- • A condition variable when the requirement is "wait until a predicate is true", which is neither exclusion nor counting. See Condition Variables: Waiting Until a Predicate Is True.
- • A latch or event for one-shot readiness that must reach all waiters. See Latches & Countdowns.
- • A channel or queue for a handoff, which carries the data as well as the signal and removes the shared state entirely. See Channels and Message Passing.
- • A single owner task for the exclusion case, which needs no primitive at all because only one task ever touches the state. See The Actor Model.
- • An atomic operation when the guarded region is a single-variable read-modify-write. See Atomics: What Is Actually Indivisible.
10,000 tasks, N permits
A mutex buys correctness with throughput
wait() in an if, or wait() in a while
lock()
if (items == 0): # checked once, before sleeping
cond.wait(lock) # releases the lock, sleeps, reacquires
take(item) # <- assumes the predicate is still true
unlock()| # | Producer | Consumer 1 | Consumer 2 | Runtime | State |
|---|---|---|---|---|---|
| 1 | · | lock(); if (items == 0) wait() | · | · | items=0 waiters=1 |
| 2 | · | · | lock(); if (items == 0) wait() | · | items=0 waiters=2 |
| 3 | lock(); items = 1; notifyAll(); unlock() | · | · | · | items=1 waiters=0 |
| 4 | · | proceed: take(item) | · | · | items=0 waiters=0 |
| 5 | · | · | proceed: take(item) | · | items=-1 waiters=0 ✕ two consumers took one item — items = -1 |
What people believe, and what is true
A binary semaphore is just a mutex.
It admits one task and provides no ownership, no reentrancy, no priority inheritance and no error on release by a non-owner. Every one of those is a diagnostic you gave up.
A mutex is just a semaphore with one permit.
It is the other way round and the difference is directional: a semaphore can do things a mutex cannot (signal across tasks), and a mutex can do things a semaphore cannot (re-enter, inherit priority, name its owner).
Releasing a semaphore from another thread is a bug.
It is the intended use for signalling. Whether it is a bug depends entirely on whether the semaphore represents a resource you hold or a message you send.
One release() wakes everyone who is waiting.
It wakes exactly one waiter, in every implementation. Broadcast readiness needs a latch, an event, or a resolved promise.