🧠 The CPython Memory Model

What "atomic" really means, which operations you can trust, and why memory visibility β€” not just mutual exclusion β€” is the reason you reach for a lock.

What "Atomic" Actually Means

An operation is atomic if it either happens completely or not at all β€” no other thread can ever observe it half-done. There's no moment where the value is "in between." Because the GIL lets only one thread run Python bytecode at a time, some operations that compile to a single bytecode can't be interrupted mid-way, so they behave atomically in CPython.

Key Insight: atomicity is about a single bytecode being indivisible. The trap is that one line of Python is often several bytecodes. The GIL protects each individual bytecode β€” it does not protect the sequence of them that makes up your statement.

The Line That Looks Atomic But Isn't

Scenario: x += 1 looks like one indivisible step. It isn't. It's a read, an add, and a store β€” three bytecodes β€” and a thread can be paused between any of them.

Disassemble it and the illusion breaks:

import dis

def f():
    x += 1

dis.dis(f)
#   LOAD_FAST    x     ← read the current value
#   LOAD_CONST   1
#   BINARY_OP    +     ← compute new value
#   STORE_FAST   x     ← write it back
# Four steps. The OS (and the GIL scheduler) can switch threads between any two of them.
sequenceDiagram participant A as Thread A participant M as x (=41) participant B as Thread B A->>M: LOAD x β†’ 41 Note over A,B: GIL handed to B mid-statement B->>M: LOAD x β†’ 41 B->>M: STORE 42 A->>M: STORE 42 Note over M: Two increments, one result.
x = 42, not 43. Update lost.

This is exactly the lost-update race from Pattern 2. The fix is a Lock around the read-modify-write.

What Is (Effectively) Atomic in CPython

A handful of operations compile to a single bytecode and touch a C-level data structure that runs to completion without releasing the GIL. In today's CPython these are effectively atomic:

OperationAtomic?Why / Why not
x = y (rebind a name)βœ… YesSingle STORE; nothing to interleave
lst.append(item)βœ… YesOne C-level call, no Python bytecode in between
d[k] = vβœ… YesSingle STORE_SUBSCR
x = lst.pop()βœ… YesThe pop itself is one C call
x += 1❌ NoRead β†’ add β†’ store: three steps
x = x + 1❌ NoSame read-modify-write, interruptible
if k not in d: d[k] = v❌ NoCheck-then-act: gap between the check and the write
d[k] += 1 / lst[i] += 1❌ NoRead the slot, add, write the slot back
total = a + b (shared a, b)❌ NoTwo reads that can straddle another thread's write
The pattern: a single mutation of a built-in container is atomic. Anything that reads a value, then acts on it (increment, check-then-set, read two related fields) is not β€” there's a gap another thread can slip into.

Why You Must Not Rely On This

Trap: "The GIL makes list.append atomic, so I'll skip the lock." That reasoning ties your correctness to a CPython implementation detail β€” not to the language.

GIL-atomicity is not a guarantee you should build logic on, for three concrete reasons:

Rule: use GIL-atomicity to understand why a race is or isn't happening β€” never as a substitute for a lock. If two threads read and write shared state, lock it. It's cheap and it's correct on every interpreter.

The Other Half: Memory Visibility

Mutual exclusion (only one thread in the critical section) is only half of what a lock buys you. The other half is visibility: the guarantee that when Thread B runs, it actually sees the writes Thread A made β€” not a stale, reordered, or partially-constructed view.

Acquiring and releasing a lock establishes a happens-before ordering. Everything a thread wrote before releasing a lock is guaranteed visible to the next thread that acquires that same lock. The lock acts as a memory barrier.

sequenceDiagram participant A as Thread A participant L as Lock participant B as Thread B A->>A: write config, then flag = True A->>L: release() β€” memory barrier Note over A,B: happens-before edge L->>B: acquire() B->>B: reads config + flag
guaranteed to see A's writes
In CPython the GIL already forces a global ordering, so visibility bugs are rarer than in Java or C++. But under free-threaded builds that free lunch disappears β€” the correct model to reason in is "the lock is what makes my writes visible," not "the GIL will sort it out."

Safe Publication of Shared State

Safe publication means: fully build an object, then hand it to other threads through a synchronization point, so no thread ever sees it half-initialized.

# ❌ Unsafe: other threads may see `ready` True before `data` is populated
data = {}
ready = False

def producer():
    global ready
    data["result"] = expensive_build()
    ready = True                 # no barrier β€” reordering / partial visibility possible

# βœ… Safe: build in a local, publish atomically under the lock
lock = threading.Lock()
shared = None                    # the single published reference

def producer():
    global shared
    result = expensive_build()   # build in isolation, no one can see it yet
    with lock:
        shared = result          # publish behind a barrier β€” fully-formed or not at all
Pattern: construct into a local variable, then publish the finished object with a single atomic rebind behind a lock (or via a queue.Queue). Readers acquire the same lock β€” or get() from the queue β€” and are guaranteed to see the complete object.

Python Has No volatile

If you come from Java or C, you might reach for a volatile field to signal between threads. Python has no such keyword. A plain boolean flag is not a synchronization primitive β€” don't hand-roll coordination on top of it. Use the tools built for the job:

You want to…Use
Signal "keep going / stop" between threadsthreading.Event (Pattern 5, Pattern 9)
Hand data from one thread to anotherqueue.Queue (Pattern 3)
Guard a read-modify-write on shared statethreading.Lock (Pattern 2)
Wait until a condition becomes truethreading.Condition (Pattern 5)
Why these work: Event, Queue, Lock, and Condition all contain internal locks, so every set()/wait(), put()/get(), and acquire()/release() is a memory barrier. They give you correct visibility for free β€” that's exactly why the standard library pushes you toward them.

The Recap: You Rarely Touch This Directly

Here's the reassuring part. If you stick to queue.Queue and ThreadPoolExecutor, you almost never think about atomicity or visibility at all. Putting work on a queue and reading a Future.result() both cross a synchronization boundary, so the memory model is handled for you.

ConceptTakeaway
AtomicHappens all-or-nothing β€” a single bytecode / one C-level container op
Effectively atomicx=y, one list.append, one d[k]=v
NOT atomicx += 1, check-then-act, any read-modify-write
GIL-atomicityAn implementation detail β€” never build correctness on it
VisibilityA lock is a memory barrier: it publishes one thread's writes to the next
No volatileCoordinate with Lock / Event / Queue, not bare flags
In practicequeue.Queue and the executor hide all of it
The one sentence to keep: the GIL is not your synchronization strategy β€” locks and queues are, and they give you both mutual exclusion and visibility on every Python implementation, GIL or not.