Spinlocks versus Blocking Locks

Imagine two threads on a multicore machine. Each races to increment a shared counter a million times. Both need the Critical Section protected. But how should a thread wait when it finds the lock already held? It could spin — burn CPU cycles checking the lock in a tight loop. Or it could sleep, asking the operating system to wake it later. It wakes when the lock becomes free. This single design decision repeats inside every kernel, database, and language runtime. It has enormous consequences for throughput, latency, and energy use. Real systems get this wrong in both directions constantly. A spinlock held across a page fault can stall an entire multiprocessor. The same happens with a preempted critical section. Every waiter burns cycles for milliseconds. A blocking lock used for a twenty-nanosecond critical section costs more too. Its context-switch overhead can exceed the work it protects. Linux's kernel spinlocks, futexes, and adaptive mutexes in Solaris and glibc all exist for this reason. So do spin-then-park hybrids in Java's `synchronized` and Rust's `parking_lot`. No single strategy dominates across workloads. The big picture is a tradeoff triangle: waiting cost, wakeup latency, and fairness. Spinning wastes CPU but reacts instantly the moment the lock is released. Blocking frees the CPU for other work. But it pays a round trip through the scheduler to resume. The right choice depends on several factors. These include expected hold time and the number of competing cores. It also depends on whether the system is single- or multi-processor. Finally, it depends on whether correctness requires disabling interrupts. This article builds both mechanisms from first principles. It proves each provides mutual exclusion. It derives when spinning beats blocking using a simple cost model. It walks through concrete traces showing catastrophic failure modes. These include the convoy effect, priority inversion, and the single-CPU spin-forever bug. It closes by connecting these primitives to Semaphores. Semaphores generalize blocking locks to counting resources.

Key takeaways
  • A spinlock makes a waiting thread poll a memory location in a tight loop until it becomes free; it never leaves the CPU.
  • A blocking lock (mutex) puts a waiting thread to sleep and relies on the OS scheduler to wake it when the lock is released.
  • Spinlocks win when critical sections are short and the number of waiters is small relative to available cores — no context-switch cost.
  • Blocking locks win when critical sections are long, when there is only one CPU, or when many threads contend, since spinning would waste cycles that could run other work.
  • Both need Atomic Operations (test-and-set, compare-and-swap) at their core to acquire the lock without a race.
  • Production systems (Linux, Java, glibc) use adaptive/hybrid locks: spin briefly, then fall back to blocking — combining the low latency of spinning with the CPU efficiency of sleeping.
The core insight

Spinning trades CPU cycles for lower wakeup latency; blocking trades a scheduler round-trip for CPU efficiency — the right lock depends on how long the critical section holds and how many CPUs are available.

The idea

A lock (or mutex) enforces mutual exclusion: at most one thread executes a designated critical section at a time. Every lock implementation must answer one question precisely — what does a thread do while the lock is held by someone else? The two canonical answers define this article's subject. A spinlock answers "keep trying." The waiting thread executes a loop that repeatedly attempts to acquire the lock via an atomic instruction (test-and-set, or compare-and-swap), never relinquishing the CPU. This is called busy-waiting. The thread is technically "running" the entire time from the scheduler's point of view, even though it makes no forward progress on real work. A blocking lock answers "let someone else use the CPU; wake me later." The waiting thread invokes a system call that removes it from the run queue and places it on a wait queue associated with the lock. When the holder releases the lock, it (or the kernel) moves one waiting thread back onto the run queue, where the scheduler will eventually dispatch it. The mechanism connecting release to wakeup is often a futex (fast userspace mutex) on Linux, which lets the fast, uncontended path stay entirely in user space and only traps into the kernel when there is actual contention. Both approaches rest on the same foundation: an atomic read-modify-write instruction that makes the "check and claim" step indivisible. The difference is entirely in what happens after a failed acquisition attempt — spin again immediately, or descend into the scheduler and give up the processor. This distinction cascades into different requirements: spinlocks must sometimes disable interrupts (to prevent the CPU itself from being ceded to a handler while holding a short lock on a single core), while blocking locks must integrate with the scheduler's wait-queue and priority machinery.

How it works

