AnyLearn
All lessons
Programmingadvanced

Memory Models, Atomics, and Lock-Free

The CPU and compiler silently reorder your code. Cache coherence does not mean coherent behavior. This lesson explains the happens-before relation, memory fences, C++ and Java atomic operations, compare-and-swap, and why `volatile` is not a synchronization primitive.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

The reordering problem: compilers and CPUs lie

The code you write is not the code that executes. Both the compiler and the CPU reorder instructions freely, as long as the reordering is invisible to a single thread. The moment you have two threads, what looks invisible locally becomes catastrophically visible globally.

Compiler example: a store to ready = true may be hoisted above the write to data if the compiler sees no dependency. CPU example: store-buffer forwarding on x86 means a core may read its own recent write before other cores see it; on ARM and POWER, even store order between two cores is not guaranteed.

Why? Performance. Reordering allows out-of-order execution, pipeline saturation, and cache-line prefetching. Modern CPUs can sustain 3–6 instructions per cycle precisely because they're free to reorder.

The implication: you cannot reason about concurrent programs by reading C or Python source. You must think in terms of the memory model the language or hardware guarantees.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. The reordering problem: compilers and CPUs lie

The code you write is not the code that executes. Both the compiler and the CPU reorder instructions freely, as long as the reordering is invisible to a single thread. The moment you have two threads, what looks invisible locally becomes catastrophically visible globally.

Compiler example: a store to ready = true may be hoisted above the write to data if the compiler sees no dependency. CPU example: store-buffer forwarding on x86 means a core may read its own recent write before other cores see it; on ARM and POWER, even store order between two cores is not guaranteed.

Why? Performance. Reordering allows out-of-order execution, pipeline saturation, and cache-line prefetching. Modern CPUs can sustain 3–6 instructions per cycle precisely because they're free to reorder.

The implication: you cannot reason about concurrent programs by reading C or Python source. You must think in terms of the memory model the language or hardware guarantees.

2. Cache coherence: necessary but not sufficient

Multi-core CPUs use a coherence protocol (MESI or derivatives) to ensure that all cores eventually agree on the value of any single memory address. If core 0 writes x = 5, core 1 will eventually see x = 5 — it will never see a stale value indefinitely.

But coherence ≠ consistency. Coherence is per-variable; consistency is about the order of multiple variables. Consider:

// Thread A (core 0)         // Thread B (core 1)
x = 1;                        y = 1;
int r1 = y;                   int r2 = x;

On x86 it's impossible for both r1 == 0 and r2 == 0. On ARM it's legal — both stores may sit in store buffers while the loads execute. Cache coherence guarantees each write is eventually visible; it does not guarantee the order threads observe each other's writes.

This is the gap a memory model fills: it tells you exactly which orderings are possible.

3. The happens-before relation

A happens-before edge (ABA \to B) means: all memory effects of AA are visible to BB before BB executes. Without such an edge, BB may see stale values, even on a cache-coherent machine.

Happens-before is established by synchronization events:

  • Releasing a mutex HB acquiring the same mutex.
  • Writing an atomic with release semantics HB reading it with acquire semantics.
  • thread.join() HB everything after the join.
  • channel send HB channel receive (in Go).

If thread A writes data, then writes ready = true (release), and thread B reads ready (acquire) and sees true, then B is guaranteed to see A's write to data — even if data is a plain variable.

Without a happens-before edge, the question "does thread B see thread A's write?" has no definite answer. The compiler or CPU may have reordered or cached it away.

4. Why `volatile` is not a lock

In Java, volatile on a field guarantees: (1) writes are not cached in registers, (2) a write happens-before a subsequent read by another thread. In C/C++, volatile only prevents the compiler from eliding reads/writes — it does not generate memory fences or prevent CPU reordering.

Common misconceptions:

ClaimJava volatileC volatile
Prevents register cachingYesYes
Prevents CPU reorderingYes (via fences)No
Makes compound ops atomicNoNo
Establishes happens-beforeYesNo

The C++ equivalent of Java volatile for concurrency is std::atomic<T> with appropriate memory ordering — not volatile. Using volatile in C++ for thread communication is a bug, not a feature. The compiler is allowed to reorder volatile stores relative to non-volatile ones, and the CPU adds further reordering on top.

5. Atomic operations and compare-and-swap

An atomic operation completes without any observable intermediate state from another thread. The most powerful atomic primitive is compare-and-swap (CAS):

// Atomically: if (*ptr == expected) { *ptr = desired; return true; } else return false;
bool compare_exchange(T *ptr, T expected, T desired);

CAS is the foundation of most lock-free algorithms. A lock-free counter in C++:

#include <atomic>
std::atomic<int> counter{0};

void increment() {
    int old = counter.load(std::memory_order_relaxed);
    while (!counter.compare_exchange_weak(
               old, old + 1,
               std::memory_order_release,   // success ordering
               std::memory_order_relaxed))  // failure ordering
    {}
    // old is updated by CAS on failure — no spurious retry wasted
}

