Concurrencymutexlockfutexcontentionspinlock

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.

LinuxC++CPythonNode.jsWindows
▶ InteractiveInterview question
Progress

The problem

Thread A is inside the critical section; thread B arrives. B must wait — but how? Spin and burn a core? Ask the kernel to sleep it and pay a syscall on every lock? And when A leaves, who tells B?

Lock, own, unlock

C++

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.

RAII: the unlock cannot be forgotten, on any path
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() here
6 ++counts[k]; // may throw bad_alloc
7} // unlock() in ~lock_guard, even on throw

The futex fast path: uncontended locks never enter the kernel

Linux

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.

The three-state futex mutex in outline (after Drepper)
1std::atomic<int> word{0}; // 0 free, 1 locked, 2 locked + waiters
2
3void lock() {
4 int c = 0;
5 if (word.compare_exchange_strong(c, 1)) return; // fast path: no syscall
6 if (c != 2) c = word.exchange(2); // announce a waiter
7 while (c != 0) {
8 futex(&word, FUTEX_WAIT, 2); // sleep only if word is still 2
9 c = word.exchange(2);
10 }
11}
12void unlock() {
13 if (word.fetch_sub(1) != 1) { // was 2: someone is sleeping
14 word.store(0);
15 futex(&word, FUTEX_WAKE, 1);
16 }
17}

Spinning vs sleeping

Linux

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

C++

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 has Atomics.wait/notify on SharedArrayBuffer.
  • 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

Mutex: one critical section, several threads
Threads alternate between private work (2 ticks) and a critical section that needs the mutex. Watch who owns it, who waits, and when the kernel gets involved.
T0T1T2
private workin critical section (owner)waiting (kernel sleep)never unlocked
Owner
T0
Wait queue
empty
Fast-path locks (no syscall)
1
futex_wait (kernel)
0
T0 lock(): CAS 0→1 succeeded — user space only
Contention time so far0 thread-ticks
Throughput (sections / tick)0.33
Ideal if no one ever waited: 0.60/tick. Actual: 0.33/tick. Longer sections and more threads push the actual toward 1/3 — the lock serialises everyone.
Futex (Linux): a mutex is a word in user memory. Uncontended lock/unlock is one atomic CAS, no kernel entry (~20 ns). Only when a thread finds it held does it call futex(FUTEX_WAIT) and sleep (~µs, a context switch).
1/36 · tick 0SimulatedLinux

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 or with would 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.