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:
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,926c.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:
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:
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:
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 pointPYTHON_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_contextis 1 on 3.14t and 0 on the GIL build, socontextvarsset beforeThread.start()are visible inside the thread only on 3.14t. warnings.catch_warningsis context-aware (context_aware_warningsis 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).
Related
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.appendthread-safe on 3.14t?” - a single append, yes: the list’s lock.x += 1is 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.