skip to content

Describe how a compare-and-swap instruction works and how you build an arbitrary atomic update on top of it with a retry loop. Why can the loop iterate more than once, and what happens to a thread that keeps losing?

level: middleimportance: must knowfreq 62%

answer

  1. if equal, store; else report failure — atomically
  2. read, compute, install-or-retry
  3. failure means someone else won
  4. f must be pure, cheap, repeatable
  5. system progresses; an individual thread can starve

basics

~20 s

Compare-and-swap atomically writes a new value only if the location still equals the value you expected, reporting success or failure. You read, compute a new value, attempt the swap, and loop on failure with a fresh read. A loop iterates when another thread updated the location first; a persistently unlucky thread can starve, though the system as a whole always progresses.

solid answer

~60 s

Compare-and-swap (CAS) takes a location, an expected value, and a new value, and atomically performs: if the location equals expected, store new and report success; otherwise change nothing and report failure. It is a universal primitive — any single-location atomic update can be built from it. The pattern: ``` loop: old = location.load() new = f(old) # any pure computation if CAS(location, old, new): break ``` The loop re-iterates when another thread's update landed between our load and our CAS — our expected value is stale, the CAS fails, and we must recompute `f` from the fresh value, because `f(old)` was derived from a state that no longer exists. Progress-wise, a failure always means someone else succeeded, so the system as a whole moves forward. An individual thread has no such guarantee: under sustained contention it can be repeatedly beaten and starve. Two practical requirements follow: `f` must be side-effect-free and cheap, since it may run many times; and heavy contention wants backoff or a design that avoids the single hot location.

code

text · 6 lines
text
updateMax(location, candidate):
  loop:
    old = location.load()
    if candidate <= old: return        # nothing to do; skip the CAS
    if CAS(location, old, candidate): return
    # failed: another thread raised it; re-read and re-decide

go deeper

for a junior

State the semantics precisely — store only if the value still matches — and show the read, compute, retry loop for an increment.

for a middle

Explain why the new value must be recomputed after a failure, and that the retry body must be pure and cheap.

for a senior

Add spurious failure on load-linked/store-conditional hardware, the cache-line ownership cost of contended attempts, and starvation versus system progress.

for a principal

Discuss when the hot single location is itself the wrong design — sharding, per-thread state, batching — and how you would measure retry rates in production before tuning backoff.

## The primitive Compare-and-swap is a single atomic instruction with the semantics: ``` CAS(location, expected, new) -> bool: atomically: if *location == expected: *location = new return true else: return false ``` Many interfaces return the value actually found instead of a boolean, which saves a re-read on failure. The key property is that the comparison and the conditional store are one indivisible step: nothing can slip between them. CAS is theoretically special — it is a *universal* primitive, meaning any concurrent object can in principle be implemented for any number of threads using it, which simple atomic reads and writes cannot achieve. Practically, it is the escape hatch for updates the hardware does not implement directly. Fetch-and-add covers addition; CAS covers everything else: maximum, saturating decrement, conditional state transitions, pushing onto a list, updating a packed struct. ## The retry loop The optimistic pattern is always the same three beats: **read, compute, install-or-retry**. ``` atomicUpdate(location, f): loop: old = location.load() new = f(old) if CAS(location, old, new): return old # else: someone else won; discard `new` and start over ``` The reason to loop rather than adjust is that `new` is only meaningful relative to `old`. If `old` is no longer current, `new` encodes a decision made about a world that no longer exists, and installing it would silently discard the other thread's update — the same lost update you were trying to prevent. This imposes discipline on `f`: - **It must be pure.** No I/O, no logging, no mutation of other shared state, no allocation you cannot afford repeatedly. Whatever it does happens once per attempt, and attempts are discarded. - **It must be cheap.** A long computation widens the window in which someone else can win, which raises the failure rate, which lengthens the loop — a self-reinforcing effect under contention. - **It must be idempotent in effect.** Only the final successful attempt counts; every earlier one must leave no trace. ## Why failures happen, and what they mean A CAS fails for exactly one reason in the abstract model: the value changed. (Some hardware, notably load-linked/store-conditional implementations, can also fail *spuriously* — the store-conditional aborts because the cache line was touched at all, including by an unrelated variable sharing the line, or because of a context switch. Code written as a retry loop absorbs this transparently, which is one reason the loop is the canonical shape rather than a single attempt.) Every real failure implies some other thread succeeded. That is the source of the progress guarantee for the structure as a whole: the system cannot deadlock on CAS, because nobody holds anything, and no thread's stall can block others — unlike a lock, where a thread preempted inside the critical section stops everyone. What is *not* guaranteed is per-thread progress: an unlucky thread can lose every race indefinitely and starve. That distinction — system-wide progress versus per-thread progress — is the point of the formal progress hierarchy. ## Cost and contention A CAS is not cheap. The executing core must take the cache line into an exclusive state, so every attempt — successful or not — pulls the line away from other cores. Under contention, N threads hammering one location produce a storm of cache-line transfers; failed attempts consume bandwidth while accomplishing nothing, and throughput can go *down* as you add cores. Mitigations: exponential backoff between retries, reading the value cheaply and skipping the CAS if the update would be a no-op, or restructuring so the hot location is not shared — per-thread or striped state combined only when read. ## Where the pattern silently breaks Two traps worth naming: **Multi-location invariants.** CAS is single-location. Keeping two variables consistent needs both packed into one atomic word, a lock, or a more elaborate protocol; two CASes in sequence give you an inconsistent window in between. **Value recurrence.** A successful CAS proves the value matches now, not that nothing happened. If the location can return to a previous value — recycled nodes, reused indices — a stale attempt can succeed on a world that has moved, which is the ABA problem and needs a version stamp or another mechanism.

  • Why must the function computing the new value be free of side effects?
    Because it may run many times and all but the last run are thrown away. If it logs, allocates something registered elsewhere, mutates other shared state, or performs I/O, those effects happen once per failed attempt and cannot be undone, so the observable behaviour depends on how many races the thread lost. Only the value installed by the successful compare-and-swap should have any consequence.
  • Can a compare-and-swap loop deadlock? Can a thread starve?
    It cannot deadlock, because no thread holds anything that another needs; a failed attempt always implies some other thread succeeded, so the system as a whole advances. An individual thread can starve, however: under sustained contention it may lose every race and never complete, which is why the guarantee is system-wide progress rather than per-thread progress. Backoff and reducing sharing are the practical mitigations.

Editing a wiki page: you load the current text, make your change, and submit with the revision you started from. If someone else saved first, the submit is rejected and you must reload and redo your edit on the new text.

saying these in an interview costs you the question

  • Treating a failed compare-and-swap as an error rather than the normal contended path
  • Reusing the previously computed new value on retry instead of recomputing from a fresh read
  • Putting logging, allocation, or I/O inside the retry body
  • Claiming a compare-and-swap loop guarantees every thread makes progress
  • Assuming compare-and-swap can atomically update two separate locations

context