Synchronization Primitives

Semaphore versus Mutex: Not the Same Primitive

A binary semaphore and a mutex both admit one task at a time, which is where the similarity ends. A mutex has an owner; a semaphore has a count. That single difference decides reentrancy, priority inheritance, who is allowed to release, whether the primitive can signal across tasks, and which failures are possible at all.

▶ Run the lab

The question this answers

The question

When the two primitives look identical at N = 1, what actually differs, and which failures does each one make possible?

The work

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.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

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.

PropertyMutexCounting semaphoreWhy it matters
Ownershipthe acquiring task owns itnone — just a countOwnership is what makes the remaining rows possible or impossible.
Who may releaseonly the owner (others are an error or undefined behaviour)anyone, including a task that never acquiredThe mutex catches a bug; the semaphore allows a technique. Same behaviour, opposite intent.
Reentrancydefinable, and available in reentrant variantsnever — a second acquire from the same task just consumes another permitA recursive call under a binary semaphore self-deadlocks with no diagnostic.
Priority inheritancepossible — the scheduler knows whom to boostimpossible — there is nobody to boostThis is why real-time systems mandate mutexes for exclusion. See Priority Inversion.
Natural useprotect a critical sectionlimit concurrent access to N resources, or signal between tasksChoose by the concept, not by the count.
Deadlock detectiontractable — a wait-for graph with known holdersmuch harder — a waiter cannot name who owes it a permitA hung semaphore acquire tells you nothing about who to blame.
Typical failuredeadlock, convoy, forgotten releaseleaked permit, double release, unbounded waiter queueDouble release silently raises the limit above the real resource capacity.
Cross-task signallingno — the owner must releaseyes — this is the pointThe producer/consumer handoff needs a primitive with no owner.
Same admission count, different primitives

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.

Two jobs, two primitives — and what happens when you swap one in for the other.ILLUSTRATIVE
Invariant · a handler proceeds only after the index is complete; at most one mutator is inside the index region and it is the one that leaves
#Loader taskHandler taskMutator task (recursive path)State
1·ready.acquire() — count is 0, so park·readyCount=0 indexLoaded=no
2load index from disk (3 s)··readyCount=0 indexLoaded=no
3ready.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 regionindexLock=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; releasesindexLock=free depth=0
8··SWAP: same code with a binary SEMAPHORE. acquire() — count 1 → 0permits=0 depth=1
9··rebuildBucket() calls acquire() again — count is 0, so M parkspermits=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.
The same admission count, two different primitives, two different outcomes. The semaphore did the job the mutex cannot — carrying readiness from one task to another — and then deadlocked on the job the mutex does trivially, because it has no owner and therefore no concept of re-entry. Note also the second-handler trap in the signalling half: a permit is consumed by whoever takes it, so one release wakes exactly one waiter. Readiness for *all* waiters is a latch or an event, not a semaphore. See Latches & Countdowns.

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.

Exclusion and signalling, side by side, in four languages — Guard a region against concurrent mutation; separately, signal readiness from one task to another.
C++LANGUAGE-SPECIFIC
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 path
6// index_mtx.unlock() from another thread -> undefined behaviour
7// index_mtx.lock() twice on one thread -> undefined behaviour
8std::recursive_mutex rec_mtx; // the reentrant variant, explicitly
9
10std::counting_semaphore<1> ready{0}; // no owner. starts empty.
11void loader() { build_index(); ready.release(); } // signaller never acquired
12void handler() { ready.acquire(); serve(); } // different task, legal
13
14// std::binary_semaphore is counting_semaphore<1>. It is NOT a mutex:
15// no ownership, no reentrancy, no priority inheritance, and no error
16// 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.

PythonCPYTHON
1import threading
2
3index_lock = threading.Lock() # owned-ish: release() from a non-owner raises
4with index_lock: mutate() # scope-bound
5
6rec_lock = threading.RLock() # reentrant: the SAME thread may re-acquire
7with 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 waiters
17def 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.

JavaScriptNODE.JS
1// No built-in mutex or semaphore, because a synchronous block already
2// 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 chain
9 return run
10}
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 this
19}
20
21// Readiness for many waiters is just a promise everyone awaits:
22const ready = buildIndex() // one promise, resolved once
23async 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.

