Two ways to wait for a lock
Every lock discussed in the previous subtopic needs an answer to one question: when a thread calls acquire() and the lock is already held, what does that thread actually do while it waits? There are exactly two families of answers, and the difference between them is one of the highest-leverage performance decisions in systems programming:
- Spin (busy-wait): the thread stays runnable and keeps checking the lock in a tight loop, burning CPU cycles until it becomes free.
- Block (sleep): the thread tells the OS scheduler "wake me when this is available" and is descheduled, freeing the CPU core for other work until it's explicitly woken up.
Both are correct mutual-exclusion strategies — the choice between them changes cost, not correctness.
Spinlocks: busy-waiting mechanics
The simplest spinlock is built directly on a hardware atomic instruction — test-and-set (TAS) or compare-and-swap (CAS) — because a plain read-then-write ("is it free? okay, set it") has exactly the race condition the lock is trying to prevent.
lock:
while atomic_test_and_set(flag) == already_set:
pass # busy-wait: keep retrying
unlock:
flag = clear
This naive version has a costly side effect on real multi-core hardware: every waiting thread's test_and_set is a write, and every write to flag invalidates every other core's cached copy of that cache line, generating a storm of cache-coherency traffic even though nothing useful is happening. Two standard refinements fix this, and are worth knowing by name:
- Test-and-test-and-set (TTAS): spin on a cheap read first (
while flag == locked: pass), and only attempt the expensive atomic write once the read suggests the lock might be free. Readers can share a cached copy of the line with no coherency traffic; only the rarer transition moments cost a real atomic op.
- Exponential backoff: after a failed acquire attempt, wait a short, randomized, doubling delay before retrying instead of immediately hammering the lock again. This reduces the "thundering herd" of every waiter retrying at the exact instant the lock frees up, at the cost of making the lock less fair — a thread that's been backing off longest isn't necessarily first in line when the lock opens — and slightly increasing average acquire latency under low contention.
Blocking locks: OS-mediated mechanics
A blocking lock, by contrast, maintains an explicit wait queue. When acquire() fails, the calling thread is moved off the CPU's run queue entirely — a context switch — and its identity is recorded so the OS or runtime can wake it later. When release() runs, it wakes some or all of the waiters, who then re-enter the run queue and compete to actually acquire the now-free lock. This is why a blocking lock costs roughly "two context switches" (sleep, then wake) per contended acquisition, plus scheduler bookkeeping — overhead a spinlock never pays, but which is negligible if the wait would have been long anyway.
When each one wins
| Factor | Favors spinlock | Favors blocking lock |
|---|
| Expected wait time | Very short (nanoseconds–low microseconds) — shorter than a context switch | Longer or unpredictable — I/O, page faults, long computation inside the critical section |
| Core availability | Multiple cores, so the lock holder is likely actively running on another core and will release soon | Single core, or the holder might itself be preempted/blocked — spinning just burns time with zero chance of progress |
| Context | Kernel/interrupt handlers, lock-free-adjacent code, extremely hot short paths | Ordinary application code, anything that might sleep, I/O-bound work |
| CPU cost model | Cheap to burn a core briefly for lower latency | Cores are precious or oversubscribed; freeing the core for other work matters more than shaving microseconds |
The single-core case deserves a callout because it's a common trick question: on a uniprocessor, a spinning thread can never make progress by spinning, because the only core that could run the lock-holder and let it call release() is the one currently wasted on the spin loop. Spinlocks are only a rational choice when another core is actively making progress on your behalf — this is precisely why they're a multi-core-era technique, largely irrelevant or actively harmful on single-core hardware.
In practice, many production mutex implementations are hybrid/adaptive: spin briefly, for a bounded number of iterations, on the assumption the critical section is short, then fall back to blocking if the lock still isn't free — trying to capture the low latency of spinning in the common case without the uniprocessor pathology or the CPU waste of unbounded spinning.
Pitfalls and interview gotchas
- Holding a spinlock across anything that can block or take a page fault — I/O, another blocking lock, memory allocation that can page — can stall every spinning core for the full duration. Spinlocks are only safe for critical sections that are both short and guaranteed non-blocking, which is why kernels use them almost exclusively for tiny, interrupt-safe sections.
- Spinning on a single-core VM or an oversubscribed cloud host (more vCPUs requested than physical cores available) reproduces the uniprocessor pathology even on "multi-core" hardware, because the scheduler can preempt the actual lock-holder mid-critical-section and give the core to the spinning thread instead — a classic real-world spinlock performance bug.
- Treating "spinlocks are always faster" as true. They only win when the wait is shorter than a context switch and another core is making progress; outside that window they're strictly worse, both for the spinning thread (wasted cycles) and for everyone else contending for CPU time.
- Forgetting fairness and starvation risk. Neither a naive TAS/TTAS spinlock nor an unfair blocking lock guarantees FIFO order; under sustained contention, a thread can be repeatedly beaten to the lock by newer arrivals. Ticket locks — spinlocks with a strict queue-position counter — or fair blocking locks trade some throughput to bound this.
- Assuming "blocking" always means an OS thread sleeps. Coroutine-based locks (like Kotlin's
Mutex) block logically — the calling coroutine waits — without blocking the underlying OS thread at all, which is a third cost model worth distinguishing from both classic spinning and classic OS-level blocking.