Skip to content

← True Parallelism and the Runtime step 8 of 18

Hard Research

Free-Threading: Writing Code That Survives It

Free-threading is the only model that gives you multi-core parallelism with shared mutable objects: no pickling, no copies, no IPC. For a workload dominated by one large in-memory graph, index or model, that is qualitatively different from every other option in this track. It is also the one that asks most of you in return.

The timeline, precisely

  • 3.13 — experimental separate build (python3.13t, --disable-gil) with “a substantial single-threaded performance hit”.
  • 3.14 (PEP 779)officially supported, still optional. The penalty is “roughly 5-10%” (the HOWTO measures ~1% on macOS aarch64 up to ~8% on x86-64 Linux), memory roughly 15-20% higher.
  • 3.15 (PEP 803) — the abi3t Stable ABI, so extension authors can ship one wheel that works on both builds.

Phase III — free-threading as the default — has no date.

Detect it two ways, for two different purposes: sysconfig.get_config_var("Py_GIL_DISABLED") for build decisions, sys._is_gil_enabled() for what is true right now.

The correctness half, stated carefully

The HOWTO is precise, and the precision matters. Built-in types have internal locks that behave “similarly to the GIL”, but:

Python has not historically guaranteed specific behavior for concurrent modifications to these built-in types, so this should be treated as a description of the current implementation, not a guarantee

and it recommends threading.Lock instead. Two things are concretely unsafe even on the free-threaded build:

  • Sharing one iterator across threads — you get duplicated or missing elements. This is real enough that 3.15 is adding serialize_iterator and concurrent_tee for it.
  • Touching frame.f_locals of a frame executing in another thread — this may crash the interpreter.

The memory overhead has documented causes, not hand-waving: all interned strings are immortal, non-GC headers are larger (None is 32 bytes rather than 16), mimalloc runs four heaps, and QSBR defers frees.

And the one that wastes the most time: a C extension without the Py_mod_gil slot silently re-enables the GIL, with a warning you have to be looking for. You pay the single-threaded overhead and get none of the parallelism.

The engineering rule

Design for the free-threaded build even if you deploy on the GIL build. Put an explicit Lock around every multi-step invariant.

Because that code is correct on both, and because the multi-step invariants you are protecting — check-then-act, read-modify-write — were never actually safe under the GIL either. The GIL made them unlikely to fail, which is the worst possible property for a bug to have.

Your task

MemoCache[K, V] with one method that has to satisfy three properties at once:

def get_or_compute(self, key: K, compute: Callable[[], V]) -> V: ...
  1. compute is called at most once per key, however many threads race.
  2. It is not called while holding a lock that serialises unrelated keys. A single global lock satisfies (1) and destroys the point of the cache — an expensive computation for key A must not block key B.
  3. An exception from compute does not poison the key. A transient failure followed by a success ends with the value cached and compute called exactly twice.
def solve(
    *, key: str, threads: int, distinct_keys: int, flaky_key: str
) -> tuple[int, str, bool, int, str]:

Three phases, one return: threads racing on one key (call count must be 1), distinct_keys threads on distinct keys meeting at a threading.Barrier (which trips only if they genuinely overlap — a global lock makes it time out and the flag goes false), and a compute that fails once and then succeeds.

The barrier is worth noticing as a technique: it turns “did these run concurrently?” from a timing question, which is untestable, into a deterministic yes or no.

The typing lesson

Encapsulation is the enforcement mechanism here. A lock only protects state that cannot be reached around it, so:

  • the dictionaries are private;
  • the public read API returns a Mapping, not a dict, so a caller cannot mutate what you spent a lock protecting, and returns a snapshot so they cannot observe a torn read either;
  • “missing” is a typed sentinel, not a bare object() and not None — because None is a legitimate V and a cache that cannot store it is a cache with a hole in it.

Those three choices are what make the invariant checkable by a reader, and by mypy --strict, rather than by hope.

Loading visualization…