Mutexes
A mutex is a lock with an owner: the thread that locks it must unlock it, a second thread that wants it waits — spinning briefly or sleeping in the kernel via a futex — and the uncontended path is a single atomic instruction that never enters the kernel.
The problem
Lock, own, unlock
A mutex (mutual exclusion lock) has two states, locked and unlocked, and one rule: only the thread that locked it may unlock it. lock() on an unlocked mutex takes it and returns; on a locked one it waits until the owner calls unlock(). That ownership is the difference from a binary semaphore (Semaphores and Condition Variables): because the mutex knows who holds it, it can detect a double-lock by the same thread, refuse an unlock from a stranger, and — with priority inheritance — boost the holder (A Taxonomy of Concurrency Bugs). The mutex is the standard guard for a critical section (Critical Sections).
The contract in every language is the same shape. C++: std::mutex with lock()/unlock(), almost always through std::lock_guard or std::scoped_lock so the unlock happens in the destructor on every exit path, exceptions included. Python: threading.Lock used as with lock:; RLock if the same thread must re-acquire. Go: sync.Mutex with defer mu.Unlock(). Java: synchronized or ReentrantLock. Rust: Mutex<T> where the guard *is* the only way to reach T, so forgetting the lock is a compile error.
JavaScript has no mutex for the shared-memory case because ordinary JavaScript has no shared memory — each realm’s heap is private, and the interleaving points are awaits. When it does share memory, via SharedArrayBuffer between workers, the primitives are Atomics.compareExchange to build a lock word and Atomics.wait/Atomics.notify to sleep and wake on it — a futex, in fact, exposed to the language. Atomics.wait is forbidden on the browser main thread, which must never block.
1std::mutex m;2std::unordered_map<std::string, int> counts;3 4void bump(const std::string& k) {5 std::lock_guard<std::mutex> guard(m); // lock() here6 ++counts[k]; // may throw bad_alloc7} // unlock() in ~lock_guard, even on throwThe futex fast path: uncontended locks never enter the kernel
A naive mutex would be a kernel object and every lock a syscall — 100–300 ns minimum, most of it wasted because most locks are uncontended. Linux’s futex ("fast userspace mutex", 2003) splits the work. The lock is a 32-bit integer in ordinary user memory. lock() is an atomic compare-and-swap (Atomic Operations) from 0 to 1; if it succeeds, the thread owns the mutex and the kernel was never involved — about 20 ns. unlock() is an atomic store of 0. Only when the CAS fails — someone else holds it — does the thread call futex(FUTEX_WAIT), which puts it to sleep on a kernel wait queue keyed by the integer’s address, *after re-checking that the value is still 1* so a wake-up between the check and the sleep is not lost.
The holder, on unlock, must know whether anyone is sleeping, or it would pay a FUTEX_WAKE syscall every time. The standard three-state protocol (Drepper’s "Futexes Are Tricky") encodes it in the same word: 0 unlocked, 1 locked with no waiters, 2 locked with waiters. A contended locker sets 2 before sleeping; an unlocker that sees 2 calls FUTEX_WAKE for one waiter. Uncontended lock and unlock therefore cost two atomics and zero syscalls; contended ones cost a syscall and a context switch each way (a few microseconds), which is the true price of contention.
glibc’s pthread_mutex_t, std::mutex on Linux, Go’s sync.Mutex, Java’s locks under HotSpot and Python’s threading.Lock all reduce to this design. Windows has the same shape with different names: CRITICAL_SECTION spins then waits on a kernel event; SRWLOCK is the leaner modern equivalent; a Mutex handle is the cross-process kernel object and costs a syscall every time. macOS uses os_unfair_lock and ulock.
1std::atomic<int> word{0}; // 0 free, 1 locked, 2 locked + waiters2 3void lock() {4 int c = 0;5 if (word.compare_exchange_strong(c, 1)) return; // fast path: no syscall6 if (c != 2) c = word.exchange(2); // announce a waiter7 while (c != 0) {8 futex(&word, FUTEX_WAIT, 2); // sleep only if word is still 29 c = word.exchange(2);10 }11}12void unlock() {13 if (word.fetch_sub(1) != 1) { // was 2: someone is sleeping14 word.store(0);15 futex(&word, FUTEX_WAKE, 1);16 }17}Spinning vs sleeping
Waiting has two costs to trade: a spin burns a core but resumes within nanoseconds of the unlock; a sleep frees the core but costs two context switches (~1–5 µs each way) and a wake-up latency that depends on the scheduler. If the holder will be done in 100 ns, sleeping is absurd; if it will be done in 10 ms, spinning is. Most user-space mutexes therefore spin briefly — a few hundred iterations with pause instructions — and then fall back to FUTEX_WAIT. glibc’s PTHREAD_MUTEX_ADAPTIVE_NP adjusts the spin count from recent history; Go’s mutex spins a few times if there are idle cores; Java’s HotSpot does the same.
Pure spinlocks belong in the kernel, where the holder cannot be preempted (the lock disables preemption) and the section is guaranteed short. In user space a spinlock is a trap: if the holder is descheduled — by the timer, or because there are more threads than cores — the spinner burns its whole timeslice waiting for a thread that cannot run *because the spinner is using the core*. The symptom is a process at 100% CPU making no progress — the high-cpu-spin-lock challenge — and the fix is a mutex that sleeps.
A related pathology is the convoy: with a strictly fair (FIFO) lock, every arriving thread queues behind sleeping ones and each handoff requires a wake-up, so the lock’s throughput collapses to one handoff per scheduling latency even though the section is tiny. Unfair locks — where a running thread may grab the lock ahead of a waiter that is still waking — avoid the convoy at the cost of possible starvation; Java’s ReentrantLock(false) and glibc’s default are unfair for exactly this reason.
- Spin if the expected hold is shorter than a context switch; sleep otherwise; adaptive mutexes guess from history.
- Never spin in user space against a holder that can be preempted.
- Fair locks convoy; unfair locks barge. Most defaults barge.
Discipline: ordering, scope, and what not to do
Two mutexes taken in different orders by two threads is the canonical deadlock (Deadlocks). The discipline is a global lock order — document it, and acquire in that order everywhere — or acquire both at once with std::scoped_lock(m1, m2), which uses a try-lock-and-back-off algorithm that cannot deadlock. Never call unknown code (callbacks, virtual methods on foreign objects, logging with pluggable sinks) while holding a lock; never block on I/O under one (Critical Sections).
Recursive mutexes let the owner re-lock; they exist to paper over designs where a locked function calls another locked function and are usually a smell — the inner function should have an unlocked variant. try_lock returns immediately with failure instead of waiting and is the building block for lock-ordering-free acquisition and for deadlock-avoiding retries. Timed locks (pthread_mutex_timedlock, try_lock_for) turn a hang into an error you can log.
Finally, a mutex is a memory-ordering device as much as an exclusion device: the unlock is a release and the lock an acquire, so everything the previous owner wrote inside the section is visible to the next owner (Atomic Operations). Reading shared data *outside* the lock — "just a quick check" — forfeits that guarantee and reintroduces the visibility bugs of A Taxonomy of Concurrency Bugs.
Key points
- A mutex has an owner; only the locker may unlock. That ownership enables error checking and priority inheritance and is what a binary semaphore lacks.
- Uncontended lock/unlock is one CAS and one store in user memory — no syscall, ~20 ns. The kernel (futex) is involved only when a thread must sleep or be woken.
- The three-state futex word (0/1/2) lets the unlocker skip the wake syscall when nobody is waiting.
- Spinning wins for holds shorter than a context switch; sleeping wins otherwise; adaptive mutexes spin then sleep. User-space spinlocks against preemptible holders burn CPU for nothing.
- RAII guards (
lock_guard,with lock:,defer Unlock()) make the unlock unforgettable. JavaScript only hasAtomics.wait/notifyonSharedArrayBuffer. - Global lock ordering or
scoped_lock; no callbacks or I/O under a lock; the lock is also your acquire/release fence.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why not make every mutex a kernel object?
Because most locks are uncontended most of the time and a syscall per lock would make every lock() 10× slower than the critical section it guards. Futexes keep the common case in user space and only pay the kernel when a thread genuinely has to sleep.
▸Why does a mutex need to know its owner?
To refuse an unlock from the wrong thread, to detect self-deadlock on a non-recursive lock, and to know which thread to boost under priority inheritance. A semaphore, having no owner, can do none of these — which is why it is the wrong tool for mutual exclusion and the right tool for signalling.
▸Why do default mutexes allow barging?
A strictly fair lock hands off to a sleeping waiter that takes microseconds to wake, so the lock sits idle; a barging lock lets whoever is running take it now. Throughput wins by default; fairness is opt-in for the cases that need it.
Mutex simulator
How it fails
What the failure looks like from inside real software.
- Process at 100% CPU, no progress: a user-space spinlock whose holder was preempted; every spinner burns its timeslice waiting (
high-cpu-spin-lock). - Throughput collapses when a hot lock becomes contended: each handoff now costs a
FUTEX_WAKE, a context switch and a wake-up latency — the lock convoy. - Unlock forgotten on an exception path; the next
lock()hangs forever. RAII orwithwould have prevented it. - Two locks in opposite orders in two code paths; a deadlock every few weeks under a specific request mix (
process-hang-deadlock). - A "quick unlocked read" of a shared structure sees a torn or stale value because the acquire/release pairing was skipped.
- A callback invoked under a lock re-enters and tries to lock the same non-recursive mutex; the thread deadlocks with itself.