Spinlock acquire/release. The canonical implementation uses an atomic test-and-set (TAS) instruction, which sets a memory location to 1 and returns its previous value, atomically: ``` // shared: lock (initially 0 = free, 1 = held) function acquire_spinlock(lock): while atomic_test_and_set(lock) == 1: // busy-wait: keep polling pass // now lock == 1 and we own it function release_spinlock(lock): atomic_store(lock, 0) ``` A refinement, test-and-test-and-set (TTAS), avoids hammering the shared cache line with atomic writes while the lock is held by someone else: ``` function acquire_spinlock_ttas(lock): while true: while atomic_read(lock) == 1: pass // spin on a local cached read if atomic_test_and_set(lock) == 0: return // acquired // else someone beat us to it; keep spinning ``` TTAS matters on real hardware: cache-coherence protocols (MESI/MOESI) turn every `test_and_set` on a contended lock into a bus transaction that invalidates every other core's cached copy. Plain spinning on a read only requires a shared (read-only) cache line, dramatically reducing bus traffic under contention. A further refinement, the ticket lock, gives strict FIFO fairness: ``` // shared: next_ticket = 0, now_serving = 0 function acquire_ticket_lock(): my_ticket = atomic_fetch_and_increment(next_ticket) while now_serving != my_ticket: pass function release_ticket_lock(): atomic_increment(now_serving) ```

Blocking lock acquire/release. The waiting thread is parked on a wait queue rather than spinning: ``` // shared: lock_state (0 = free, 1 = held), wait_queue = empty function acquire_blocking_lock(): disable_interrupts() // atomic section begins if lock_state == 0: lock_state = 1 enable_interrupts() return // fast path, uncontended else: add_current_thread_to(wait_queue) block_current_thread() // yields CPU; scheduler picks another thread enable_interrupts() // resumes here after being woken by release() // (implementation re-checks lock_state to guard against // spurious wakeups, then retries acquisition) function release_blocking_lock(): disable_interrupts() if wait_queue is not empty: t = remove_one_thread_from(wait_queue) lock_state = 1 // ownership passed directly to t wake_up(t) // moves t to run queue else: lock_state = 0 enable_interrupts() ``` The interrupt-disable/enable pair (or an internal spinlock protecting the queue, in a multiprocessor kernel) makes the check-and-enqueue step atomic, exactly analogous to the atomic instruction in the spinlock. This is the essential insight: a blocking lock is built out of a small spinlock (or interrupt masking) plus a wait queue — you cannot escape busy-waiting entirely, you only shrink it to a few instructions of internal bookkeeping instead of the whole critical section.

Adaptive/hybrid locking. Most production mutexes spin for a short bounded time first, then fall back to blocking if the lock is still not free: ``` function acquire_adaptive_lock(lock, SPIN_LIMIT): for i in 1 .. SPIN_LIMIT: if atomic_test_and_set(lock) == 0: return // got it while spinning pause_instruction() // hint to CPU, saves power/pipeline // spin budget exhausted — park acquire_blocking_lock(lock) ``` The `pause_instruction()` (e.g., x86 `PAUSE`) tells the CPU this is a spin-wait loop, reducing pipeline speculation misprediction penalties and power draw without giving up the core.

Figure
Spinlock vs. blocking lock: state machine for a waiting thread
Spinlock path RUNNING SPINNING CRITICAL SEC. lock held poll again TAS succeeds Blocking lock path RUNNING BLOCKED (on wait queue) READY (run queue) CRITICAL SEC. lock held; block() release() wakes it scheduler dispatches no CPU released, no scheduler call CPU freed for other threads

Complexity & correctness

Correctness of the spinlock. Mutual exclusion follows directly from the atomicity of `test_and_set`: the hardware guarantees that if two threads execute TAS on the same word "simultaneously," one observes the prior value 0 (and proceeds) and the other observes 1 (and spins), never both observing 0. This is the invariant: at most one thread transitions the lock from 0 to 1 at any instant. Progress (no deadlock) holds because the lock is released unconditionally by whoever holds it, so some spinning thread's next TAS attempt eventually observes 0 — assuming the holder is not preempted forever and the scheduler is fair. Plain TAS spinlocks provide no fairness guarantee (a thread can starve if unlucky), which the ticket lock fixes by handing out FIFO sequence numbers; its correctness invariant is that `now_serving` increases by exactly one per release and a thread proceeds only when its ticket matches, so waiters are served in arrival order.

