skip to content

Why do real spin loops read the lock word before attempting an atomic write, and why do they add backoff and adaptive spin limits instead of retrying as fast as possible?

level: seniorimportance: should knowfreq 40%

answer

  1. atomic write needs the line exclusive ⇒ ping-pong
  2. test-and-test-and-set: read (shared) then attempt
  3. release invalidates once ⇒ thundering herd ⇒ backoff + jitter
  4. pause hint: SMT sibling + pipeline + power
  5. MCS/CLH: spin on your own cache line, FIFO

basics

~20 s

An atomic write takes the cache line exclusively, so hammering it makes every spinner steal the line from the holder and each other. Reading first (test-and-test-and-set) spins on a shared copy; backoff and adaptive limits cut the remaining traffic and stop pointless spinning.

solid answer

~1 min

A naïve `while (test_and_set(lock)) {}` performs a write-class atomic every iteration. Under a cache-coherence protocol, that requires exclusive ownership of the lock's cache line, so each spinner repeatedly invalidates every other copy — including the holder's. With k spinners you get k-way ping-pong, the interconnect saturates, and the holder itself runs slower, lengthening exactly the hold time you were betting would be short. **Test-and-test-and-set** fixes the bulk of it: spin on a plain read until the line reads FREE, and only then attempt the atomic. Reads can share the line, so waiters sit quietly in the shared state and generate no traffic while the lock is held. **Backoff** (exponential, ideally randomised) thins the thundering herd at the moment of release, when every waiter attempts at once. A CPU **pause/yield hint** in the loop body reduces power and stops the spinner starving its SMT sibling. **Adaptive spinning** bounds the bet: spin only while it is plausibly worth it — a budget derived from past hold times, or only while the owner is observed to be running — then park. **Queue locks** (MCS/CLH) go further, giving each waiter its own cache line to spin on, so a release touches exactly one line and hand-off is FIFO.

code

text · 14 lines
text
# naive: an exclusive-ownership request every iteration
while atomic_test_and_set(lock) == LOCKED:
    pass                          # k spinners => k-way cache-line ping-pong

# TTAS + pause + capped randomised backoff
delay = 1
loop:
    while load(lock) == LOCKED:   # shared reads: no coherence traffic
        cpu_pause_hint()
    if atomic_test_and_set(lock) == FREE:
        return
    sleep_spin(random(0, delay))  # thin the herd after a release
    delay = min(delay * 2, CAP)
    goto loop

go deeper

for a junior

Know that a spin loop should read the lock before trying to take it, and that hammering an atomic in a loop is expensive rather than free.

for a middle

Explain cache-line ownership: atomic writes need the line exclusive, plain reads can share it, so test-and-test-and-set removes most of the traffic while the lock is held.

for a senior

Cover the thundering herd after release, randomised capped backoff, the pause hint's SMT and pipeline effects, and adaptive budgets driven by owner state or hold-time history.

for a principal

Compare the damping approach (TTAS plus backoff) with the structural fix (MCS/CLH queue locks), and reason about which belongs in a general-purpose lock given the uncontended fast-path cost and cancellation complexity.

