Explain the futex-style design used by modern mutexes: why an uncontended acquire needs no call into the operating system, and what has to happen once the lock is contended.
answer
- lock word in user memory; uncontended = one CAS
- syscall only when a thread must actually sleep
- wait call = "sleep on this address IF it still equals V"
- that conditional check kills the lost-wake-up race
- 3 states: free / locked / locked-with-waiters
basics
~20 sThe lock state is an ordinary word in user memory, so an uncontended acquire is one atomic compare-and-swap — a few nanoseconds, no kernel. Only when a thread must actually wait does it make a wait system call naming that address; the kernel queues it and the releaser wakes it.
solid answer
~1 minThe insight is that **the common case is uncontended**, so it should never pay kernel costs. The lock is a word in ordinary user memory. Acquiring is an atomic compare-and-swap from FREE to LOCKED: a handful of nanoseconds, entirely in user space. Releasing is a store. No system call is involved in either. Only on contention does the kernel appear. The waiter marks the word as "locked with waiters" and issues a wait call that says, in effect, *"block me on the queue keyed by this address, but only if the word still holds this value"*. The kernel re-checks the value atomically against the expected one before sleeping, which closes the lost-wake-up race where the holder releases between the failed acquire and the syscall. On release, the holder inspects the word: if it says there are waiters, it makes a wake call; if it says plainly LOCKED, it just stores FREE and returns without any syscall. Hence the classic three states — free, locked, locked-with-waiters — which exist precisely so the uncontended unlock is also syscall-free. The kernel therefore holds no per-lock state until someone actually blocks, so a program can have millions of these locks for the price of one word each.
code
text · 14 lines# 0 = free, 1 = locked, 2 = locked, waiters may exist
acquire():
if CAS(word, 0, 1): return # fast path: no kernel at all
spin_briefly()
while true:
if CAS(word, 0, 2): return # took it, mark as contended
old = atomic_swap(word, 2)
if old == 0: return # it was free after all
wait_on_address(&word, expect = 2) # kernel re-checks, then sleeps
release():
if atomic_swap(word, 0) == 2:
wake_one(&word) # syscall ONLY if waiters markedgo deeper
Know that acquiring an uncontended lock is just an atomic instruction on a word in memory, and that the operating system only gets involved when a thread has to wait.
Explain the fast path and slow path separately, and why the cost asymmetry between an atomic operation and a system call makes the split worthwhile.
Reconstruct the lost-wake-up race and how the compare-in-the-blocking-call closes it, plus the three-state encoding that keeps the uncontended unlock syscall-free.
Generalise the pattern — atomic word for the fast path, address-keyed kernel wait queue for the slow one — to semaphores, condition variables and latches, and weigh the loss of kernel-enforced ownership and death recovery against the throughput gained.
## The problem being solved Early kernel-provided mutexes required a system call on every lock and unlock, even when nobody was competing for the lock. Since well-designed programs acquire uncontended locks constantly and contend rarely, that made the *common* path pay the cost of the *rare* one. It also meant every mutex needed a kernel object, so kernel memory grew with the number of locks a process created. The futex design ("fast userspace mutex") inverts this: the lock lives in user memory, and the kernel is consulted only when a thread genuinely has to sleep. ## The user-space fast path The lock is one machine word in the process's own memory. Acquisition is an atomic compare-and-swap: ``` acquire(): if CAS(word, FREE, LOCKED) succeeded: return # done, no kernel release(): store(word, FREE) # done, no kernel ``` On a modern CPU an uncontended CAS on a cache line already in the local cache is a few nanoseconds. A system call is hundreds of nanoseconds to low microseconds once you include the mode transition and the associated cache and pipeline effects — and considerably more with speculative-execution mitigations enabled. So the fast path is two to three orders of magnitude cheaper than the syscall it avoids. Because the kernel has no idea the lock exists until someone blocks on it, a program may hold millions of such locks — one per hash bucket, one per object — at a cost of one word each and zero kernel objects. ## The contended path When the CAS fails, a thread must wait. It calls into the kernel with an operation of the form: > *wait on the queue identified by this memory address, but first verify that the address still contains the value V; if it does not, return immediately.* That conditional check is the crux. Consider the race without it: 1. Thread B fails to acquire; the lock is held by A. 2. **A releases** and, seeing no recorded waiters, issues no wake. 3. B enters the kernel and sleeps — forever, because the wake already happened. The kernel closes this by re-reading the word under its own internal lock and comparing it to the value the caller expected. If the value changed (because A released), the call returns immediately instead of sleeping and the thread retries in user space. This is a compare-and-block primitive, and it is what makes the whole scheme sound. The kernel keys its wait queues by the physical address of the word (or by an equivalent identity for shared mappings), so two processes mapping the same shared memory can synchronise through the same lock without either owning a kernel handle. ## Why three states, not two If the word were only FREE/LOCKED, the releaser could never know whether anyone was waiting, so it would have to issue a wake system call on *every* unlock — reintroducing exactly the cost the design removed. So the canonical encoding is: - **0 — free** - **1 — locked, no waiters known** - **2 — locked, waiters may exist** A thread that is about to block first sets the word to 2, then waits on the expected value 2. Release becomes: ``` release(): old = atomic_swap(word, 0) if old == 2: wake_one_waiter(&word) # syscall only in this branch ``` So the uncontended unlock — the overwhelmingly common case — is a single atomic store with a branch that is not taken. "May exist" is deliberate: the state can be conservatively 2 when the last waiter has already gone, costing at most one spurious wake call, which is a performance blemish rather than a correctness bug. ## How spinning fits in Most real implementations put a short adaptive spin between the failed CAS and the wait call: if the hold time is very short, the lock frees during the spin and the syscall is avoided entirely. So the full contended path is *CAS → brief spin → mark waiters → wait syscall*, and each stage exists to catch a different regime of hold time. The spin also has to be bounded, because parking is the only correct response to a long or blocked holder. ## Consequences worth naming - **Correctness lives in user space.** The lock word is ordinary process memory, so a buggy or malicious thread can corrupt it. There is no kernel-enforced ownership; this is the price of the fast path. - **Wake-ups need care.** Waking all waiters when only one can proceed creates a thundering herd; waking one plus "requeue to another wait queue" operations exist to move waiters between queues efficiently, which is how condition-variable signalling avoids a wake-then-immediately-block storm. - **Robustness on death.** If a thread dies holding the lock, the word simply stays locked. Recovering from that requires an explicit protocol (a registered owner list the kernel can mark as inconsistent), because nothing about the fast path tracks ownership by itself. - **The same machinery generalises.** Semaphores, condition variables, latches and read-write locks in modern runtimes are all built on the same pattern: an atomic word for the fast path, a kernel wait queue keyed by that word's address for the slow one. ## The one-sentence version Put the state where the fast path can reach it cheaply, and pay for the kernel only when a thread actually has to stop running — with an atomic compare inside the blocking call so the release cannot be missed.
- Why does the blocking call take an expected value rather than just a lock address?Without it there is a window between the failed user-space acquire and entering the kernel in which the holder can release; the release would find no recorded waiter and issue no wake, so the thread would sleep forever. Passing the expected value lets the kernel atomically re-check the word against it under its own lock and return immediately if it changed. That turns a lost wake-up into a harmless retry.
- What is the third state ("locked with waiters") for?It lets the releasing thread know whether a wake system call is necessary. With only free and locked states, every unlock would have to call the kernel just in case someone was waiting, which reintroduces the cost the design exists to avoid. The state is conservative — it can say waiters exist when the last one has left — costing at most one spurious wake, which is a performance cost rather than a correctness problem.
- What are the downsides of putting the lock state in user memory?There is no kernel-enforced ownership or validity, so a bug elsewhere in the process can corrupt the word and break mutual exclusion with no diagnostic. If a thread dies while holding the lock, the word simply stays locked and nothing recovers it unless an explicit robustness protocol registers the owner with the kernel. You gain enormous speed on the common path and give up the safety a kernel-mediated object could enforce.
A shared meeting room with a paper sign on the door. Flipping the sign is instant and involves nobody else. Only if you actually need to wait do you go to reception and ask to be called — and reception checks the sign one more time before letting you go and sit down, so you are not left waiting for a call that already went out.
saying these in an interview costs you the question
- Believing every mutex lock and unlock enters the kernel
- Thinking the kernel keeps a persistent object per lock even when uncontended
- Missing why the wait call must compare against an expected value
- Assuming release always issues a wake system call
- Claiming the fast path is safe from corruption because "the kernel manages the lock"