Correctness of the blocking lock. The invariant is enforced identically at the moment of state transition — the `disable_interrupts()` / internal-spinlock region makes "check `lock_state`, then either take it or enqueue" atomic, so no two threads can both read `lock_state == 0` and both proceed. The wait queue provides liveness: `release()` explicitly hands ownership to a dequeued waiter (rather than merely setting `lock_state = 0` and hoping someone notices), which avoids a missed-wakeup race where a thread parks after the release already happened and would otherwise sleep forever. This handoff discipline is why real implementations pass ownership directly rather than waking all waiters to race for a freshly-cleared flag (the latter is simpler but reintroduces a stampede and possible starvation).

Time complexity. Under contention, a spinlock's acquire cost is O(1)O(1) in the best case (uncontended TAS succeeds immediately) but its wait time is unbounded in the worst case, bounded above by (number of contenders) × (critical section length) under a fair scheduler — since every other spinning thread wastes full CPU quanta doing nothing but polling. A blocking lock's acquire cost is O(1)O(1) atomic operations plus a context-switch cost CC (typically hundreds to a few thousand nanoseconds, including scheduler overhead and cache/TLB effects) whenever contention occurs; its wait time is bounded by the time until the lock is released plus one scheduling latency, but it does not burn CPU cycles while waiting.

The core tradeoff, quantified. Let TcsT_{cs} be the average critical-section hold time, CC the cost of a context switch (park + wake + reschedule), and nn the number of runnable threads waiting for the lock. Expected wasted work:

Spin cost≈n⋅Tcs(CPU cycles burned by all waiters, roughly proportional to wait time)\text{Spin cost} \approx n \cdot T_{cs} \quad \text{(CPU cycles burned by all waiters, roughly proportional to wait time)}
Block cost≈2C(one switch out, one switch back in, per waiter)\text{Block cost} \approx 2C \quad \text{(one switch out, one switch back in, per waiter)}

A spinlock is the better choice when Tcs<2CT_{cs} < 2C per waiter — i.e., the critical section is shorter than the round-trip cost of sleeping and waking. This is why kernels use spinlocks almost exclusively for very short critical sections (updating a run-queue pointer, a few instructions) and blocking locks for anything that might touch disk I/O, allocate memory, or otherwise run for microseconds or longer. On a single-processor system, a spinlock is almost always wrong for anything but disabling interrupts briefly: if the lock holder cannot run (because the only CPU is busy spinning in the waiter), the lock will never be released — a genuine liveness bug, not just an inefficiency.

Space complexity. A spinlock needs O(1)O(1) words of shared state (just the lock flag, or two counters for a ticket lock). A blocking lock needs O(1)O(1) words plus O(w)O(w) space for the wait queue, where ww is the number of currently blocked threads — each entry typically reuses the thread's existing control block, so the marginal cost is a queue-link pointer per waiter.

Worked example

Consider four threads (T1–T4) on a 4-core machine contending for a lock protecting a critical section that takes 50 ns. Assume a context switch (park, later wake, reschedule) costs 1000 ns round trip.

