The question this answers
Which operations does an atomic variable actually make indivisible, and which ones does it leave exposed?
Eight worker threads each increment one shared completed counter after finishing a request.
One 64-bit counter that every worker reads and writes. Nothing else is shared between the workers.
The counter equals the number of completed requests — every increment lands exactly once and none is overwritten by a stale value.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
What `counter += 1` actually is
The first thing to internalise is that counter += 1 is not one operation. It is a load, an add, and a store, and every one of those boundaries is a place another thread can run. Operating Systems covers what makes that interruption possible — see Atomic Operations and Context Switching. What this domain cares about is the schedule it produces and the invariant it kills.
Two threads, both incrementing, both correct in isolation. Run them one after the other and the counter is 2. Run them interleaved at the load boundary and the counter is 1 — and, crucially, nothing errors. Both threads returned success. Both callers believe they incremented. The only evidence is a number that is quietly, permanently low, which is why lost updates surface as "our request count is about 3% under the load balancer's" rather than as a crash.
An atomic fetch-add collapses those three steps into one indivisible operation. No thread can be scheduled in the middle of it; no thread can observe the counter after the load and before the store. That is the whole of what "atomic" means here — indivisibility with respect to other observers, not speed, not ordering of anything else.
| # | Worker A | Worker B | State |
|---|---|---|---|
| 1 | load counter -> reg = 0 | · | counter=0 A.reg=0 |
| 2 | · | load counter -> reg = 0 | counter=0 A.reg=0 B.reg=0 |
| 3 | add 1 -> reg = 1 | · | counter=0 A.reg=1 B.reg=0 |
| 4 | · | add 1 -> reg = 1 | counter=0 A.reg=1 B.reg=1 |
| 5 | store counter = 1 | · | counter=1 |
| 6 | · | store counter = 1 | counter=1 ✕ Two increments completed; counter reads 1. B's store was computed from a value A had already superseded — a lost update. |
The four operations an atomic actually gives you
An atomic type is not one guarantee, it is a small family. Load and store give you a value that is never torn — you never read the high half of an old value and the low half of a new one. Exchange writes and hands back what was there. Fetch-and-add (and its siblings: fetch-sub, fetch-or, fetch-and) does a whole read-modify-write indivisibly. Compare-and-swap conditions the write on the value it expected, which is what Compare-and-Swap and the Retry Loop is about.
The distinction that matters in review is *how many locations* each one touches. All of them touch exactly one. There is no atomic operation in any of these languages that indivisibly updates two independent variables, which is the entire reason Atomics Are Not Magic is a lesson rather than a footnote.
The second distinction is what an atomic promises about *other* memory. In C++ that is a per-operation choice: the default memory_order_seq_cst also constrains the visibility of surrounding non-atomic accesses, while memory_order_relaxed guarantees only the atomicity of that one word and orders nothing else. Reaching for relaxed because it "sounds faster" and then relying on ordering is one of the most common serious bugs in this area. See Memory Barriers Constrain Ordering, Not Caches.
| Operation | What is indivisible | What it returns | What it does NOT promise |
|---|---|---|---|
| atomic load | The read of one location | The value | That the value is still current by the time you use it |
| atomic store | The write of one location | Nothing | That anyone reads it soon, or that a concurrent writer is ordered against you |
| exchange | Read-then-write of one location | The previous value | That the previous value tells you the full history — see The ABA Problem: The Value Came Back |
| fetch-add / fetch-sub | Load, arithmetic, store of one location | The value before the add | That a second location moved with it |
| compare-and-swap | Compare-then-conditional-write of one location | Success, plus the value actually found | That it succeeds first try, or that equal value means unchanged world |
The same counter in four languages
The mechanisms genuinely differ here — this is not four spellings of one API. C++ gives you a first-class atomic type with an explicit memory-ordering parameter. JavaScript gives you atomics only over a SharedArrayBuffer, because that is the only memory two agents can share at all. TypeScript adds nothing of its own; it compiles away and inherits whatever the host runtime does. CPython (3.12) ships no general atomic integer type at all, and the correct answer there is a lock.
The CPython case is the one people state wrongly. It is not true that "Python is safe because of the GIL". What is true of CPython 3.12 is that only one thread executes bytecode at a time, and n += 1 on a shared name compiles to several bytecodes with interpreter switch points between them. The lost-update schedule above is reproducible in CPython; it just needs more iterations to show up. See Python: Threads, Processes and the GIL.
None of these four gives you a way to make two variables move together atomically. That is a property of the hardware and the language model, not an API gap somebody forgot to fill.
1#include <atomic>2std::atomic<uint64_t> completed{0};3 4// indivisible load-add-store; default ordering is seq_cst5completed.fetch_add(1);6 7// same atomicity, no ordering promise about other variables8completed.fetch_add(1, std::memory_order_relaxed);A real atomic type with per-operation memory ordering. Reading completed with a plain (non-atomic) variable of the same name would be a data race and therefore undefined behaviour.
1// Atomics only work over memory two agents can actually share.2const sab = new SharedArrayBuffer(8)3const counter = new BigInt64Array(sab)4 5Atomics.add(counter, 0, 1n)6Atomics.load(counter, 0)Ordinary JS objects are never shared between agents — a worker gets a structured clone, not a reference. Atomics exist only for SharedArrayBuffer-backed integer TypedArrays, and browsers additionally require the page to be cross-origin isolated.
1// TypeScript has no concurrency semantics of its own.2const counter = new BigInt64Array(new SharedArrayBuffer(8))3const bump = (): bigint => Atomics.add(counter, 0, 1n)4 5// The type system will not stop you writing counter[0]++ ,6// which is a non-atomic read-modify-write.7 Types do not encode atomicity. counter[0]++ type-checks perfectly and reintroduces the exact three-step race from the schedule above.
1import threading2 3completed = 04lock = threading.Lock()5 6def done():7 global completed8 with lock: # the standard-library answer; there is no atomic int9 completed += 1CPython 3.12 exposes no general atomic integer. Only one thread runs bytecode at a time, but completed += 1 spans several bytecodes with switch points between them, so increments are genuinely lost without the lock.
- C++ is the only one of the four with a formally specified per-operation memory ordering, and the only one where getting it wrong is undefined behaviour rather than a wrong number.
- JavaScript agents share no objects at all — atomics apply to a byte buffer, so "which variable is atomic" is really "which offset in which buffer".
- TypeScript contributes nothing at runtime; every claim about it is a claim about Node or the browser underneath.
- CPython 3.12 has no atomic integer type, so the idiomatic fix is a
threading.Lock; the free-threaded build introduced in 3.13 changes the surrounding assumptions and is still experimental.
Key points
x += 1is a load, an add and a store; two threads interleaving at those boundaries lose an update with no error anywhere.- Atomic means indivisible with respect to other threads — nothing more. It is not a speed claim and not an ordering claim.
- Every atomic operation in these languages touches exactly one memory location. There is no two-variable atomic.
- In C++, ordering is a separate per-operation choice;
relaxedgives atomicity of that word and orders nothing else. - CPython 3.12 has no atomic integer type. "The GIL makes it safe" is false for any operation spanning more than one bytecode.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • The compiler emits a single read-modify-write instruction (or an equivalent LL/SC pair) rather than three separate memory accesses.
- • The hardware guarantees no other core observes the location between the read and the write of that instruction.
- • The chosen memory ordering additionally constrains how surrounding loads and stores may be reordered around it — in C++ explicitly, in other languages by whatever the runtime specifies.
- • The atomic type also forbids tearing: a reader never sees a half-updated value, which a plain 64-bit variable is not guaranteed to promise on every target.
- • Nothing above extends to a second variable, so any invariant relating two locations still needs a lock, a queue or a single owner.
- • A loads 0; B loads 0; A stores 1; B stores 1 — two increments, counter reads 1, both callers returned success. The classic lost update.
- • A fetch_add(1) -> returns 0; B fetch_add(1) -> returns 1 — atomic version: the two operations serialise in some order, the counter is 2, and each thread learns which one it was from the return value.
- • A loads counter (=5) for a log line; B fetch_add(1); A logs "5" — the atomic held, but A's *read* is already stale by the time it is used. Atomicity of the read says nothing about the value still being current.
- • A fetch_add(inFlight, 1); B reads inFlight (=1) and reads queueDepth (=0) for a ratio — both reads atomic, the pair is not, so B computes a ratio from a state that never existed. This is Atomics Are Not Magic.
- • Promises: the operation is indivisible; no thread observes an intermediate state of that one location; no torn reads of that location.
- • Promises: fetch-add returns the previous value, which is often the only way a thread learns whether it was the one that crossed a threshold.
- • Does NOT promise: that the value is still true after the operation returns. Any decision made from an atomic read is a decision about the past.
- • Does NOT promise: that two atomic operations performed back to back are jointly atomic.
- • Does NOT promise: any ordering of surrounding non-atomic accesses, unless the language and the chosen ordering say so.
relaxedin C++ explicitly gives none. - • Does NOT promise: better performance. A contended atomic on a hot line can be slower than an uncontended lock — see What a Shared Write Costs.
- • Every thread performing an atomic RMW on the same location needs exclusive ownership of that cache line, so the line migrates between cores on every operation.
- • Throughput on one hot atomic saturates and then degrades with core count; the counter is correct the whole time, which is why this shows up as a performance ticket rather than a correctness one.
- • A counter incremented on every request is a classic hot line. Per-thread counters summed on read remove the contention entirely at the cost of an approximate read — see False Sharing: Different Variables, Same Cache Line for the adjacent trap.
- • Lost update — the load/store interleaving above; the number is silently low.
- • Data race — in C++, a plain (non-atomic) concurrent read and write of the same object is undefined behaviour, not merely a wrong value. See Data Race Is Not Race Condition.
- • Torn read — a wide value read as two halves from different generations, on targets where plain access to that width is not atomic.
- • Stale-read-then-act — the read was atomic, the decision made from it was not, giving a check-then-act race.
- • False confidence — an atomic added to one variable in a function whose invariant spans three, leaving the bug in place and the code looking synchronized.
- • A single counter, flag, sequence number or high-water mark updated by many threads, where nothing else depends on it in the same instant.
- • Reference counts, where the whole invariant genuinely is "this one number tracks these owners".
- • A stop flag polled by workers, where you need visibility rather than mutual exclusion.
- • Building blocks for the primitives in Compare-and-Swap and the Retry Loop and A Lock-Free Stack, and What the Teaching Version Omits, where a lock would defeat the point.
- • When the invariant spans more than one location — then the atomic is decoration and the bug survives.
- • When the code does read, decide, write as three statements: the atomic makes each step indivisible and the decision still races.
- • When contention on one line is high enough that coherence traffic dominates; a sharded counter or a per-thread accumulator would be faster.
- • When it replaces a mutex purely to avoid the word "lock", trading a readable critical section for a subtle ordering argument nobody on the team wants to re-derive.
- • Compare the counter against an independent source of truth — load-balancer request counts, log line counts — and look for a deficit that grows with concurrency. A shortfall proportional to thread count is a lost update, not sampling.
- • Run the increment loop under a thread sanitizer (TSan) or a race detector; an unsynchronized concurrent read/write on the same location is reported directly. See Race Detectors: What They Find, and What They Structurally Cannot.
- • For the performance side, profile for time attributed to the increment instruction itself and watch it grow superlinearly with core count — that is line ownership migration, not compute. Self Time, Total Time, and Where the CPU Went covers reading that attribution.
- • Stress with more threads than cores and a switch interval turned down (CPython:
sys.setswitchinterval) to widen the window the bug needs. See Stress Testing: A Test That Passed Once Proves Nothing.
- • You now have a variable whose correct use depends on a rule the type does not enforce: every access must go through the atomic API. One stray plain read reintroduces the race.
- • In C++ the memory-ordering argument becomes part of the API contract of anything holding the atomic, and reviewers must be able to justify it.
- • Debugging is harder than a lock: there is no held-lock state to inspect in a thread dump, so Reading a Thread Dump tells you much less than it would about a mutex.
- • The code reads as if it is synchronized, which raises the bar for the next person to notice that the invariant it needed was never single-location.
- • A mutex around the update. If the region is short and uncontended this is simpler, and it extends for free the day the invariant grows a second variable. See Mutexes: What They Protect and What They Do Not and Mutexes.
- • Per-thread counters summed on read. Zero contention, exact totals once threads are joined, and only an approximate value while they run.
- • A queue to a single owning thread that keeps the counter privately — no shared mutable state at all. See Message Passing.
- • Push the counter to where concurrency is already solved: a database
UPDATE ... SET n = n + 1or a metrics backend that aggregates. See The Database Solves Concurrency For Its Data, Not For Your Memory.
counter++ with and without atomicity
r ← counter r ← r + 1 counter ← r
fetch_add(counter, 1) # no schedule can cut inside this
if (balance >= 100) withdraw(100) — drive it until it overdraws
balance = 100
withdraw(amount): # both tasks run this concurrently
b = read(balance) # 1
if b >= amount: # 2 <- decided on a value that may already be stale
debit(amount) # 3| # | Withdrawal A (100) | Withdrawal B (100) | State |
|---|---|---|---|
| 1 | rA ← read balance | · | balance=100 paidOut=0 |
| 2 | if rA >= 100 | · | balance=100 paidOut=0 |
| 3 | debit 100 | · | balance=0 paidOut=100 |
What people believe, and what is true
Making the variable atomic makes the function thread-safe.
It makes that one location indivisible. If the function's invariant relates two locations, or reads then decides then writes, the race is untouched.
Atomics are faster than locks.
An uncontended lock is cheap and a contended atomic on a hot cache line is not. Atomics are chosen for what they guarantee, not for speed — measure before claiming either.
The GIL makes Python increments safe.
In CPython 3.12 only one thread runs bytecode at a time, but n += 1 is several bytecodes with switch points between them. Increments are demonstrably lost.
Go deeper
Overview
An atomic operation cannot be observed half-finished. Use one when many threads update a single number and you need every update to land.
Practical
Reach for fetch-add for counters and reference counts, and use the returned previous value when you need to know which thread crossed a threshold. The moment your invariant mentions a second variable, stop and use a lock.
Advanced
In C++ separate the two questions the atomic answers: atomicity of this location, and ordering of everything around it. relaxed answers only the first. A counter that nothing else is ordered against is the textbook legitimate use of relaxed; a publication flag is not.
Internals
The compiler emits lock-prefixed RMW on x86-64 or an LL/SC (load-exclusive / store-exclusive) retry on ARM. The retry structure of LL/SC is why an atomic RMW on ARM already looks like the CAS loop in Compare-and-Swap and the Retry Loop even when you wrote fetch-add. The cache-protocol reason contention costs what it does belongs to Computer Architecture.