## Why the naïve loop is bad The textbook spinlock is: ``` while atomic_test_and_set(lock) == LOCKED: /* retry */ ``` Every iteration executes an atomic read-modify-write. Under a MESI-style coherence protocol, any write-class operation requires the cache line in the **exclusive/modified** state, which means sending an invalidate to every other core holding a copy and waiting for acknowledgement. So each spinner, on each iteration, rips the line away from every other core — including the core running the lock holder, whose release will need that same line. The consequences compound: - Coherence traffic grows with the number of spinners, saturating the interconnect and hurting unrelated work. - The holder is slowed by having its line stolen, so the hold time **H** grows — the very quantity the spin decision assumed was tiny. - Throughput can decrease as you add cores: the classic negative-scalability curve. ## Test-and-test-and-set The standard fix is to separate the cheap check from the expensive attempt: ``` loop: while load(lock) == LOCKED: # plain read: line stays SHARED cpu_pause_hint() if atomic_test_and_set(lock) == FREE: return # got it goto loop # lost the race, back to reading ``` Plain reads can be satisfied from a shared copy in the local cache with no bus traffic at all, so waiters spin in silence while the lock is held. Only when the holder writes FREE — invalidating the shared copies once — do the waiters observe the change and attempt the atomic. This converts continuous traffic into a single burst per release. The remaining burst is the *thundering herd*: k waiters all attempt at once, one wins, k−1 retry. ## Backoff To thin the herd, waiters wait a growing interval between attempts — exponential backoff, capped, with randomisation to break lockstep between waiters that started together. This is the same idea as network collision backoff and for the same reason: uncoordinated retriers converge into synchronised storms unless you inject randomness. Backoff trades latency for traffic. Too aggressive and a waiter is still sleeping when the lock becomes free, adding delay; too timid and the herd persists. It is a heuristic, which is part of why queue locks (below) are attractive: they remove the herd structurally instead of damping it. ## The pause / yield hint Processors expose a hint instruction intended for spin loops. It has two effects worth knowing: - It relieves the pipeline of a burst of speculative loads that will be squashed when the value finally changes, avoiding a memory-order violation penalty on exit from the loop. - On simultaneous-multithreading cores it de-prioritises the spinning thread so its sibling — which may be doing real work, or may even be the lock holder — gets the shared execution units. Without it, a busy spinner can roughly halve its sibling's throughput. It also lowers power draw, which matters for turbo headroom on dense machines. ## Adaptive spinning Adaptive spinning makes the spin budget a function of evidence rather than a constant: - **History-based.** Track how long this particular lock has been held recently; spin proportionally. A lock that has always been released in 80 ns earns a spin; one that historically holds for 20 µs does not. - **Owner-state-based.** If the runtime can see whether the lock's current owner is presently executing on a CPU, spin only while it is. If the owner is descheduled, spinning cannot possibly succeed soon, so park immediately. This is the single most valuable adaptive signal, because it directly tests the precondition that makes spinning sound. - **Success-feedback.** If recent spins on this lock mostly ended in a park, shrink the budget; if they mostly succeeded, grow it. The universal fallback is spin-then-park, which bounds the wasted CPU at roughly the cost of the park it was trying to avoid. ## Queue locks: fixing it structurally MCS and CLH locks eliminate both the ping-pong and the herd. Each waiter enqueues a node it owns and **spins on a flag inside its own node**, hence on its own cache line. The holder, on release, writes to the next waiter's node only. Properties: - One cache-line transfer per hand-off, independent of the number of waiters. - FIFO order, so no starvation and a bounded wait. - Cost: an extra node per waiter and a slightly more expensive uncontended path, plus more complex cancellation. This is why kernel and runtime lock implementations converge on queue-based designs for heavily contended locks while keeping a simple atomic fast path for the uncontended case. ## Putting it together A modern lock's contended path typically reads: try the atomic once; if it fails, test-and-test-and-set with a pause hint for a short adaptive budget informed by owner state; if that fails, enqueue and park. Each stage exists because a specific cost dominates in a specific regime, and being able to name which cost each stage attacks — coherence traffic, herd synchronisation, SMT starvation, wasted CPU on a descheduled owner — is what the question is really testing.

  • Why does test-and-test-and-set still need the atomic attempt after the read says FREE?
    The read is only a hint: between observing FREE and acting on it, another core may have taken the lock. Mutual exclusion requires a single atomic read-modify-write to claim it, so the read filters out hopeless attempts while the atomic provides the actual guarantee. Losing that race simply sends the thread back to the cheap read loop.
  • What does an MCS-style queue lock buy over test-and-test-and-set with backoff, and what does it cost?
    It removes both remaining problems structurally: each waiter spins on a flag in its own node, so a release generates exactly one cache-line transfer regardless of waiter count, and the queue gives FIFO hand-off with no thundering herd and no starvation. The costs are an extra node per waiter (or per acquisition), a marginally more expensive uncontended fast path, and considerably harder cancellation and timeout handling.
  • Which adaptive signal is the most useful, and why?
    Whether the current lock owner is executing on a CPU right now. Spinning is only sound when the holder can make progress in parallel, so this signal tests the precondition directly rather than inferring it from history. If the owner is descheduled, no amount of spinning will shorten the wait, and the waiter should park immediately.

Everyone in a room grabbing at one microphone every second (naïve spin) versus watching until it is put down and only then reaching (test-and-test-and-set), versus forming a queue where each person is tapped on the shoulder by the one in front (MCS).

saying these in an interview costs you the question

  • Believing a tight atomic retry loop is harmless because "it's just one instruction"
  • Not knowing that atomic writes require exclusive cache-line ownership
  • Treating the pause hint as a delay loop rather than an SMT and pipeline hint
  • Adding backoff without randomisation and expecting the herd to disperse
  • Assuming a fixed spin count is appropriate for every lock in a program

context