Time (ns)T1 (holds lock)T2 (spinlock)T3 (blocking lock)T4 (blocking lock)Event
0enters CStries TAS → fails, spinstries lock → fails, blocks (park)tries lock → fails, blocks (park)T1 wins the race for the lock
0–50executing CSspins (burns CPU)asleep, off run queueasleep, off run queueT2 wastes a full core; T3, T4 free a core each
50releases lockTAS succeeds → enters CSstill asleepstill asleeprelease() sets flag to 0 for spinner; queue is separate for blockers
50–100idle/other workexecuting CSstill asleepstill asleepT2's spin paid off almost immediately (50 ns wasted)
100—releases lockwoken by scheduler at some later release (say T2's release)still asleeprelease() dequeues T3, hands it ownership
100–1100—idlecontext switch in progressstill asleepT3 pays ~1000 ns wakeup latency even though CS is free
1100——executing CSstill asleepT3 finally runs the 50 ns section

Reading the trace. T2 (spinning) wasted exactly 50 ns of CPU time but acquired the lock the instant it was free — negligible latency. T3 (blocking) wasted zero CPU time while parked, freeing its core for other work, but paid roughly 1000 ns of wakeup latency before it could even begin its 50 ns critical section — a 20× latency penalty relative to the work being protected. This single trace is the entire tradeoff in miniature: with four cores and short critical sections, spinning is clearly better for this lock's latency, though it costs CPU cycles that could otherwise run T3 or T4's non-critical work. If instead the critical section were 10,000 ns (e.g., it performs disk I/O), the spinning thread would waste 10,000 ns of a whole core versus the blocker's fixed ~1000 ns overhead — now blocking wins decisively. This crossover is exactly the TcsT_{cs} vs. 2C2C comparison derived above, and it is precisely why adaptive locks spin only up to a bounded threshold (often tuned near CC) before parking.

Common pitfalls & traps

PitfallWhy it happensConsequence
Spinning on a single-CPU system without disabling interrupts/preemptionThe lock holder cannot be scheduled while the waiter spins forever on the only coreDeadlock / permanent hang, not just slowness
Holding a spinlock across a blocking call (I/O, `sleep`, page fault)Programmer assumes "spinlock = fast" without bounding the actual critical sectionAll other spinning cores stall for the full I/O duration
Forgetting to re-check the condition after waking from a blocking lockAssuming a wakeup implies immediate exclusive ownership without verificationSpurious wakeup races, subtle correctness bugs
Naively waking all waiters on release ("thundering herd")Simpler to implement than handing off to exactly one threadWasted context switches; most woken threads immediately re-block
Using plain TAS instead of TTAS/backoff under heavy contentionOverlooking cache-coherence traffic caused by repeated atomic writesSevere slowdown from bus/cache-line ping-pong across cores
Ignoring the convoy effect: one preempted lock holder stalls all waitersScheduler preempts the holder mid-critical-section while others spin/queueWhole-system throughput collapse under load

Pioneers & history

Origins · 1 minEdsger W. Dijkstra and how this was discovered
EW
Edsger W. Dijkstra
1930–2002 · Formalized mutual exclusion and synchronization primitives

Dijkstra's 1965 paper on the mutual exclusion problem, and his subsequent invention of the semaphore, gave concurrent programming its first rigorous vocabulary. His insistence on proving algorithms correct — rather than testing them into apparent correctness — shaped how spinlocks and blocking locks are analyzed to this day: as objects with formal invariants (mutual exclusion, bounded waiting, progress), not just "code that happens to work."

History
1965
— Dijkstra publishes the mutual exclusion problem and its first software solutions.
1968
— Dijkstra introduces the semaphore, generalizing lock-and-wait into counting synchronization.
1972
— Test-and-set and compare-and-swap hardware instructions become standard, enabling true spinlocks.
1991
— Mellor-Crummey and Scott publish scalable queue-based spinlocks (MCS locks) for large multiprocessors.
1990
s–2000s — futex-based blocking mutexes (Linux) and adaptive hybrid locks become standard in mainstream OS kernels and runtimes.

Where this matters

Operating system kernels use spinlocks pervasively for data structures accessed from interrupt handlers, where blocking is impossible or unsafe — Linux's `spin_lock_irqsave`, protecting run queues and device driver state, is a textbook example. Database engines and in-memory key-value stores use short spinlocks for latch-protected page headers but blocking locks (or condition variables built atop them) for anything touching disk. Language runtimes hedge their bets: Java's intrinsic locks, glibc's `pthread_mutex`, and Rust's `parking_lot` all implement adaptive spin-then-park strategies, because application code cannot know in advance whether a given critical section will be nanoseconds or milliseconds. Hardware transactional memory and lock-free data structures are attempts to sidestep the tradeoff entirely, but they still fall back to one of these two waiting disciplines whenever a conflict is detected. Understanding this tradeoff is also the on-ramp to Semaphores, which extend blocking locks from a binary "held/free" flag to a counted resource with a wait queue, and to Condition Variables, which layer richer waiting conditions on top of a blocking lock's queue machinery.

Ask the tutor: How does a ticket lock's fairness guarantee change the worst-case waiting time compared to a plain test-and-set spinlock?

Why not just always use blocking locks, since they never waste CPU cycles?

Because putting a thread to sleep and waking it later is not free — it costs a context switch, which involves the scheduler, possibly a trip through the kernel, and the loss of cache/TLB warmth for both the departing and arriving thread. If a critical section takes only a few dozen nanoseconds, as many kernel-level locks do, the overhead of blocking (hundreds to thousands of nanoseconds) vastly exceeds the work being protected. In that regime, spinning for a short bounded time is strictly cheaper. The rule of thumb: block when the expected wait is long or unpredictable, spin when it is short and the machine has spare cores to burn briefly.

Why does spinning even work — doesn't it require the lock holder to make progress on another core?

Yes, and that dependency is exactly why spinlocks are a multiprocessor-oriented tool. On a machine with multiple cores, the lock holder runs on a different physical core from the spinner, so the spinner's busy loop does not prevent the holder from executing and eventually releasing the lock. The spinner "wastes" its own core's cycles, but does not block the holder's progress. On a single core, this assumption breaks: the spinner and the holder must time-share the one CPU, and if the spinner never yields, the holder never runs — a genuine deadlock, not just inefficiency.

What is a futex, and why does it make blocking locks fast in the common case?

A futex ("fast userspace mutex") is a Linux mechanism that lets the uncontended fast path of acquiring and releasing a lock happen entirely in user space with a single atomic instruction, avoiding a system call altogether. Only when a thread actually needs to sleep (lock is held) or actually needs to wake someone (a waiter exists) does the thread trap into the kernel to use the futex syscall, which manages the actual wait queue. This means the overwhelming majority of lock operations — which are uncontended in well-designed programs — cost roughly the same as a spinlock's single atomic instruction, while contended cases still get proper blocking semantics.

What is the convoy effect, and how does it relate to this tradeoff?

The convoy effect occurs when a thread holding a lock is preempted (by the scheduler, an interrupt, or a page fault) partway through its critical section, and every other thread waiting for that lock — whether spinning or blocked — is stalled until the holder is rescheduled and finishes. For spinning waiters, this can waste an entire scheduling quantum's worth of CPU cycles across every waiting core. For blocking waiters, it simply delays the wakeup. The effect is worse for spinlocks precisely because the wasted CPU time scales with the number of spinning waiters, whereas blocked waiters cost nothing while idle — one reason kernels avoid holding spinlocks across any operation that might be preempted or block.

Are adaptive (spin-then-block) locks always better than a pure spinlock or a pure blocking lock?

In practice, yes for general-purpose libraries facing unknown workloads, because they approximate whichever pure strategy would have been optimal without requiring the programmer to predict critical-section length in advance. An adaptive lock spins briefly — often for a duration comparable to a context-switch cost — and only pays the full blocking overhead if the lock is still unavailable after that budget. The cost is implementation complexity and a small amount of wasted spinning even in the case where blocking would have been strictly better, but this is usually negligible compared to the benefit of automatically matching the workload's actual hold-time distribution.

How does priority inversion interact with spinlocks versus blocking locks?

Priority inversion happens when a low-priority thread holds a lock that a high-priority thread needs, and the high-priority thread is stuck waiting while lower-priority threads run instead. With blocking locks, schedulers can implement priority inheritance — temporarily boosting the lock holder's priority so it finishes and releases the lock quickly — because the wait queue explicitly tracks who is waiting and at what priority. Pure spinlocks have no natural hook for this: a high-priority thread spinning for a low-priority holder simply burns cycles without any scheduler-level intervention, which is one reason real-time kernels are careful about which locks are spin-based and add explicit priority-inheritance protocols where needed.

Why does this topic lead naturally into semaphores?

A blocking lock is really a binary semaphore: a counter that can be 0 or 1, with the same block-when-unavailable, wake-a-waiter-on-release structure. Semaphores generalize this to non-negative integers, enabling not just mutual exclusion but bounded resource pools (e.g., N available buffers) using the identical wait-queue mechanism this article describes. Once you understand why a blocking lock parks and hands off ownership rather than merely flipping a bit, the semaphore's `wait()`/`signal()` operations and their producer-consumer applications become a direct extension rather than a new idea.

Before this

Where this leads