Reasoning About Python List Thread Safety
CPython 3.16 guarantees that individual list methods are atomic (thread-safe) with respect to other threads calling list methods on the same list — even under free-threaded builds (--disable-gil). This means:
Pythonshared = [] # Safe: each individual call is atomic shared.append(1) # atomic shared.pop() # atomic len(shared) # atomic shared[0] = "x" # atomic # NOT safe: multi-step sequences are not atomic as a whole if shared: # check ... val = shared.pop() # ... then act — race condition between the two
The rule of thumb: one list method call = safe. Multiple calls composed together = not safe, regardless of GIL status.
- Identify the operation(s) on the shared list.
- Classify as single-call or multi-call:
- Single call to a list method/operator → covered by CPython's internal thread safety, no lock needed for corruption-avoidance purposes.
- Sequence of calls implementing a check-then-act, read-modify-write, or iterate-then-mutate pattern → needs external synchronization (a
threading.Lock) even though each individual call is safe.
- Check for concurrent mutation during iteration. Iterating a list (
for x in lst) while another thread mutates it is not guaranteed to raise or behave deterministically — treat as unsafe unless the algorithm tolerates skipped/duplicated elements. - Confirm which guarantee applies:
- Guarantee is: no memory corruption / crashes / undefined behavior from concurrent single-method calls.
- Guarantee is NOT: any particular interleaving order, or consistency across multiple calls, or atomicity of user-level compound expressions like
lst[i] += 1(that's__getitem__+ arithmetic +__setitem__— not atomic).
- Add locks where compound operations are needed, and document why (the individual calls being "safe" is a red herring for correctness of the compound operation).
Progress checklist for a code review pass:
- List every shared
listinstance touched by more than one thread - For each, enumerate the operations performed on it
- Mark each operation as atomic-safe or compound
- For compound operations, verify a lock guards the whole sequence
- For iteration-while-mutating patterns, verify either a lock or a defensive copy (
for x in list(shared)) - Flag
+=,-=, and other augmented assignments on list elements/slices as compound
Example 1: Input:
Pythoncounter_list = [0] def worker(): counter_list[0] += 1 # called from many threads
Output: Unsafe. counter_list[0] += 1 desugars to __getitem__(0), add, __setitem__(0, ...) — three separate operations. Each is individually atomic, but the sequence is not, so increments can be lost. Fix: wrap in a threading.Lock, or use collections.Counter with explicit locking, or an itertools/atomic-counter alternative.
Example 2: Input:
Pythonqueue = [] def producer(): queue.append(item) # many threads def consumer(): if queue: item = queue.pop(0)
Output: append alone is safe. But consumer's check-then-pop is a race: two consumers can both pass if queue: before either pops, and the second pop(0) can raise IndexError or pop unexpected data. Fix: guard with a lock, or use queue.Queue/collections.deque with a condition variable instead of hand-rolled list-based queue.
Example 3: Input:
Pythondata = [1, 2, 3, 4, 5] def reader(): for x in data: process(x) def writer(): data.append(6) data.remove(2)
Output: No crash is guaranteed (list internals won't corrupt), but the iteration in reader may see an inconsistent snapshot — skipping or repeating elements — while writer mutates concurrently. If deterministic iteration semantics matter, snapshot first: for x in list(data): process(x), or hold a lock across both the iteration and the mutation.
- Treat "thread-safe" for CPython lists as "won't segfault or corrupt memory," not "produces correct concurrent programs for free."
- Prefer higher-level concurrency-safe containers (
queue.Queue,collections.dequefor FIFO push/pop from both ends, or explicit locks) over relying on rawlistfor producer/consumer patterns. - When reviewing code for the free-threaded build specifically, don't assume old GIL-based reasoning ("only one bytecode runs at a time so my sequence is safe") still holds — the per-method atomicity is now the explicit, documented guarantee, and it does not extend to compound expressions.
- Document, next to any lock, exactly which invariant it's protecting (e.g., "protects check-then-pop atomicity") so future editors don't remove it thinking single calls are already safe.
- For slice assignment (
lst[a:b] = other) and other single expressions that map to one method call, they are atomic — but confirm it's genuinely one call and not something that expands (e.g., list comprehensions building up a shared list across iterations are not one call).
- Assuming
len(lst) > 0followed bylst.pop()is safe because both are "thread-safe" — the composition isn't. - Assuming augmented assignment (
+=,*=) on list elements is atomic — it isn't. - Assuming iteration is safe against concurrent mutation just because individual
append/removecalls are atomic. - Conflating "thread-safe" (no corruption) with "linearizable" or "serializable" (correct outcomes under concurrency) — CPython's guarantee is the former only.
- Forgetting this guarantee is about the
listtype specifically; other containers (custom classes, third-party structures) don't inherit it just because they wrap a list internally, unless they add their own synchronization.