Analyzing Python Thread Safety
When asked "is this list/dict operation thread-safe?", check:
- Is it a single bytecode-level operation (e.g.,
list.append,dict[key] = val,list.pop())? → Generally atomic under GIL. - Is it a compound read-modify-write expressed across multiple statements (e.g.,
if key not in d: d[key] = v)? → NOT atomic, needs a lock even under GIL. - Is the code targeting free-threaded CPython (3.13+
--disable-gil/ 3.16 default free-threading)? → Re-verify: many single-method atomicity guarantees still hold (CPython uses internal per-object locks for these), but interleaving of separate method calls is never guaranteed.
Pythonimport threading shared = [] lock = threading.Lock() def worker(): shared.append(1) # atomic, no lock needed (single op) with lock: # compound op, lock required if shared: shared.pop()
Progress:
- Identify the container type (list, dict, set, deque, etc.)
- Identify whether the operation is a single built-in method call or a multi-step sequence
- Check if it's a mutating op (append, extend, pop, insert, remove, sort) vs. just iteration
- Determine execution mode: GIL-enabled CPython vs free-threaded build
- Classify: atomic (safe standalone) / non-atomic (needs lock) / undefined (avoid pattern)
- If iterating while another thread mutates, flag as unsafe regardless of build
- Recommend fix: lock,
queue.Queue, copy-before-iterate, or immutable snapshot
Atomic (safe from a single call, no torn state):
list.append(x),list.pop(),list.pop(i)list[i] = x,del list[i]list.extend(iterable)— atomic as a whole op, but ifiterableis itself being mutated concurrently, behavior is undefinedx in list,list[i](read)
NOT atomic / unsafe to assume ordering:
list.sort()— atomic w.r.t. not corrupting internal state, but concurrent reads during sort may see partially-sorted data; concurrent mutation during sort raisesValueError/undefined in some cases- Iterating a list (
for x in lst) while another thread appends/removes — length/index changes mid-iteration can skip or repeat elements; free-threaded builds do NOT make this deterministic - Any sequence like
lst[i] = lst[i] + 1(read-modify-write) — race condition even though each half is atomic len(lst)followed by indexed access — TOCTOU race
Free-threaded build specifics (3.13+):
- Individual list/dict/set methods remain internally consistent (no memory corruption, no crashes) because CPython holds a per-object critical section lock during the call.
- This does not extend to multi-statement logic — same rules as GIL builds apply for compound operations.
- Do not assume
del/resize during iteration is safe just because it's free-threaded; it can still raiseRuntimeError: list changed size during iterationor skip elements.
Example 1:
Input: counts[word] = counts.get(word, 0) + 1 shared across threads without a lock.
Output: Unsafe — this is a read-modify-write compound operation. .get() and the assignment are two separate steps; a race can lose updates. Fix: wrap in with lock: or use collections.Counter with an external lock, or use multiprocessing/concurrent.futures with per-thread local counters merged at the end.
Example 2:
Input: Thread A does my_list.append(item); Thread B does len(my_list) concurrently, no lock.
Output: Safe. Both are single atomic operations on the list; no corruption or crash. B may see the list before or after A's append, but never a torn/partial state.
Example 3:
Input: Thread A iterates for x in shared_list: while Thread B does shared_list.remove(x).
Output: Unsafe regardless of build — mutating a list during iteration in another thread can cause skipped elements or RuntimeError. Fix: iterate over list(shared_list) (a snapshot copy) or use a lock around the whole iteration+mutation region.
- Prefer high-level concurrency primitives (
queue.Queue,concurrent.futures,threading.Lock) over relying on low-level atomicity guarantees. - Treat "atomic" as meaning "won't corrupt memory," not "produces the logically correct result" — logical correctness still requires locks for compound operations.
- When reviewing code for the free-threaded build, don't assume behavior identical to GIL builds beyond single-call atomicity; re-audit any code that relied on the GIL as an implicit global lock across multiple statements.
- Snapshot before iterating if concurrent mutation is possible:
for x in list(shared): ....
- Assuming "the GIL makes everything thread-safe" — it only guarantees atomicity of individual bytecode-level operations, not sequences of them.
- Assuming free-threaded CPython removes the need for locks — it changes performance characteristics, not the need for correctness locking on compound logic.
- Using
if key not in d: d[key] = ...without a lock ("check-then-act" race). - Iterating and mutating the same container from different threads without a lock or a copy.