Concurrency models: threads, processes, free-threaded 3.14t¶
At a glance
Priority: P0 · Complexity: ⅘ · Est. time: 4 h · Phase: 5 · Prereqs: asyncio in depth, CPython internals You're done when: you can choose between asyncio, threads, processes, subinterpreters and free-threaded threads for a given workload with numbers, and you can audit a codebase for thread-safety once the GIL is gone.
Why it matters¶
Since PEP 779 (Python 3.14, Oct 2025) the free-threaded build (3.14t) is officially supported, and PEP 703's implementation is complete. In 3.14 the single-thread penalty is roughly 5-10% (per the 3.14 release notes) and the specializing interpreter works in free-threaded mode. For AI engineering this changes the calculus for CPU-heavy in-process work: tokenisation, chunking, embedding pre/post-processing, in-memory rerankers, and running many agent workers sharing a large read-only model or index without pickling costs. It also changes what "thread-safe" means for your code and your dependencies.
Core concepts¶
Decision table¶
| Workload | Best model | Why |
|---|---|---|
| 1,000s of concurrent LLM/HTTP calls | asyncio | Cheapest per-connection cost, cancellation, backpressure |
| Blocking I/O libs (boto3, sync DB drivers) | threads (to_thread, ThreadPoolExecutor) |
GIL released during I/O |
| CPU-bound pure Python, GIL build | ProcessPoolExecutor (forkserver/spawn) |
True parallelism, but pickling and startup cost |
| CPU-bound, big shared read-only data | free-threaded threads (3.14t) | Shared memory, no serialisation |
| Native code (NumPy, Polars, tokenizers-rs) | threads even on GIL build | They release the GIL |
| Isolation with cheaper-than-process | concurrent.interpreters (PEP 734, 3.14) |
Per-interpreter GIL, but limited sharing |
Measured example (10-core Apple silicon, 4 workers, each 3M-iteration pure-Python loop)¶
| Mode | 3.14.5 (GIL) | 3.14.6t (free-threaded) |
|---|---|---|
| serial (4 jobs) | 0.70 s | 0.62 s |
| ThreadPoolExecutor | 0.70 s (no speedup) | 0.24 s (2.6x) |
| ProcessPoolExecutor | 0.45 s | 0.37 s |
Read it correctly: threads on the GIL build give zero CPU speedup. Free-threaded threads beat processes here because there is no pickling or spawn cost. On short jobs process startup dominates. Your numbers will vary, so re-run bench in the lab.
import sys
from concurrent.futures import ThreadPoolExecutor
def work(n: int) -> int:
return sum(i * i % 7 for i in range(n))
print("GIL enabled:", sys._is_gil_enabled()) # False on 3.14t (unless re-enabled)
with ThreadPoolExecutor(4) as ex:
print(list(ex.map(work, [3_000_000] * 4)))
What free-threading changes¶
- Per-object locks + biased/deferred reference counting replace the global lock. Built-in
list,dict,setremain safe against crashes (internally locked), but compound operations are still not atomic (x += 1, check-then-act on a dict,if k not in d: d[k] = f()). The GIL used to hide many of these races only by accident of scheduling. - C extensions must declare support (
Py_mod_gil). Importing an extension that doesn't re-enables the GIL at runtime with a warning (unlessPYTHON_GIL=0/-X gil=0). Check the tracker at py-free-threading.github.io. As of Sept 2026 the major scientific stack, Pydantic-core, and many others shipcp314twheels, but verify each dependency in your lockfile. uv python install 3.14tanduv run --python 3.14t. Detect at runtime:sys._is_gil_enabled(),sysconfig.get_config_var("Py_GIL_DISABLED").- 3.14 adds context-aware warnings (
-X context_aware_warnings, default on for free-threaded) sowarnings.catch_warningsbecomes thread-safe.
Making code thread-safe¶
import threading
from collections import defaultdict
class Metrics:
def __init__(self) -> None:
self._lock = threading.Lock()
self._counts: defaultdict[str, int] = defaultdict(int)
def inc(self, k: str) -> None:
with self._lock: # required even though dict ops are individually safe
self._counts[k] += 1
Guidelines: prefer immutable data + message passing (queue.Queue), thread-locals or contextvars for per-request state, and threading.Lock around read-modify-write. Lazy-init singletons (if _cache is None: _cache = build()) need a lock or functools.cache. Use threading.Barrier/Event in tests to force interleavings, and run test suites under pytest-run-parallel-style tools and PYTHON_GIL=0 in CI.
Processes¶
- Start method matters: 3.14 makes
forkserverthe default on Linux (wasfork).forkafter threads exist is unsafe (locks held by other threads), and code relying on fork inheriting globals breaks. macOS/Windows usespawn. - Pickle is the tax: sending a 500 MB object graph to each worker defeats the point. Use
multiprocessing.shared_memory, memory-mapped Arrow/NumPy, or files. - Pool sizing: CPU count for CPU work. Set
max_tasks_per_childto bound leaks in native libs.
Subinterpreters (PEP 734)¶
concurrent.interpreters and InterpreterPoolExecutor (3.14) run isolated interpreters in one process, each with its own GIL. Data crosses via queues or shareable types. Cheaper than processes, but extension-module support is uneven. Good for plugin isolation experiments, less proven than processes or free-threading for production.
flowchart TD
A{Workload} -->|I/O fan-out| B[asyncio]
A -->|blocking I/O lib| C[threads]
A -->|CPU-bound| D{Shared large state?}
D -->|yes, deps support 3.14t| E[free-threaded threads]
D -->|no| F[processes forkserver]
D -->|isolation, small messages| G[interpreters]
Senior nuance¶
- Async + threads compose:
asyncio.to_threadfor blocking calls,loop.run_in_executor(process_pool, ...)for CPU. The default executor hasmin(32, cpu+4)workers, so size it deliberately. - Free-threading is not a reason to stop using asyncio for network fan-out. Threads cost ~8 MB virtual stack each and context switching, and cancellation is cooperative-only in asyncio, impossible for threads.
- Sync
defFastAPI endpoints run in AnyIO's threadpool (default 40 tokens). On 3.14t they can actually parallelise CPU work. - Memory: free-threaded builds use somewhat more memory (per-object header changes, mimalloc). Measure RSS before rollout.
- Ecosystem readiness is the real gate: one non-compatible extension flips the GIL back on for the whole process, silently apart from a warning. Fail CI if
sys._is_gil_enabled()is true on the 3.14t job.
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| Python Free-Threading Guide |
docs | Community hub: porting, tracker of compatible packages, testing tips | advanced | free |
| Porting extensions / code guide |
docs | Concrete thread-safety patterns | advanced | free |
| HOWTO: Python support for free threading | docs | Official behaviour and flags | intermediate | free |
| PEP 703 / PEP 779 | spec | Design and supported-status criteria | advanced | free |
| PEP 734: multiple interpreters | spec | Model and limits of subinterpreters | advanced | free |
| What's New in 3.14 | docs | Free-threading numbers, forkserver default, interpreters | intermediate | free |
| Python behind the scenes #13: the GIL |
article | Best explanation of what the GIL does and how it is released | advanced | free |
| Itamar Turner-Trauring: Python concurrency articles | article | Practical framing for data workloads | intermediate | free |
| concurrent.futures | docs | Executor semantics you'll actually use | intermediate | free |
Hands-on lab¶
Goal (60-90 min): measure and decide.
uv python install 3.14 3.14t.- Write
bench.pythat runs thework()function serially, inThreadPoolExecutor(4), andProcessPoolExecutor(4); printsys._is_gil_enabled(). - Run under both interpreters:
uv run --python 3.14 bench.py,uv run --python 3.14t bench.py. Expected: threads ≈ serial on 3.14, and roughlyserial / min(4, cores)on 3.14t (allow overhead, ~2-3x on 4 workers). - Write a racy counter (
self.n += 1in 8 threads x 1M loops), run on 3.14t, and observe a total below 8,000,000. Fix with a lock and compare time. uv add polarsthenuv run --python 3.14t python -c "import polars, sys; print(sys._is_gil_enabled())". If any import prints a GIL warning, note which package.- Stretch: chunk 5,000 documents with a pure-Python splitter using threads on 3.14t vs processes, comparing wall time and peak RSS.
Questions¶
L1 - Recall¶
Q1. What does the GIL protect, and what does it not?
Answer
It serialises execution of bytecode so CPython's internal state (refcounts, object internals) stays consistent. It does not make your compound operations atomic: x += 1 and check-then-act can interleave between bytecodes. Free-threaded builds remove the GIL, exposing races that were merely unlikely.
Q2. Name three ways to detect that you're on a free-threaded interpreter with the GIL actually off.
Answer
sys._is_gil_enabled() returns False; sysconfig.get_config_var("Py_GIL_DISABLED") == 1 (build capability); python -VV / sys.version shows "free-threading build". A build can be free-threaded yet have the GIL re-enabled at runtime by an incompatible extension.
Q3. Which start method became the default on Linux in 3.14 and why?
Answer
forkserver (was fork). Forking a multi-threaded process can deadlock (locks held by threads that don't exist in the child) and is unsafe with many native libs. forkserver spawns workers from a clean single-threaded server process.
Q4. Why do threads speed up NumPy-heavy code even on the GIL build?
Answer
Native extensions release the GIL around long C operations, so several threads run in parallel in C. Pure-Python loops can't.
L2 - Apply¶
Q5. This runs on 3.14t with 8 threads and prints less than 800000. Why, and fix it.
Answer
counter += 1 is load, add, store, which is not atomic. Threads interleave and lose updates. (It can happen on the GIL build too, but is far rarer.) Fix: with lock: around the update, or use per-thread counters and sum them, or itertools.count/queue patterns. Lock cost is small relative to the work if you batch.
Q6. Your ProcessPoolExecutor job is slower than serial. Give three likely causes.
Answer
(1) Payload pickling dominates (large args/results), (2) tasks too small so spawn/forkserver startup and IPC overhead dominate, (3) workers each re-import heavy modules or load a model, (4) oversubscription: NumPy/BLAS threads × process count > cores. Fixes: batch tasks, share data via shared memory or files, initialise workers once (initializer=), cap BLAS threads.
Q7. A FastAPI sync endpoint runs a 200 ms CPU-bound chunker. On 3.13 it can't use more than one core. What changes on 3.14t?
Answer
Sync endpoints run in AnyIO's threadpool, so on 3.14t concurrent requests can run the pure-Python chunker on multiple cores in one process, sharing loaded models/indices without pickling. Still verify all dependencies are free-thread compatible, and size the threadpool (default 40 tokens) to cores for CPU-bound work.
Q8. if key not in cache: cache[key] = expensive(key) from many threads. What are the failure modes and fix?
Answer
Duplicate computation (two threads pass the check) and possibly inconsistent results if expensive has side effects. The dict itself won't corrupt. Fix: a lock per key or global (with lock: double-checked), functools.cache (still may call twice concurrently in some cases, so don't rely on exactly-once), or store a Future in the dict (setdefault then .result()) so concurrent callers wait on one computation.
L3 - Design & trade-offs¶
Q9. Embedding pre-processing (tokenise/chunk 2M docs) — processes, free-threaded threads, or asyncio + to_thread?
Answer
It's CPU-bound, so asyncio adds nothing. Processes: proven, isolated, pay pickling and memory duplication (each worker loads tokenizer state), fine if inputs are file paths and outputs are compact arrays. Free-threaded threads: shared tokenizer and no serialisation, best throughput per GB, but depends on all deps being 3.14t-compatible (Rust tokenizers release the GIL anyway, so threads may already work on the GIL build). Recommendation: benchmark both with real data; default to processes for the production path today unless the dependency check passes and RSS/latency wins are measured, then run 3.14t behind a canary.
Q10. How would you roll out 3.14t for a fleet of 40 services?
Answer
Not fleet-wide at once. Inventory deps with cp314t wheels (uv lock plus tracker), add a 3.14t CI job (fail on GIL re-enable, run tests with threads stress and PYTHON_GIL=0), pick 2-3 CPU-bound, thread-heavy services as pilots, and canary with p99 latency, RSS and error metrics. Keep the GIL build as rollback (same code). Document thread-safety rules and audit global mutable state. Promote once the tracker shows your stack green and pilots show a real win (cost per request), otherwise wait.
Q11. Threads vs interpreters vs processes for running untrusted plugin code that must not corrupt host state.
Answer
Processes give the strongest isolation (crash containment, OS limits, seccomp/containers). Interpreters isolate Python state but share the process, so a native crash kills everything and extension support is limited. Threads share everything. For untrusted code choose processes (or containers/microVMs), possibly with interpreters for trusted-but-isolated modules where startup cost matters.
L4 - Staff-level ambiguity¶
Q12. The platform team wants to 'go free-threaded' to cut compute costs 30%. Evaluate the claim and craft the plan.
Answer
Break down cost by workload: if 80% is I/O waiting on LLMs, free-threading saves little (asyncio already efficient). Savings appear where CPU-bound work is currently parallelised via processes with duplicated memory or where cores idle due to the GIL. Quantify: CPU profile per service (py-spy), memory duplication, current pod counts. Model expected savings, add the 5-10% single-thread penalty and extra memory, then pilot. Risks: compatibility, subtle races, ecosystem lag. Deliver a decision memo with break-even and go/no-go criteria per service class, not a blanket mandate.
Q13. Design a thread-safety review process for a codebase moving to 3.14t.
Answer
Static: grep for module-level mutable state, singletons, lazy init, class-level caches, and global. Dynamic: run tests with many threads and thread sanitizer-style stress (repeat, randomised sleeps), plus pytest-run-parallel-style parallel test execution. Establish patterns (immutability, locks around state, queue-based handoff), a lint checklist in PR templates, and ownership of shared components. Track dependencies' free-threading status in a table with owners. Metric: races found in canary, and services passing the 3.14t CI job.
Real-world use cases¶
- Batch chunking/embedding prep: threads sharing a tokenizer on 3.14t beat process pools by removing pickling.
- Model-serving sidecars: many worker threads share one in-memory index without copy-on-write page duplication.
- Agent gateways: asyncio for network fan-out plus a small thread pool for sync SDKs and PDF parsing.
- Data platforms: DuckDB/Polars already release the GIL, so threads suffice there. 3.14t helps the pure-Python glue.
Pitfalls & anti-patterns¶
- Assuming dict/list atomicity means your logic is thread-safe.
- Using
forkafter starting threads or loading CUDA. - Sending huge objects through pool pickling.
- Ignoring the GIL-reenable warning on 3.14t.
- Threads for 10k concurrent sockets (use asyncio).
- Benchmarking on tiny inputs where startup dominates.
- Global
random/logging config mutated at runtime from many threads.
Checklist¶
- I can explain what free-threading changes and what it does not (compound ops, extensions)
- I ran the lab benchmark on 3.14 and 3.14t and recorded my numbers
- I reproduced and fixed a data race
- I answered all L3 questions out loud in < 3 min each