count++ is three machine operations, and two threads can interleave them and
lose an update. A mutex fixes that by
excluding everyone else.
For a single counter, that is a lot of machinery. The CPU can do better.
One instruction, indivisible
Processors provide instructions that read and write in a single step nothing can interleave with:
counter.fetchAdd(1); // read, add, write — atomicallyNo lock, no waiting, no kernel involvement. The hardware guarantees that no other core observes a half-finished state.
Compare-and-swap is the general one
fetchAdd handles addition. For anything else there is one primitive that
handles everything:
compare-and-swap(expected, next) — if the value is still
expected, writenextand report success. Otherwise change nothing and report failure.
Wrap it in a retry loop and you can apply any update at all:
in memory
7
I expected
7
I want to write
8
do { expected = counter.load(); next = expected + 1; } while (!counter.compareExchange(expected, next));
✗ rejected
Read 7, planned to write 8 — but another thread got there first and memory is now 9.
function increment(counter: Atomic<number>) { let expected: number; let next: number;
do { expected = counter.load(); next = expected + 1; // any computation you like goes here } while (!counter.compareExchange(expected, next));}The ABA problem
CAS asks “is it still what I expected?” — and cannot tell “unchanged” from “changed and changed back”.
Thread 1: reads A, gets descheduledThread 2: changes A → B → AThread 1: CAS(A, next) succeeds ← but the world moved underneath itFor a counter this is harmless; the value is all that matters. For a pointer it is a memory-corruption bug: the node you read may have been freed and its address reused for something else entirely.
The standard fixes:
- A tagged pointer — pack a version counter alongside the address, so A-with-tag-5 differs from A-with-tag-7. This is what double-width CAS is for.
- Hazard pointers or epoch reclamation — do not free a node while any thread might still hold it.
- A garbage-collected language — the node cannot be reused while reachable, so the problem does not arise. This is why lock-free structures are so much easier in Java and Go than in C++.
When atomics beat a lock, and when they do not
Better for a single word: counters, flags, one pointer swap. No syscall, no descheduling, and no risk of deadlock.
Worse under heavy contention. Every failed CAS is wasted work and a cache line bounced between cores. A mutex under contention parks the loser, which uses no CPU; a CAS loop burns it. Past some contention level the lock wins, and the crossover is lower than most people assume.
Impossible for anything spanning more than one word. Two accounts cannot be updated atomically by CAS. That needs a lock, or a transaction, or a redesign.
In JavaScript
The main thread is single-threaded, so most code never needs any of this. With
Web Workers and a SharedArrayBuffer it becomes real:
const shared = new Int32Array(new SharedArrayBuffer(4));Atomics.add(shared, 0, 1);Atomics.compareExchange(shared, 0, expected, next);Atomics.wait(shared, 0, 0); // block until it changes — workers onlySharedArrayBuffer requires cross-origin isolation headers, which is the
Spectre mitigation and is why it is not available by default.