Backend / Async & concurrency / 19_free_threading.md

Free-threaded Python

Updated 4 interview angles 7 min read source
On this page9
  1. What the build gives and costs
  2. How it works without the GIL
  3. What is safe and what is not
  4. C extensions decide whether the GIL stays off
  5. Smaller behaviour changes
  6. Where it breaks in production
  7. The trade not taken
  8. Related
  9. Interview angle

Free-threaded Python

Article.

Since Python 3.14 the free-threaded build, python3.14t, is officially supported (PEP 779): no GIL, threads run Python bytecode in parallel. It is still a separate build, not the default. What the GIL is, and the headline speedup, are in The GIL, and free-threaded Python. This note is about what changed inside the interpreter to make it possible, what it costs, and what breaks.

Should a team switch its service to 3.14t? The answer depends on numbers below, all measured on CPython 3.14.5 and 3.14.5t on Windows with 12 logical CPUs, plus 3.14.8t on Linux where noted.

What the build gives and costs

Measured 3.14 (GIL) 3.14t
CPU work, 8 threads 1.0× 4.0×
single-threaded run 0.466 s 0.536 s
small dict, per object 271 B 303 B
8 threads, one list 0.148 s 0.784 s
8 threads, own lists 0.136 s 0.034 s

Three facts in that table:

  • Parallelism is real: 4× on 8 threads for independent work.
  • Everything else pays a little: single-threaded code ran about 15% slower, and objects took about 12% more memory.
  • Shared mutable objects become the bottleneck. Eight threads appending to one list were five times slower than with the GIL, and 23 times slower than appending to their own lists. Per-object locks serialise access, and the cache line holding the object bounces between cores. Free-threading rewards designs that do not share.

How it works without the GIL

The GIL protected four things at once; each needed its own replacement.

Reference counts: biased counting

Every Py_INCREF used to be a plain increment, safe because only one thread ran. Atomic increments everywhere would be too slow.

So each object now has two counts: a local one for the thread that created it, updated without atomics, and a shared one that other threads update atomically. Most objects are only ever touched by their creating thread, so the fast path stays fast. Some objects (functions, code, modules) use deferred counting, and many built-in constants are immortal: their counts never change (Python memory model — allocation, refcounting, GC).

Containers: per-object locks

list, dict and set carry a small lock. Single operations (append, d[k] = v, pop) take it, which is why the shared list ended with exactly 8 × 300,000 items. Some reads avoid the lock entirely and retry if the container changed underneath them. What the lock does not cover is a sequence of operations:

text
8 threads x 200,000 unlocked increments (want 1,600,000)
  GIL build:     attribute 1,600,000   dict value 1,600,000
  free-threaded: attribute   699,817   dict value   588,926

c.n += 1 is a read, an add and a write. The GIL build got the right answer by luck of where threads switch; the free-threaded build lost more than half the updates. Code like this was always racy (Synchronization primitives).

Memory: mimalloc and delayed freeing

pymalloc assumes one thread at a time, so the build uses mimalloc, which is thread-safe and lets the interpreter find every object in its heap. Memory that another thread may still be reading lock-free (the old item array of a resized list) is freed only once every thread has passed a safe point, a scheme called quiescent-state-based reclamation.

Garbage collection: stop the world

The cycle collector pauses all threads while it runs, and the free-threaded build uses a single, non-generational collection. Collections are rarer but each one stops every thread.

What is safe and what is not

Operation Without the GIL
list.append, d[k] = v safe: the object’s lock
x += 1, check-then-set lost updates: use a lock
iterating while mutated no crash; may skip or repeat
one shared iterator unsafe, can crash

A shared generator, run

The last row is not theoretical:

python
g = gen()                         # one generator
for t in threads: t.start()       # 4 threads each run: for _ in g: pass
Build (10 runs) Result
3.14.5, GIL ValueError: generator already executing
3.14.5t, Windows segmentation fault in 6
3.14.8t, Linux segmentation fault in 4

The free-threading HOWTO says sharing an iterator between threads “is generally not safe” and may crash. With the GIL the same code raised a clean error. Give each thread its own iterator, or hand items over through a queue:

python
q = queue.Queue(maxsize=1000)