compare_exchange_weak may fail spuriously (on some architectures it's LL/SC under the hood). The loop retries until the CAS succeeds. This is wait-free in the absence of contention; under high contention, all threads retry simultaneously — a CAS storm.

6. Memory ordering: acquire, release, seq_cst

C++ std::atomic exposes six memory orderings. In practice you use three:

  • memory_order_relaxed: no ordering guarantees beyond atomicity. Use for counters where exact order doesn't matter (stats, hit counts).
  • memory_order_acquire: no reads/writes in the current thread can be reordered before this load. Pairs with release on the writer side.
  • memory_order_release: no reads/writes in the current thread can be reordered after this store. Establishes happens-before to an acquire load that sees this value.
  • memory_order_seq_cst (default): full sequential consistency — a global total order over all seq_cst operations across all threads. Safest; most expensive (full fence on some architectures).

The acquire/release pair is the standard idiom for a one-producer, one-consumer channel:

// Producer (thread A)
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release);  // publishes data

// Consumer (thread B)
while (!ready.load(std::memory_order_acquire)) {} // sees data after this
use(data.load(std::memory_order_relaxed));          // guaranteed to see 42

7. The ABA problem in lock-free code

CAS checks that the value equals expected. But what if another thread changed the value from A to B and back to A between your load and your CAS? The CAS succeeds even though the world changed — this is the ABA problem.

Example: a lock-free stack. Thread A reads head=A. Thread B pops A, pops B, pushes A back. Thread A's CAS succeeds (head is still A) and sets head to A's old next — which now points to freed memory.

Fixes:

  • Tagged pointers / version counters: pack a monotonically increasing version into the pointer (common on 64-bit: use the upper 16 bits). CAS on both pointer and version — B→A will have a different version.
  • Hazard pointers: mark pointers in use before loading; defer freeing until no hazard pointer references them.
  • Epoch-based reclamation (EBR): threads publish their epoch; memory is freed only when all threads have advanced past the epoch that freed it.

ABA is why lock-free data structures are harder to write correctly than lock-based ones. Prefer battle-tested library implementations.

8. Acquire-release: establishing happens-before

A release store on thread A synchronizes with an acquire load on thread B, making all of A's prior writes visible to B.

sequenceDiagram
  participant A as Thread A
  participant M as Shared Memory
  participant B as Thread B
  A->>M: data.store(42, relaxed)
  A->>M: ready.store(true, RELEASE)
  B->>M: ready.load(ACQUIRE) -> true
  B->>M: data.load(relaxed) -> 42
  Note over A,B: happens-before edge: A's writes visible to B

9. Lock-free vs wait-free vs obstruction-free

These terms have precise meanings that are often blurred in blog posts:

GuaranteeDefinitionExample
Obstruction-freeA thread makes progress if it runs alone (others suspended)Weakest useful guarantee
Lock-freeAt least one thread makes progress system-wide at all timesCAS retry loops
Wait-freeEvery thread completes its operation in a bounded number of stepsFetch-and-add on x86

Most "lock-free" data structures in practice are lock-free but not wait-free: under adversarial scheduling, a single thread could be starved (though not permanently, by the system-level progress guarantee). True wait-freedom requires algorithms like Kogan-Petrank queues — complex and rarely faster in practice.

The practical takeaway: lock-free structures reduce worst-case tail latency and eliminate deadlock, but are not magic. Under low contention a well-written mutex is often faster due to simpler code and better branch prediction.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. On ARM, both `r1 == 0` and `r2 == 0` can result from this code run concurrently: ``` Thread A: x=1; r1=y; Thread B: y=1; r2=x; ``` Why is this not possible on x86?
    • x86 has cache coherence; ARM does not.
    • x86 enforces Total Store Order — stores are globally visible in program order; ARM's weaker model allows stores to sit in per-core buffers.
    • x86 compilers refuse to reorder stores; ARM compilers are allowed to.
    • ARM CPUs run threads on separate cores; x86 CPUs time-slice on one core.
  2. In C++, what does `volatile int x` guarantee in a multi-threaded context?
    • Reads and writes to x are atomic and cannot be torn.
    • Writes to x are visible to other threads immediately.
    • The compiler will not optimize away reads/writes, but CPU reordering is still possible and no happens-before is established.
    • x behaves like Java's volatile — writes happen-before subsequent reads in other threads.
  3. A compare-and-swap on a lock-free stack succeeds, but the node it targeted has been popped and re-pushed by another thread in between. What problem is this?
    • A spurious wakeup caused by a condition variable.
    • The ABA problem — the value looks unchanged but the state has changed underneath.
    • A torn read caused by non-atomic 64-bit access.
    • A deadlock because both threads tried to pop the same node.
  4. Thread A stores `ready = true` with `memory_order_release`. Thread B spins on `ready` with `memory_order_acquire` and sees `true`. Which statement is guaranteed by the C++ memory model?
    • Thread B will see all writes Thread A performed before the release store.
    • Thread B will see Thread A's writes only if they also used memory_order_release.
    • Thread B will see Thread A's writes only on x86; the guarantee does not hold on ARM.
    • The acquire load must be on the same core that performed the release store.
  5. A lock-free queue guarantees lock-free progress. Does this mean no thread can be starved?
    • Yes — lock-free means every thread completes its operation in a bounded number of steps.
    • No — lock-free guarantees system-wide progress (some thread advances) but an individual thread may retry indefinitely under adversarial scheduling.
    • Yes — the absence of locks eliminates all forms of priority inversion and starvation.
    • No — lock-free queues are wait-free only on hardware with native CAS support.

Related lessons