02 — Atomics and memory models: what a store actually promises¶
Tier 0, doc 02. Prerequisites:
01-memory-hierarchy-and-caches.md— specifically §5 (MESI, and the store-buffer paragraph that ends it). This document is the continuation of that paragraph. Also useful:00-cpu-execution-model.md(out-of-order execution, speculation). Feeds directly into:03-lockfree-and-reclamation.md,24-the-gil.md,26-free-threading.md,30-concurrency-correctness.md,25-threads-and-synchronization.md.THESIS: a store instruction is not a promise that anyone else will see the value, and it is certainly not a promise about when. Doc 01 established that coherence makes every core eventually agree on the value of one location. That is a much weaker guarantee than programmers assume, because it says nothing about the order in which different locations become visible. The gap between "coherent" and "ordered" is where store buffers live, and it is the entire subject matter of this document. On x86 that gap is narrow enough that broken code usually works anyway. On the ARM machine this repo lives on, the gap is wide, and broken code breaks. Everything free-threaded CPython does with
_Py_atomic_*is an attempt to name that gap precisely enough to stay on the right side of it.Measurement provenance. Every number and every assembly listing below was produced on the machine this repo lives on: Apple M3 Pro (5 P-cores + 6 E-cores), macOS, arm64, 128-byte cache lines, 16 KB pages, with Apple clang 21.0.0 (clang-2100.1.1.101), CPython 3.14.6 and CPython 3.14.6 free-threading (
sys._is_gil_enabled()verifiedFalse). Assembly was generated withclang -O2 -S; x86-64 listings come fromclang -arch x86_64 -O2 -Son the same machine — cross-compiled, and I verified the cross-compiler works (see §7.3 for the one place I also ran x86-64 code, and the large caveat attached to it). Items marked (measured) are live output captured while writing this document. Items marked (documented) are architectural facts from the ARM ARM or the C standard that I did not independently verify by execution. Anything I could not confirm is flagged in place. The heterogeneous P/E cores make this a hostile benchmarking host — see §12.4 before trusting any contended number, including mine.
Contents¶
- Where doc 01 stopped: the store buffer
- Invalidate queues: the same problem on the reader's side
- Two reorderings, two different enemies
volatileis not a concurrency tool — proved in assembly- x86-TSO vs AArch64: the spine of this document
- The litmus tests, run on this machine
- The C11/C++11 memory model, and what it compiles to
- Fences vs. ordered accesses
- Read-modify-write: CAS, LL/SC, and the exclusive monitor
compare_exchange_weakvsstrong, in real instructions- Why
fetch_addbeats a CAS loop under contention - The atomic cost model — measured
- Alignment, and what a split atomic really does
- Data races are undefined behaviour, and SC-DRF is the bargain
- CPython's
_Py_atomic_*API, verified against the source - Python has no memory model
- Lab exercises
- Question bank
- Sources
1. Where doc 01 stopped: the store buffer¶
Doc 01 §5 ended with four bullet points about store buffers. Here is the mechanism behind them.
MESI is correct but slow. To write a line held in Shared state, a core must broadcast a request-for-ownership and wait for every other core to acknowledge invalidation. That is an interconnect round trip — 40–100+ cycles on this machine's figures, and worse across the P/E cluster boundary or a NUMA hop.
A core that stalled for that on every store would retire roughly one store per hundred cycles. So it doesn't stall. It writes the value into a small, fully-associative, per-core FIFO called the store buffer and retires the instruction immediately. The coherence transaction completes in the background, and the entry drains into L1 when ownership arrives.
CORE 0 CORE 1
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
│ execution units │ │ execution units │
│ │ store x=1 ▲ load y │ │ │ store y=1 ▲ load x │
│ ▼ │ │ │ ▼ │ │
│ ┌─────────────┐ │ │ │ ┌─────────────┐ │ │
│ │STORE BUFFER │─────────┘ │ │ │STORE BUFFER │─────────┘ │
│ │ [x=1] ... │ store-to-load │ │ │ [y=1] ... │ forwarding │
│ └──────┬──────┘ forwarding: I see │ │ └──────┬──────┘ (my own writes │
│ │ my own store even │ │ │ are visible to me │
│ │ drains though nobody │ │ │ drains immediately) │
│ ▼ else does yet │ │ ▼ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ L1D y=0 │ │ │ │ L1D x=0 │ │
│ └──────┬──────┘ │ │ └──────┬──────┘ │
└─────────┼────────────────────────────┘ └─────────┼────────────────────────────┘
└───────────────┬───────────────────────────┘
┌───────┴────────┐
│ coherence │ ← both stores are "in flight" here.
│ fabric (MESI) │ Neither core's store has reached the
└────────────────┘ other core's L1 yet.
RESULT: Core 0's load of y misses the buffered y=1 and reads 0 from L1.
Core 1's load of x misses the buffered x=1 and reads 0 from L1.
BOTH threads read 0. Under sequential consistency this is impossible:
one of the two stores must have "happened first".
Three consequences follow immediately, and they are the whole game:
1. Your stores become visible to others later than they execute. The store retired; the value is not architecturally visible to anyone else. There is a window.
2. You can read your own write before anyone else can. Store-to-load forwarding lets a load snoop the store buffer. Single-threaded code therefore sees a perfectly sequential world — which is exactly why single-threaded reasoning survives — while cross-thread observers see something else.
3. A memory barrier is, mechanically, "wait for the store buffer to drain." That is not a metaphor. It is what the instruction does, and it is why barriers cost real time rather than being free annotations. §12 measures it.
The diagram above is the SB litmus test (also called Dekker's, because it is exactly the failure mode of Dekker's mutual-exclusion algorithm without fences). §6 runs it on real hardware and observes it 15,093 times per million trials (measured).
Why this is not a bug. Nothing here violates coherence. Coherence is a per-location guarantee: all cores agree on the sequence of values taken by
x, and separately on the sequence fory. Nobody promised that the interleaving betweenxandyis consistent across cores. That stronger promise is called sequential consistency, and no shipping CPU provides it for free, because it would cost the store buffer.
2. Invalidate queues: the same problem on the reader's side¶
The store buffer is the writer's optimization. There is a symmetric one on the reader's side, and it is the reason ARM is weaker than x86 rather than merely equal to it.
When Core 0 broadcasts an invalidate for line L, Core 1 must acknowledge it. If Core 1's cache is busy, acknowledging promptly would stall the coherence fabric. So Core 1 acknowledges immediately and parks the invalidate in an invalidate queue, applying it to L1 later.
Core 0 Core 1
────── ──────
store DATA = 42 ─┐ ┌── L1D: DATA = 0 (stale, Shared)
│ store buffer │ FLAG = 0
store FLAG = 1 ─┐│ │
││ │ ┌──────────────────┐
▼▼ │ │ INVALIDATE QUEUE │
┌──────────────┐ invalidate DATA │ │ [inval DATA] ←──┼── acked, but
│ coherence │ ─────────────────────▶│ │ NOT YET
│ fabric │ invalidate FLAG │ │ [inval FLAG] │ APPLIED
└──────────────┘ ─────────────────────▶└──────────────────┘
│
│ Core 1 executes:
│ r1 = FLAG → misses L1, refetches → 1 ✓
│ r2 = DATA → HITS stale L1 line → 0 ✗
│ (its invalidate is
│ still in the queue)
▼
r1 == 1 && r2 == 0 ← the MP test's forbidden outcome
That outcome — reading the flag as set but the data as unwritten — is the MP (message passing) litmus test, and it is the single most consequential difference between x86 and ARM:
- On x86-TSO it cannot happen. TSO has no invalidate-queue-visible reordering and no
StoreStore reordering; if you observe
FLAG == 1you are guaranteed to observeDATA == 42. (documented; and consistent with the x86 run in §6) - On AArch64 it happens. §6 observes it 923–1,190 times per million trials (measured).
This is why the "it works on my Intel laptop, it corrupts on Graviton" bug exists as a genre. The publish-then-read-flag idiom — the most common lock-free pattern there is — is accidentally correct on x86 and actually wrong on ARM.
An honest limit on this explanation. The invalidate queue is McKenney's model, and it is the standard pedagogy. My §6 measurement proves the outcome is architecturally permitted and empirically frequent on an M3 Pro. It does not prove which microarchitectural structure produced it: Apple does not document its coherence implementation, and the same outcome can arise from store-buffer drain reordering on the writer, load speculation/reordering on the reader, or a non-multi-copy-atomic interconnect. Do not claim you have measured an invalidate queue. You have measured that the AArch64 memory model permits what x86-TSO forbids, which is the part you reason with.
3. Two reorderings, two different enemies¶
Every discussion of memory ordering that stays confusing is confusing because it merges two independent phenomena. Separate them and everything gets simpler.
| Compiler reordering | Hardware reordering | |
|---|---|---|
| Who does it | the optimizer, at build time | the core, at run time |
| Why | register allocation, CSE, dead-store elimination, loop-invariant hoisting, vectorization | store buffers, invalidate queues, out-of-order issue, speculation |
| What it's allowed to do | anything invisible to a single-threaded observer (the "as-if" rule) | anything the ISA memory model permits |
| Visible where | in the generated assembly | only across cores |
| Defeated by | compiler barriers, volatile, _Atomic |
CPU barriers, ordered instructions |
| Present on x86? | yes, fully | partly (StoreLoad only) |
| Present on ARM? | yes, fully | yes, all four |
Both must be stopped, and they are stopped by different mechanisms. A C11 atomic operation with a non-relaxed order stops both at once, which is the entire reason the C11 model exists: it gives you one knob instead of two, expressed in terms of program semantics rather than this year's CPU.
The four elementary reorderings, in the standard naming (Preshing's, and it is worth memorizing):
LoadLoad a later load moves before an earlier load
LoadStore a later store moves before an earlier load
StoreStore a later store moves before an earlier store ← MP test dies here
StoreLoad a later load moves before an earlier store ← SB test dies here
| Reordering | x86-TSO | AArch64 |
|---|---|---|
| LoadLoad | no | yes |
| LoadStore | no | yes |
| StoreStore | no | yes |
| StoreLoad | yes | yes |
Read that table as the compressed form of this whole document. x86 permits exactly one
of the four; ARM permits all four. StoreLoad is the one nobody can avoid, because
avoiding it means draining the store buffer before every load, which no design does. It
is why even x86 needs a fence in Dekker's algorithm, and why mfence / dmb ish exist
at all.
4. volatile is not a concurrency tool — proved in assembly¶
This is the most durable misconception in systems programming, and it is settleable by looking at real output rather than arguing. Three C functions, compiled here (measured):
int data; int flag;
volatile int vdata; volatile int vflag;
atomic_int adata, aflag;
void plain_reorder(void) { flag = 1; data = 42; flag = 2; }
void volatile_reorder(void) { vflag = 1; vdata = 42; vflag = 2; }
void volatile_publish(void) { vdata = 42; vflag = 1; }
void release_publish(void) { atomic_store_explicit(&adata, 42, memory_order_relaxed);
atomic_store_explicit(&aflag, 1, memory_order_release); }
4.1 What volatile does buy you: the compiler stops deleting your stores¶
clang -O2 -S, AArch64 (measured):
_plain_reorder: ; flag=1; data=42; flag=2;
mov w9, #42
str w9, [x8] ; data = 42
mov w9, #2
str w9, [x8] ; flag = 2
ret ; ← "flag = 1" IS GONE. Dead-store elimination.
; Two stores emitted for three in the source.
_volatile_reorder: ; vflag=1; vdata=42; vflag=2;
mov w9, #1
str w9, [x8] ; vflag = 1 ← survives
mov w10, #42
str w10, [x9] ; vdata = 42
mov w9, #2
str w9, [x8] ; vflag = 2
ret ; ← all three, in source order.
So volatile is real: it forbids elision, duplication, and reordering of volatile
accesses relative to each other. That is genuinely useful for memory-mapped I/O
registers, for variables touched by a signal handler on the same thread, and for
setjmp/longjmp locals. Those are its three legitimate uses.
4.2 What volatile does not buy you: any hardware ordering at all¶
Same compiler, same flags, the publish idiom (measured):
_volatile_publish: ; vdata = 42; vflag = 1;
mov w9, #42
str w9, [x8] ; plain store
mov w9, #1
str w9, [x8] ; plain store ← NO BARRIER. NOTHING.
ret
_release_publish: ; relaxed store; release store
mov w9, #42
str w9, [x8] ; plain store
mov w9, #1
stlr w9, [x8] ; STORE-RELEASE ← the ordering instruction
ret
volatile_publish emits two ordinary str instructions. The AArch64 core is free to
let those two stores reach other cores in either order, and §6 measures that it does.
volatile constrained the compiler and said nothing to the machine. On a weakly
ordered ISA that is approximately half of the job, and the half it skips is the half that
bites.
4.3 The reason the myth is so persistent¶
Compile the same release store for x86-64 (measured):
_release_publish: ## x86-64
movq _adata@GOTPCREL(%rip), %rax
movl $42, (%rax) ## plain mov
movq _aflag@GOTPCREL(%rip), %rax
movl $1, (%rax) ## plain mov ← a RELEASE store is a plain mov
retq
On x86-64, a release store compiles to exactly the same instruction as a volatile
store, because TSO already forbids StoreStore reordering. So on x86, volatile really
does happen to give you release/acquire ordering for aligned word-sized accesses — by
accident, as a side effect of the ISA. A generation of Windows and Linux developers
learned "volatile works" on x86, and MSVC even formalized the accident with
/volatile:ms. That knowledge does not port. It is the single most common source of
code that is correct on x86-64 and broken on aarch64 with no source change — the exact
question README's Tier 0 checklist asks.
The rule: volatile is for memory that changes for reasons outside the C abstract
machine. _Atomic / std::atomic is for memory shared with other threads. They are not
interchangeable, they solve different problems, and only one of them talks to the CPU.
5. x86-TSO vs AArch64: the spine of this document¶
5.1 x86-TSO in one paragraph¶
Total Store Order. Every core has a FIFO store buffer. Loads may be satisfied from the local store buffer (forwarding) or from memory. Stores drain to a single global memory in FIFO order per core, and all cores observe that single global sequence of drains. Consequences: a core's stores reach memory in program order (no StoreStore reordering); loads are not reordered with each other (no LoadLoad); and the only visible reordering is a load overtaking an earlier store to a different address (StoreLoad). Locked read-modify-writes are full barriers.
x86-TSO is a genuinely rigorous model — Sewell, Sarkar, Owens et al. formalized it after Intel's own prose documentation was found to be ambiguous and, in places, wrong. That history is worth knowing: the vendor could not state its own memory model precisely until academics forced the issue.
5.2 AArch64 in one paragraph¶
Weakly ordered, other-multi-copy-atomic. Loads and stores to different addresses may
be observed in any order by other cores unless ordering is explicitly requested. Ordering
comes from three places: address/data/control dependencies (which the hardware respects),
explicit barriers (dmb, dsb, isb), and the load-acquire / store-release instruction
family (ldar/ldapr/stlr and the ldaxr/stlxr exclusives). Since ARMv8, the
architecture is other-multi-copy-atomic: if any core other than the writer observes a
store, all such cores observe it — which rules out the nastiest IRIW-style outcomes that
ARMv7 and POWER permit. (documented — ARM ARM.)
That last property matters more than it sounds. ARMv8 is weak, but it is not as weak
as POWER or the DEC Alpha. Alpha famously reordered dependent loads, which is why the
Linux kernel needed smp_read_barrier_depends() — a barrier for a case every other
architecture handles for free. ARMv8 respects address dependencies. You still cannot
rely on that in portable C, because the compiler will happily break a dependency the
hardware would have honoured (§14).
5.3 The comparison that matters¶
| x86-64 (TSO) | AArch64 (weak) | |
|---|---|---|
| Plain load | acquire-ish for free | no ordering |
| Plain store | release-ish for free | no ordering |
| Acquire load | mov (free) |
ldapr / ldar |
| Release store | mov (free) |
stlr |
| Seq-cst store | xchg (or mov+mfence) |
stlr |
| Seq-cst fence | lock or $0,(%rsp) / mfence |
dmb ish |
| RMW | lock-prefixed, always a full barrier |
ldadd/cas families, ordering is encoded per-instruction |
| Cost of getting it wrong | usually nothing | usually a bug |
The bottom-right cell is the practical thesis. On x86 the memory model is forgiving enough that a large fraction of incorrect concurrent code passes its tests. On ARM the same code fails — and fails rarely, non-deterministically, and under load, which is the worst possible failure profile.
The right-hand column also explains something in
24-the-gil.md §9: the free-threading
single-thread overhead is reported at roughly 1% on macOS aarch64 and 8% on x86-64
Linux. That spread is not noise and it is not "ARM is faster". It is that AArch64's
acquire/release instructions are individually ordered — ldar and stlr are single
instructions carrying their own semantics — whereas x86 must express the same intent
either for free (release/acquire) or with a full-barrier RMW (lock-prefixed, seq-cst,
no cheaper option available). Where CPython needs a seq-cst RMW, x86 has exactly one
price and it is high; AArch64 has a ladder of prices and CPython can sometimes buy
lower down.
6. The litmus tests, run on this machine¶
A litmus test is a minimal program whose outcome distinguishes memory models. Two
threads, a handful of accesses, and one outcome that is either permitted or forbidden.
This is how memory models are specified, tested, and argued about — the litmus7 /
herd7 tools from the Sewell/Maranget group do exactly this at scale.
The harness used here: a sense-reversing two-thread barrier, then a randomized
nop-delay so the two threads' critical windows overlap, then the test, then a second
barrier, then a referee that classifies the round. 1,000,000 rounds per configuration.
Full source is in lab 1 (§17); the delay magnitude is a swept parameter because the
observation rate is extremely sensitive to it (see §6.3).
6.1 SB — store buffering (Dekker)¶
initially X = 0, Y = 0
Thread 0 Thread 1
───────────────────── ─────────────────────
store X = 1 store Y = 1
r1 = load Y r2 = load X
Forbidden under sequential consistency: r1 == 0 && r2 == 0
Permitted under x86-TSO: YES (StoreLoad)
Permitted under AArch64: YES (StoreLoad)
(measured, native arm64, 1,000,000 rounds each)
| Configuration | r1==0 && r2==0 observed |
Rate |
|---|---|---|
both accesses memory_order_relaxed |
15,093 | 1.509% |
both accesses memory_order_seq_cst |
0 | 0% |
relaxed + atomic_thread_fence(seq_cst) between store and load |
0 | 0% |
Two things to take from this. First, 1.5% is not a rare race. A million-iteration loop hits it fifteen thousand times. Anyone who says "the window is too small to matter in practice" has not measured it. Second, both fixes work and they work completely — zero occurrences in a million rounds, not "fewer".
6.2 MP — message passing, the one that separates the architectures¶
initially DATA = 0, FLAG = 0
Thread 0 (publisher) Thread 1 (consumer)
───────────────────── ─────────────────────
store DATA = 42 r1 = load FLAG
store FLAG = 1 r2 = load DATA
Forbidden under sequential consistency: r1 == 1 && r2 == 0
Permitted under x86-TSO: NO ← no StoreStore, no LoadLoad
Permitted under AArch64: YES ← both are permitted
(measured, 1,000,000 rounds each)
| Configuration | Binary | FLAG==1 && DATA==0 |
Rate |
|---|---|---|---|
all relaxed |
native arm64 | 1,190 / 923 (two runs) | 0.119% / 0.092% |
release store + acquire load |
native arm64 | 0 | 0% |
all relaxed |
x86-64 (see caveat) | 0 | 0% |
release store + acquire load |
x86-64 | 0 | 0% |
That table is the document. Identical C source. Identical machine. Identical physical
cores. The arm64 binary observes the outcome roughly a thousand times per million; the
x86-64 binary observes it exactly zero times, because x86-TSO forbids it. And on arm64,
changing two memory_order_relaxed tokens to release/acquire — a change that costs
you str→stlr and ldr→ldapr, two instructions — eliminates it entirely.
The SB test is the control: it should be observable on both, since both models permit StoreLoad. It is (measured):
| SB relaxed | Observed / 1M | Rate |
|---|---|---|
| native arm64 | 14,335 | 1.434% |
| x86-64 binary | 13,694 | 1.369% |
Nearly identical rates. The two architectures agree about SB and disagree about MP, which is exactly what the §3 reordering table predicts. That the control matches is what makes the MP result trustworthy rather than an artifact of the x86 build being slower or differently scheduled.
The Rosetta caveat — read this before quoting the x86 numbers¶
I do not have an Intel or AMD machine. The x86-64 binary above was cross-compiled with
clang -arch x86_64and executed under Rosetta 2 on the same M3 Pro. Rosetta translates x86-64 to AArch64 and enables Apple Silicon's hardware TSO mode, a per-thread configuration bit that makes the core's load/store unit enforce total-store-order semantics. So what I measured is: x86-TSO semantics, enforced by Apple hardware, on the same physical cores.What this legitimately shows: the ordering semantics are what x86-TSO specifies, and the difference is attributable to the memory model rather than to the machine, the scheduler, the cores, or the thermal state — every one of which is held constant. As a controlled experiment isolating the memory model, it is unusually clean.
What it does not show: that a real Intel or AMD chip produces these exact rates. Rosetta's TSO mode is an implementation of TSO, not an Intel core. The SB rates matching within 5% is encouraging but is not proof of equivalence, and I have not validated Rosetta's TSO mode against real x86 silicon. If you have an x86 box, lab 2 asks you to rerun this and tell me whether I'm right.
6.3 The methodological trap, stated honestly¶
My first three attempts at this measurement produced zero observations, and the conclusion "AArch64 doesn't reorder" was available and completely wrong. What was actually happening:
- The first harness had a broken barrier — a non-sense-reversing counter that deadlocked as soon as one thread lapped the other. It hung for two minutes and produced nothing.
- The fixed harness produced 0/100,000 because the post-barrier delay was too short. The two threads exit a barrier hundreds of cycles apart (the second arriver proceeds immediately; the first is spinning on a cache line that must migrate), so their critical windows never overlapped.
Sweeping the delay magnitude (measured, 200,000 rounds, SB relaxed):
| max delay (nops) | observed | rate |
|---|---|---|
| 2 | 11 | 0.006% |
| 8 | 25 | 0.013% |
| 32 | 2 | 0.001% |
| 128 | 266 | 0.133% |
| 512 | 3,184 | 1.592% |
| 2048 | 1,182 | 0.591% |
There is a resonance at ~512 nops and it falls off on both sides. A negative result in
a memory-model experiment is nearly worthless unless you have swept the timing. This
is the concurrency-testing version of the lesson in
31-measurement-methodology.md, and it is why
30-concurrency-correctness.md argues for model
checkers (herd7, TSan, Loom) over stress tests: a stress test that finds nothing has
told you nothing.
7. The C11/C++11 memory model, and what it compiles to¶
C11 and C++11 adopted essentially the same model (Boehm & Adve's design). It is the
first time a mainstream language specified what a shared-memory program means, and
every language since — Rust, Swift, and CPython's _Py_atomic_* — is either a copy of it
or a deliberate deviation from it.
7.1 The five orderings¶
| Order | Guarantee | Use it for |
|---|---|---|
relaxed |
atomicity only — no ordering with any other access | counters you only ever read at the end; refcount fast paths; statistics |
acquire |
on a load: no later access may move before it | acquiring a lock; reading a flag whose payload you're about to read |
release |
on a store: no earlier access may move after it | releasing a lock; publishing a payload then setting its flag |
acq_rel |
both, on a read-modify-write | a CAS that both consumes and publishes |
seq_cst |
acquire/release plus a single total order all threads agree on | the default; anything you are not sure about |
Two mental models are worth carrying:
Acquire/release are one-way barriers. This is the picture that makes them click:
── ACQUIRE (a load) ── ── RELEASE (a store) ──
...anything... ...anything...
│ │
│ ▲ may move DOWN across ▼ │ may move UP across
▼ │ (upward motion blocked) │ ▼ (downward motion blocked)
┏━━━━━━━━━━━━━━━━━━┓ ┏━━━━━━━━━━━━━━━━━━┓
┃ ldapr / ldar ┃ ┃ stlr ┃
┗━━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━━┛
▲ │ │ ▲
│ │ nothing below may move UP ───┘ │ nothing above may move DOWN
│ ▼ past the acquire │
...critical section... ...critical section...
A critical section is exactly an acquire at the top and a release at the bottom. Things may leak into it from either side; nothing may leak out. That asymmetry is why the pair is cheaper than two full barriers, and why the MP test needs precisely one of each.
release + acquire on the same variable creates a synchronizes-with edge. If
thread A does a release store to F and thread B does an acquire load of F and reads
the value A stored, then everything A did before the store happens-before everything B
does after the load. That is the entire cross-thread ordering primitive; locks,
channels, futures and Py_INCREF's escape path are all built from it.
7.2 What each ordering actually compiles to¶
clang -O2 -S on this machine, both targets, from the same source file (measured).
This table is the one to memorize:
| C11 operation | AArch64 (Apple target) | x86-64 |
|---|---|---|
load(relaxed) |
ldr w0, [x8] |
movl (%rax), %eax |
load(acquire) |
ldapr w0, [x8] |
movl (%rax), %eax |
load(seq_cst) |
ldar w0, [x8] |
movl (%rax), %eax |
store(relaxed) |
str w0, [x8] |
movl %edi, (%rax) |
store(release) |
stlr w0, [x8] |
movl %edi, (%rax) |
store(seq_cst) |
stlr w0, [x8] |
xchgl %edi, (%rax) |
fetch_add(relaxed) |
ldadd w9, w0, [x8] |
lock xaddl %eax, (%rcx) |
fetch_add(acq_rel) |
ldaddal w9, w0, [x8] |
lock xaddl %eax, (%rcx) |
fetch_add(seq_cst) |
ldaddal w9, w0, [x8] |
lock xaddl %eax, (%rcx) |
exchange(seq_cst) |
swpal w0, w0, [x8] |
xchgl %eax, (%rcx) |
compare_exchange_weak |
casal w9, w1, [x10] |
lock cmpxchgl %esi, (%rdx) |
compare_exchange_strong |
casal w9, w1, [x10] |
lock cmpxchgl %esi, (%rdx) |
thread_fence(acquire) |
dmb ishld |
(nothing emitted) |
thread_fence(release) |
dmb ish |
(nothing emitted) |
thread_fence(seq_cst) |
dmb ish |
lock orl $0, -64(%rsp) |
Five things in that table are worth stopping on.
(a) On x86-64, the entire left half of the ordering spectrum is free. Relaxed,
acquire, and seq-cst loads are the identical movl. Relaxed and release stores are
the identical movl. There is literally no instruction difference. This is TSO's dividend
and it is why x86 programmers underestimate memory ordering: on their machine three of
the five orderings are the same instruction.
(b) On x86-64, seq-cst stores are where you suddenly pay. store(seq_cst) becomes
xchgl — a locked RMW, a full barrier, tens of cycles — while store(release) was a
free mov. The single largest x86 optimization in concurrent code is downgrading a
seq-cst store to a release store, and it is invisible in the source until you look at
the assembly. (Clang chooses xchg over mov; mfence here; both are correct and xchg
is generally faster on modern parts.)
© The acquire load is ldapr, not ldar. ldar is Load-Acquire with
sequentially consistent semantics (RCsc): it also orders against earlier stlrs.
ldapr is Load-AcquirePC (RCpc, ARMv8.3-A) and provides only the acquire guarantee C11
asks for. Clang emits ldapr for memory_order_acquire and reserves ldar for
memory_order_seq_cst (measured — and confirmed by targeting a pre-8.3 core: compiling
the same source with -mcpu=cortex-a53 emits ldar for the acquire load, because
ldapr does not exist there). This is a real, visible cost difference between
acquire and seq_cst on AArch64 that has no x86 analogue. (The RCpc-vs-RCsc semantics
are documented in the ARM ARM; I verified the instruction selection, not the hardware
semantics.)
(d) AArch64 encodes the ordering into the RMW opcode. ldadd / ldadda / ldaddl /
ldaddal are four distinct instructions for relaxed / acquire / release / acq_rel. x86
has one lock xadd and it is always seq-cst. So on ARM, fetch_add(relaxed) is
genuinely cheaper than fetch_add(seq_cst); on x86 they are byte-identical. §12
measures whether that translates into time (spoiler: uncontended, barely; contended,
yes).
(e) Clang does not use mfence for a seq-cst fence. It emits
lock orl $0, -64(%rsp) — a locked no-op on the red zone. A locked RMW is a full barrier
and is measurably faster than mfence on most modern x86 parts; the -64(%rsp) offset
keeps it clear of live stack data. This is the kind of detail that makes people think
they've found a compiler bug. They haven't. (measured emission; the "faster than mfence"
claim is well-established folklore that I did not benchmark here.)
7.3 The consume ordering, and why you should ignore it¶
C11 also defines memory_order_consume, intended to expose the fact that hardware
respects address dependencies so a dependent load needs no barrier at all (the RCU idiom).
It has never been implemented: every major compiler silently promotes consume to
acquire, because tracking dependency chains through arbitrary optimizer passes turned
out to be intractable to specify. It is effectively deprecated. Know the word so you
recognize it in kernel code; do not use it. This is a rare, honest instance of a
standards committee shipping something that could not be built.
8. Fences vs. ordered accesses¶
There are two ways to express ordering and they are not equivalent.
Ordered accesses (ldar, stlr, ldaddal) attach ordering to a specific memory
operation. Fences (dmb ish) order everything before against everything after,
and are not tied to any address.
ORDERED ACCESS (stlr) FENCE (dmb ish)
str w0, [data] str w0, [data]
stlr w1, [flag] ← ordering rides dmb ish ← orders ALL prior
on this one store str w1, [flag] against ALL later
to this one address
Cheaper. Expresses "publish this". More expensive. Expresses "everything
The hardware can be surgical. up to here is done." Blunt instrument.
Prefer ordered accesses. Reach for a fence when the ordering isn't attached to a single
access — most commonly the SB/Dekker pattern, where you need StoreLoad ordering between a
store to X and a load of Y, two different addresses. §6.1 measured that
relaxed store; fence(seq_cst); relaxed load fixes SB just as completely as making both
accesses seq_cst.
Note the asymmetry the §7.2 table exposes: on AArch64 thread_fence(acquire) is
dmb ishld (a load-ordering barrier — cheaper, it lets stores pass) while
thread_fence(release) is the full dmb ish. There is no "store-only" dmb variant
suitable for a release fence, so release fences cost more than acquire fences on ARM
(measured emission).
9. Read-modify-write: CAS, LL/SC, and the exclusive monitor¶
Ordering is half the problem. The other half is atomicity of read-modify-write: performing load-modify-store such that no other core can interleave. Two hardware families exist.
9.1 Atomic instructions (x86 always; ARM since ARMv8.1-A "LSE")¶
A single instruction does the whole RMW. lock xadd, lock cmpxchg, xchg on x86;
ldadd, swp, cas on AArch64 with LSE (Large System Extensions). Apple Silicon
has LSE, which is why every listing in §7.2 shows ldadd and casal rather than a loop.
The important architectural property: an LSE atomic can be executed at the point of coherence rather than requiring the line to migrate into the requesting core's L1. For a heavily contended counter, that turns "N cores take turns owning the line" into "N cores send N requests to one place". That is a fundamentally better scaling shape, and it is why ARM added LSE for server parts. §12 shows this machine still degrades under contention — LSE improves the constant, not the asymptote.
9.2 Load-Linked / Store-Conditional (the classic ARM/RISC-V/POWER mechanism)¶
Before LSE, and still on any pre-8.1 core and on RISC-V, ARM has no atomic RMW instruction at all. It has a pair:
ldxr(Load Exclusive) — load, and mark this address in the core's exclusive monitor.stxr(Store Exclusive) — store only if the monitor is still marked; write success/failure into a register.
Anything that might have disturbed the location clears the monitor: another core's write, a context switch, sometimes an interrupt, sometimes an unrelated access to the same reservation granule (an implementation-defined block, typically the cache line, not the word).
Forcing a pre-LSE target (measured, clang -mcpu=cortex-a53 -O2 -S):
_fa_relaxed: ; atomic_fetch_add_explicit(&g, 1, relaxed)
adrp x8, _g@GOTPAGE
ldr x8, [x8, _g@GOTPAGEOFF]
LBB6_1:
ldxr w0, [x8] ; load exclusive, set monitor
add w9, w0, #1
stxr w10, w9, [x8] ; store conditional; w10 = 0 on success
cbnz w10, LBB6_1 ; ← retry loop. THE ATOMIC IS A LOOP.
ret
_fa_seqcst: ; same, seq_cst
LBB8_1:
ldaxr w0, [x8] ; load-acquire exclusive
add w9, w0, #1
stlxr w10, w9, [x8] ; store-release conditional
cbnz w10, LBB8_1
ret
Note that the ordering is again encoded in the opcode: ldxr/stxr for relaxed,
ldaxr/stlxr for seq-cst. Same loop, different letters.
9.3 Why LL/SC can livelock¶
Core 0 Core 1 Reservation granule
────── ────── (one cache line)
ldxr [addr] ── monitor set ─▶ ... Core0 owns
add ldxr [addr] ── monitor set ▶ Core1 owns
(Core0's CLEARED)
stxr [addr] ── FAILS ──────── retry
ldxr [addr] ── monitor set ─▶ (Core1's CLEARED)
stxr [addr] ── FAILS ────── retry
... ldxr [addr] ── monitor set ▶
stxr [addr] ── FAILS ──────── retry
... ... forever
Neither core is blocked; both are making no progress. This is livelock, and it is a different failure from deadlock — nothing is waiting, everything is spinning. The architectures give only weak forward-progress guarantees here; ARM specifies constraints on when an LL/SC pair must eventually succeed, but they are conditional on the loop being small and containing no intervening memory accesses, which optimizers and debuggers can silently violate. In practice you get:
- Exponential backoff in the retry loop (add a
yield/wfeand grow the delay). - LSE — the real fix, and why ARM built it.
ldaddandcascannot livelock, because there is no reservation to lose. - On RISC-V, the ISA defines a constrained LR/SC sequence with an architectural forward-progress guarantee, but only if you obey strict rules about what may appear between the two instructions.
Two secondary hazards worth naming:
False sharing kills LL/SC harder than it kills anything else. The reservation granule is typically a whole cache line. Two threads doing LL/SC on different words in the same 128-byte line will clear each other's monitors forever. This is doc 01 §6's false sharing upgraded from "slow" to "may never terminate."
LL/SC solves ABA that CAS doesn't — the monitor is cleared by any intervening write,
so an A→B→A sequence fails the stxr even though the value compares equal. That is a
genuine advantage of LL/SC over CAS, and it is the entry point to
03-lockfree-and-reclamation.md.
Honesty note. I did not reproduce an LL/SC livelock. Apple Silicon has LSE and clang targets it by default, so the compiler will not emit LL/SC for me without a
-mcpuoverride, and constructing a genuine livelock requires inline assembly plus favourable scheduling. The livelock mechanism above is documented architecture (ARM ARM, and it is the standard motivation given for LSE), not something I measured. Lab 6 attempts it if you want to settle it yourself.
10. compare_exchange_weak vs strong, in real instructions¶
C11 gives you two CAS variants and the difference confuses everyone. It stops being confusing the moment you look at the code generation, because the difference is the code generation.
atomic_compare_exchange_weak_explicit(&g, &expected, desired, acq_rel, acquire);
atomic_compare_exchange_strong_explicit(&g, &expected, desired, acq_rel, acquire);
strong fails only if *obj != expected. weak may also fail spuriously —
returning false with expected unchanged even though the comparison would have succeeded.
10.1 On an LSE target, they are identical¶
(measured, default Apple target):
_cas_weak: _cas_strong:
ldr w8, [x0] ldr w8, [x0]
mov x9, x8 mov x9, x8
casal w9, w1, [x10] casal w9, w1, [x10]
cmp w9, w8 cmp w9, w8
cset w8, eq cset w8, eq
... ...
Byte-for-byte the same, because casal is a single instruction that cannot fail
spuriously. Same on x86-64: both are lock cmpxchgl (measured). On both of the
architectures most people use today, weak and strong generate identical code for a
single CAS. So why does the distinction exist?
10.2 On an LL/SC target, the difference is an entire loop¶
(measured, -mcpu=cortex-a53):
_cas_weak:
ldaxr w8, [x9] ; load exclusive
cmp w8, w10
b.ne LBB10_2 ; value mismatch → report failure
stlxr w10, w1, [x9] ; try to store
cmp w10, #0
csetm w9, eq ; success = (stxr returned 0)
... ; ← NO RETRY. A failed stxr just returns false.
LBB10_2:
mov w9, wzr
clrex ; clear the reservation
...
_cas_strong:
LBB11_1: ; ←──────────────────┐
ldaxr w9, [x8] │
cmp w9, w10 │
b.ne LBB11_4 ; real mismatch → out│
stlxr w11, w1, [x8] │
cbnz w11, LBB11_1 ; spurious failure ──┘ RETRY
...
LBB11_4:
mov w8, wzr
clrex
...
There it is. strong is weak plus a retry loop, and the retry exists precisely to
absorb a failed stxr that was not caused by a value mismatch. A spurious failure is
not a fiction; it is a cleared exclusive monitor.
10.3 The rule that follows¶
/* CORRECT and optimal: you were going to loop anyway. Use weak. */
long expected = atomic_load_explicit(&counter, memory_order_relaxed);
while (!atomic_compare_exchange_weak_explicit(
&counter, &expected, expected + 1,
memory_order_release, memory_order_relaxed)) {
/* `expected` was reloaded for us by the failed CAS — recompute and retry */
}
/* CORRECT: a one-shot CAS with no loop of your own. Use strong,
or you may report failure when you should have succeeded. */
if (atomic_compare_exchange_strong_explicit(&head, &expected, node,
memory_order_acq_rel, memory_order_acquire)) { /* claimed it */ }
If your CAS is already inside a retry loop, use weak — you pay nothing for a
spurious failure because you were going to go round again anyway, and you save the
compiler's inner loop. If a single failure changes your control flow, use strong.
On LSE and x86 this costs you nothing today; on any LL/SC target it is the difference
between correct and subtly wrong, and it is free portability.
A detail people trip on: on failure, expected is updated to the value actually
found. That is the mechanism that makes the loop converge, and it means you must
recompute the desired value from the new expected inside the loop body — not hoist it
out. Hoisting it is one of the classic lock-free bugs.
11. Why fetch_add beats a CAS loop under contention¶
A CAS loop can implement any RMW, so it is tempting to reach for it always. It is a trap, and the reason is structural rather than a matter of instruction cost.
fetch_add: EVERY participant succeeds. CAS loop: ONE participant succeeds
per round.
4 cores, 4 increments: 4 cores, 4 increments:
core0 ldadd ──▶ ✓ core0 ld;cas ──▶ ✓
core1 ldadd ──▶ ✓ core1 ld;cas ──▶ ✗ retry ─▶ ✓
core2 ldadd ──▶ ✓ core2 ld;cas ──▶ ✗ ✗ retry ─▶ ✓
core3 ldadd ──▶ ✓ core3 ld;cas ──▶ ✗ ✗ ✗ retry ─▶ ✓
4 line acquisitions, 4 increments. 10 line acquisitions, 4 increments.
Work per increment: O(1). Work per increment: O(N).
Progress guarantee: WAIT-FREE. Progress guarantee: LOCK-FREE only.
Any individual thread may starve.
Two distinct problems compound:
- Quadratic wasted work. With N contending threads, the expected number of CAS attempts to complete N increments is O(N²). Every failed attempt still acquired the cache line exclusively, so every failure costs a full coherence round trip — you are paying maximum price for zero progress.
- The progress guarantee is weaker.
fetch_addis wait-free: every thread completes in a bounded number of its own steps. A CAS loop is only lock-free: the system makes progress, but an unlucky thread can fail forever. On heterogeneous cores this is not theoretical — an E-core thread competing with P-core threads for the same line loses repeatedly. (Progress hierarchies get their proper treatment in03-lockfree-and-reclamation.md.)
Measured on this machine, aggregate throughput in millions of increments/second, all threads hammering one shared counter (measured, 50M ops per thread):
| threads | fetch_add(relaxed) |
CAS loop | CAS loop penalty |
|---|---|---|---|
| 1 | 511 | 503 | 1.0× |
| 2 | 265 | 321 | 0.83× (CAS faster) |
| 3 | 188 | 148 | 1.27× |
| 4 | 122 | 98 | 1.25× |
| 5 | 77 | 37 | 2.11× |
| 6 | 90 | 31 | 2.93× |
| 8 | 52 | 39 | 1.33× |
Uncontended they are the same instruction cost. At 5–6 threads the CAS loop is 2–3× worse, which is exactly where §12 shows contention becoming the dominant term. (The non-monotonicity at 2 threads and at 8 is real run-to-run structure on this machine, not a typo — see §12.4 on why this laptop cannot produce a clean scaling curve.)
The rule: if the operation you need has a native atomic, use it. fetch_add,
fetch_sub, fetch_or, fetch_and, exchange. Reserve CAS for operations that
genuinely have no atomic form — updating a pointer, a multi-field state word, or anything
conditional. This is also why CPython's Py_INCREF uses _Py_atomic_add_ssize on its
escape path rather than a CAS loop (§15).
12. The atomic cost model — measured¶
This is the mandatory lab from the roadmap, and it is the number every engineer arguing about lock-free data structures should have in their head.
12.1 Uncontended: what one atomic costs¶
Single thread, 50,000,000 operations, clang -O2 (measured, ns/op, two runs each):
| Operation | AArch64 instruction | ns/op | ≈ cycles @ ~4 GHz |
|---|---|---|---|
plain volatile increment (baseline) |
ldr/add/str |
0.27 | ~1 |
| atomic load, relaxed | ldr |
1.20 – 1.27 | ~5 |
| atomic load, acquire | ldapr |
1.36 | ~5.5 |
| atomic store, release | stlr |
0.27 | ~1 |
fetch_add, relaxed |
ldadd |
1.95 | ~8 |
fetch_add, seq_cst |
ldaddal |
1.90 – 1.92 | ~8 |
| CAS loop (uncontended) | casal |
1.90 | ~8 |
Findings, and two of them are surprising.
An uncontended atomic RMW costs about 2 ns — roughly 7× a plain increment, not 100×.
The folklore figure of "an atomic costs 100 cycles" is about the contended case. When
the line is already in your L1 in M state and nobody else wants it, an ldadd is
single-digit cycles. Doc 01 §1's table says 20–50 cycles for an uncontended atomic RMW;
on this machine I measure roughly 8. That row in doc 01 is pessimistic for Apple
Silicon with LSE, and I am flagging it rather than quietly agreeing with it.
Relaxed and seq_cst fetch_add cost the same uncontended (1.95 vs 1.90 ns). Despite
being different instructions (ldadd vs ldaddal), the ordering is nearly free when
there is nothing to order against — the store buffer is empty and there is no coherence
traffic to serialize. The cost of ordering is not the barrier instruction; it is the
work the barrier makes you wait for. This is the single most useful correction to
intuition in this section, and it means "use relaxed for speed" is close to worthless
advice in the uncontended case and worth real money in the contended one (§12.2).
A release store (stlr) is as cheap as a plain store here (0.27 ns each). Publishing
correctly costs approximately nothing on this microarchitecture. There is no performance
excuse for the volatile publish idiom of §4.2.
12.2 Contended: the scaling collapse¶
All threads hammering one shared 64-bit counter, versus each thread owning a 128-byte-padded counter. Aggregate throughput in millions of operations/second (measured, 50M ops per thread):
| threads | padded (per-thread) | shared relaxed | shared seq_cst | CAS loop | false-shared (8 B apart) |
|---|---|---|---|---|---|
| 1 | 485 | 511 | 490 | 503 | 502 |
| 2 | 1,007 | 265 | 153 | 321 | 252 |
| 3 | 1,484 | 188 | 158 | 148 | 195 |
| 4 | 1,972 | 122 | 113 | 98 | 146 |
| 5 | 2,331 | 77 | 60 | 37 | 59 |
| 6 | 2,631 | 90 | 65 | 31 | 83 |
| 8 | 3,260 | 52 | 46 | 39 | 53 |
Aggregate Mops/s vs thread count (log-ish, measured on M3 Pro)
3260 ┤ ● padded
│ ●
2000 ┤ ●
│ ●
1000 ┤ ●
│
500 ┤●●●●● ← all five modes start together at 1 thread
│ ╲
250 ┤ ●───●
│ ╲───●
100 ┤ ╲──●
│ ╲●───●──● shared / false-shared / seq_cst / CAS
25 ┤
└──┬────┬────┬────┬────┬────┬────┬──
1 2 3 4 5 6 8 threads
Read the first and last rows against each other. Going from 1 thread to 8:
- Padded: 485 → 3,260 Mops/s. A 6.7× speedup on 8 threads (11 cores, 5 of them fast). Near-linear until the E-cores join. This is what "scales" looks like.
- Shared: 511 → 52 Mops/s. A 9.8× slowdown in total system throughput. Eight cores doing one tenth the aggregate work of one core.
That is the whole lesson. Contention on an atomic does not merely fail to scale — it scales negatively. Every added core makes the total worse, because each core spends its time acquiring a cache line that the next core immediately takes away. Per-operation cost went from 1.96 ns to 19.05 ns (measured): a 10× penalty per operation, on top of getting no parallelism for it.
This is exactly the shape of the Gilectomy's first failure —
24-the-gil.md §7:
"roughly a 30% slowdown — and it got worse with more threads." Larry Hastings made
ob_refcnt atomic and got this table. You have now reproduced it in 60 lines of C.
Ordering is not free once contended. seq_cst vs relaxed on the same shared
counter: 490 vs 511 at one thread (a wash, §12.1), but 60 vs 77 at five threads and 46
vs 52 at eight — a consistent 10–25% penalty (measured). The ldaddal's ordering
requirement forces the store buffer to drain while a coherence transaction is already
outstanding. That is the mechanism by which "ordering costs nothing" becomes false.
False sharing costs as much as true sharing. The 8-bytes-apart column tracks the truly-shared column within noise across the whole sweep (53 vs 52 at 8 threads). The hardware cannot tell the difference, because the hardware's unit is the line. This is doc 01 §6 measured with atomics instead of plain stores, and it confirms doc 01's advice: pad to 128 bytes on this machine, unconditionally.
12.3 The cost model to carry in your head¶
| Situation | Cost | What dominates |
|---|---|---|
| Atomic, line in local L1, no sharers | ~2 ns / ~8 cycles | instruction latency |
| Atomic, line shared read-only, first write | + coherence round trip | invalidating other cores |
| Atomic, line ping-ponging between 2 cores | ~4 ns | line migration |
| Atomic, line ping-ponging among 5+ cores | ~13–27 ns and rising | serialization |
| Atomic under a CAS loop, 5+ cores | ~27–32 ns | wasted retries |
| Per-thread padded atomic, any N | ~0.3–2 ns, scales linearly | nothing |
The design rule that falls out: the cost of an atomic is not a property of the instruction. It is a property of how many cores touch the line. Which means the optimization is never "use a cheaper atomic"; it is always "make fewer cores touch the line." Sharding, per-thread accumulators, biased reference counting, immortalization — every technique in PEP 703 is an instance of that one move.
12.4 Why you should distrust my contended numbers (and yours)¶
This laptop is a bad benchmark host and I want that on the record next to the table:
- Heterogeneous cores. 5 P-cores (128 KB L1d, 16 MB shared L2) and 6 E-cores (64 KB L1d, 4 MB shared L2). At ≤5 threads macOS usually uses P-cores; beyond that you get a mix, and a mixed set of cores contending for one line behaves qualitatively differently from a homogeneous set. The 5→6 thread non-monotonicity in every shared column (77→90, 60→65, 59→83) is almost certainly this, not a real inflection.
- Cross-cluster coherence. A line ping-ponging between a P-core and an E-core crosses a cluster boundary. That is a different, more expensive transaction than P↔P.
- No pinning, no PMU. macOS gives no
sched_setaffinityequivalent for hard pinning and there is noperf(1); I cannot confirm which cores ran, and I cannot count coherence events to prove the mechanism. - No frequency control. No way to disable turbo or fix the clock. The cycle conversions in §12.1 assume ~4 GHz and are therefore approximate by construction.
What survives all of that: the shapes. Padded scales up; shared collapses; the
collapse is an order of magnitude; CAS is worse than fetch_add under contention;
seq_cst is worse than relaxed under contention. Those conclusions are robust to a factor
of two in any individual cell. The individual cells are not. Carry the shapes, re-measure
the cells. See 31-measurement-methodology.md.
13. Alignment, and what a split atomic really does¶
Every treatment of atomics warns that an atomic straddling two cache lines is
catastrophic. On x86 that is true and specific: lock-prefixed instructions on
misaligned memory are architecturally required to work, and the CPU implements that by
falling back to a bus lock — historically asserting LOCK#, on modern parts stalling
the entire coherence fabric. A single split-lock instruction can cost thousands of cycles
and stalls every core in the system, not just the offender. Linux gained
split_lock_detect to trap them, precisely because one misbehaving process could degrade
an entire host. (documented — I have no x86 hardware to demonstrate it.)
On AArch64 the answer is completely different, and I measured it.
64-bit __atomic_fetch_add at various offsets in a 256-byte-aligned buffer, 2,000,000
iterations each (measured):
| offset | 8-byte aligned? | straddles a 128 B line? | result |
|---|---|---|---|
| 0 | yes | no | OK, 1.94 ns/op |
| 8 | yes | no | OK, 1.95 ns/op |
| 60 | no | no | SIGBUS |
| 64 | yes | no | OK, 1.96 ns/op |
| 120 | yes | no | OK, 1.95 ns/op |
| 124 | no | yes | SIGBUS |
| 126 | no | yes | SIGBUS |
| 127 | no | yes | SIGBUS |
And 128-bit atomics (which on AArch64 use the casp register-pair family), 1,000,000
iterations (measured):
| offset | 16-byte aligned? | straddles a 128 B line? | result |
|---|---|---|---|
| 0 | yes | no | OK, 8.14 ns/op |
| 16 | yes | no | OK, 7.24 ns/op |
| 112 | yes | no | OK, 8.43 ns/op |
| 120 | no | yes | SIGBUS |
| 128 | yes | no | OK, 6.76 ns/op |
The finding, stated precisely: on AArch64 a misaligned atomic is not slow, it is
illegal. The core raises an alignment fault and the process takes SIGBUS. Note offset
60 — inside a single line, not straddling anything, but not 8-byte aligned: it faults
anyway. The architecture requires natural alignment for atomics regardless of line
geometry.
Then notice the corollary, which is the actually useful part: a naturally aligned atomic
can never straddle a cache line. An 8-byte value at an 8-byte-aligned address cannot
cross a 64- or 128-byte boundary; a 16-byte value at a 16-byte-aligned address cannot
either. The only way to construct offset 120's failure is to deliberately under-align a
16-byte atomic, which C11 will not let you do (_Atomic types carry their alignment
requirement) — I had to reach for __atomic_fetch_add on a hand-cast pointer.
So the practical guidance differs by architecture and both halves matter:
- x86-64: misaligned atomics are legal and catastrophic. This is a performance and
blast-radius problem, invisible until profiled, and it happens in real code — packed
structs,
#pragma pack, hand-rolled serialization buffers, atomics inside achar[]arena. - AArch64: misaligned atomics are illegal and immediate. This is a crash, which is strictly better engineering: you find it on the first execution rather than in production tail latency.
- Both: use
_Atomic/std::atomictypes and let the compiler enforce alignment. Never place an atomic in a packed struct. Never cast achar*offset into an atomic pointer.
Also worth noting from the second table: a 128-bit atomic costs ~7–8 ns versus ~2 ns for
a 64-bit one — roughly 4× more, uncontended (measured). Double-width CAS is the
standard implementation of tagged pointers for ABA avoidance, so that 4× is the price of
the simplest ABA fix. 03-lockfree-and-reclamation.md
weighs it against hazard pointers and epochs, and this measurement is the input to that
comparison.
14. Data races are undefined behaviour, and SC-DRF is the bargain¶
Everything so far has been mechanism. This section is about what the standard says, because that is what the optimizer obeys.
14.1 The definitions, precisely¶
A data race exists when two threads access the same memory location, at least one access is a write, they are not ordered by happens-before, and at least one is non-atomic. In C11, C++11 and Rust, a program with a data race has undefined behaviour — the entire program, not just the racy access.
A race condition is different and is not necessarily a bug in the language-semantics
sense: it is an outcome that depends on timing. x += 1 from two threads under the GIL
is a race condition (you lose updates) but not a data race (the GIL orders the accesses).
24-the-gil.md §6 draws
exactly this line, and it is the distinction that makes the atomicity table there make
sense.
14.2 Why "UB" is not lawyer-talk¶
The standard could have said "a racy read returns some value." It says UB instead, and the reason is that the optimizer needs the stronger statement to do ordinary work:
/* The optimizer is entitled to assume no other thread writes `flag`,
because if one did without synchronization, the program is UB. */
while (!flag) { do_work(); }
/* So it may legally hoist the load out of the loop: */
if (!flag) { for (;;) { do_work(); } } /* infinite loop. Legal. */
This is not hypothetical; it is one of the most common real manifestations of racy code, and it is why "the value will be stale for a while but eventually we'll see it" is a false model. There is no "eventually." The load may never be executed again.
Similarly, the compiler may invent writes (speculative store into a location it
proved it would write anyway), tear a write it thinks is unobserved, or rematerialize
a load so two reads of "the same" variable return different values within one expression.
Every one of these is legal for non-atomic accesses and forbidden for atomic ones. Making
a variable _Atomic with memory_order_relaxed costs you nothing at the instruction
level on either architecture (§7.2: relaxed load is a plain ldr/movl) and buys you
all of these guarantees. Relaxed atomics are, on both of these architectures, free
protection against the compiler. That is why PEP 703 requires them (§15).
14.3 SC-DRF: the deal the whole model is built on¶
The C11/C++11 model's central theorem is SC-DRF — sequential consistency for data-race-free programs:
If your program contains no data races when every atomic operation is
seq_cst, then its behaviour is exactly as if all operations executed in a single global interleaving of the threads' program orders.
Read that as a contract with two sides:
- You promise: no data races. Every conflicting access pair is either ordered by synchronization or uses atomics.
- The implementation promises: you may reason with sequential consistency — the simple, intuitive model where the machine interleaves your threads and nothing reorders.
This is why "just use seq_cst and locks" is genuinely correct engineering advice and not
a cop-out. It buys back the mental model that store buffers took away. The relaxed
orderings are an opt-out from SC-DRF, and every use of them is a promise that you have
reasoned about the specific reordering you just permitted. Herlihy & Shavit's framing is
the one to keep: sequential consistency is not the default behaviour of hardware; it is
a service you purchase with barriers, and DRF is the discount coupon.
CPython, notably, has adopted exactly this posture in its own atomics API — the header says so in as many words (§15).
15. CPython's _Py_atomic_* API, verified against the source¶
Everything in this section was extracted from the actual header on this machine,
Include/cpython/pyatomic.h from the CPython 3.14.6 installation at
~/.local/share/uv/python/cpython-3.14.6-macos-aarch64-none/ (measured — I parsed the
installed header, not the GitHub main branch, and not the .c sources, which are not
part of this installation).
15.1 The shape of the API¶
Include/cpython/pyatomic.h declares the portable surface; three backends implement it:
| File | Backend |
|---|---|
Include/cpython/pyatomic.h |
the public declarations + the documentation comment |
Include/cpython/pyatomic_gcc.h |
GCC/clang __atomic_* builtins |
Include/cpython/pyatomic_msc.h |
MSVC intrinsics (_Interlocked*) |
Include/cpython/pyatomic_std.h |
C11 <stdatomic.h> fallback |
153 distinct _Py_atomic_* functions (measured — counted by parsing the header).
The ordering-suffix census:
| Suffix | Count | Meaning |
|---|---|---|
| (none) | 88 | sequentially consistent |
_relaxed |
49 | relaxed |
_release |
8 | release |
_acquire |
7 | acquire |
_seq_cst |
1 | _Py_atomic_fence_seq_cst |
And the header states its philosophy directly (quoted verbatim from the installed file):
// Operations are sequentially consistent unless they have a suffix indicating
// otherwise. If in doubt, prefer the sequentially consistent operations.
That is CPython choosing the §14.3 bargain, in a header comment. The default is the
safe one; you must type extra characters to give up sequential consistency. Compare the
C++ standard library, where memory_order_seq_cst is also the default but the API makes
relaxed equally easy to reach.
15.2 The design decision hiding in the census¶
Look at which operations have relaxed variants and which do not (measured — exact function-name families from the header):
| Family | Orderings available |
|---|---|
_Py_atomic_load_* |
seq_cst, _relaxed, _acquire |
_Py_atomic_store_* |
seq_cst, _relaxed, _release |
_Py_atomic_add_* |
seq_cst only |
_Py_atomic_and_*, _Py_atomic_or_* |
seq_cst only |
_Py_atomic_exchange_* |
seq_cst only |
_Py_atomic_compare_exchange_* |
seq_cst only |
_Py_atomic_fence_* |
_acquire, _release, _seq_cst |
There is no _Py_atomic_add_ssize_relaxed. Every read-modify-write in CPython is
sequentially consistent, full stop. That is a deliberate, conservative choice: RMWs are
where lock-free algorithms live, they are where subtle ordering bugs are unfixable
after the fact, and the extra cost is small relative to the coherence traffic the RMW was
always going to cause (§12.1: 1.95 vs 1.90 ns uncontended — nothing). Relaxed is offered
only for plain loads and stores, where the win is real and the reasoning is local.
The exact acquire/release surface is small enough to list (measured):
_Py_atomic_load_int_acquire _Py_atomic_store_int_release
_Py_atomic_load_ptr_acquire _Py_atomic_store_ptr_release
_Py_atomic_load_ssize_acquire _Py_atomic_store_ssize_release
_Py_atomic_load_uint32_acquire _Py_atomic_store_uint32_release
_Py_atomic_load_uint64_acquire _Py_atomic_store_uint64_release
_Py_atomic_load_uintptr_acquire _Py_atomic_store_uint_release
_Py_atomic_store_uintptr_release
Pointer-sized and word-sized only. These are for exactly one job: publishing an object
and then publishing the pointer to it — the MP pattern of §6.2, which is the pattern
CPython's lock-free dict and list reads depend on.
The GCC backend is a thin, honest wrapper (measured, verbatim):
static inline void *
_Py_atomic_load_ptr_acquire(const void *obj)
{ return (void *)__atomic_load_n((void * const *)obj, __ATOMIC_ACQUIRE); }
static inline void
_Py_atomic_store_ptr_release(void *obj, void *value)
{ __atomic_store_n((void **)obj, value, __ATOMIC_RELEASE); }
static inline Py_ssize_t
_Py_atomic_add_ssize(Py_ssize_t *obj, Py_ssize_t value)
{ return __atomic_fetch_add(obj, value, __ATOMIC_SEQ_CST); }
static inline void
_Py_atomic_fence_seq_cst(void) { __atomic_thread_fence(__ATOMIC_SEQ_CST); }
So on this machine, _Py_atomic_load_ptr_acquire compiles to ldapr and
_Py_atomic_store_ptr_release to stlr (§7.2), and on x86-64 both compile to a plain
mov. Everything in §7 applies directly to CPython's source.
A note for readers of
24-the-gil.md§3. That document quotes eval-loop code using a generic_Py_atomic_load_relaxed(...). In 3.14.6 the names are type-suffixed —_Py_atomic_load_int_relaxed,_Py_atomic_load_uintptr_relaxed, and so on. The generic spelling is from an older CPython (the pre-3.13pyatomic.hused_Py_atomic_load_relaxedon an_Py_atomic_intstruct type). The mechanism is unchanged; the spelling is not. Check the header for the version you are reading.
15.3 The payoff: Py_INCREF on the free-threaded build¶
Here is the free-threaded Py_INCREF, verbatim from Include/refcount.h of the
3.14.6 free-threading installation (measured):
#if defined(Py_GIL_DISABLED)
uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
uint32_t new_local = local + 1;
if (new_local == 0) {
_Py_INCREF_IMMORTAL_STAT_INC();
// local is equal to _Py_IMMORTAL_REFCNT_LOCAL: do nothing
return;
}
if (_Py_IsOwnedByCurrentThread(op)) {
_Py_atomic_store_uint32_relaxed(&op->ob_ref_local, new_local);
}
else {
_Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT));
}
Every claim in this document is visible in those eleven lines:
The immortality check is first (new_local == 0, i.e. ob_ref_local was
UINT32_MAX). No memory is written at all for None, True, small ints, interned
strings. Zero coherence traffic — doc 01 §6's
"only not writing can save you", implemented.
The owned fast path uses _relaxed load and _relaxed store. Per §7.2 those compile
to a plain ldr and a plain str on AArch64 — byte-identical to the GIL build's
non-atomic increment. So why write _Py_atomic_* at all? Because of §14.2: another
thread may concurrently read ob_ref_local (the GC, a debugger, sys.getrefcount, the
escape path), and a plain non-atomic access racing with any concurrent access is UB, which
licenses the compiler to tear, invent, or hoist it. The relaxed atomic buys correctness
against the optimizer at exactly zero instruction cost. PEP 703 says this explicitly:
"Note that the above is pseudocode: in practice, the implementation should use 'relaxed atomics' to access
ob_tidandob_ref_localto avoid undefined behavior in C and C++." — PEP 703, §Biased Reference Counting
That sentence is the entire §14 argument, in one line, in a PEP.
The escape path uses _Py_atomic_add_ssize — seq_cst, and fetch_add, not CAS. §11's
rule, applied: the operation has a native atomic, so use it rather than a CAS loop. And
it is seq_cst because §15.2 offers nothing else — CPython does not permit itself relaxed
RMWs.
The refcount states are transitioned with CAS. PEP 703 describes ob_ref_shared's
low two bits as a state machine (0b00 default → 0b01 weakrefs → 0b10 queued → 0b11
merged) and notes: "Transitioning states requires an atomic compare-and-swap on the
ob_ref_shared field." That is the legitimate use of CAS from §10.3 — a conditional
multi-field update with no native atomic form.
One more measured fact that surprised me: Include/cpython/pyatomic.h is
byte-identical between the GIL build and the free-threaded build (measured — I
compared the two installed files). The atomics API is always present and always
compiled; only the call sites differ, gated on Py_GIL_DISABLED. That is a sane
engineering choice — one API, no #ifdef maze at the point of use — and it means you can
read and reason about pyatomic.h from a normal Python install.
15.4 Where free-threaded CPython actually needs acquire/release¶
Per PEP 703's Optimistically Avoiding Locking section, dict.__getitem__,
list.__getitem__, PyDict_FetchItem/GetItem, PyList_FetchItem/GetItem, and the
dict/list iterators built on them all have a lock-free fast path. The PEP's
motivation is worth quoting because it is the coherence argument from doc 01, applied:
"Dictionaries hold top-level functions in modules and methods for classes. These dictionaries are inherently highly shared by many threads in multi-threaded programs. Contention on these locks in multi-threaded programs for loading methods and functions would inhibit efficient scaling in many basic programs."
Every method call in every thread touches the class dict. Put a lock on it and you have
rebuilt the GIL with extra steps. So those reads are lock-free — which means they are
exactly the MP pattern from §6.2, and they need release stores on the writer and
acquire loads on the reader, or free-threaded CPython on ARM would read a published
pointer to an unpublished object. That is what the pointer-sized acquire/release surface
in §15.2 exists for, and it is why the dict fast path is a memory-model artifact,
not just a lock-elision trick.
Reclaiming the memory those lock-free readers might still be looking at is a separate,
harder problem, solved with QSBR in Python/qsbr.c (borrowed from FreeBSD, per
24-the-gil.md §8.6).
That is 03-lockfree-and-reclamation.md's subject.
16. Python has no memory model¶
Here is the fact that ends most conversations: the Python language specification does not define a memory model. There is no chapter of the language reference that says what one thread is guaranteed to observe of another thread's writes. There is no happens-before relation, no synchronizes-with edge, no data-race definition. C has one. C++ has one. Java has a famously rigorous one. Go has a deliberately minimal one. Rust inherits C++'s. Python has none.
For thirty years this did not matter, because the GIL was the memory model: one thread runs bytecode at a time, so a bytecode-granular sequential interleaving was the whole answer. Free-threading removed the mechanism without replacing the specification.
16.1 What free-threading guarantees instead¶
PEP 703 does not specify a memory model. It specifies container thread-safety — a different, weaker, and much more operational promise:
"In CPython, the global interpreter lock protects against corruption of internal interpreter states when multiple threads concurrently access or modify Python objects. For example, if multiple threads concurrently modify the same list, the GIL ensures that the length of the list (
ob_size) accurately matches the number of elements, and that the reference counts of each element accurately reflect the number of references to those elements. Without the GIL — and absent other changes — concurrent modifications would corrupt those fields and likely lead to program crashes." — PEP 703, §Container Thread-Safety
So the guarantee is: the interpreter's own invariants hold, even under racy Python-level
access. Your program may compute nonsense; the runtime will not corrupt itself, segfault,
or hand you a list whose ob_size disagrees with its contents.
Sam Gross situated this explicitly, in an LWN comment responding to a reader who pointed out that Java's and Go's guarantees are quite different from one another:
"Russ describes Go's approach to data races as a middle ground between Java's and C++'s approaches. The no-GIL CPython also occupies a middle ground, but it's a different one from Go. The motivation is similar: to make 'errant programs more reliable and easier to debug.' The no-GIL CPython needs some behavior that is stronger than Java's guarantees. For example,
dictandlistneed to behave reasonably even with racy accesses. (In Java, racy accesses toHashMapcan lead to infinite loops.) Other behavior is weaker. For example, CPython doesn't have the sandbox security of the JVM." — Sam Gross (colesbury), LWN comment, 15 Oct 2021
That is the precise statement, and it is worth being able to reproduce it:
| Java | C/C++/Rust | Go | Free-threaded CPython | |
|---|---|---|---|---|
| Formal memory model | yes, rigorous | yes, rigorous | yes, deliberately minimal | no |
| Data race = UB? | no — constrained to "unpredictable but legal values" | yes, whole-program UB | no UB, but racy access to multi-word values may corrupt | no |
| Racy container access | may hang (HashMap infinite loop) |
UB | may corrupt | must not corrupt |
| What you get instead | happens-before + final-field semantics | SC-DRF | happens-before, minimal | operational container guarantees |
The row that defines CPython's position is the third: stronger than Java on containers, weaker than Java overall, and with no formal model at all. Sam Gross's "different middle ground" is exactly that trade.
16.2 What this means for you, measured¶
The guarantee is real and it is worth seeing. Eight threads, 200,000 operations each, both builds (measured, 5 repetitions each):
Test 1 — obj.n += 1, the classic lost-update race:
| Build | Result across 5 reps | Lost |
|---|---|---|
| 3.14.6 (GIL) | 1,600,000 / 1,600,000 in all five reps | 0.00% |
| 3.14.6t (free-threaded) | 457,066 / 404,517 / 421,825 / 414,671 / 402,572 | 71.4% – 74.8% |
Test 2 — list.append from 8 threads:
| Build | len(L) |
All elements valid |
|---|---|---|
| 3.14.6 (GIL) | exactly 1,600,000 | yes |
| 3.14.6t (free-threaded) | exactly 1,600,000 | yes |
Test 3 — dict under 8 concurrent writers + 2 concurrent readers (readers doing
len(), list(D), D.get() in a loop):
| Build | Corruption | Exception |
|---|---|---|
| 3.14.6 (GIL) | none | none |
| 3.14.6t (free-threaded) | none | none |
Read those three tables together, because together they are §16.1:
Test 1 is the race condition, and free-threading exposes it brutally. The GIL build
lost zero updates in five consecutive runs — the race is real, the bytecode is still
LOAD/ADD/STORE, but the interleaving window essentially never opened at this scale. The
free-threaded build lost three quarters of all updates, every single run. This is
24-the-gil.md §6's claim —
"free-threading doesn't create new race conditions in Python code so much as it raises
the probability of ones you already had from 'once a month in prod' to 'immediately'" —
measured, and the measurement is more violent than the prose suggests. Not "more likely."
Reliably, reproducibly, 73% wrong.
Tests 2 and 3 are the PEP 703 guarantee holding. Eight threads hammering a list with
no lock produced a list of exactly the right length with exactly the right contents. Eight
writers and two readers on a dict produced no exception and no corruption. That is not
luck; it is the per-object locks and the acquire/release-based lock-free read paths of
§15.4 doing their job.
The distinction those tables teach is the one that matters in review: free-threaded
Python protects the runtime's invariants, not your invariants. list.append is
atomic; if x not in L: L.append(x) is not, and never was. The runtime will not crash.
Your program will be wrong.
16.3 The gap is recognized, and there is a live proposal¶
This is not settled ground as of August 2026. Community discussion has characterized Python's memory model as "whatever CPython happened to have implemented at the time PEP 703 was written." In November 2025, Mark Shannon published a draft PEP 805, "Safe Parallel Python", proposing runtime-enforced thread safety: additional per-object state so the interpreter can check cheaply, at runtime, whether an operation is thread-safe and raise an exception when it is not — objects must be explicitly made shareable, or sharing is prohibited. LWN covered it in "An explicit thread-safety proposal for Python" (Daroc Alden, 3 Nov 2025).
Status, honestly. As of my research for this document, PEP 805 is a draft (first published Sept 2025), had no working prototype and therefore no measured performance impact, and has not gone to the Steering Council. I could not confirm any status change between then and August 2026 — the canonical URL I found was still a readthedocs preview build rather than a published
peps.python.orgpage, which is itself a signal that it has not advanced. Treat it as "a serious proposal exists", not as "Python is getting this." Verify before repeating.
What to say in an interview. "Python has no formal memory model. Under the GIL that
didn't matter — the GIL was the model. PEP 703 replaced it with an operational guarantee:
the interpreter's own invariants survive racy access, so dict and list can't be
corrupted, which is deliberately stronger than Java on containers. But there is no
happens-before relation you can reason with, which means for Python-level code the only
sound rule is 'use a lock', and for C-extension code you are in C's memory model with all
of C's undefined behaviour. Mark Shannon's draft PEP 805 is one proposal to close the
gap." That answer is rung 5 on README §14's ladder, because it names what is guaranteed,
what isn't, and where the model stops.
17. Lab exercises¶
Reading this document leaves you at rung 3 (README §14) — you can now say "store
buffer" and "acquire/release" fluently, which is exactly the trap. These labs move you
to rung 4. All were run on an M3 Pro; all need only clang and wall-clock timing.
1 — Reproduce the MP litmus test, and get a negative result first. Write the two-thread
harness: sense-reversing barrier, randomized nop delay, MP test, referee. Run it with
a short delay and observe zero violations. Then sweep the delay from 2 to 2048 nops and
find your machine's resonance. Do not skip the negative result — the point of this lab
is that you can "prove" AArch64 is sequentially consistent by measuring badly. Proves you
can distinguish "did not observe" from "cannot happen", which is the single most
transferable skill in this document. (§6)
2 — Run it on x86 and settle my Rosetta caveat. Same source, real Intel or AMD hardware (a cloud VM is fine). Confirm SB is observable and MP is not. If you have both x86 and Graviton, run both and put the table in your notes — that table is the most persuasive artifact you can bring to a design review about ARM migration. Proves §5's architecture table with hardware I did not have. (§6.2)
3 — Measure the atomic cost model and find the collapse. N threads incrementing (a)
private padded counters, (b) one shared atomic, © one shared atomic via a CAS loop, (d)
counters 8 bytes apart. Sweep N from 1 to your core count. Predict the shape of all four
curves before you run it. Then explain why (b) loses total throughput as you add
cores. Proves §12, and it is the same experiment as
doc 01 lab 4 and
24-the-gil.md lab 1 — do it once, use it three times.
4 — Read the assembly for all five orderings, both ISAs. Write the twelve one-line
functions from §7.2, compile with clang -O2 -S for arm64 and -arch x86_64, and build
the table yourself. Then find the three cells where the two architectures disagree most
and explain each. Proves you can answer "what does release cost?" with an instruction
instead of an adjective. (§7)
5 — Break volatile on purpose. Take the MP test and implement the publisher with
volatile instead of atomics. Confirm from the assembly that no barrier is emitted, then
confirm from execution that the violation rate is the same as the fully-relaxed version.
Then compile the same source for x86-64 and observe that volatile "works" there.
Proves §4, and inoculates you against the most persistent myth in the field.
6 — Force LL/SC and try to livelock it. Compile with -mcpu=cortex-a53 to defeat LSE,
confirm ldxr/stxr in the assembly, then run N threads doing CAS loops on one word and
look for a thread that starves. I did not manage this and I have said so (§9.3) — if
you succeed, you have gone past this document. Then re-enable LSE and compare throughput.
Proves §9, and rewards you for not trusting a document that admits its gaps.
7 — Fix Dekker's algorithm. Implement Dekker's or Peterson's mutual-exclusion
algorithm with plain _Atomic relaxed accesses. Instrument it to detect both threads in
the critical section simultaneously, and watch it fail. Add exactly one
atomic_thread_fence(memory_order_seq_cst) in the right place and watch it stop failing.
Then find the minimum ordering that works and justify it. Proves §6.1 and §8, and it is
the classic exercise for a reason.
8 — Measure the free-threading race amplification. Run the §16.2 tests on both builds
on your machine: obj.n += 1, list.append, and a dict under concurrent write+read.
Then write the one racy Python program that does produce visibly wrong output from
list — e.g. if x not in L: L.append(x) from 8 threads — and confirm the list is
internally consistent while the program's logic is wrong. Proves §16.2's central
distinction: the runtime's invariants versus yours. Take this one to
26-free-threading.md.
18. Question bank¶
Staff-level. If you can't answer from your own model, the section to reread is noted.
- A store instruction retires. Name three things that are still not true about it. (§1)
- Why does store-to-load forwarding exist, and why does it make single-threaded reasoning safe while breaking multi-threaded reasoning? (§1)
- Which of the four elementary reorderings does x86-TSO permit, and why can no practical architecture forbid that one? (§3)
- Your code is correct on x86-64 and corrupts on Graviton with no source change. Give the most likely litmus test, and the two-token fix. (§6.2)
volatileprevents the compiler from reordering. Why is that not enough on ARM, and why is it usually enough on x86? (§4)- What does
memory_order_releasecompile to on x86-64? What doesmemory_order_seq_cststore compile to? Why is that difference the largest optimization available in x86 concurrent code? (§7.2) - Clang emits
ldaprfor an acquire load andldarfor a seq_cst load. What is the difference, and what does it cost you? (§7.2) - When would you use a fence instead of an ordered load or store? (§8)
- Explain spurious failure of
compare_exchange_weakin terms of hardware. On which targets does it actually occur? (§10) - You have a CAS loop and a
fetch_addavailable for the same operation. Give the asymptotic argument forfetch_add, and then the progress-guarantee argument. (§11) - An uncontended atomic RMW costs ~2 ns on this machine. A contended one costs ~19 ns. What changed, and what is the only class of fix? (§12.3)
- Eight cores incrementing one shared atomic have less aggregate throughput than one core. Explain the mechanism, and name the CPython project that discovered this the expensive way. (§12.2,
24-the-gil.md§7) - What happens to a misaligned atomic on x86-64? On AArch64? Why is the AArch64 behaviour better engineering? (§13)
- Why is a data race undefined behaviour rather than "you get a stale value"? Give a concrete optimization that exploits it. (§14.2)
- State SC-DRF as a two-sided contract. Which side do you break when you write
memory_order_relaxed? (§14.3) - CPython's
Py_INCREFfast path uses relaxed atomics that compile to a plainldr/str. If the instructions are identical to a non-atomic access, what did the atomic buy? (§15.3, §14.2) - Why does
Include/cpython/pyatomic.hhave no relaxed variant of_Py_atomic_add_ssize? (§15.2) - Does Python have a memory model? What does free-threaded CPython guarantee instead, and how does it differ from Java's guarantees in both directions? (§16)
- Eight threads do
list.appendwith no lock. Is the list valid afterwards? Eight threads doobj.n += 1. Is the count right? Justify both from §16.1's guarantee, not from bytecode. (§16.2) - Your free-threaded build loses 73% of increments where the GIL build lost 0%. Did free-threading introduce a bug? (§16.2)
19. Sources¶
Primary — the hardware view
- Paul McKenney, Memory Barriers: a Hardware View for Software Hackers — the source of §1 and §2. Store buffers and invalidate queues explained by someone who had to make Linux correct on a dozen architectures. Verdict: read it once, completely, before anything else here. Also in expanded form as Appendix C of Is Parallel Programming Hard, And, If So, What Can You Do About It? 🆓, which is the best free book on this topic that exists.
- Adve & Gharachorloo, Shared Memory Consistency Models: A Tutorial (1995) 🆓 — the paper that made "memory consistency model" a term working engineers use. Predates C11 and is better for it: it reasons about hardware models directly. Verdict: the single best 25 pages on why SC is not free.
- ARM Architecture Reference Manual, ARMv8-A, §B2 (The AArch64 Application Level Memory Model) — the normative source for ldar/ldapr/stlr, ldxr/stxr, dmb variants, and other-multi-copy-atomicity. Verdict: reference, not reading. Consult it for a specific instruction; do not attempt it linearly.
- Sewell, Sarkar, Owens et al., x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors (CACM 2010) 🆓 — where §5.1's model comes from, including the history of Intel's own documentation being ambiguous. Verdict: read the first four pages; the formalism after that is optional.
- herd7 / litmus7 (Alglave, Maranget et al.) — the tools that turn §6 from an experiment into a decision procedure. Verdict: use these instead of stress tests once you are past this document.
Primary — the language view
- Boehm & Adve, Foundations of the C++ Concurrency Memory Model (PLDI 2008) 🆓 — the design rationale for §7 and §14, including why data races are UB rather than merely unspecified. Verdict: the answer to "why did they make it so complicated?"
- Hans Boehm, Threads Cannot Be Implemented As a Library (2005) 🆓 — the paper that forced C and C++ to specify a memory model at all. Verdict: short, devastating, and the historical key to §14.
- cppreference — std::memory_order — the normative-adjacent reference for §7.1. Note its own warning about memory_order_consume. Verdict: the page to keep open while writing atomics.
Preshing — the best free explanations anywhere
- Weak vs. Strong Memory Models — §3 and §5 in blog form. Verdict: if §5 didn't land, read this instead and come back.
- Acquire and Release Semantics — the source of the one-way-barrier picture in §7.1.
- Memory Reordering Caught in the Act — the SB experiment and, crucially, the randomized-delay methodology that §6.3 rediscovered the hard way. Verdict: read this BEFORE attempting lab 1 and save yourself my two wasted hours.
- An Introduction to Lock-Free Programming — the bridge to 03-lockfree-and-reclamation.md.
Books - Herlihy, Shavit, Luchangco & Spear, The Art of Multiprocessor Programming, 2e (2020) — ch. 3 (concurrent objects, linearizability) and ch. 7 (spin locks, contention, backoff). Verdict: the theory §11's progress guarantees come from. Buy it if you intend to write lock-free code; skip it if you intend to use locks. - Sorin, Hill & Wood, A Primer on Memory Consistency and Cache Coherence, 2e (2020) — the rigorous joint treatment of coherence (doc 01 §5) and consistency (this document). Verdict: the book that makes clear these are two different problems, which is the confusion this document exists to prevent. - Anthony Williams, C++ Concurrency in Action, 2e — ch. 5 is the most practical prose on the C11/C++11 model. Verdict: best applied treatment of §7 even if you don't write C++.
CPython — verify against these, not against this document
- Include/cpython/pyatomic.h and its three backends pyatomic_gcc.h, pyatomic_msc.h, pyatomic_std.h — §15's source. You already have these: they ship in any CPython install's include/ directory. Verdict: read the top comment block in full; it states the design philosophy in ten lines.
- Include/refcount.h — §15.3's Py_INCREF. Read the Py_GIL_DISABLED branch alongside PEP 703's pseudocode and note where they differ.
- Python/qsbr.c — the reclamation scheme that makes §15.4's lock-free reads safe. Deferred to 03-lockfree-and-reclamation.md.
- PEP 703 — read §Reference Counting (the "relaxed atomics" note quoted in §15.3), §Container Thread-Safety, and §Optimistically Avoiding Locking. Verdict: the three sections that make this document's Python half make sense.
The memory-model gap - Sam Gross on Java, Go and CPython's "different middle ground" — LWN comment, 15 Oct 2021. §16.1's quote. Verdict: the single most precise statement of what free-threaded Python guarantees, and it is a comment on a news site rather than a specification. That is itself the point. - Russ Cox, Updating the Go Memory Model — the "middle ground" framing Gross is referencing, and the best argument anywhere for saying less in a memory model. Verdict: read it for the design philosophy even though it's about Go. - LWN — An explicit thread-safety proposal for Python (Daroc Alden, 3 Nov 2025) — coverage of Mark Shannon's draft PEP 805. Verdict: the current state of the argument; check for movement before quoting §16.3. - Thread safety now and in the future (no-gil) — discuss.python.org, Oct 2025. Where the "whatever CPython happened to have implemented" characterization appears.
Sibling docs
- 01-memory-hierarchy-and-caches.md §5–§6 — MESI and false sharing. This document is its §5 continued; §12.2 is its lab 3 with atomics.
- 03-lockfree-and-reclamation.md — ABA (teased in §9.3), tagged pointers (priced in §13), hazard pointers, epochs, QSBR.
- 24-the-gil.md §1, §7, §9 — the coherence origin of the GIL, and the Gilectomy's atomic-refcount failure, which §12.2 reproduces.
- 26-free-threading.md — where §15 and §16 get applied to migration decisions.
- 30-concurrency-correctness.md — data race vs race condition (§14.1), and the testing tools that §6.3 argues you need.
- 31-measurement-methodology.md — the discipline §6.3 and §12.4 are pleading for.
Next: 03-lockfree-and-reclamation.md — you can now
make a single word change atomically and in order. The remaining problem is freeing the
memory a lock-free reader might still be looking at, and that turns out to be harder than
everything in this document.