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.
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.
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.
This is exactly the lost-update race from Pattern 2. The fix is a
Lock around the read-modify-write.
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:
| Operation | Atomic? | Why / Why not |
|---|---|---|
x = y (rebind a name) | β Yes | Single STORE; nothing to interleave |
lst.append(item) | β Yes | One C-level call, no Python bytecode in between |
d[k] = v | β Yes | Single STORE_SUBSCR |
x = lst.pop() | β Yes | The pop itself is one C call |
x += 1 | β No | Read β add β store: three steps |
x = x + 1 | β No | Same read-modify-write, interruptible |
if k not in d: d[k] = v | β No | Check-then-act: gap between the check and the write |
d[k] += 1 / lst[i] += 1 | β No | Read the slot, add, write the slot back |
total = a + b (shared a, b) | β No | Two reads that can straddle another thread's write |
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:
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.
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
queue.Queue). Readers acquire the same
lock β or get() from the queue β and are guaranteed to see the complete object.
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 threads | threading.Event (Pattern 5, Pattern 9) |
| Hand data from one thread to another | queue.Queue (Pattern 3) |
| Guard a read-modify-write on shared state | threading.Lock (Pattern 2) |
| Wait until a condition becomes true | threading.Condition (Pattern 5) |
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.
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.
| Concept | Takeaway |
|---|---|
| Atomic | Happens all-or-nothing β a single bytecode / one C-level container op |
| Effectively atomic | x=y, one list.append, one d[k]=v |
| NOT atomic | x += 1, check-then-act, any read-modify-write |
| GIL-atomicity | An implementation detail β never build correctness on it |
| Visibility | A lock is a memory barrier: it publishes one thread's writes to the next |
No volatile | Coordinate with Lock / Event / Queue, not bare flags |
| In practice | queue.Queue and the executor hide all of it |