We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← True Parallelism and the Runtime step 8 of 18
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
abi3tStable 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_iteratorandconcurrent_teefor it. -
Touching
frame.f_localsof 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
Lockaround 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: ...
-
computeis called at most once per key, however many threads race. - 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.
-
An exception from
computedoes not poison the key. A transient failure followed by a success ends with the value cached andcomputecalled 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 adict, 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 notNone— becauseNoneis a legitimateVand 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…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.