The question this answers
Which operations does my language actually promise are indivisible, and which merely look that way?
Three operations that every engineer writes without thinking: counter += 1 on a shared integer, results.append(x) on a shared list, and if key not in cache: cache[key] = compute().
One integer, one list and one dictionary, each reachable from several tasks. All three are ordinary objects with no synchronization around them.
counter equals the number of completed increments; results contains exactly one entry per completed task; cache[key] is computed at most once and every caller receives the same object.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
One source line, several steps — the evidence
The fastest way to stop believing that x += 1 is atomic is to look at what the runtime actually executes. Below is the disassembly of a module-level increment on CPython 3.12 and the equivalent for a list append. The append is one bytecode after its arguments are on the stack; the increment is four. A thread switch may occur between bytecodes, which is exactly the distinction that makes one of these safe and the other not.
This is also where the most persistent piece of folklore in Python gets settled. The interpreter lock serialises *bytecode execution*; it does not serialise *statements*. list.append happens to be atomic on CPython because the append itself is a single bytecode implemented in C that does not release the lock partway. counter += 1 is not atomic because the load, the add and the store are separate bytecodes with switch points between them. Neither fact is a design guarantee you should lean on across versions or implementations — see the caveats.
The same reasoning applies everywhere, with different units. On preemptive threads the unit is the machine instruction and *nothing* multi-instruction is atomic. In a single-threaded event loop the unit is the synchronous run-to-completion block, which is much larger — so counter += 1 genuinely is indivisible there, and counter = await f() + 1 is emphatically not.
>>> import dis
>>> dis.dis(compile("counter += 1", "<s>", "exec"))
0 RESUME 0
1 LOAD_NAME 0 (counter) <-- shared READ
LOAD_CONST 0 (1)
BINARY_OP 13 (+=) <-- private
STORE_NAME 0 (counter) <-- shared WRITE
RETURN_CONST 1 (None)
# a thread switch is permitted between ANY two of these.
# LOAD_NAME ... STORE_NAME is read-modify-write: lost updates.
>>> dis.dis(compile("results.append(x)", "<s>", "exec"))
0 RESUME 0
1 LOAD_NAME 0 (results)
LOAD_METHOD 1 (append)
LOAD_NAME 2 (x)
CALL 1 <-- the append itself
POP_TOP
RETURN_CONST 1 (None)
# the CALL runs list.append in C. It does not release the
# interpreter lock partway, so no element is lost.
# This is an implementation property, not a language promise.
evidence that the difference is real, CPython 3.12, 8 threads x 100_000 ops:
counter += 1 expected 800000 observed 231447 <-- 71% lost
results.append(x) expected 800000 observed 800000 <-- none lost
(numbers are ILLUSTRATIVE of the shape; the exact loss rate varies
with the switch interval, core count and load on every run.)What each language actually promises
The honest answer to "is this atomic?" is always "in which language, on which runtime, at which version". The comparison below is the set of promises you can actually rely on, separated from the behaviours that happen to hold today.
One rule cuts across all four and is worth memorising: atomicity of an operation is not atomicity of a sequence. Every language in this table has some genuinely atomic operations, and in every one of them a check-then-act built from two atomic operations is not atomic. That is the same point Finding the Critical Section makes about thread-safe collections, arrived at from the language side.
1int counter = 0;2counter++; // NOT atomic. Read-modify-write; also a data3 // race if two threads do it => undefined behaviour.4 5std::atomic<int> counter{0};6counter++; // ATOMIC. Compiles to a single lock-prefixed7 // read-modify-write instruction on x86-64.8counter.fetch_add(1); // same thing, explicit.9 10std::vector<int> v;11v.push_back(x); // NOT atomic, and may reallocate the buffer12 // while another thread holds a pointer into it.13 14std::map<K,V> m;15if (!m.count(k)) m[k] = f(); // NOT atomic even with a thread-safe map:16 // two operations, one gap. Use try_emplace17 // under a lock, or std::call_once for init.Nothing is atomic unless it is std::atomic or under a lock. The compiler will also happily reorder plain accesses, so "it looked atomic in the assembly I read once" is not an argument.
1let counter = 02counter++ // indivisible WITHIN one synchronous block,3 // because nothing else runs. Not a language4 // guarantee about atomics — a consequence of5 // run-to-completion scheduling.6 7counter = (await load()) + 1 // NOT indivisible. The await is a switch point8 // and the gap is a network round trip.9 10arr.push(x) // safe within one isolate; nothing interleaves.11 12const buf = new Int32Array(new SharedArrayBuffer(4))13buf[0]++ // NOT atomic across workers: real shared memory.14Atomics.add(buf, 0, 1) // atomic. The only correct form here.Run-to-completion gives you free atomicity for synchronous blocks and takes it away the instant an await appears. Across workers with a SharedArrayBuffer you are back to full memory-model rules and Atomics is mandatory.
1// Identical runtime semantics to JavaScript. The type system does not2// model atomicity and cannot warn you about the gap:3let inflight = 04async function handle(): Promise<void> {5 if (inflight >= MAX) throw new Error('busy') // check6 inflight++ // still same tick: fine7 try { await work() } // <-- gap; other tasks run8 finally { inflight-- } // safe: same tick as resume9}10// The counter arithmetic is atomic. The check-then-act across the whole11// function is NOT, because MAX is compared before any await and inflight12// is only incremented after. Reordering those two lines matters; putting13// an await between them breaks it entirely.The types tell you nothing about concurrency. What saves you here is the runtime's run-to-completion property, and what breaks you is the placement of await relative to the check and the act.
1counter += 1 # NOT atomic: 4 bytecodes, switch points between.2lst.append(x) # atomic on CPython today: one C-level call.3d[k] = v # atomic store on CPython today.4d[k] += 1 # NOT atomic: read, add, store.5if k not in d: d[k] = f() # NOT atomic: two operations, one gap.6 7# The guaranteed-correct forms, which work on every implementation:8from threading import Lock9lock = Lock()10with lock:11 counter += 112 13import itertools14counter = itertools.count() # thread-safe increment via next(counter)15d.setdefault(k, v) # single call; no gap between check and insert16# (setdefault still evaluates v eagerly - use a lock or functools.cache17# when computing v is expensive.)The "atomic" entries are properties of the current CPython implementation, not of the Python language. PyPy, Jython and free-threaded CPython 3.13+ do not necessarily agree, so code that relies on them is code that relies on an implementation detail.
- C++ promises nothing unless you ask: only
std::atomicand locks are indivisible, and everything else is subject to compiler reordering as well as scheduling. - JavaScript and TypeScript get atomicity of synchronous blocks for free from run-to-completion, and lose it entirely at every
await— the gap moves from nanoseconds to milliseconds. - CPython's atomic-looking operations are implementation properties of the current build, not language guarantees; the free-threaded build changes them.
- In all four, an atomic operation composed with another atomic operation is not atomic. Check-then-act is the universal counterexample.
- The portable answer everywhere is the same: name the invariant, and if it spans more than one operation, take a lock or use a single primitive that performs the whole thing.
Atomic operations, non-atomic sequence
The schedule below uses list.append, which really is atomic on CPython, inside a membership check, which really is atomic on CPython — and produces a duplicate anyway. This is the cleanest demonstration available that "each operation is atomic" and "the code is correct" are unrelated claims.
It is also the exact shape of the most common real bug in this family: the memoised cache. if key not in cache: cache[key] = expensive() runs expensive() twice under concurrency, which is merely wasteful if the function is pure and is a genuine incident if it opens a connection, allocates a licence, or charges a card. See Initialization Races and Single-Flight Coalescing for the two standard answers.
| # | Worker A — ensure "eu-west" client exists | Worker B — ensure "eu-west" client exists | State |
|---|---|---|---|
| 1 | "eu-west" not in clients → True [atomic] | · | clients={} |
| 2 | · | "eu-west" not in clients → True [atomic] | clients={} ✕ Both workers have decided to construct. The invariant is already lost, with no write yet performed and no unsynchronized access anywhere. |
| 3 | connect() → opens TCP connection #1 | · | clients={} conns=1 |
| 4 | clients["eu-west"] = client1 [atomic] | · | clients={eu-west: c1} conns=1 |
| 5 | · | connect() → opens TCP connection #2 | clients={eu-west: c1} conns=2 |
| 6 | · | clients["eu-west"] = client2 [atomic] | clients={eu-west: c2} conns=2 ✕ Constructed twice. c1 is now unreachable from the map but still holds an open socket — a leaked connection with no owner and no code path that will ever close it. |
| 7 | uses client1 for the rest of this request | · | clients={eu-west: c2} conns=2 |
Key points
- Syntax is not a guarantee.
x += 1is one token and three shared-memory steps in every mainstream language. - Atomicity is a property of one operation on one location. It never composes: two atomic operations with a gap are not atomic.
- CPython's interpreter lock serialises *bytecodes*, not statements.
list.appendis atomic today as an implementation detail;counter += 1is not, at any version. - JavaScript and TypeScript get atomicity for free within a synchronous block and lose it at every
await— where the gap is milliseconds, not nanoseconds. - C++ promises nothing without
std::atomicor a lock, and a data race there is undefined behaviour rather than a wrong number. - The memoised-cache shape —
if not present then compute and insert— is the atomicity illusion's most expensive form, because the duplicated work has side effects. - The portable fix is always the same: if the invariant spans two operations, use one operation that does the whole thing, or take a lock.
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 or interpreter lowers each source statement into several primitive steps: a load of a shared location, some private computation, a store back.
- • The runtime chooses a granularity at which it will not interrupt: a machine instruction on threads, a bytecode on CPython, a synchronous run-to-completion block in an event loop.
- • Anything smaller than that granularity is indivisible; anything larger is not, and the source syntax gives no indication of which side of the line a statement falls on.
- • A library operation implemented in native code may be indivisible because it never yields — which is a property of that implementation at that version, not a promise in the language specification.
- • Composing two indivisible operations produces a divisible sequence, because the runtime's non-interruption promise covers each one separately and nothing in between.
- •
counter += 1from two threads: A loads 0, B loads 0, A stores 1, B stores 1. One increment lost. On CPython this fires constantly — measured loss rates above 50% at eight threads are ordinary. - •
results.append(x)from eight threads on CPython: no interleaving loses an element, because the append is one uninterrupted C call. The same code on a free-threaded build has no such protection. - •
if k not in d: d[k] = f()from two tasks: both membership tests return True,f()runs twice, two objects are constructed and one is orphaned along with whatever resources it holds. - •
inflight++before anawaitandinflight--after: correct on a single-threaded runtime, because both arithmetic statements execute within a single uninterrupted block. - • A check placed before an
awaitand its act placed after: broken on the same runtime, because the gap is now the width of the awaited operation.
- • C++ guarantees indivisibility only for
std::atomicoperations and for regions under a lock. Everything else is subject to reordering as well as interleaving. - • CPython guarantees that a single bytecode does not interleave with another thread's bytecode on the standard build. It does not guarantee which operations map to one bytecode, and that mapping changes between versions.
- • A single-threaded event loop guarantees that a synchronous block runs to completion. It guarantees nothing across an
await, ayield, or a callback boundary. - • A thread-safe collection guarantees each of its methods is atomic. It explicitly does not guarantee that your sequence of calls is — which is why
putIfAbsent,setdefaultandcomputeexist as single operations. - • An atomic increment guarantees the counter is exact. It guarantees nothing about any other variable that is supposed to agree with it.
- • Making a counter atomic moves the contention onto a single cache line: every increment invalidates the line in every other core's cache, so throughput can fall as cores are added. See False Sharing: Different Variables, Same Cache Line and What a Shared Write Costs.
- • CPython's interpreter lock is itself a contention point — CPU-bound threads serialise on it regardless of how atomic your operations are. That is a throughput property, not a correctness one; see Python: Threads, Processes and the GIL.
- • The
if absent then computeshape has a contention profile that is invisible until startup: N workers all miss the cache simultaneously and all perform the expensive work at once. See Thundering Herd.
- • Lost update — the read-modify-write case, silent and load-proportional.
- • Duplicate initialisation — the check-then-act case, which leaks whatever the duplicate construction allocated.
- • Torn read or write, where a value wider than the platform's atomic unit is observed half-updated. Rare in managed languages; real for 64-bit values on 32-bit targets and for structs in C++.
- • Data race, in C and C++, with undefined behaviour rather than a wrong value. See Data Race Is Not Race Condition.
- • Version-dependent correctness: code that relies on an operation being atomic on CPython 3.11 and breaks on a free-threaded 3.13 build, with no source change and no warning.
- • Knowing what is genuinely atomic lets you delete locks that are not needed — a single atomic counter needs no mutex, and a run-to-completion block needs no lock at all.
- • It resolves the most common review argument in Python and JavaScript teams with evidence rather than opinion: run
dis, count the bytecodes, look for theawait. - • It tells you when the cheap fix is available: many "add a lock" problems are actually "use
setdefault/putIfAbsent/fetch_add" problems.
- • Relying on implementation-detail atomicity is a time bomb.
list.appendbeing atomic is true of CPython today and is not something the language promises. - • The knowledge invites cleverness — lock-free code written because an operation "is atomic anyway" is where the subtlest memory-model bugs come from. See Atomics Are Not Magic.
- • Over-applied, it produces atomic variables everywhere, which is slower than a lock under contention and does nothing for multi-variable invariants.
- • Disassemble.
dis.disin Python,-Sor Compiler Explorer for C++, and for JavaScript simply look forawaitbetween the check and the act. This is a two-minute check with a definitive answer. - • Run the operation N times from K tasks and assert exactly N×K. Approximate assertions hide exactly the bug you are looking for.
- • Count side effects, not values: log connection opens, object constructions and external calls. Duplicate initialisation shows up there long before it shows up in a counter.
- • Test on the runtime you deploy on, at the version you deploy. An atomicity assumption that holds on CPython 3.11 and fails on a free-threaded build will not be caught by any static tool.
- • Every reliance on an implementation-level atomicity guarantee is an undocumented dependency on a runtime version, and it will not announce itself when that version changes.
- • The rules differ per language in a codebase that spans several, so the same reviewer must hold three different models — and the Python one changes between builds.
- • The safe alternatives (a lock, a single compound operation) are cheap to write and cheap to read, which is why leaning on subtle atomicity is usually a bad trade even when it is correct.
- • Use the single compound operation the library already provides:
setdefault,putIfAbsent,compute,fetch_add,Atomics.add,INCR. One call, no gap, and it is the reason those methods exist. - • Use a lock. Under low contention it costs a few tens of nanoseconds uncontended and removes the entire question. See Mutexes: What They Protect and What They Do Not.
- • Use a proper once-initialiser for the memoised-cache shape:
std::call_once,functools.cache, a module-level singleton evaluated at import. See Initialization Races. - • Do not share the value at all — accumulate per task and combine at the end. Correct on every runtime and every version. See Parallel Reduce.
counter++ with and without atomicity
r ← counter r ← r + 1 counter ← r
fetch_add(counter, 1) # no schedule can cut inside this
Two increments, twenty schedules: find the one that loses an update
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=0 |
| 2 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 3 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 4 | · | rB ← counter | counter=1 rA=1 rB=1 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=2 |
| 6 | · | counter ← rB | counter=2 rA=1 rB=2 |
compare_exchange in a loop — retries, and the pointer that lied
do {
old = counter.load(); # 1 read
next = old + 1; # compute off to the side
} while (!counter.compare_exchange(old, next)); # swap only if unchanged| # | T1 — pop() via CAS | T2 — another thread | State |
|---|---|---|---|
| 1 | old ← head (= A) | · | head=A stack=A→B→C |
| 2 | · | pop() → A | head=B stack=B→C |
| 3 | · | pop() → B | head=C stack=C |
| 4 | · | push(A) | head=A stack=A→C |
| 5 | CAS(head, A, B) → SUCCESS | · | head=B stack=B→ freed ✕ head now points at B, which was popped and freed. Node C has vanished from the stack and T1 returned a node it never observed being on top. |
| 6 | return A to the caller | · | head=B stack=corrupt |
What people believe, and what is true
The GIL makes Python thread-safe.
It serialises bytecode execution, which prevents torn values. counter += 1 is four bytecodes and loses updates at a rate you can measure in seconds.
counter++ is one instruction, so it is atomic.
On x86-64 it is typically three: a load, an add and a store. Only lock-prefixed forms — what std::atomic emits — are indivisible.
My runtime is single-threaded, so atomicity is not my problem.
Atomicity within a synchronous block is free; across an await it is gone. The single-threaded runtime moved the boundary, it did not remove it.
I used a concurrent map, so if absent then put is safe.
Each operation is atomic and the pair is not. That is precisely why concurrent maps ship a separate putIfAbsent — it exists because the composition is broken.
Go deeper
Overview
Operations that look like one step are usually several. The runtime can interrupt between them, and then two tasks interfere.
Practical
Ask what the runtime's non-interruption unit is: a machine instruction, a bytecode, or a synchronous block. Anything larger than that unit is not atomic, however short the source line.
Advanced
Distinguish language guarantees from implementation properties. std::atomic is a guarantee; list.append being atomic is a CPython implementation detail that free-threaded builds change. Build on the first, never the second.
Internals
On x86-64, atomicity for a read-modify-write comes from the lock prefix, which holds the cache line exclusively for the duration of the operation. Weaker architectures use load-linked/store-conditional pairs that fail the store if the line was touched — the hardware form of Compare-and-Swap and the Retry Loop. In both cases atomicity is per cache line, which is why two unrelated atomic counters in the same line contend; see False Sharing: Different Variables, Same Cache Line.