Concurrency Roadmap/Locks & Mutual Exclusion

Spinlocks vs. Blocking Locks

Busy-wait on a CPU core or ask the OS to put you to sleep — the same mutual-exclusion guarantee, two wildly different cost models depending on critical-section length and core count.

~3/5Theory: 35m
Language-specific mechanics: Concurrency Language Manual — Locks, Mutexes & Synchronized Access

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:

  1. 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.
  2. 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

FactorFavors spinlockFavors blocking lock
Expected wait timeVery short (nanoseconds–low microseconds) — shorter than a context switchLonger or unpredictable — I/O, page faults, long computation inside the critical section
Core availabilityMultiple cores, so the lock holder is likely actively running on another core and will release soonSingle core, or the holder might itself be preempted/blocked — spinning just burns time with zero chance of progress
ContextKernel/interrupt handlers, lock-free-adjacent code, extremely hot short pathsOrdinary application code, anything that might sleep, I/O-bound work
CPU cost modelCheap to burn a core briefly for lower latencyCores 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.
Reference implementations in:

Naive Spinlock via Compare-And-Swap

Busy-waits on a hardware CAS instruction — correct, but the calling thread never stops spending CPU cycles while contended.

var locked atomic.Bool func lock() { for !locked.CompareAndSwap(false, true) { runtime.Gosched() // yield so the holder can run; no PAUSE intrinsic } } func unlock() { locked.Store(false) }

atomic.Bool.CompareAndSwap is the CAS primitive. Spinning is legal but rarely idiomatic in Go — sync.Mutex already short-spins in user space before parking, so a hand-rolled spinlock is almost always the wrong tool. runtime.Gosched() yields the goroutine; there is no Thread.onSpinWait equivalent. Never spin without a yield or you can livelock a GOMAXPROCS=1 program: the holder never gets scheduled.

Blocking Lock: Letting the OS (or Runtime) Park the Waiter

Same critical-section contract as the spinlock above, but the waiting thread is descheduled instead of burning cycles.

var mu sync.Mutex func withCriticalSection(work func()) { mu.Lock() // blocks: runtime parks this goroutine if contended defer mu.Unlock() // wakes one parked waiter, if any work() }

Under contention, Mutex.Lock() parks the goroutine (after a short spin) rather than burning CPU. Parking a goroutine is cheaper than parking an OS thread, but the contract is the same: the waiter is off the run queue until Unlock. Do not mix this with a hand-rolled spin on the same data.

Further Resources (Optional)