TypeScriptNODE.JS
1// Same runtime as JavaScript. Types can at least make the distinction
2// 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 public
6
7// The Mutex interface deliberately offers no release(): the only way to
8// leave the region is to return from the callback, so no path can forget
9// it and no other task can call it. That is ownership, expressed the only
10// way this runtime allows.
11
12declare const indexLock: Mutex
13await indexLock.withLock(async () => { mutate() })
14
15declare const dbPermits: Permits // 20 permits; released by whoever holds one
16try { 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.

What actually differs
  • C++ separates them explicitly and makes both misuses undefined behaviour, so nothing at runtime will diagnose them.
  • Python distinguishes Lock, RLock, Semaphore and Event as 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.

How it works
  • 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.
Interleavings that matter
  • 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.
What it guarantees — and does not
  • 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::mutex and threading.Lock deadlock 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.
Where contention appears
  • 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.
How it fails
  • 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.
When it helps
  • 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.
When it hurts
  • 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.
How you would know
  • 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.
Complexity it introduces
  • 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.
Simpler alternatives

10,000 tasks, N permits

10,000 tasks, N permits
A semaphore is a counter of permits. Take one to proceed, return it when done — so at most N pieces of work exist at once, and everyone else waits.
Burst: all 10K arrive at once
holding a permit20 · 20 running — this is the only concurrency that exists
blocked on acquire()9,980 · 9,980 tasks parked, holding memory and a stack, doing nothing
drain time
12.5 s
ceiling
800/s
Steady: 600/s, e.g. a connection pool
permits busy0.8 · 75.0%
wait for a permit
1.0 ms
queued
1
state
healthy
20 permits serve 600/s with 75.0% utilisation and 1.0 ms of wait. The permits are the point: the 10K-task burst above does not become 10K concurrent database connections, it becomes 20. Raising the permit count is not free capacity — the permits exist because the resource behind them (connections, file handles, an API quota, memory) has a real limit. Size them from that limit, and remember the semaphore is a counter, not a lock: it does not protect any invariant and it does not make the work inside it thread-safe.
SIMULATEDQueue times from an M/M/c approximation with smooth arrivals. Real traffic is burstier, so real queues form earlier than this.

A mutex buys correctness with throughput

A mutex buys correctness with throughput
The same counter, unlocked and locked. Left column: what the schedules do. Right column: what the lock costs. Both are always on screen because you never get to choose only one.
4 cores · 4 ms CPU per task
No lock18/20 schedules lose an update
correct schedules2 · 20 possible interleavings of the two tasks
throughput
952/s
effective parallelism
3.81
Mutex around the incrementalways 2
correct schedules2 · 2 possible interleavings of the two tasks
throughput
500/s
effective parallelism
2.00
The lock removes every failing schedule — not by making them unlikely, but by making them unreachable: with the read-modify-write inside one critical section there are only 2 schedules left and neither loses an update. It costs 47.5% of throughput (952/s → 500/s) and drops effective parallelism from 3.8 to 2.00 on 4 cores. At 2 ms the region is small relative to the 4 ms of work, so most of the task still runs in parallel. This is what "small critical section" buys — and it is the only knob here that is free. What the mutex does not give you: ordering between the tasks, fairness, or protection for any other variable. It protects the region you put it around, and nothing else.
SIMULATEDSIMPLIFIEDSchedule counts are exact for this model; throughput comes from the lab model, not a measurement.

wait() in an if, or wait() in a while

wait() in an if, or wait() in a while
A consumer waits for the buffer to be non-empty. The difference between the two spellings is one keyword, and it is the difference between correct and corrupt.
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()
Invariant · items >= 0 — a consumer only takes an item that exists.
#ProducerConsumer 1Consumer 2RuntimeState
1·lock(); if (items == 0) wait()··items=0 waiters=1
2··lock(); if (items == 0) wait()·items=0 waiters=2
3lock(); 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
notifyAll() woke both consumers, but only one item exists. C1 relocked first and took it; C2 then ran straight past a condition that had already become false again, because `if` checked the predicate once — before it slept. A wakeup is not a promise that the predicate is true; it is only a hint that it may be worth looking. The rule has no exceptions worth remembering: always wait in a loop over the predicate, and hold the lock while checking it. The condition variable carries no state and remembers no notifications — a notify() sent while nobody is waiting is simply lost, which is why the shared predicate, not the signal, is the source of truth.
SIMPLIFIEDA schedule the runtime is allowed to produce, not one it must. That is the point: this failure is legal and rare.

What people believe, and what is true

Claim

A binary semaphore is just a mutex.

Reality

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.

Claim

A mutex is just a semaphore with one permit.

Reality

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

Claim

Releasing a semaphore from another thread is a bug.

Reality

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.

Claim

One release() wakes everyone who is waiting.

Reality

It wakes exactly one waiter, in every implementation. Broadcast readiness needs a latch, an event, or a resolved promise.

Apply it