Atomics, Memory Ordering & Lock-Free — Races, Barriers & When Locks Win
Interview map for process-local concurrency: data races versus race conditions, atomics and CAS, memory orders, lock-free structures and ABA, and when a mutex still wins.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What is a data race?
Answer
Two conflicting accesses to one location, at least one a write, with no happens-before between them. In C++ and Rust that is undefined behavior.
L2
How is a race condition different?
Answer
A race condition is logic that depends on timing. Every access can be locked and the business rule can still be wrong, such as check-then-act across the lock.
L3
What does lock-free mean?
Answer
Some thread completes its operation in a finite number of its own steps, even if others stall. It is not 'we called CAS' and it is not wait-free.
L4
When is acquire and release enough?
Answer
A single flag or pointer that publishes a payload. Release-store the flag after the payload. Acquire-load it before reading the payload.
L5
When do you pay for seq_cst?
Answer
When several atomics need one total order that every thread agrees on, such as an IRIW or two-flag protocol you cannot reduce to one release flag.
L6
Why does a lock-free stack need ABA protection?
Answer
CAS compares bits. A node can be popped, freed, and pushed again with the same address. A tagged pointer, hazard pointer, epoch, or a garbage collector closes that hole.
L7
When do you refuse lock-free?
Answer
Multi-field invariants, a need to sleep, fairness tails, a long critical section, or a team that cannot own reclamation. Measure first. A sharded mutex often wins.
Failure modes
Two relaxed stores as a publish
Each word is atomic and the pair is not. A reader can observe the flag and a stale or torn payload.
CAS storm on one cache line
Removing the mutex pegs cores. MESI traffic dominates. A parking lock or a shard would have been faster.
ABA on a reused node
The CAS succeeds on a recycled address and swings the stack into freed memory.
Laptop-only proof
x86 TSO hides store-buffer bugs. The same binary fails on ARM, or an optimizer deletes the check.
Misconceptions
Any use of atomics is lock-free.
Lock-free is a progress claim about the algorithm. A CAS loop that then takes a mutex is blocking.
volatile or AtomicBoolean makes a struct update atomic.
They order that one location. A second field still needs the same happens-before edge or a lock.
Lock-free is faster under contention.
Uncontended atomics are cheap. Contended CAS spins. A short critical section that parks can win on CPU and on tail latency.
Interviewer traps
Re-teaching condition variables, deadlock order, or Mesa versus Hoare.
Point at the mutex cluster and stay on visibility, progress, and the publish edge.
Answering a process-local ABA question with Redis SET NX or a ZooKeeper lease.
Those are distributed locks. Name a tagged pointer or hazard pointer, then stop.
Design scenario
Same prompt for every reader.
Requirements
The counter must not lose increments. Readers must never observe a half-written config. The queue must not corrupt under reuse of nodes. A contended path must not peg every core.
Traffic / scale
Hundreds of thousands of counter updates per second on one process, rare config publishes, and a task queue that is usually SPSC.
Latency
The counter must stay a single RMW. Config readers must not take a lock on the fast path. Queue p99 must not be a CAS storm.
Consistency
A reader that sees the new config flag must see the payload that flag published. Queue pops must not return a recycled node.
Availability
A stalled publisher must not block counter increments. A stalled consumer may leave the queue full, but a dead thread must not pin a mutex forever if you claimed lock-free.
Failure assumptions
- The binary ships on x86 and on ARM.
- Nodes in a freelist are reused.
- Someone will test only on a laptop.
Constraints
- Do not invent a lock-free map.
- Do not hold a lock across I/O.
Prompt
A multi-core service increments a request counter, publishes a config struct to readers, and sometimes swaps a small queue of tasks. The counter is hot. The config changes rarely. The queue is usually one producer and one consumer, and sometimes many.
API
Which operations are fetch-add, which are release and acquire, and which stay behind a mutex?
Data
What word publishes the config, and what tag or epoch protects a reused queue node?
Architecture
Where is the SPSC ring, and when do you refuse to upgrade it to MPMC?
What should own the shared state
Prefer
Mutex for the invariant, atomic for the word
A counter is a fetch-add. A config struct is a release flag after the payload, or a mutex if several fields change together. A queue you did not take from a paper stays locked.
- One word and no waiter: atomic RMW plus a memory order you can say out loud.
- Several fields, or a thread that must sleep: mutex. Park the waiter.
- Lock-free only for a named structure with a reclamation plan.
Alternative
Lock-free by default
Two atomic fields with no edge between them, a CAS loop that never backs off, and a freelist that reuses addresses.
- The laptop on x86 stays green. ARM or an optimizer does not.
- Contended CAS pegs cores that a short mutex would have parked.
- ABA turns a successful CAS into a use-after-free.
Pick the primitive before you pick the order
Invariant first. Profile second. Memory order third. Sibling pages hold the mechanics.
- 1
Name the shared data
If nothing is shared, copy it or pass ownership. A channel is an ownership transfer, not a faster mutex. - 2
Count the words in the invariant
One flag, counter, or pointer can be atomic. Two fields that must match need a mutex or one published immutable blob. - 3
Ask whether anyone must sleep
Empty queue, full buffer, or a need to wait for I/O is a lock and a waiter. Atomics do not park a thread. - 4
Only then name a lock-free algorithm
Treiber, Michael-Scott, or an SPSC ring. Bring ABA mitigation. If you cannot name it, keep the mutex.
Overview
Most bugs labeled "race" are one of two different failures. A data race is a conflicting access with no happens-before edge. In C++ and Rust that is undefined behavior: the compiler may delete the check. A race condition is a timing-dependent logic bug that can happen even when every access is synchronized. Check-then-act on a balance is the usual example.
This cluster is process-local shared memory: atomics, the C++11 / Java / Go / Rust mental model, CAS loops, lock-free stacks and queues, ABA, and the honest case where a mutex still wins. It does not re-teach condition variables or distributed leases.
By the end of the cluster you should be able to:
- Distinguish a data race from a race condition and name the happens-before edges that close a data race.
- Write a CAS loop and say whether it is wait-free, lock-free, or just a spinlock.
- Choose relaxed, acquire/release, or seq_cst, and know what seq_cst costs on ARM.
- Sketch a Treiber stack, an SPSC ring, and one ABA mitigation.
- Decide from contention, fairness, and review cost when the mutex stays.
Why this shows up in production
Teams reach for AtomicInteger, std::atomic, sync/atomic, or AtomicUsize when a counter must stay coherent, when a mutex is bouncing a cache line, or when a runtime already lives in lock-free code. The failures are quiet:
- A torn 64-bit write on a 32-bit ABI, or a wide struct that was never one word.
- A reorder that is invisible on a laptop and broken on ARM.
- ABA after a freelist reuses a node.
- A CAS spin that pegs CPUs a short critical section would have parked.
Interviews ask whether you can talk about visibility and progress, not whether you can spell synchronized.
Mutex, atomics, and channels
| Dimension | Mutex | Atomics and CAS | Channel |
|---|---|---|---|
| Mental model | The critical section owns the invariant | One word, or a protocol you can draw | Ownership moves with the message |
| Data-race risk | Low if every shared access takes the lock | High if the order is wrong or a field is non-atomic | Low for the transferred value. Shared memory beside the channel still races |
| Contention | Park. Unfairness is possible | Spin and bounce one cache line | Queue plus scheduler. Backpressure is natural |
| Composition | Deadlock if lock order is sloppy | Multi-word atomics do not compose | Select and fan-in |
| Fairness | Ticket and fair locks exist | CAS barges | The scheduler decides |
| Best fit | Multi-field updates, I/O, waiting | Counters, flags, rings | Pipelines and request ownership |
| Avoid when | The hot path is one flag you have measured | The invariant spans objects | You still mutate a shared graph in place |
Start with a mutex when the invariant spans fields. Promote a single word to an atomic when a profile says the lock is hot and the protocol stays one word. Prefer a channel when you can stop sharing the memory. Do not invent a lock-free map on the day of a launch.
Decisions
- 1
Shared mutable state?
- NoCopy or pass ownership
- YesOne word or flag?
- 2
Copy or pass ownership
- ?
One word or flag?
- NoMutex for the invariant
- YesAtomic RMW plus an order
- 4
Mutex for the invariant
- 5
Atomic RMW plus an order
Lesson map
Atomics, Memory Ordering & Lock-Free — Races, Barriers & When Locks Win
Interview map for process-local concurrency: data races versus race conditions, atomics and CAS, memory orders, lock-free structures and ABA, and when a mutex still wins.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Shared mutable state?"] b["Copy or pass ownership"] c["One word or flag?"] d["Mutex for the invariant"] a -->|No| b a -->|Yes| c c -->|No| d
If the word is a custom structure, the next question is whether you will own ABA and reclamation. If you will not, the arrow goes back to the mutex. That choice is when locks win.
What this cluster covers
- Data races - undefined behavior, happens-before, and the tools that see it.
- CAS - RMW, retry loops, and progress guarantees.
- Memory ordering - relaxed, acquire/release, seq_cst, and fences.
- Lock-free structures - stacks, queues, SPSC rings, and ABA.
- When locks win - contention, fairness, and hybrids.
The ring returns here after the last page.
Happens-before, at hub level
Thread A happens-before thread B's observation when a synchronization edge connects them: unlock then a later lock of the same mutex, a release store then an acquire load that sees that store, or thread start and join. Without that edge, a non-atomic read of another thread's write is a data race. C++ and Rust call that undefined behavior. Java can still surprise you. Go's race detector flags it. The full edge list is the next page.
Flow
- 1
1. Write the payload
- next2. Release-store the flag
- 2
2. Release-store the flag
- next3. Acquire-load the flag
- 3
3. Acquire-load the flag
- next4. Read the payload
- 4
4. Read the payload
Say it in this order in an interview:
- Publish the payload first. It can be non-atomic if the flag will carry the edge.
- Release-store the flag or pointer so earlier writes become visible.
- Acquire-load that same flag.
- Read the payload only after the acquire succeeds.
- A relaxed flag does not publish the payload. The data itself must be atomic or immutable, and you should be able to prove it.
What each primitive costs
| Cost | Mutex | CAS loop |
|---|---|---|
| Uncontended | Thin lock, tens of nanoseconds | One RMW, a few to tens of nanoseconds |
| Contended | Park. The OS is involved | Spin. The cache line bounces. Unfair |
| Critical section | Can hold real logic, briefly | Must stay tiny or you have reinvented a bad lock |
| How it fails | Lock order, deadlock | Reorder, ABA, lost updates that never crash |
Progress words you must not mix up
| Guarantee | Meaning | Usual example |
|---|---|---|
| Wait-free | Every thread finishes in a bounded number of its own steps | Hardware fetch-add |
| Lock-free | Some thread always finishes in finite steps | Treiber stack CAS loop |
| Obstruction-free | A thread finishes only if the others pause | Some STM designs |
| Blocking | A thread may wait on another | Mutex, condition variable |
Lock-free is not wait-free. It is not "no lock anywhere in the process" (the allocator may lock). It is not faster under contention. Using an atomic inside a method that also takes a lock does not make the method lock-free.
Sandbox
The first snippet models a lost update: both workers read the same value and both write their own increment. The second is one atomic RMW, so both increments stick. Neither snippet needs a real thread. The bug is the split read and write.
ExpectedThe racy interleaving returns 1. The atomic path returns 2.
Press Run. Snippets must be self-contained — no network, files, or native modules.
ExpectedEight successful increments land. A stale expected value does not.
Press Run. Snippets must be self-contained — no network, files, or native modules.
A Go counter with sync/atomic is the same RMW, expressed with the race detector available:
var n atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100000; j++ {
n.Add(1)
}
}()
}
wg.Wait()
fmt.Println(n.Load())CPython's GIL often hides a torn x += 1. That does not make the increment atomic on a free-threaded build, on PyPy, or in a C extension. Prefer a real atomic or a lock.
Interview Q&A
Data race versus race condition?
Answer
A data race is concurrent conflicting access to a location, at least one write, and no happens-before edge. C++ and Rust treat that as undefined behavior. A race condition is a logic bug that depends on timing. It can happen when every access is locked, if the protocol is wrong. Fix a data race with an atomic, a lock, or by not sharing. Fix a race condition by fixing the protocol.
Why can a shared x += 1 be wrong even when the process never crashes?
Answer
It is a read, a modify, and a write. Two threads can both read the same value and both write it back. One update disappears. Weaker hardware and sanitizers make that visible. A GIL can hide it and still leave the program wrong.
When is seq_cst the right order?
Answer
When you need one total order across several atomic variables and you cannot prove a single acquire/release flag is enough. IRIW and some two-flag protocols are the usual cases. A single publish flag should be release and acquire. Measure before you put seq_cst on a hot counter.
Why do lock-free stacks need ABA protection?
Answer
CAS succeeds when the bits match. Another thread can pop the node, reuse the address, and push it again. Your CAS still sees the old bits and swings the stack onto a stale next pointer. Tag the pointer, publish a hazard pointer, defer the free with an epoch, or rely on a garbage collector that does not recycle the identity.
Mutex or atomic flag for one-time initialization?
Answer
Prefer std::call_once, sync.Once, or the language's once type. Hand-rolled double-checked locking needs a correct acquire and release, which is how people reinvent the once type badly.
Does Go's race detector catch a bad memory order on ARM?
Answer
It catches unsynchronized access to shared memory. It does not replace the atomic order on a publish protocol that is technically race-free and still wrong. Go's atomic operations synchronize. Read the current memory model for the version you ship.
When do locks win?
Answer
Multi-field invariants, a need to wait, fairness, a long critical section, low contention where clarity matters, or a lock-free design the team cannot review. The decision page is when locks win.
How do you test this?
Answer
ThreadSanitizer for C, C++, and Rust. go test -race. jcstress or Lincheck for Java litmus tests. A stress test in CI on the architecture you ship, not one green run on a laptop. A race detector does not catch a locked check-then-act bug. That still needs a spec or a model.
Pitfalls
- Calling a relaxed counter a publish protocol.
- Splitting one increment into a load and a store and calling each half atomic.
- Testing only on x86 and treating that as a memory model.
- Holding a "lock-free" path that takes a mutex on failure and then advertising lock-free.
- Rewriting the mutex tutorial or a Redis lease while answering an ABA question.
Take a metrics counter, a two-field config publish, and a freelist stack. For each, say mutex, atomic, or a named lock-free structure, and name the memory order or the ABA mitigation. If the answer is "CAS everywhere," start again at the invariant.
Go Deeper
- Jeff Preshing, memory barriers are like source control operations, and the acquire/release series on the same site.
- Herb Sutter, atomic weapons.
- Rustonomicon atomics and Go's sync/atomic.
- LLVM atomics and C++ memory_order.
Next: Data races.