def producer():
    for item in gen():              # one thread owns the generator
        q.put(item)
    q.shutdown()                    # 3.13+: wakes every consumer

def consumer():
    while True:
        try:
            item = q.get()
        except queue.ShutDown:
            return
        handle(item)

C extensions decide whether the GIL stays off

An extension module declares free-threading support with the Py_mod_gil slot. Importing one that does not re-enables the GIL for the whole process, with a RuntimeWarning, and the program silently runs as if on the normal build. Check after start-up:

python
import sys, sysconfig
assert sysconfig.get_config_var("Py_GIL_DISABLED")     # the t build
if sys._is_gil_enabled():
    log.warning("GIL re-enabled by an extension")      # lost the point

PYTHON_GIL=0 or -X gil=0 forces it off anyway, at your own risk. Extensions also need separate wheels (cp314t tags); a dependency without them builds from source or fails to install. The ecosystem moved fast for the core scientific stack, but every dependency has to be checked.

Smaller behaviour changes

  • New threads inherit the caller’s context: sys.flags.thread_inherit_context is 1 on 3.14t and 0 on the GIL build, so contextvars set before Thread.start() are visible inside the thread only on 3.14t.
  • warnings.catch_warnings is context-aware (context_aware_warnings is 1 on 3.14t), so filters set in one thread no longer leak into others.
  • The specializing interpreter is enabled in 3.14t; in 3.13t it was off, which made the single-thread penalty much larger.

Where it breaks in production

Races that were always there

Counters, caches filled by check-then-set, lazy initialisation without a lock: on the GIL build they failed rarely enough to go unnoticed. On 3.14t they fail constantly. Run the test suite under 3.14t with many threads (pytest-run-parallel runs each test in several threads) before deploying.

The GIL quietly coming back

One transitive dependency without Py_mod_gil support, and the process runs with the GIL, slower than the normal build. Assert sys._is_gil_enabled() is false at start-up and fail the health check otherwise.

Contention instead of speedup

A shared dict used as a cache, a shared list used as a queue, a global counter: every thread now fights over one object’s lock and cache line. The service uses more CPU and goes slower. Partition state per thread, use queue.Queue, or aggregate at the end.

Library thread-safety assumptions

Pure-Python libraries that relied on the GIL for safety (module-level caches, lazy singletons) have the same races as your code. Native libraries may hold global state. Until a library says it is tested under free-threading, treat it as not.

The trade not taken

The alternatives keep the GIL’s guarantees. Processes (ProcessPoolExecutor) give full isolation and real parallelism at the cost of pickling and memory per process. Subinterpreters (InterpreterPoolExecutor, 3.14) give each worker its own GIL inside one process: isolated like processes, cheaper to start, still with pickling between them (concurrent.futures).

Free-threading is the only one that shares objects without copying, which is exactly what makes it fast and what makes it unsafe.

For most web services, which are I/O-bound, none of this matters: asyncio or threads with the GIL already scale (asyncio and threads).

Interview angle 4

  • “How does CPython run without the GIL and keep refcounts correct?” - biased reference counting: a non-atomic count for the owning thread and an atomic one for others; deferred counting for some objects, immortal constants. Containers have per-object locks; memory comes from mimalloc and is freed only after every thread passes a safe point.
    • Follow-up: “What does it cost?” - here, about 15% on single-threaded code and 12% more memory per small object, in exchange for 4× on 8 threads of CPU work.
  • “Is list.append thread-safe on 3.14t?” - a single append, yes: the list’s lock. x += 1 is not: 8 threads lost more than half of 1.6 million increments.
    • Follow-up: “Then why is my shared list slower than with the GIL?” - every thread contends for one lock and one cache line: 0.784 s against 0.034 s for per-thread lists.
  • “Can Python code crash the free-threaded interpreter?” - yes, through documented-unsafe sharing: one generator iterated from four threads segfaulted in 6 of 10 runs, where the GIL build raised generator already executing.
  • “Should we move our service to 3.14t?” - only for CPU-bound pure-Python work that can be split across threads, after checking every C extension supports it, asserting the GIL stays off, and running the test suite under 3.14t. I/O-bound services gain nothing.