Python GIL & Free Threading
debt(d9/e7/b7/t7)
Closest to 'silent in production until users hit it' (d9). No linter warns you that your threaded CPU-bound code fails to scale; py-spy and sys._is_gil_enabled only help after you suspect a problem. The lack of speedup is silent until benchmarked under load.
Closest to 'cross-cutting refactor across the codebase' (e7 minus 0 = e7). The quick_fix says swap threading for multiprocessing/ProcessPoolExecutor, but this changes data-sharing semantics (pickling, shared memory), touches worker lifecycle, and often ripples through the codebase. For C extensions, adding Py_mod_gil support is a per-extension rework.
Closest to 'strong gravitational pull' (b7). Concurrency model choice shapes every future feature: data structures, IPC, deployment, library selection. Applies across web/cli/queue/library contexts, and switching later is painful.
Closest to 'serious trap' (t7). The misconception is explicit: developers assume Python threads run in parallel like Java/Go threads — contradicting how threading works in nearly every other mainstream language. The 'obvious' way (add threads for speedup) is wrong for CPU-bound work.
Also Known As
TL;DR
Explanation
CPython's Global Interpreter Lock (GIL) is a mutex that permits only one thread to execute Python bytecode at a time. Even on a 16-core machine, a pure-Python multithreaded workload runs on effectively one core - threads take turns holding the GIL, releasing it every few bytecode instructions or when doing I/O. This is why threading in CPython is great for I/O-bound work (network calls, file reads release the GIL) but useless for CPU-bound work, where multiprocessing or C extensions (numpy, which releases the GIL inside native code) are needed.
PEP 703, accepted for CPython 3.13, introduces an official free-threaded build (python3.13t) that removes the GIL entirely. It replaces GIL-protected reference counting with biased reference counting and deferred reference counting, uses per-object locks for containers, and adds atomic operations for critical sections. In this build, threads run Python bytecode truly in parallel across cores. Free-threading is experimental in 3.13, opt-in via a separate build, and comes with a single-threaded performance cost (roughly 5-10% slower per thread) that is expected to shrink in 3.14+.
Practical impact: existing pure-Python code mostly just works, but C extensions must be recompiled and marked GIL-safe (Py_mod_gil slot). Libraries with global mutable state, unsynchronized caches, or lazy singletons need audit. Data races become possible where they were previously masked by the GIL - for example, dict updates from multiple threads. Use threading.Lock, queue.Queue, or immutable data structures deliberately.
For now, treat the standard 3.13 build as GIL-enabled and the -t suffix build as free-threaded. Check with sys._is_gil_enabled(). Choose asyncio for I/O concurrency, multiprocessing or concurrent.futures.ProcessPoolExecutor for CPU work on GIL builds, and consider free-threading builds for CPU-bound workloads that share large data structures too expensive to fork.
Common Misconception
Why It Matters
Common Mistakes
- Using threading for CPU-bound work and expecting multi-core speedup - the GIL serializes execution, so use multiprocessing or ProcessPoolExecutor instead.
- Assuming dict or list operations are thread-safe because the GIL protects them - compound operations like check-then-set still race.
- Shipping C extensions without declaring Py_mod_gil support, so they force the GIL back on under free-threaded 3.13.
- Benchmarking free-threaded Python on single-threaded code and concluding it is slower - the point is scaling across cores, not per-thread speed.
- Forgetting that I/O and native calls (numpy, requests, sqlite) already release the GIL, so threading is genuinely useful for those workloads.
Avoid When
- Avoid free-threaded builds in production until critical C extensions declare Py_mod_gil support.
- Avoid threading for pure-Python CPU-bound work on standard CPython - it will not scale.
- Avoid assuming GIL-based thread safety when writing code intended to run on future free-threaded interpreters.
- Avoid mixing multiprocessing and threading without careful design - forked child processes with live threads deadlock easily.
When To Use
- Use threading (with GIL) for I/O-bound workloads where threads spend most time waiting on network or disk.
- Use multiprocessing or ProcessPoolExecutor for CPU-bound work on standard CPython builds.
- Consider free-threaded 3.13+ builds for CPU-bound workloads sharing large in-memory data structures too costly to duplicate via fork.
- Use asyncio when you have thousands of concurrent I/O operations and want low per-task overhead.
Code Examples
import threading
import time
def cpu_bound(n):
# Pure Python arithmetic - holds the GIL
total = 0
for i in range(n):
total += i * i
return total
# Expect 4x speedup on a 4-core machine. Get ~1x.
start = time.perf_counter()
threads = [threading.Thread(target=cpu_bound, args=(10_000_000,))
for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f'Threaded: {time.perf_counter() - start:.2f}s')
# Threads serialize on the GIL - no faster than sequential
import sys
from concurrent.futures import ProcessPoolExecutor
def cpu_bound(n):
total = 0
for i in range(n):
total += i * i
return total
if __name__ == '__main__':
# Check runtime: standard build vs free-threaded (python3.13t)
gil_on = getattr(sys, '_is_gil_enabled', lambda: True)()
if gil_on:
# Standard CPython: use processes for CPU-bound work
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(cpu_bound, [10_000_000] * 4))
else:
# Free-threaded 3.13+: threads run truly in parallel
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(cpu_bound, [10_000_000] * 4))