Atomic Operations Explained: Why counter += 1 Is Not Atomic
8 min readBytePatterns
What an atomic operation is, why counter += 1 is three steps that can tear, which Python operations are atomic, and why two atomic steps still race, with code.
An atomic operation is one that cannot be seen half-done. Every other thread observes the world either before it or after it, never in the middle, so no lock is needed around it. The idea is simple; the trap is that operations which look like one step, such as counter += 1, are several steps underneath, and that two atomic steps in a row are not one atomic step.
The problem it solves
Shared state breaks when a thread can be interrupted between reading a value and writing back a result computed from it. That window is a race condition, and the usual fix is a mutex around the critical section. A lock works, but it costs: threads wait, someone must remember to release it, and lock ordering can deadlock.
When the whole critical section is a single operation, an increment, a swap, appending to a list, an atomic operation does the same job with no lock at all. Hardware provides these as single instructions such as fetch-and-add and compare-and-swap; languages expose them as AtomicInteger.incrementAndGet() in Java, std::atomic with fetch_add in C++, and sync/atomic in Go.
The intuition
Three things to hold on to:
- Atomic is about visibility of intermediate states, not speed. An atomic step has no "midway" another thread can see.
- Read-modify-write is the danger shape.
counter += 1compiles to read the value, add one in a register, store it back. A thread switch between read and store lets another thread's update be overwritten: a lost update. - Atomicity does not compose.
if key not in dis atomic in CPython, andd[key] = valueis atomic, but the pair is not: two threads can both pass the check before either writes. This check-then-act bug survives every individually atomic line. Fix it with one operation that does both (dict.setdefault, compare-and-swap) or with a lock.
In CPython, the global interpreter lock makes many single operations on built-in types atomic. The Python FAQ lists examples such as list.append, list.pop, d[k] = v and d.update(other) as atomic, and i = i + 1 or d[k] = d[k] + 1 as not (from memory, as of October 2026). These are properties of the implementation rather than language promises, and the free-threaded build available since Python 3.13 changes how they are achieved, so check the documentation for your version before relying on them. Since CPython 3.10, the classic lost-update demo with a bare += loop rarely loses updates in practice, because the interpreter switches threads less often (from memory); that makes the bug rarer, not impossible.
Watch it run
The animation draws the two kinds side by side. counter += 1 looks like one step, but the interpreter sees three: read, add, store. Thread A reads the counter and gets 0. It adds one in a register, while the counter in memory is still 0. A is swapped out right there, mid-operation, and B reads, and reads 0. A comes back and finishes: it stores its 1. B stores its 1 too. The increment tore in half and one of them is gone: the counter says 1 where it should say 2. Then the other kind: next(itertools.count()) is one indivisible step. A pulls a ticket, and tearing the tab and advancing the roll are the same motion. B pulls the next one, and nobody can be handed a half-torn 47. They keep alternating, and every number comes out distinct, four unique tickets with no lock anywhere. The last frame states the limit: atomicity covers one step, most real invariants span several, and that is what a lock is for.
Atomic Operations
Step 1 of 11
counter += 1 looks like one step. The interpreter sees three: read, add, store.
The same interactive animation as the lesson — step through it with the controls.
The code
A toy model: instead of trusting the thread scheduler to show the bug, enumerate every possible interleaving. Each thread's += is three steps; an atomic add is one:
from collections import Counter, defaultdict
from itertools import combinations
def schedules(steps_a, steps_b):
"""Every way to interleave two threads' steps, keeping each thread's own order."""
n = steps_a + steps_b
for picks in combinations(range(n), steps_a):
yield ["A" if i in picks else "B" for i in range(n)]
def run(order, atomic):
"""One schedule. counter += 1 is read, add, store; an atomic add is one step."""
counter, reg, pc = 0, defaultdict(int), Counter()
for t in order:
phase, pc[t] = pc[t] % 3, pc[t] + 1
if atomic:
counter += 1 # fetch-and-add: nothing can come between
elif phase == 0:
reg[t] = counter # read
elif phase == 1:
reg[t] += 1 # add, in a register
else:
counter = reg[t] # store, overwriting whatever is there
return counter
def outcomes(increments, atomic):
steps = increments * (1 if atomic else 3)
return dict(sorted(Counter(run(o, atomic) for o in schedules(steps, steps)).items()))
print(outcomes(1, atomic=False)) # {1: 18, 2: 2}
print(outcomes(2, atomic=False)) # {2: 702, 3: 216, 4: 6}
print(outcomes(2, atomic=True)) # {4: 6}
With one increment each, 18 of the 20 possible schedules lose an update. With two each, only 6 of 924 schedules produce the right answer, and the counter can end as low as 2. The atomic version is right in every schedule. Next, check-then-act, written as Python generators so each yield marks a point where the other thread may run:
def drive(threads, order):
"""Run generator 'threads' one step (up to the next yield) at a time."""
for t in order:
next(threads[t], None)
def get_id_racy(state, out, me): # two atomic lines, not one atomic action
absent = "key" not in state["ids"]
yield
if absent:
state["minted"] += 1
state["ids"]["key"] = state["minted"]
yield
out[me] = state["ids"]["key"]
def get_id_atomic(state, out, me): # dict.setdefault: check and act in one step
state["minted"] += 1
out[me] = state["ids"].setdefault("key", state["minted"])
yield
def disagreements(get_id, steps):
bad = 0
for order in schedules(steps, steps):
state, out = {"ids": {}, "minted": 0}, {}
drive({t: get_id(state, out, t) for t in "AB"}, order)
bad += out["A"] != out["B"]
return bad, sum(1 for _ in schedules(steps, steps))
print(disagreements(get_id_racy, 3), disagreements(get_id_atomic, 2)) # (4, 20) (0, 6)
In 4 of 20 schedules the two threads walk away with different ids for the same key; with setdefault, never. Finally, two cross-checks. The step machine is compared against real Python read-add-store code driven by 3,000 seeded random schedules of two or three threads, and the lesson's ticket dispenser runs on four real threads:
import itertools, random, threading
def incr(state, k): # the brute-force reference: real Python code
for i in range(k):
reg = state["c"]
yield
reg += 1
yield
state["c"] = reg
if i < k - 1:
yield
rng = random.Random(35)
ok = True
for _ in range(3000):
names, k = "ABC"[: rng.randint(2, 3)], rng.randint(1, 3)
order = [t for t in names for _ in range(3 * k)]
rng.shuffle(order) # a random interleaving
state = {"c": 0}
drive({t: incr(state, k) for t in names}, order)
ok &= state["c"] == run(order, atomic=False) and 1 <= state["c"] <= len(names) * k
dispenser, tickets = itertools.count(1), []
def take(n):
for _ in range(n):
tickets.append(next(dispenser)) # both calls are single atomic steps
crowd = [threading.Thread(target=take, args=(50_000,)) for _ in range(4)]
for th in crowd:
th.start()
for th in crowd:
th.join()
ok &= sorted(tickets) == list(range(1, 200_001))
print(ok) # True
Two hundred thousand tickets from four threads, every number from 1 to 200,000 exactly once.
The complexity
- An atomic instruction: constant time, but not free; on multicore hardware it needs exclusive ownership of the cache line holding the value, so a heavily contended counter bounces between cores and can be slower than per-thread counters summed later.
- Interleavings: two threads of
ssteps each haveC(2s, s)schedules, which is why testing by running code rarely finds these bugs.
Where it goes wrong
- Assuming
+=is atomic. It is read, add, store in Python, Java and C alike, unless you use an atomic type. - Composing atomic calls. Check-then-act and read-then-write across two calls race even if each call is atomic.
- Multi-field invariants. Keeping a balance and a history consistent needs a lock or a transaction; no single atomic covers two fields.
- Relying on implementation details. CPython's atomic built-ins are a side effect of its design; code meant to be portable should say what it needs with a lock or a thread-safe type. More on choosing between them in thread safety explained.
When it shows up in interviews
In concurrency rounds as "is count++ thread-safe?", "implement a thread-safe counter" or "what is the difference between a mutex and an atomic?", and in LLD questions where an id generator or rate limiter is shared between threads. The follow-up is usually check-then-act: a lazy singleton, a cache fill, or a "create if missing".
How to say it in an interview
"An atomic operation can't be observed half-done: other threads see it before or after, never midway, so it needs no lock. counter += 1 isn't atomic, because it's a read, an add and a store, and a thread switch between read and store loses an update. I'd use an atomic increment, like AtomicInteger or fetch_add, or a lock. The catch is scope: atomicity covers one step. Two atomic calls in a row, like check-then-act, still race, so for invariants that span steps I need one combined operation such as compare-and-swap, or a lock."