Skip to content
BytePatterns

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:

  1. Atomic is about visibility of intermediate states, not speed. An atomic step has no "midway" another thread can see.
  2. Read-modify-write is the danger shape. counter += 1 compiles 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.
  3. Atomicity does not compose. if key not in d is atomic in CPython, and d[key] = value is 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 s steps each have C(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."