Hardware Synchronization

Coherence and consistency let threads see each other's data. But seeing is not enough: two threads that both run \texttt{count = count + 1} can each read the same old value, each add one, and each write back — and one increment simply vanishes. This is the synchronization problem, and it cannot be solved by ordinary loads and stores alone. It needs a new kind of instruction the hardware must provide: an atomic read-modify-write.

An atomic RMW reads a memory location, computes a new value, and writes it back as one indivisible step — no other core can sneak in between the read and the write. Every lock, every mutex, every lock-free data structure in existence is built on a handful of these primitives. This lesson is about what they are, how they are built on the coherence machinery you already know, and why they are so surprisingly expensive.

The three primitives

Almost every ISA offers one or more of these atomic building blocks:

CAS is the workhorse. Its power is that it lets you attempt an update and find out whether you were beaten to it — the foundation of every optimistic, lock-free algorithm.

Building a spinlock from CAS

A spinlock is the simplest lock there is: to acquire it, keep trying CAS to flip the lock word from "free" (0) to "held" (1) until you win; to release it, store 0. The loop "spins" — burns CPU — until it succeeds. Here it is, with a simulated cell you can CAS. The demo also shows the lost-update bug that CAS prevents:

// An atomic memory cell: compareAndSwap is the one indivisible operation. class AtomicCell { private v: number; constructor(init: number) { this.v = init; } load(): number { return this.v; } store(x: number): void { this.v = x; } // Atomically: if current == expected, set to next and return true. compareAndSwap(expected: number, next: number): boolean { if (this.v === expected) { this.v = next; return true; } return false; } } // --- A CAS spinlock --- const lock = new AtomicCell(0); // 0 = free, 1 = held function acquire(): number { let spins = 0; while (!lock.compareAndSwap(0, 1)) { spins++; } // spin until we flip 0 -> 1 return spins; } function release(): void { lock.store(0); } acquire(); console.log(`lock acquired, held = ${lock.load()}`); release(); console.log(`lock released, held = ${lock.load()}`); // --- Why atomicity matters: a CAS-retry counter never loses an update --- const counter = new AtomicCell(0); function atomicInc(): void { let old: number; do { old = counter.load(); } while (!counter.compareAndSwap(old, old + 1)); // retry on interference } for (let i = 0; i < 5; i++) atomicInc(); console.log(`counter after 5 atomic increments = ${counter.load()}`); // exactly 5, never fewer

Why atomics are expensive: the cache-line ping-pong

Here is the sting. A CAS or test-and-set must write the lock word, and by MESI a write demands the block in Modified state — the only copy. So every core that even attempts the lock must pull that cache line to itself in M, invalidating everyone else. When many cores spin on one lock, its cache line ping-pongs between them, and each bounce is a full coherence transaction across the interconnect. The cores spend more time shuttling the lock line than doing useful work.

The classic fix is test-and-test-and-set (TTAS): before doing the expensive atomic write, spin on an ordinary read of the lock (which lets every core keep the line in cheap Shared state), and only attempt the CAS when the read says the lock looks free. That collapses the traffic from "everyone writes constantly" to "everyone reads quietly, one writes when it's worth trying." Beyond that, real systems use back-off, queue locks (MCS), or ticket locks to tame the contention entirely.

LL/SC: the same idea without a fused instruction

CISC machines like x86 have a single fused atomic (\texttt{LOCK CMPXCHG}). RISC machines dislike fusing a read and a write into one instruction, so they split it: load-linked reads the word and quietly records a reservation on that address; store-conditional writes back only if the reservation is still intact — that is, if no other core wrote the line since the LL. If someone did, SC fails, returns a flag, and you loop. It is CAS assembled from two half-instructions, and the coherence protocol itself supplies the "did anyone touch it?" signal, since a remote write to the line already had to invalidate your copy.

PrimitiveWhat it does atomicallyStyle / ISA
Test-and-setset to 1, return old valuesimplest; older machines
Compare-and-swapif == expected, swap in new; report successx86 LOCK CMPXCHG
LL / SCread+reserve, then write-only-if-untouchedARM, RISC-V, POWER

CAS checks a value, not a history. Suppose CAS expects the pointer A. Between your read and your CAS, another thread changes it to B and then back to A — perhaps freeing and reusing that very address. Your CAS sees A, thinks nothing changed, and succeeds — even though the world moved underneath it. This is the notorious ABA problem in lock-free code, and it can corrupt a data structure silently. Fixes include tagged pointers (a version counter bumped on every change, so A never looks identical twice) or hazard pointers. LL/SC sidesteps ABA entirely: it fails if the line was touched at all, regardless of whether the value ended up the same — it checks history, not equality.

Beginners see \texttt{lock.compareAndSwap(...)} as a single, presumably cheap, operation. It is one instruction but potentially hundreds of cycles: it must obtain the cache line in Modified state (invalidating every other copy across the machine), it often acts as a memory fence that stalls the pipeline, and under contention it triggers the cache-line ping-pong. A single uncontended CAS might cost tens of cycles; a hotly contested one can cost thousands in wasted coherence traffic. This is why "just use a lock-free algorithm" is not automatically faster — the atomics themselves are the bottleneck.