Concurrency
Part 6 of 6 · ConcurrencyAtomics vs Locks
A lock protects an invariant that spans several fields and makes waiters block. An atomic does one read-modify-write on one machine word (load, store, fetch_add, compare-and-swap) without blocking anyone. Use atomics for single-word counters, flags and published pointers; use a lock as soon as two fields must change together, because making each field atomic does not make the group atomic. Neither removes contention on one hot word: atomics fail by retrying while the cache line bounces, locks fail by queueing.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Lesson map
Atomics vs Locks
Thread 1 runs a CAS loop on one atomic word while Thread 2 uses fetch_add. The mutex is the blocking alternative, not part of the happy path.
Architecture. Thread 1 CAS set 7. Atomic counter Value 7. Thread 2 Added 1. Mutex Idle
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB thread_1["Thread 1 CAS set 7"] counter["Atomic counter Value 7"] thread_2["Thread 2 Added 1"] mutex["Mutex Idle"] thread_1 -->|Load 5| counter thread_2 -->|Fetch add| counter thread_1 -->|CAS miss| counter thread_1 -->|CAS set 7| counter thread_1 -->|No block| thread_2 thread_1 -->|Retry storm| counter thread_2 -->|ABA| counter thread_2 -->|Convoy| mutex thread_1 -->|Torn pair| thread_2 counter -->|Hot lock| mutex mutex -->|Shard| counter
Overview
Interviewers want the edge where atomics stop helping: multi-field invariants, CAS retry storms, ABA, and memory ordering. They also want you to refuse the claim that lock-free means faster.
In production a hot lock shows up as convoys and p99 spikes. A hot atomic shows up as CPU burned in retries with flat throughput. The fix for both is usually less sharing, not a different primitive.
By the end you should be able to:
- Pick an atomic or a lock from the invariant, not from the vibe
- Walk a CAS loop and name ABA, retry storms, and side effects that run twice
- Contrast blocking, lock-free, and wait-free
- Choose release/acquire or relaxed and say why a lock gets ordering for free
- Say what a torn pair looks like when each field is atomic
One word vs two fields that must move together
Prefer
Atomic for one word. Lock as soon as two fields change together.
Counters, flags, sequence numbers, and a pointer to an immutable snapshot map to one instruction. A balance and a version, a queue length and a buffer, or anything that must wait for a predicate, belongs under a lock.
- fetch_add finishes in a bounded number of steps on hardware that has it.
- A CAS loop retries when another thread won. Under contention those retries are the cost you measure.
- A mutex parks waiters. A preempted holder stalls everyone, and several locks can deadlock.
Alternative
Atomics on every field, or a lock around a hot counter
Each field updates atomically and the pair is still torn. A lock around a one-word counter pays for a handoff and a convoy when one fetch_add would do.
- A reader can load the new balance and the old version between the two writes.
- A CAS storm burns CPU while the cache line moves between cores.
- Waiters queue behind a preempted holder and latency spikes.
Happy path: one word, CAS and fetch_add
The five steps match Diagram 1 below. Nobody sleeps. Thread 1 pays one retry.
- 1
Thread 1 loads the counter
The atomic load reads 5. - 2
Thread 2 does fetch_add 1
One instruction. The counter is now 6. Thread 2 never waited. - 3
Thread 1 CAS(expect 5, set 6) fails
The value is already 6. The CAS reports the current value. - 4
Thread 1 retries CAS(expect 6, set 7)
The retry succeeds. - 5
Nobody blocked
Thread 1 paid one retry and Thread 2 never waited. Under heavy contention those retries are the cost you measure.
Diagrams - step by step
Three small diagrams for Atomics vs Locks. Step numbers in the labels give the animation order.
Diagram 1 - Happy path: lock-free update of one word with CAS and FAA
Sequence
- 1
Thread 1 → Atomic counter
Step 1 load - reads 5
- 2
Thread 2 → Atomic counter
Step 2 fetch_add 1 - counter is now 6
- 3
Thread 1 → Atomic counter
Step 3 CAS expect 5 set 6 - fails, sees 6
- 4
Thread 1 → Atomic counter
Step 4 CAS expect 6 set 7 - succeeds
- 5
Thread 1
Step 5 nobody blocked - T1 retried once, T2 never waited
Atomics work on a single word with read-modify-write instructions. FAA (fetch_add) always succeeds in one step; a CAS loop re-reads and retries when another thread changed the value first. No thread sleeps or holds anything, which is why simple counters and flags are the sweet spot for atomics.
Diagram 2 - Contention: CAS retry storm vs lock convoy
Flow
- 1
Step 1 Many threads hit one hot word
- nextStep 2a CAS loop on the word
- nextStep 2b Mutex around the update
- 2
Step 2a CAS loop on the word
- nextStep 3a Most CAS attempts fail and retry
- 3
Step 2b Mutex around the update
- nextStep 3b Lock holder is preempted or runs long
- 4
Step 3a Most CAS attempts fail and retry
- nextStep 4a Cache line bounces between cores - retry storm burns CPU
- 5
Step 4a Cache line bounces between cores - retry storm burns CPU
- nextStep 5 Fix - shard or batch the hot value, add backoff
- 6
Step 3b Lock holder is preempted or runs long
- nextStep 4b Waiters queue and sleep - lock convoy and latency spikes
- 7
Step 4b Waiters queue and sleep - lock convoy and latency spikes
- nextStep 5 Fix - shard or batch the hot value, add backoff
- 8
Step 5 Fix - shard or batch the hot value, add backoff
Neither tool removes contention on one hot location. Atomics fail by spinning: retries waste CPU while the cache line moves between cores. Locks fail by queueing: one slow holder makes everyone wait in line. The real fix is to reduce sharing, for example per-core counters merged later.
Diagram 3 - Decision: atomic or lock, and what goes wrong with the other
Decisions
- ?
Step 1 Invariant spans more than one field?
- YesUse a lock
- NoStep 2 Simple counter or flag?
- 2
Use a lock
- Wrong pick: lock on a hot one-word counterBlocking and convoys for a single add
- ?
Step 2 Simple counter or flag?
- YesUse an atomic - FAA or load and store
- NoStep 3 Profiling proves the lock is the hot path?
- 4
Use an atomic - FAA or load and store
- Wrong pick: atomics for multi-field stateEach field atomic but the pair is inconsistent
- ?
Step 3 Profiling proves the lock is the hot path?
- NoUse a lock
- YesConsider a carefully reviewed lock-free structure
- 6
Consider a carefully reviewed lock-free structure
- 7
Each field atomic but the pair is inconsistent
- 8
Blocking and convoys for a single add
Locks are the default for clarity, and they are required when an invariant covers several fields. Atomics win for single-word counters and flags. Making each field atomic does not make the group atomic, so a multi-field invariant built from atomics can still be torn.
What an atomic gives you
Hardware instructions make one read-modify-write on one aligned word indivisible: x86 LOCK XADD and CMPXCHG, ARM LDXR/STXR or LSE atomics. fetch_add always succeeds in one step. compare_and_swap(expected, new) succeeds only if nobody changed the word since you read it, so anything more complex than an add becomes a CAS loop: read, compute, CAS, retry on failure.
| Op | What it guarantees |
|---|---|
| load / store | One atomic word. The memory order still matters. |
| fetch_add / FAA | Wait-free increment on hardware that has the instruction. |
| compare_and_swap / CAS | Writes new only if the word is still expected. Otherwise the caller retries. |
| exchange / swap | Atomic replace, in one step. |
Python has no user-level hardware CAS. The sample below emulates one instruction with a tiny internal lock. The algorithm is the same one C++ compare_exchange, Java AtomicInteger, and JavaScript Atomics.compareExchange run in hardware. Progress terms and CAS-loop structure are on atomic operations and CAS loops.
What a lock gives you
Mutual exclusion over an arbitrary block of code, so several fields, a whole data structure, or a syscall can change as one unit. Uncontended, a good mutex is itself one atomic CAS to acquire and one store to release. The cost appears under contention, when waiters spin and then sleep in the kernel (futex) and every handoff is a context switch.
A lock also gives acquire on lock and release on unlock. That is one reason locks are easier to get right than a hand-rolled atomic flag. Ordering rules: relaxed, acquire/release, seqcst. Waiting for a predicate is a condition variable, not a spin on a flag. Several locks still need an order, or you deadlock: deadlock prevention.
Progress guarantees
| Term | Guarantee |
|---|---|
| Blocking | A thread may wait for another. A mutex is blocking: if the holder is preempted, everyone waits. |
| Lock-free | Some thread always makes progress. One unlucky thread can retry many times. A CAS loop is lock-free. |
| Wait-free | Every thread finishes in a bounded number of steps. A hardware fetch_add is wait-free. |
Using AtomicInteger inside a method that also takes a lock is not a lock-free algorithm. Lock-free is a property of the algorithm, not of the type you imported.
Memory ordering
An atomic also orders the memory around it. Use release on the store that publishes data and acquire on the load that reads the flag. Relaxed is only for values nobody uses to publish other data, such as statistics counters. A reader that sees a relaxed flag set can still observe stale payload.
A lock gives these edges for free. Two relaxed stores of balance and version are a torn pair even though each store is atomic.
Contention
One hot word bounces between cores whichever primitive you use. Shard it (per-thread or per-core counters summed on read, like Java LongAdder or Linux per-CPU counters), batch updates locally and flush, or add backoff to CAS loops. That removes the shared cache line. A faster primitive on the same line does not. When the profile says the lock is the hot path and the structure is one you can review, that is the page on when locks win.
What goes wrong with the wrong pick
Atomics for multi-field state: each field updates atomically, but a reader can see the new balance with the old version number. Fix it with a lock, a seqlock (writers bump a sequence around the update and readers retry if it changed), or by packing both into one word or one immutable object swapped by pointer.
A lock around a hot one-word counter: every increment pays for a lock handoff, and waiters convoy behind a preempted holder, when one fetch_add would do.
ABA is the other CAS failure. The value goes A to B to A, and the CAS succeeds on a different A. Pointer-based lock-free stacks and queues hit this when memory is reused. Tagged pointers, hazard pointers, or epoch reclamation are the usual fixes. That is lock-free queues, stacks, and ABA.
Pros and cons
| Approach | Pros | Cons |
|---|---|---|
| Atomic (fetch_add, load/store) | Never blocks. Wait-free on supporting hardware. Cheapest for counters and flags. | One word only. Cache-line ping-pong on hot words. Ordering rules are subtle. |
| CAS loop / lock-free structure | Lock-free progress. Works for any single-word update. No convoys. | Retry storms and wasted CPU under contention. ABA. Hard to review and test. |
| Mutex | Protects multi-field invariants. Easy to reason about. Ordering for free. Waiters sleep instead of spinning. | A preempted holder stalls everyone. Convoys and context switches under contention. Deadlock risk with several locks. |
Working Python
Run python3 atomics_locks.py (stdlib only, a few seconds). AtomicInt emulates one hardware instruction with a tiny internal lock so the lost-update, CAS, and mutex paths share the same race window (time.sleep(0)). Run it locally. The in-page sandbox has no operating-system threads.
"""Atomics vs Locks: lost updates, a CAS retry loop, and a mutex, under the same contention.
Run: python3 atomics_locks.py (stdlib only, a few seconds)
Python has no user-level hardware CAS, so AtomicInt emulates one instruction with a tiny
internal lock. The algorithm (read, compute, compare-and-swap, retry) is exactly what
C++ compare_exchange, Java AtomicInteger or JS Atomics.compareExchange do in hardware.
"""
import threading
import time
THREADS, OPS = 8, 5_000
class AtomicInt:
def __init__(self, v: int = 0) -> None:
self._v, self._hw = v, threading.Lock() # _hw stands in for the CPU instruction
def load(self) -> int:
return self._v
def compare_and_set(self, expected: int, new: int) -> bool:
with self._hw: # one indivisible step
if self._v != expected:
return False # someone else won, caller retries
self._v = new
return True
def run(worker) -> float:
ts = [threading.Thread(target=worker) for _ in range(THREADS)]
t0 = time.perf_counter()
for t in ts: t.start()
for t in ts: t.join()
return time.perf_counter() - t0
def racy() -> None:
box = {"n": 0}
def worker() -> None:
for _ in range(OPS):
v = box["n"] # read
time.sleep(0) # yield: another thread can run between read and write
box["n"] = v + 1 # write back a stale value = lost update
secs = run(worker)
print(f"plain read-modify-write: {box['n']:>6} of {THREADS*OPS} ({secs:.2f}s) lost updates")
def cas_loop() -> None:
a, retries = AtomicInt(), [0] * THREADS
def worker(i: int) -> None:
for _ in range(OPS):
while True:
v = a.load()
time.sleep(0) # same window as above, but CAS detects the conflict
if a.compare_and_set(v, v + 1):
break
retries[i] += 1 # contention shows up as wasted retries, not wrong data
ts = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)]
t0 = time.perf_counter()
for t in ts: t.start()
for t in ts: t.join()
secs = time.perf_counter() - t0
print(f"CAS loop : {a.load():>6} of {THREADS*OPS} ({secs:.2f}s) retries={sum(retries)}")
def with_lock() -> None:
box, lock = {"n": 0}, threading.Lock()
def worker() -> None:
for _ in range(OPS):
with lock: # nobody else can enter, so the same window is harmless
v = box["n"]
time.sleep(0) # but every other thread now waits while we sleep
box["n"] = v + 1
secs = run(worker)
print(f"mutex : {box['n']:>6} of {THREADS*OPS} ({secs:.2f}s) zero retries, waiters block")
if __name__ == "__main__":
racy()
cas_loop()
with_lock()Sample run, 8 threads times 5000 increments: plain read-modify-write ended at 5008 of 40000 (lost updates). The CAS loop reached 40000 of 40000 in 1.56s with 177830 retries. The mutex reached 40000 of 40000 in 2.62s with zero retries, and the other threads blocked inside the lock.
Working TypeScript
Run npx tsx atomics_locks.ts (Node 16+, no dependencies). Four worker_threads share one SharedArrayBuffer and compare a plain load/store, Atomics.add, a CAS loop, and a futex-style mutex (Atomics.wait / Atomics.notify). Run it in Node. The page sandbox cannot start worker_threads.
// Atomics vs Locks with real threads: worker_threads sharing one SharedArrayBuffer.
// Run: npx tsx atomics_locks.ts (Node 16+, no dependencies)
import { Worker } from "node:worker_threads";
const THREADS = 4, OPS = 200_000;
// Worker body. It runs in a separate V8 isolate, so it is plain JS passed via eval.
// Slots in the shared Int32Array: [0] counter, [1] mutex word (0 free, 1 held), [2+i] retries of worker i
const WORKER_JS = `
const { workerData, parentPort } = require("node:worker_threads");
const { buf, mode, ops, id } = workerData;
const s = new Int32Array(buf);
for (let i = 0; i < ops; i++) {
if (mode === "plain") {
s[0] = s[0] + 1; // load then store: two steps, updates get lost
} else if (mode === "faa") {
Atomics.add(s, 0, 1); // one fetch-and-add instruction, never retries
} else if (mode === "cas") {
for (;;) { // CAS loop: read, compute, swap if unchanged
const v = Atomics.load(s, 0);
if (Atomics.compareExchange(s, 0, v, v + 1) === v) break;
Atomics.add(s, 2 + id, 1); // lost the race, count the retry
}
} else { // "mutex": a lock built from atomics plus a futex wait
while (Atomics.compareExchange(s, 1, 0, 1) !== 0) Atomics.wait(s, 1, 1);
s[0] = s[0] + 1; // plain access is safe inside the critical section
Atomics.store(s, 1, 0);
Atomics.notify(s, 1, 1); // wake one sleeper if any
}
}
parentPort.postMessage("done");
`;
async function trial(mode: string): Promise<void> {
const buf = new SharedArrayBuffer(4 * (2 + THREADS));
const s = new Int32Array(buf);
const t0 = performance.now();
await Promise.all(Array.from({ length: THREADS }, (_, id) => new Promise<void>((ok, fail) => {
const w = new Worker(WORKER_JS, { eval: true, workerData: { buf, mode, ops: OPS, id } });
w.once("message", () => { void w.terminate(); ok(); });
w.once("error", fail);
})));
const ms = (performance.now() - t0).toFixed(0);
let retries = 0;
for (let i = 0; i < THREADS; i++) retries += s[2 + i];
const note = mode === "cas" ? ` retries=${retries}` : "";
console.log(`${mode.padEnd(5)}: ${String(s[0]).padStart(7)} of ${THREADS * OPS} in ${ms} ms${note}`);
}
async function main(): Promise<void> {
for (const m of ["plain", "faa", "cas", "mutex"]) await trial(m);
}
main();Sample run, 4 workers times 200000 increments on 8 cores: plain ended at 521093 of 800000 in 85 ms (lost updates). Atomics.add reached 800000 in 77 ms. The CAS loop reached 800000 in 103 ms with 293620 retries. The futex-style mutex reached 800000 in 264 ms.
Interview Q&A
When is an atomic the right tool instead of a lock?
Answer
When the shared state is one word and the operation maps to one atomic instruction: counters, flags, sequence numbers, a pointer to an immutable snapshot. Anything that must keep two fields consistent needs a lock, or a single word or object that holds both.
Walk through a CAS loop. What can go wrong?
Answer
Read the current value, compute the new one, CAS with expected equal to the old value. If it fails, another thread won, so re-read and retry. Risks: retry storms under contention (add backoff or shard), ABA when a value goes A to B to A and the CAS wrongly succeeds (version tags, hazard pointers, or epochs), and side effects inside the loop that run more than once.
Is an atomic always faster than a mutex?
Answer
No. Uncontended, both cost about one atomic instruction. Under heavy contention both bounce the same cache line, and a CAS loop can burn more CPU than a mutex that parks its waiters. In the TypeScript demo fetch_add is fastest, the CAS loop is close but retries hundreds of thousands of times, and the mutex is slowest because of handoffs. Measure at your real core count.
Two atomic fields, balance and version. Why is that not safe?
Answer
Each update is atomic but the pair is not. A reader can load the new balance and the old version between the two writes. Fix it with a lock, a seqlock (writers bump a sequence number around the update and readers retry if it changed), or by packing both into one word or one immutable object swapped by pointer.
What does memory ordering have to do with atomics vs locks?
Answer
An atomic flag that publishes data needs release on the store and acquire on the load, otherwise a reader can see the flag before the data it guards. Relaxed is fine for a statistics counter. A mutex gives these guarantees automatically at lock and unlock. The edges are the same story as happens-before.
How do you fix a hot counter that every request increments?
Answer
Shard it. Per-thread or per-core slots, updated with relaxed atomics and summed on read (Java LongAdder, Linux per-CPU counters), or batch locally and flush periodically. That removes the shared cache line instead of trying a faster primitive on it.
What is the difference between lock-free and wait-free?
Answer
Lock-free: the system as a whole always makes progress, but a single thread may retry indefinitely (a CAS loop). Wait-free: every thread finishes in a bounded number of steps (a hardware fetch_add). A lock is neither, because a preempted holder blocks everyone.
Pitfalls
Sketch a lost update on a shared counter, then the CAS loop that repairs it, and count the retries. Then publish balance and version as two atomics and show a reader that observes the new balance with the old version. If the structure is a lock-free queue, name the reclamation scheme before you call it done.
Go Deeper
- Mara Bos, Rust Atomics and Locks (free online book)
- Rust Atomics and Locks: the processor (cache lines and contention)
- MDN: JavaScript Atomics
- Node.js worker_threads and SharedArrayBuffer
- Jeff Preshing, An Introduction to Lock-Free Programming
- Compare-and-swap and the ABA problem (Wikipedia)
Cluster: happens-before · hub · CAS loops · memory ordering · lock-free queues · when locks win