Assessing Thread Safety
When asked "is this thread-safe?", check three things in order:
- Which interpreter build? GIL-enabled (default) vs free-threaded (
--disable-gil/tsuffix, PEP 703). Guarantees differ substantially. - What's the operation category? Atomic bytecode op, container mutation, C-API call, or third-party C extension?
- What does CPython actually guarantee vs what "seems safe"? Many operations look atomic but aren't guaranteed across versions/builds.
Python# Example triage import sys free_threaded = sys._is_gil_enabled is not None and not sys._is_gil_enabled() # dict.setdefault, list.append: atomic under GIL, but under free-threaded # builds only *individual* container ops are internally locked - # compound operations (check-then-act) are NEVER safe in either mode. counter = {} counter[key] = counter.get(key, 0) + 1 # NOT thread-safe in any mode
Progress:
- Identify interpreter mode (GIL vs free-threaded) the code must support
- Classify each shared-state operation
- Check against known-atomic operation list
- Check C extensions for
Py_mod_gilslot / explicit free-threading support - Flag compound/check-then-act patterns regardless of atomicity
- Recommend explicit synchronization (
threading.Lock,queue.Queue, immutable data) where guarantees are insufficient - Note version-specific caveats (behavior can change between minor versions; nothing here is a language-level contract unless documented)
1. Interpreter mode matters
- GIL-enabled build: bytecode-level atomicity for single ops; the GIL serializes execution, giving many single opcode operations effective atomicity as a side effect (not a guarantee).
- Free-threaded build (PEP 703, 3.13+): no GIL; CPython instead guarantees specific operations are safe via internal per-object locks (critical sections). Anything not explicitly documented as safe should be treated as unsafe.
2. Operation classification
- Documented atomic: e.g., single container item get/set/del on
dict,list,setfor built-in types — safe from corrupting interpreter state, but does not mean no race conditions in program logic. - Compound operations:
x += 1,if k not in d: d[k] = v,list.sort()interleaved with reads — never safe without a lock, in any mode. - Reference counting: under free-threaded builds, refcounting uses biased/deferred reference counting; safe by design, not something users manage directly.
- C extensions: must opt in to free-threading support (
Py_MOD_GIL_NOT_USED); otherwise CPython re-enables the GIL automatically when such a module is imported. Flag any extension without explicit multi-phase init support as unsafe to assume free-threaded-safe.
3. Never trust "looks atomic"
Even single bytecode instructions are an implementation detail unless explicitly documented in threadsafety-style guarantees. Recommend explicit locking for any correctness-critical shared mutable state.
Example 1:
Input: shared_list.append(x) called from multiple threads, free-threaded build, no lock.
Output: Safe — list.append is a documented atomic operation protected by internal critical sections in free-threaded CPython. No external lock needed for this single call, but iterating and mutating from another thread concurrently still needs care (iteration is not itself atomic).
Example 2: Input:
Pythonif key not in cache: cache[key] = expensive_compute(key)
Output: Not thread-safe in either GIL or free-threaded mode. This is a check-then-act compound operation — two threads can both pass the not in check before either writes. Recommend cache.setdefault(key, expensive_compute(key)) (still recomputes redundantly but avoids corruption) or a lock/functools.lru_cache with internal locking, or dict.setdefault combined with a sentinel + lock if compute is expensive.
Example 3:
Input: A C extension module with no free-threading support, imported under python3.13t.
Output: CPython will implicitly re-enable the GIL for the whole process (with a RuntimeWarning) when it detects the extension lacks the Py_mod_gil slot declaring support. Flag this as: (a) safe from corruption, but (b) silently defeats the purpose of running free-threaded — surface this to the user as a performance/architecture issue, not just a safety one.
- Default recommendation for shared mutable state: use
threading.Lock/RLockor higher-level primitives (queue.Queue,concurrent.futures) rather than relying on interpreter internals. - Prefer immutable data structures or message-passing (queues) over shared mutable state — sidesteps the entire question.
- When reviewing library code intended to support free-threaded Python, check for explicit test matrix entries (
3.13t,3.14t) and CI runs withPYTHON_GIL=0. - Cite the specific CPython version when giving atomicity guarantees — behavior is not a stable language guarantee, only what a given release documents/implements.
- For C extensions, check for
Py_mod_gilmodule slot andPy_MOD_GIL_NOT_USEDusage as the marker of intentional free-threading support.
- Assuming "the GIL makes everything thread-safe" — it only serializes bytecode execution, not multi-step logic.
- Assuming free-threaded builds require locks for everything — many single container operations remain internally safe by design.
- Treating undocumented "seems atomic" behavior as a guarantee across Python versions.
- Forgetting that iteration over a container while another thread mutates it is unsafe even when individual get/set ops are safe.
- Ignoring C extensions as a hidden source of GIL re-activation or actual data races when they lack free-threading support.