Preface
This book was produced while developing my 2026 Pycon presentation titled “Demystifying the GIL.” That presentation was inspired by a conversation with Python’s creator, Guido van Rossum, a year or so prior. Guido made a comment to the effect of, “I think people will be surprised by the side effects of working without the GIL.” Eventually I began wondering what the simplest side effects might be. My goal for the presentation is to show how behavior changes when the GIL is removed from simple code examples, and to explain why that happens. This way people might be a little less surprised when these changes occur and have a sense of how to attack the problem.
The explanation turned out to be more challenging than I had anticipated. The research generated this book.
Concurrency
Concurrency is the efficient use of multiple processors. It is a complex topic because the meaning of “efficient” and “processor” vary significantly depending on the class of problem you are solving. This produces numerous strategies which are applied according to your design constraints.
A useful way to categorize concurrency strategies is by where the processors live relative to your program: under the program’s direct control, or somewhere outside it. If the program (and the runtime it controls) is the system, everything else (other machines, the OS kernel, the network, the user, the disk) sits outside that system. The line between the two is the system boundary.
When multiple processing units are inside the system boundary, we call concurrency “parallelism.”1 Because multiple processing units are under control of the system, each processor can perform calculations in parallel with the other processors.
When the additional processing units are outside the system boundary, we call concurrency “asynchrony” or “cooperative multitasking” or “coroutines.” These are still processing units working in parallel, but they are outside the control of the system. For example, when you make a call to an external server, that server is driven by its own processor(s). Waiting for that result when you could be doing something in the meantime reduces progress. Asynchrony mechanisms automate the disconnection from waiting for that result.
The same pattern covers system responsiveness. If a human cannot give input because the system is busy elsewhere, the system is effectively blocked on a slow external processor (the human). Asynchrony lets the system keep working while still listening.
Asynchrony is often conflated with IO. The two are related but not the same. Any time you reach outside the CPU you perform an IO operation. Even something as seemingly straightforward as reading a timer (which may be on the chip die but outside the CPU) involves IO. If an operating system (OS) is present, this means making a request to the OS and then periodically checking (via asynchrony) to see if the OS has completed that request. IO is the dominant reason to reach for asynchrony; asynchrony is the mechanism that makes IO efficient.
Why Concurrency?
The only justification for concurrency is to increase system speed. With rare exceptions, performance optimization adds complexity to your system. This can add significant overhead to writing and maintaining code. Concurrency will impact effort, schedule, cost and technical debt. If you don’t need it, this expense will be a recurring waste. I’ve seen needlessly complex systems generated by an architect who was certain there would be a performance problem. This certainty was never tested and turned out to be wrong, but the architect was convinced it was necessary.
The complexity jump is big: suddenly you must think about things you’ve previously been able to ignore. Any form of concurrency is not simple, and opens a Pandora’s Box of issues you must understand intimately. If your system is fast enough, avoid concurrency. If it is indeed too slow, first consider simpler alternatives (use Occam’s razor):
-
Try Faster Hardware. Sometimes this is the cheapest and easiest solution.
-
Profile your system and identify bottleneck functions. Convert these into Rust modules via PyO3 (a crate that lets Rust code expose Python-callable functions). Using AI, this is now surprisingly straightforward and reliable. If this solves your performance problem it will usually be an easier approach than dealing with concurrency.
-
NumPy / SciPy / array-oriented libraries. If your CPU bottleneck is numeric work, the answer is often “stop looping in Python.” NumPy operations drop into C/Fortran, release the GIL, and are already parallelized internally (via BLAS, LAPACK, etc.). No threading required.
-
Dask / Ray / joblib. Distributed task schedulers handle parallelism above the process level. Dask in particular integrates with NumPy/Pandas idioms. This is applicable when the dataset or compute exceeds a single machine.
-
GPU compute (CuPy, PyTorch, JAX). For the right workloads (dense numeric, ML), the GPU has thousands of cores and bypasses the GIL entirely. This approach is not universally applicable, but when it fits, nothing else competes.
-
mmap + worker processes reading shared data. For the large-dataset-in-memory problem: memory-map a file and let multiple processes read it without copying. The OS handles sharing at the page level.
Workload Categories
-
CPU-bound, independent tasks (embarrassingly parallel). No shared data between tasks. Each unit of work is self-contained. Examples: image resizing, password hashing, compression, rendering frames. Best fit: multiprocessing, free-threaded threads (threads in Python’s free-threaded build, where the GIL is removed and threads can execute Python in parallel on multiple cores), Rust extensions.
-
CPU-bound, shared large dataset. All workers need read access to the same large data structure simultaneously. Copying it per-process is impractical. Examples: ML inference, search index queries, large matrix operations. Best fit: NumPy/GPU (vectorized, avoids the problem), shared memory, or free-threaded threads (one copy in memory).
-
CPU-bound, shared mutable state. Workers both read and write common state. This is the most difficult category. Examples: simulation with interacting agents, graph algorithms. Best fit: careful free-threaded threading with fine-grained locks, or redesign to reduce sharing.
-
I/O-bound, many concurrent connections. Waiting dominates. The bottleneck is latency, not compute. Examples: web scraping, API clients, WebSocket servers, database query fans. Best fit: async/await.
-
I/O-bound, blocking libraries. Same waiting problem but you can’t go async because the library doesn’t support it. Examples: legacy database drivers, synchronous SDKs. Best fit: threading (GIL build is fine; no-GIL makes thread executors more powerful).
-
Pipeline / producer-consumer. Data flows through stages with different bottlenecks at each stage. One stage might be I/O-bound, the next CPU-bound. Examples: media transcoding, event processing. Best fit: queues connecting threads or processes, or async with executor offload for CPU stages.
-
Extract, Transform, Load (ETL). A data pipeline pattern:
- Extract: pull data from a source (database, API, files, streams)
- Transform: clean, reshape, or compute on it (filter rows, join tables, aggregate, normalize)
- Load: write the result to a destination (data warehouse, another database, files)
Classic example: nightly job that pulls sales records from a transactional database, calculates daily summaries, and writes them to a reporting database.
It naturally fits the pipeline/producer-consumer category because each stage has a different bottleneck: Extract is I/O-bound, Transform is often CPU-bound, Load is I/O-bound again.
-
Background / fire-and-forget. Work that must not block the main thread but doesn’t need to return a result quickly. Examples: sending emails, logging to a remote service, cache warming. Best fit: threading or async tasks; multiprocessing if isolation matters.
-
Latency-sensitive / event-driven. Must respond to external events within a deadline. Examples: trading systems, game servers, UI event loops. Best fit: async/await (predictable yield points), or carefully tuned threading.
-
Distributed / beyond one machine. The problem is too large for one process or one machine. Examples: batch ML training, large-scale web crawling. Best fit: Dask, Ray, Celery; the GIL is irrelevant at this level.
The GIL matters most for the first three categories. For everything I/O-bound, it is not the bottleneck.
Concurrency Problems
Broadly, concurrency problems fall into these categories:
-
Race conditions. Two or more threads read and write shared state without coordination. The result depends on timing.
-
Atomicity violations. A sequence of operations that must happen as a unit gets interrupted mid-way.
stats_race.pyis a good example: count and total are updated on separate lines, so a thread can observe them in an inconsistent intermediate state. -
Order violations. Code assumes operations happen in a specific order across threads, but nothing enforces that order. Thread A assumes Thread B has finished initialization before using the result; sometimes it has, sometimes it hasn’t.
-
Deadlock. Two threads each hold a lock the other needs. Both wait forever. Classic with two locks acquired in opposite order.
-
Livelock. Threads keep responding to each other but make no progress. Neither is blocked, but neither advances. Less common in Python but possible with complex retry logic.
-
Starvation. One thread never gets scheduled or never acquires a lock because others keep taking priority. Can happen with unfair lock implementations or heavy contention.
-
Memory visibility. On hardware with weak memory models, one thread’s writes may not be visible to another thread without a memory barrier (a synchronization point that forces pending writes to become visible across threads, instead of sitting in a CPU’s local store buffer or cache). Python largely hides this, but C extensions that bypass the object model can encounter it.
-
Convoying. A slow thread holds a lock and forces all other threads to queue behind it, serializing what should be parallel work. A subtle performance problem rather than a correctness problem.
-
You might have heard the phrase “concurrency is not parallelism.” This is better stated as “concurrency is not only parallelism.” ↩
Concurrency Strategies
The main division between concurrency models is whether concurrent units share memory or stay isolated.
At the OS level, this maps to the distinction between threads and processes. A process owns a virtual address space, heap, and file descriptors. A thread is a unit of execution running inside a process. Threads in the same process share that address space; separate processes do not. Shared-memory strategies typically run as threads inside one process, and isolated-memory strategies typically run as separate processes. Cooperative concurrency (event loops, coroutines) is a third case: many tasks multiplexed onto a single thread.
Shared memory. All units see the same data structures. Communication is as cheap as reading a pointer. A thread can hand a 10 GB dataset to another thread by passing its address, zero copying. This is why scientific computing, game engines, and databases live here: the working set often doesn’t fit anywhere else, and the cost of duplicating it is prohibitive.
The price: any mutation someone else can observe is a hazard. Reads that
look simple (total += 1) are read-modify-write sequences, and a
concurrent writer breaks them. An operation is atomic if no other
thread can observe it half-done: it has either not started or has
finished, never an in-between state. Most bugs in shared-memory code
come from the gap between “this line looks atomic” and “this line is
actually atomic.”
Isolated memory. Each unit has private state, and communication crosses a boundary (a channel, a message queue, a socket). Whole categories of bugs vanish because there is nothing to race on. But every piece of shared data now has to be copied or serialized, which makes communication expensive and caps the size of datasets you can move around cheaply.
Most real systems pick a point on this axis, or mix both: shared memory inside a process, message passing across processes or machines.
Strategies for Shared Memory
Locks (mutexes, semaphores)
This wraps any shared mutation in a mutex, a lock that permits one holder at a time. Whoever holds it has exclusive access. A semaphore generalizes this to allow up to N simultaneous holders, useful for capping concurrent access to a pool of resources (database connections, worker slots) rather than enforcing strict mutual exclusion.
Apply when: critical sections are short, contention is low, and the data being protected fits naturally behind one lock.
Struggles when: you need multiple locks (deadlock risk), locks are held across slow operations (convoying), or the critical section is large enough that serializing it erases the parallelism gain.
Nearly every mainstream language provides locks. Locks are flexible but error-prone: the programmer is responsible for knowing what each lock protects, the order to acquire multiple locks, and when a lock should be released.
Atomic operations and lock-free data structures
This uses hardware primitives (compare-and-swap, atomic increment, fetch-and-add: CPU instructions guaranteed indivisible by the hardware, so no other core can observe them half-done) to build data structures that don’t need locks. A lock-free queue lets producers and consumers make progress simultaneously without blocking each other.
Apply when: a specific data structure is a contention hotspot, and a specialist can invest in a careful lock-free implementation.
Struggles when: you need to compose multiple operations atomically. Lock-free algorithms protect individual operations, not sequences of them. The code is also notoriously hard to get right; memory-ordering bugs manifest only on certain CPUs and only under specific interleavings.
Used in kernel data structures, high-performance databases, and language runtimes (allocators, garbage collectors). Almost never written by application programmers.
Software Transactional Memory (STM)
Instead of locking, mark a block of code as a transaction. The runtime records every read and write, and commits the transaction only if no concurrent transaction touched the same data. If a conflict is detected, the transaction is rolled back and retried.
Apply when: you want composable atomicity.
atomically { account.withdraw(100); other.deposit(100) } is a single
unit; two of them running concurrently will not interleave, even though
neither knew about the other. Locks do not compose this way. Writing two
correctly-locked functions and calling them in sequence does not give you
a correctly-locked sequence.
Struggles when: transactions have side effects that can’t be rolled back (I/O, network calls, printing). The runtime also pays a read/write tracking cost, which makes STM slower than well-tuned locks for uncontended workloads.
Haskell and Clojure have production-quality STM. Java, Scala, and others have library implementations with caveats. STM has not displaced locks in mainstream use, partly because integrating with the rest of the ecosystem (which does do I/O) is awkward.
Immutability and persistent data structures
These sidestep the problem: if data never changes, concurrent readers can’t conflict with writers, because there are no writers. Updates produce a new version of the structure that shares unchanged parts with the old one (a persistent data structure).
Apply when: the programming model fits, especially for functional-leaning code. Readers never need synchronization. Time-travel, undo, snapshotting, and versioning all become natural.
Struggles when: you need high write throughput to a single structure (each update allocates), or the mutation pattern doesn’t decompose into functional updates (e.g., fine-grained in-place edits to a large numerical matrix).
Clojure’s core collections, Scala’s immutable data, Rust’s ownership model with shared-borrow references, and functional languages broadly. Often combined with one of the other strategies (e.g., STM over immutable values) to cover the mutation case.
Strategies for Isolated Memory
Actors
Each unit of work (an actor) has private state and communicates only via messages sent to other actors’ inboxes. An actor processes messages one at a time, so its internal state is single-threaded by construction.
Apply when: the problem decomposes naturally into independent entities with their own lifecycles (telecom switches, chat systems, game entities, distributed supervisors). Failure isolation is excellent: a crashed actor doesn’t corrupt others, and supervision trees can restart them automatically.
Struggles when: actors need to reach consensus across many participants, or when request-reply patterns dominate (you end up serializing logic that would be trivial with a direct call). Debugging is also harder because control flow is distributed across message traces rather than visible in one call stack.
Erlang built an industry on this (Ericsson’s telecom switches). Elixir carries that model forward on the BEAM (Erlang’s virtual machine) runtime. Akka brought it to the JVM.
CSP (Communicating Sequential Processes)
CSP is a communication discipline, not a memory model. All the units of work live in one process and one address space. They could touch each other’s memory; the rule is that they don’t. Instead, they exchange values through named channels, and the language’s runtime provides cheap lightweight units (goroutines, coroutines, fibers) that the scheduler multiplexes onto a handful of OS threads.
Channels are first-class values. Multiple senders can write to one channel; multiple receivers can read from it. Sends and receives take nanoseconds, because a channel is just an in-memory queue with some synchronization around it. No kernel involvement, no serialization.
Rob Pike’s slogan for Go captures the stance: “Don’t communicate by sharing memory; share memory by communicating.” The memory is shared (that’s why passing a pointer through a channel is instant); the communication is what’s disciplined.
Apply when: you want concurrency that reads like a sequential
program, inside one process. Each goroutine looks like a little main
function, and the channel declarations make the communication topology
explicit in the source. Back-pressure falls out naturally: a full
buffered channel blocks the sender until a receiver catches up.
This is also the model most loved by programmers for its cognitive economy. Because the idiom is “data flows through channels,” most shared-state bugs simply never get written. The cost of a channel send is low enough that you don’t hesitate to use one.
Struggles when: the coordination is request-reply with many-to-many fan-out, or when the isolation guarantee actually matters. CSP’s isolation is by convention only. A goroutine that ignores the discipline and pokes a shared variable gets the same races as any other threaded code. The runtime will not catch it, and a crash in one goroutine takes the whole process down.
Go is the mainstream example. Occam built the original version in the
1980s for the Transputer hardware. Clojure’s core.async is a library
implementation on the JVM.
Isolated processes with IPC
IPC is physical isolation. Each process has its own address space, its own heap, its own file descriptors. The OS and the hardware MMU (Memory Management Unit, which translates virtual addresses to physical memory) enforce this: one process literally cannot read another’s memory without an explicit, mapped-in shared segment. A segfault, a null-pointer dereference, or an abort in one process affects only that process. The others keep running.
Communication crosses the kernel: pipes, sockets, message queues, explicit shared memory segments. Every message involves a syscall, a context switch, and usually serialization into bytes (since native pointers don’t mean anything in another address space). The overhead is microseconds to milliseconds per message, orders of magnitude more than a CSP channel send. Data has to be copied or serialized; you cannot just pass a pointer. This is the price of the hard isolation guarantee.
Apply when: isolation is the point. A crash in one worker must not affect the others. Workers may be in different languages or different versions of the same language. Security boundaries need to be real (sandboxing, privilege separation). Workloads span multiple machines (sockets generalize to TCP; processes generalize to hosts).
Struggles when: communication is frequent or data is large. Every message crosses a kernel boundary and carries serialization cost. Duplicating a large dataset across N workers costs N times the RAM, unless you opt into explicit shared memory, which gives back the isolation you just paid for.
IPC is seen everywhere at the OS level. Also:
- Unix pipelines
- Web servers spawning worker processes
- Database systems with separate query processes
- Python’s
multiprocessing - MPI for scientific computing across cluster nodes
CSP vs. IPC
CSP is “threads that agree not to share”; IPC is “processes that cannot share without asking the OS.” The first is cheap and conventional; the second is expensive and enforced. They are aimed at different problems and often appear in the same system at different layers.
Non-memory-based Strategies
The previous strategies were determined by whether you need to share or isolate memory. The strategies presented here solve different problems.
Cooperative scheduling (event loops, coroutines, async/await)
This runs tasks one at a time on a single thread, switching between them at well-defined yield points.
Apply when: the bottleneck happens because you’re waiting on something (network, disk, user input),
rather than computing something. Tens of thousands of concurrent connections can share
one thread because most are idle most of the time. There are no race conditions on
shared state between yield points, because there are no preemptive
switches. Control flow is visible in the source because yields are
explicit (await, yield, channel operations).
Struggles when: any task is CPU-bound. A coroutine that doesn’t yield starves every other coroutine on the loop. Integrating with blocking libraries requires offloading to a thread pool, which brings shared memory back into the picture.
JavaScript lives here by construction. Python’s asyncio, C#’s
async/await, Rust’s async ecosystem, Kotlin’s coroutines. Arguably the
most successful concurrency model of the last fifteen years, because
I/O-heavy workloads are extremely common and this model fits them
precisely.
Data parallelism (SIMD, GPU, vectorized operations)
Apply the same operation to many data elements at once. The hardware exposes wide vector registers (SIMD) or thousands of simple cores (GPU), and code expresses work as “do this to every element” rather than “loop over elements.”
Apply when: the same operation applies uniformly to large arrays (image filtering, matrix multiplication, neural network layers, physics simulations). Hardware speedups are enormous: 10-100x on CPU SIMD, 100-1000x on GPU.
Struggles when: the work is branchy, data-dependent, or irregular. A GPU executing “different things on different elements” spends most of its time idle, because the hardware is lockstep by design. Memory transfer to and from the GPU also caps throughput for anything short-lived.
NumPy, SciPy, TensorFlow, PyTorch, CUDA, OpenCL, SIMD intrinsics, auto-vectorizing compilers. The foundation of modern numerical computing.
Fork/join and task parallelism
Decompose a problem into tasks. A scheduler picks tasks off a pool and runs them on worker threads. Tasks can spawn subtasks and wait for their results. The scheduler handles load balancing via work stealing.
Apply when: the problem has recursive structure (divide-and-conquer sorts, tree traversals, parallel search) and tasks are CPU-bound but unpredictable in duration. The runtime handles balancing without the programmer specifying it.
Struggles when: tasks have side effects on shared state (back to locks), or when fine-grained tasks have overhead that swamps their actual work.
Cilk was the research system. Java’s Fork/Join framework, .NET’s TPL, Intel TBB, and OpenMP tasks brought it to production. Rust’s Rayon is a modern library version.
MapReduce and dataflow
Structure the computation as a graph of operations on datasets, and let a framework schedule it across many machines. Map, filter, and reduce primitives compose into pipelines; the framework handles partitioning, shuffling, and failure recovery.
Apply when: data doesn’t fit on one machine, computation is embarrassingly parallel over partitions, and the framework’s assumptions match yours (batch, high throughput, tolerant of restart).
Struggles when: you need low latency or tight coordination between workers. MapReduce-style frameworks are built for throughput, not interactive queries.
Examples include Hadoop, Spark, Flink, Dask, and Ray.
Cross-Cutting Observations
The RAM argument for shared memory
The strongest argument for shared-memory concurrency is that modern machines have enough memory that many real datasets can fit in one process. A single server with 512 GB of RAM can hold most datasets an organization cares about, and accessing that data from multiple threads only takes a pointer dereference. The moment you split into isolated processes, the same data has to be duplicated across processes (multiplying memory cost) or accessed through a communication mechanism (adding latency on every access). For problems dominated by data access rather than communication, shared memory wins by orders of magnitude.
This is why the GIL has been such a persistent pain point for numeric and scientific Python: the workloads need shared memory, the hardware supports it, the data is already sitting in one address space, and the GIL prevented threads from using it in parallel.
The correctness argument for isolation
Conversely, the strongest argument for isolation is that most concurrency bugs come from unintended sharing, and eliminating shared mutable state eliminates the bugs at the source. A team that can’t find its race conditions doesn’t benefit from having twice the hardware parallelism; it just produces incorrect results twice as fast. CSP and actors put a non-trivial cost on communication precisely to force the programmer to be explicit about what crosses between units. The runtime cost of sending a message buys back engineering time spent hunting for races.
Preemptive vs. cooperative
A second axis cuts across the first: does the scheduler preempt units at arbitrary points, or do units yield control voluntarily?
- Preemptive (OS threads, free-threaded Python threads): the scheduler can switch at almost any instruction. It’s easy to write code that blocks one unit without blocking others. But it’s hard to reason about interleavings, because they can happen anywhere.
- Cooperative (event loops, coroutines, Go’s scheduler at channel ops): switches happen only at named points. It’s easy to reason about interleavings. But a misbehaving unit can block everything by refusing to yield.
The trade-off is symmetric: preemption gives robustness against uncooperative code at the cost of reasoning difficulty; cooperation gives reasoning simplicity at the cost of requiring every participant to yield.
Why real systems mix models
No model wins on every axis, so real systems stack them. A typical web backend:
- Across machines: isolated processes communicating over TCP.
- Within a machine: an event loop per process handling many connections.
- For CPU-heavy work inside a request: thread pool with shared memory.
- For data-parallel work inside those threads: SIMD, or offload to a GPU.
This is not a failure of any one model; it is how you exploit different levels of the hardware. The GPU wants data parallelism. The thread wants shared memory. The process wants isolation. The network wants messages. Picking one strategy for the whole stack means losing orders of magnitude at some level.
Summary Table
| Model | Sharing | Scheduling | Best at | Worst at |
|---|---|---|---|---|
| Locks | Shared | Preemptive | General-purpose mutation | Composition; deadlock |
| Atomics / lock-free | Shared | Preemptive | Hotspot data structures | Correctness under review |
| STM | Shared (logical) | Preemptive | Composable atomicity | I/O inside transactions |
| Immutability | Shared (safe) | Any | Read-heavy, snapshot-friendly | In-place mutation |
| Actors | Isolated | Preemptive per actor | Independent entities, fault isolation | Consensus, RPC-heavy flows |
| CSP | Isolated | Preemptive | In-process coordination, pipelines | Distributed consensus |
| Isolated processes | Isolated | Preemptive | Crash isolation, polyglot systems | Large shared datasets |
| Event loop / async | Shared (one thread) | Cooperative | Many concurrent I/O waits | CPU-bound work |
| Data parallel / SIMD / GPU | Specialized | Lockstep | Uniform ops over big arrays | Branchy, irregular work |
| Fork/join | Shared | Preemptive + work stealing | Divide-and-conquer, recursive | Side effects on shared state |
| MapReduce / dataflow | Isolated partitions | Batch | Data that doesn’t fit on one machine | Low-latency, interactive |
The right strategy is whichever one matches the shape of the work, the shape of the data, and the budget for debugging. This is the design challenge, and most non-trivial systems mix several strategies.
Python Concurrency Strategies
In the previous chapter we looked at general concurrency strategies, and here we will look at the specific Python approaches.
Quick Reference
| Strategy | CPU parallel | I/O concurrent | GIL removal impact |
|---|---|---|---|
async/await | No | Yes | run_in_executor gains true CPU parallelism |
threading | No → Yes | Yes | Major: unlocks CPU parallelism; shared state now needs explicit locks |
multiprocessing | Yes | Yes | Minor: threads become a lower-overhead alternative |
| Subinterpreters | Yes | Yes | Significant: per-interpreter GIL disappears; isolation remains, parallelism model changes |
ThreadPoolExecutor | No → Yes | Yes | run_in_executor and thread pools gain true CPU parallelism |
ProcessPoolExecutor | Yes | Yes | None: already process-isolated |
async/await
Cooperative, single-threaded concurrency. A coroutine is a function that can pause itself: when it executes await, it returns control to a scheduler called the event loop, which picks another ready coroutine and resumes it. All coroutines run on one thread, taking turns at explicit yield points. No thread is ever switched preemptively.
Strengths:
- No race conditions on shared state between
awaitpoints - Very low overhead (no OS threads, no context switching cost)
- Scales to thousands of concurrent connections
- Explicit yield points make control flow readable
Weaknesses:
- Requires async-aware libraries throughout
- No CPU parallelism
- A long CPU-bound loop blocks all other coroutines
Use when: You have many concurrent I/O operations (HTTP requests, database queries, WebSocket connections) and can commit to an async library stack.
Not appropriate for: CPU-bound work, or when you need to call synchronous blocking libraries without a workaround.
# examples/async_fetch.py
import asyncio
async def fetch(name: str, delay: float) -> str:
print(f" start {name}")
await asyncio.sleep(delay) # simulates I/O latency
print(f" done {name}")
return f"result for {name}"
async def main() -> None:
results = await asyncio.gather(
fetch("a", 0.3),
fetch("b", 0.1),
fetch("c", 0.2),
)
print(results)
if __name__ == "__main__":
asyncio.run(main())
GIL vs. no-GIL
The event loop always runs on a single thread, so a blocking call made directly inside a coroutine stalls everything regardless of the build. The difference appears in the workaround: result = await loop.run_in_executor(None, blocking_call, arg) offloads a blocking call to a thread pool.
With the GIL, this works well for I/O-bound blocking calls (the thread releases the GIL during I/O), but for CPU-bound blocking calls the executor thread holds the GIL and stalls the event loop anyway. Without the GIL, the executor thread runs truly in parallel with the event loop for both I/O and CPU-bound work. The need for async-aware libraries remains, but the cost of not having them is reduced.
Threading
Preemptive multitasking using OS threads. The OS schedules threads normally. The threading module API is identical in both builds; the GIL determines whether threads run in parallel or take turns.
Strengths:
- Simpler than
async/awaitfor some patterns - Existing blocking libraries work without modification
- Good I/O concurrency in both builds (GIL releases during I/O; no GIL means it just runs)
- Familiar programming model
Use when: You have I/O-bound work and blocking libraries to call, or you are integrating with code that cannot be made async.
GIL vs. no-GIL
This is where the difference is most significant.
With the GIL, only one thread executes Python bytecode (the compiled instruction stream the interpreter runs) at a time. I/O-bound threads make progress concurrently (the GIL releases during I/O), but two threads doing CPU work simply take turns. The GIL also makes race conditions rare, which creates false confidence: code that “works” may be subtly wrong.
Without the GIL, threads run in true parallel on multiple cores. CPU-bound work scales. But all the races the GIL was quietly suppressing are now real and frequent. Every piece of shared mutable state needs explicit synchronization.
| With GIL | Without GIL | |
|---|---|---|
| CPU parallelism | No | Yes |
| I/O concurrency | Yes | Yes |
| Shared state safety | Accidental (mostly) | Explicit locks required |
| Race condition frequency | Rare | Continuous |
| Single-threaded overhead | None | Small (atomic refcounting) |
GIL build not appropriate for: CPU-bound work.
No-GIL build not appropriate for: Code with heavy shared-state contention (lock overhead can make it slower than the GIL build), or production systems relying on libraries not yet tested under free-threading.
multiprocessing
Separate OS processes, each with its own Python interpreter and GIL. The OS provides true parallelism. Processes communicate via queues, pipes, or shared memory.
Strengths:
- True CPU parallelism, works with any Python build today
- Process isolation: a crash in one worker does not affect others
- No shared state by default, which eliminates entire classes of bugs
- Compatible with all existing libraries
Weaknesses:
- High startup cost. Forking clones the parent process (fast, but inherits its locks, threads, and open file descriptors, which can misbehave). Spawning starts a fresh interpreter (slower, cleaner state). Spawn is the default on Windows and macOS; fork is still available on Linux.
- Data passed between processes must be serialized (pickled), which is slow for large objects
- Shared state requires explicit mechanisms (
multiprocessing.shared_memory,Manager,Value) - Higher memory usage (each process has its own heap)
Use when: You have CPU-bound work expressible as independent tasks, startup cost is acceptable, and data exchange between workers is infrequent.
Not appropriate for: Fine-grained parallelism with frequent communication, tasks where startup overhead dominates runtime, or workloads with large datasets that must be in memory simultaneously. Each worker gets its own copy of the data, multiplying memory usage by the number of processes. Workarounds exist (multiprocessing.shared_memory, memory-mapped files) but add significant complexity.
# examples/mp_pool.py
from multiprocessing import Pool
def crunch(chunk: list[int]) -> int:
return sum(x * x for x in chunk)
if __name__ == "__main__":
data_chunks = [
list(range(i * 1_000_000, (i + 1) * 1_000_000)) for i in range(4)
]
with Pool() as pool:
results = pool.map(crunch, data_chunks)
print(f"chunk totals: {results}")
print(f"grand total: {sum(results)}")
GIL vs. no-GIL
multiprocessing is largely unaffected by the GIL, since each process has its own interpreter. The behavior and performance are the same in both builds.
What changes is the relative appeal. With the GIL, multiprocessing is often the only practical way to achieve CPU parallelism in Python, so its overhead is accepted as a necessary cost. Without the GIL, free-threaded threading can achieve similar parallelism with no process creation cost and no serialization overhead. For workloads where isolation is not the primary goal, multiprocessing becomes less compelling as free-threading matures.
Subinterpreters
Multiple Python interpreters running in the same OS process, each with its own GIL. Added at the C API level in Python 3.12 (PEP 554, a Python Enhancement Proposal); higher-level Python APIs are still evolving (PEP 734).
Strengths:
- Intra-process parallelism with stronger isolation than threads
- Lower overhead than
multiprocessing(shared process heap, no fork) - Objects that cannot cross interpreter boundaries create an early error rather than a silent race
Weaknesses:
- Objects cannot be shared directly; data passes through channels using pickling or
memoryviewof shared memory - Most C extensions assume a single interpreter per process and will fail
- The Python-level API is not yet stable or ergonomic
- Effectively experimental for application code in 2026
Use when: You need isolation stronger than threads but lighter than processes, you control all the code involved, and you are willing to work at a low level.
GIL vs. no-GIL
Subinterpreters were designed specifically to provide CPU parallelism within a single process while keeping the GIL. Each interpreter has its own GIL, so they run simultaneously without interfering with each other.
Without the GIL, the per-interpreter GIL disappears alongside the main one. Subinterpreters still provide isolation (separate module namespaces, type objects, and memory allocator state), but the parallelism they offer is no longer distinct from what plain threads provide. In the free-threaded build, subinterpreters become a tool for isolation rather than a tool for parallelism. Their practical advantages over threads shrink considerably.
concurrent.futures
A high-level interface over threads and processes. ThreadPoolExecutor backs tasks with threads; ProcessPoolExecutor backs them with processes. The same submit/map API works for both, and it integrates with asyncio via loop.run_in_executor().
Strengths:
- Simple API for embarrassingly parallel tasks
- Easy to switch between thread and process backends
- Handles pool lifecycle, exception propagation, and result collection
Weaknesses:
- Inherits all the limitations of the underlying backend
- Less control than using threads or processes directly
Use when: You have a collection of independent tasks and want a clean API without managing pools manually. This is the right default for most fan-out-and-collect patterns.
# examples/process_pool_executor.py
from concurrent.futures import ProcessPoolExecutor
def crunch(chunk: list[int]) -> int:
return sum(x * x for x in chunk)
if __name__ == "__main__":
data_chunks = [
list(range(i * 1_000_000, (i + 1) * 1_000_000)) for i in range(4)
]
with ProcessPoolExecutor() as ex:
results = list(ex.map(crunch, data_chunks))
print(f"chunk totals: {results}")
print(f"grand total: {sum(results)}")
GIL vs. no-GIL
ProcessPoolExecutor is unaffected; it already isolates work in separate processes.
ThreadPoolExecutor changes significantly. With the GIL, it provides I/O concurrency but no CPU parallelism. Without the GIL, it provides both. This also applies to loop.run_in_executor() in async code: offloading a CPU-bound call to a ThreadPoolExecutor actually runs in parallel with the event loop in the free-threaded build, whereas it would stall it in the GIL build.
| Executor | With GIL | Without GIL |
|---|---|---|
ThreadPoolExecutor | I/O parallel only | I/O and CPU parallel |
ProcessPoolExecutor | I/O and CPU parallel | I/O and CPU parallel (unchanged) |
Decision Guide
Is your bottleneck I/O (network, disk, database)?
├── Can you use async libraries throughout?
│ └── Yes → async/await
└── No (blocking libraries, legacy code)
├── GIL build → threading or ThreadPoolExecutor
└── No-GIL build → same, and blocking calls in executors no longer stall event loops
Is your bottleneck CPU?
├── Need it to work today on any Python build?
│ └── multiprocessing / ProcessPoolExecutor
├── Willing to use the free-threaded build and audit shared state?
│ └── threading or ThreadPoolExecutor (no-GIL build)
└── Need isolation stronger than threads, lighter than processes?
└── Subinterpreters (experimental; check library compatibility first)
Tasks share data heavily?
└── Contention limits gains regardless of strategy; reconsider the design
What the GIL Was Actually Solving
All of these strategies exist partly because of what the GIL was doing quietly:
- Thread-safe reference counting
- Mutual exclusion across interpreter internals
- Accidental safety for user code that never considered concurrency
Free-threading replaces the first two with atomic operations and finer-grained internal locks. The third item becomes the programmer’s responsibility. The other strategies (async, multiprocessing, subinterpreters) sidestep the problem entirely by limiting or eliminating shared mutable state between concurrent units.
The cleaner the data boundaries between concurrent units, the easier the code is to reason about, regardless of which strategy or Python build you choose.
The History of the GIL
The GIL might look like a design mistake: a lock that prevents Python from exploiting multi-core hardware. However, it is the natural consequence of four architectural decisions, each of which was correct in isolation and each of which reinforced the others.
This document traces that evolution. The claim is not that the GIL was optimal, but that, given the path Python actually took, it was the only realistic outcome at the point it was needed.
The Four Decisions
- Reference counting as the memory management (garbage collection, GC) strategy.
- A direct C extension API (Application Programming Interface) that exposes refcount manipulation to extension authors, turning Python into a “coordination language” for C libraries.
- OS-level threads, added for I/O concurrency.
- A single interpreter-wide lock as the cheapest sufficient way to make (1), (2), and (3) coexist.
Remove any one of (1), (2), or (3) and the GIL either isn’t needed or isn’t the obvious answer. Keep all three, and (4) is essentially forced. The rest of this document works through each step.
1990: Reference Counting
Guido van Rossum started Python in December 1989. One of the earliest architectural decisions was how to manage memory. He chose reference counting.
Every object carries an ob_refcnt field. When something starts pointing at the
object, the count goes up. When something stops, the count goes down. When it
reaches zero, the object is freed immediately.
This was a good choice in 1990:
- Simple to implement. A small number of macros (
Py_INCREF,Py_DECREF) and no separate collector thread. - Deterministic destruction. Files close when their last reference drops. Locks release. Sockets shut down. No “wait for the garbage collector to get around to it.” This matters for a language designed to glue C libraries together, where those libraries hold OS resources.
- No world-stop pauses. Tracing garbage collectors of the era stopped every thread to scan the heap. Refcounting spreads the cost across every operation and never pauses.
- Cache-friendly. Objects die close to their last use, often while still in cache.
- Tractable in an extension API. Extension authors could reason about ownership locally: “I take a reference here, I release it there.” No need to tell a tracing collector which roots to scan.
The cost of the choice, not yet visible in 1990: every Py_INCREF and
Py_DECREF is a read-modify-write sequence.
old = obj->ob_refcnt // LOAD
obj->ob_refcnt = old+1 // STORE
This is fine on a single thread. With two threads, updates can be lost. A lost
Py_INCREF leaves the refcount too low and the object gets freed while still in
use. A lost Py_DECREF leaks it. The first is a memory safety bug; the second
is a resource leak. Refcount operations also happen millions of times per
second in normal execution, so any fix must be essentially free on the
common path.
Nothing about this mattered in 1990, because Python had no threads. But the decision was now baked in. The rest of the story is shaped by it.
One other limitation of pure refcounting is worth flagging here: it cannot
reclaim cyclic garbage. Two objects that point at each other keep each other’s
refcount above zero forever. CPython did not address this until Python 2.0
(October 2000) added a cycle-detecting collector in the gc module. See
chapter 7 for how it works.
1991: A Direct C Extension API
Python 0.9.0 shipped in February 1991. One of its defining features was how
easy it was to write C extensions. The C API did not hide the object model: it
exposed PyObject* as a raw pointer and Py_INCREF/Py_DECREF as public
macros.
This was a deliberate design choice. Python became a coordination language: the glue used to drive numeric libraries, database drivers, graphics toolkits, and scientific code written in Fortran and C. The scientific Python stack (NumPy, SciPy, pandas, PyTorch) exists because writing extensions was easy.
The cost: the reference count is now part of the public ABI.
Where an API is a contract at the source-code level (function names, signatures, types that a compiler checks), an Application Binary Interface (ABI) is the contract at the compiled-binary level: struct layouts, field offsets, calling conventions, symbol names, which things are inlined vs. called through a function. Two libraries are ABI-compatible if a binary compiled against one version still runs against another without recompiling.
Reference counting is part of the ABI: extension authors manipulate refcounts
directly. Every extension written between 1991 and today contains code that
assumes ob_refcnt is a plain integer and that incrementing it is just an
integer add. You cannot change how refcounting works without breaking every
extension in existence.
Compare this to Java’s JNI, which hides the GC entirely. Extensions get opaque object handles; the GC can relocate objects, change its algorithm, or run concurrently, and no JNI code notices. Python chose the opposite trade: a leakier but simpler API, and got a richer ecosystem in exchange for a much tighter constraint on future evolution.
1992: Threads are Added
Python 0.9.x introduced threading around 1992, wrapping the platform’s OS
threads (pthreads on Unix, native threads on Windows) and exposing them through
the thread (later threading) module.
The motivation was I/O concurrency, not multi-core performance:
- Multiprocessor machines were rare and expensive.
- Network servers wanted to handle multiple clients without blocking on one slow read.
- GUIs wanted to keep the interface responsive while work ran in the background.
- Event loops existed (GUI toolkits like X11 and Tcl/Tk ran them, as did
servers built directly on
select()), but programming against them required manual state machines or callback chains. The ergonomic syntax came much later: generators in 2001,yield fromin 2008,asyncandawaitin 2015.
A thread that blocks on a syscall doesn’t need CPU; having another thread ready to use the CPU while it waits is the entire point. This use case doesn’t need threads to run Python code in parallel; it just needs them to exist and to share memory the way C threads do.
With threads added, all three ingredients are now present:
- Refcounts that need atomicity across threads (from 1990).
- An extension ecosystem manipulating those refcounts directly (from 1991).
- Threads that share memory (from 1992).
Real programs can now have races during object reference counts. Synchronization is required.
The Synchronization Choice
Given the three decisions above, the options are:
Atomic refcount operations
Replace obj->ob_refcnt++ with a CPU atomic (LOCK XADD on x86). Cheaper than
a mutex but still far more expensive than a plain integer add, especially under
contention on shared cache lines. On 1992 hardware, atomics were substantially
slower than they are today.
More fundamentally: atomics on refcounts protect refcounts, but they don’t protect dict internals, the import system, the bytecode interpreter’s own bookkeeping, the module loader, the type system. You still need locks on all of that. Meanwhile, you’ve already slowed down every single-threaded Python program for the benefit of the multi-threaded case.
Fine-grained per-object locking
Give every mutable object its own lock. Take it when you mutate the object, release it when you’re done.
This was tried. Greg Stein’s 1996 “free-threaded Python” patch implemented exactly this. Result: roughly 2× slower on single-threaded code. Every container operation now required lock acquire/release. Every refcount update did too.
Nobody was going to accept a 2× penalty on existing programs so that a minority of workloads could scale on multiple cores, especially when those workloads were rare (multicore wasn’t mainstream until the mid-2000s).
This approach would also require auditing every C extension. The cost wasn’t just in the interpreter; it was ecosystem-wide.
Switch to tracing garbage collection
Remove refcounting entirely. No refcounts means no refcount races. This is what Java, C#, and JavaScript did.
But this undoes 1990 and 1991 simultaneously. Every extension that
manipulates ob_refcnt breaks. The entire scientific Python stack would have
to be rewritten. The guaranteed deterministic destruction that enables
with open(...) as f: would have to be replaced with some less
predictable mechanism.
Jython (on the Java Virtual Machine, or JVM) and IronPython (on .NET) actually did this: they run Python on tracing GCs and correspondingly have no GIL. Neither achieved anywhere near CPython’s adoption, and the reason is exactly the extension story: they can’t host NumPy or any other CPython C extension without emulation.
Remove threads
Don’t have threads at all. JavaScript took this path, not by choice but because it was born in the browser next to a non-thread-safe DOM. It grew an event loop instead, and parallelism came much later via Web Workers that don’t share memory.
Python could have done this in 1992, but users who wanted threads for I/O already had them. Taking them away would have broken code that already worked. Python’s embedding story (CPython runs inside C programs that might already be multithreaded) also made “no threads” a non-starter.
Single interpreter-wide lock
One mutex, held whenever a thread runs Python bytecode. Released around blocking I/O so other threads can run during the wait. Given the three prior decisions, this is almost free:
- Refcount operations are automatically safe, with no code changes anywhere, not in the interpreter, not in any extension.
- Interpreter internals are automatically safe: dict, list, type objects, import, the ready queue.
- Every existing extension is automatically safe, because the C API’s implicit single-threaded assumption is now explicitly enforced.
- Single-threaded performance is essentially unaffected; the lock is uncontended, held across long stretches, and cheap on modern hardware.
- The I/O use case still works: threads release the GIL around blocking syscalls, so another thread can run Python during the wait.
The GIL was not chosen because it was the best concurrency model. It was chosen because it was the cheapest synchronization mechanism compatible with decisions already made. Every alternative required undoing one of those decisions, at costs ranging from “2× slowdown” to “break the entire ecosystem.”
How Other Languages Avoided This
The four-decision chain is a useful lens. Languages that look GIL-free mostly skipped one of the decisions:
- JavaScript skipped decision (3). No threads with shared memory, ever. The DOM forced the choice and the language stuck with it. Web Workers came later, but they’re message-passing only.
- Java and C# skipped decision (1). Tracing GC from day one. They also skipped the leaky-extension-API problem via JNI and P/Invoke, which hide the GC. They also had decision (3) designed in from the start, with language-level synchronization primitives and a specified memory model.
- Erlang skipped a more fundamental premise: no shared mutable state between processes at all. A coherent design, but not one you can retrofit onto a language that already has shared objects.
- Jython and IronPython are Python with decision (1) replaced: they run on the JVM and the Common Language Runtime (CLR) with tracing GC. They have no GIL. They also cannot host CPython’s C extension ecosystem, which is why most users stayed on CPython.
- Ruby (MRI) made all four of the same decisions as CPython and got the same result: a Global VM Lock. JRuby and TruffleRuby, running on different VMs, have no GVL, the same pattern as Jython and IronPython.
The pattern is consistent: inherit CPython’s (or MRI’s) decisions, inherit the lock. Change any of them, and the lock becomes unnecessary or impossible.
1996: The First Attempt to Remove It
Greg Stein’s free-threaded patch, mentioned above, was the first serious attempt. It used fine-grained locking throughout the interpreter. It worked correctly. It was about 2× slower on single-threaded code. Guido rejected it, and in doing so set the standing challenge that would define the next three decades:
Remove the GIL without slowing down single-threaded code.
Several later attempts, including Larry Hastings’s “gilectomy” (2016), tried and failed to clear that bar.
The difficulty was not implementation skill; the people working on it were excellent. The difficulty was structural. Refcounting imposes a per-operation cost that you pay whether or not you have threads, and making refcounts thread-safe with traditional techniques always makes that cost visibly higher. As long as that was true, the challenge couldn’t be met.
2008: multiprocessing
Python 2.6 (2008) added the multiprocessing module. It exposes a
threading-like API (Process, Queue, Pool) but spawns OS processes
instead of threads. Each process has its own interpreter, its own memory
space, and its own GIL, so CPU-bound work scales linearly across cores with
no lock contention.
The GIL is still there, and the API cost is real:
- Process startup is expensive.
forkis cheap on Linux, butspawn(the default on Windows and macOS for recent versions) serializes and re-imports everything, which can take hundreds of milliseconds per worker. - Sharing goes through pickling. Objects passed between processes are
serialized, which is slow and rejects many types.
multiprocessing.shared_memory(3.8) andManagerproxies help, at the cost of extra code. - Debugging, logging, and exception handling all cross process boundaries, which complicates the tooling story.
multiprocessing works well for coarse-grained parallelism: map a function
over a large input, run a pool of long-lived workers. It is a poor fit for
fine-grained sharing, which is exactly the case threads handle well on
other runtimes. Closing that gap is what per-interpreter GIL and
free-threading are for.
2014: asyncio and async/await
The original 1992 motivation for threads was I/O concurrency. Python 3.4
(2014) introduced the asyncio module, and Python 3.5 (2015) added the
async and await keywords. Together they provide an event-loop-based
alternative to threads for the exact workload threads were added to handle:
programs that spend most of their time waiting on I/O.
What asyncio solves
An async def function is a coroutine. A single thread runs the event loop
and drives thousands of these coroutines. When one suspends on await, the
loop picks up another coroutine that is ready to run. No OS threads are
created; no refcount races are possible; no GIL is ever contended, because
there is only ever one thread running Python code.
For the original 1992 use cases this is often a better fit than threads:
- Network servers. A single-threaded event loop handles tens of thousands of concurrent connections at a fraction of the memory cost of one OS thread per connection.
- Clients making many parallel requests.
asyncio.gatherruns hundreds of HTTP calls concurrently with no synchronization code. - Responsive applications. Long-running work expressed as coroutines
yields to the loop at each
await, keeping the program responsive.
Because all coroutines run on one thread, none of the problems this project
demonstrates exist in asyncio code. There is no shared-state race, because
there is no concurrent execution of Python code: context switches happen
only at explicit await points, which makes the interleavings visible in
the source.
What asyncio does not solve
- CPU-bound work. A coroutine that computes without awaiting starves
every other coroutine on the loop. A long regex, a large JSON parse, a
numeric loop without a release point: all of these freeze the loop. CPU
parallelism still requires threads (on the free-threaded build) or
processes (
multiprocessing,ProcessPoolExecutor). - Blocking libraries.
asyncioonly helps if every I/O call goes through an async-aware API. A singlerequests.get(),psycopg2query, ortime.sleep()blocks the loop and kills concurrency for every other task. The standard workaround isloop.run_in_executor(), which runs the blocking call on a background thread pool. That puts threads back in the picture. - Function coloring.
asyncfunctions can only be awaited from otherasyncfunctions. Introducing async into an existing synchronous codebase is not a local refactor; it propagates up every call site. Large conversions are effectively rewrites. - C extension behavior. Async only controls how Python-level coroutines are scheduled; it doesn’t change how C extensions run. A C extension that blocks on a syscall without releasing the GIL still blocks the event loop.
How asyncio affects the GIL story
asyncio retroactively weakens the 1992 argument for threads. If
async/await had existed in 1992, Guido might have chosen a single-threaded
event-loop model (roughly what JavaScript later did) and skipped decision
(3) entirely. No threads, no refcount races, no GIL.
But asyncio arrived 22 years after threads did. By 2014:
- Threading was established in the language and in production code.
- The CPython C extension ecosystem had been built on the assumption that threads exist and the GIL serializes them.
- Users who needed CPU parallelism (numeric computing, machine learning) still needed something threads or processes could provide and coroutines could not.
So asyncio does not remove the need for threads; it removes the need for
some common uses of threads. The case of “many concurrent I/O operations
in one process” is now often better served by coroutines, and modern
Python code increasingly uses asyncio for that workload. Threads remain
necessary for:
- CPU-bound parallelism on the free-threaded build.
- Integrating with blocking libraries that have no async equivalent.
- GUI frameworks with their own event loops that need worker threads for background work.
- Embedding scenarios where a host C program calls into Python from multiple threads.
The four-decision chain still holds. asyncio provides an alternative
concurrency model for one workload, not a replacement for threads in
general, so the GIL (or the PEP 703 machinery that replaces it) is still
required.
2023: Per-Interpreter GIL
CPython has always supported multiple interpreters inside one process
through the Py_NewInterpreter C API. Until recently they all shared one
GIL and most of the runtime’s global state, so they offered no parallelism
benefit.
PEP 684, accepted in 2022 and shipped in Python 3.12 (October 2023), gave
each subinterpreter its own GIL by moving runtime state off globals and
onto per-interpreter structures. PEP 734, shipped in Python 3.14, adds the
stdlib concurrent.interpreters module so Python code (not just C code)
can create and drive them.
The model: one OS process, multiple interpreters, each with its own GIL and its own set of imported modules. Interpreters communicate through explicit channels rather than shared objects, closer to Erlang processes than to traditional threading.
The shared address space is an implementation detail, not a programming
model. Goroutines share memory directly: any goroutine can touch any
variable another sees. Subinterpreters don’t. Each one has its own
sys.modules and its own object world, and Python code cannot reach
across and mutate another interpreter’s state. The shared address space
exists so channels can hand off buffers without copying, not so code can
share objects.
Compared to multiprocessing:
- Cheaper startup, with no new process and no re-import.
- Lower inter-process communication (IPC) overhead. Channels can pass a limited set of types without pickling.
- Same address space, leaving room for zero-copy sharing of immutable data.
Compared to free-threading (PEP 703):
- Existing extensions keep working, as long as they are interpreter-aware. This is a much weaker requirement than full thread safety.
- No shared mutable state. That is a safety property, not a limitation to overcome.
Subinterpreters and free-threading target different workloads. Free-threading is for code that wants the shared-memory thread model running on multiple cores. Subinterpreters are for code that wants isolation and message passing on multiple cores without the cost of separate processes.
2023: PEP 703
Sam Gross’s PEP 703, accepted in October 2023, is the first approach that actually met Guido’s challenge. It did so by attacking the refcount cost directly, using techniques that didn’t exist (or weren’t mature enough) in the 1990s:
- Biased reference counting. Most objects are only touched by one thread throughout their lifetime. Those refcount operations don’t need atomics at all; they use plain integer ops, with a fallback to atomics only when an object becomes shared.
- Immortal objects.
None,True,False, small integers, interned strings get a sentinel refcount that is never modified. No atomics, no locks, ever. - Deferred reference counting. Certain objects (top-level functions, modules) defer their refcount updates to safe points, avoiding contention on frequently-referenced objects.
- Per-object locks for mutable containers. Dicts and lists get their own locks, acquired only when needed. This is acceptable because they’re not the hot path for refcounts.
- A thorough audit of interpreter state. Thousands of places that implicitly assumed “I’m the only thread here” had to be found and fixed.
This is the free-threaded build (3.13t, 3.14t) that meets the single-threaded performance bar.
It is still opt-in. It also changes the contract that extension authors have
relied on for three decades (see 08-TheBrokenContract.md).
Where Free-Threading (FT) Helps
FT wins when threads spend most of their time on work they don’t share,
and any contention is bounded. The ideal is embarrassingly parallel
work: completely independent threads, no shared state. embarrassingly_parallel.py
is the simplest case: each thread does its own CPU-bound loop and never
touches the others’ data.
Real problems often look shared at first. The patterns below either restructure a shared problem into the embarrassingly-parallel form, or limit the remaining sharing enough that FT still wins:
- Sharded accumulators. Restructure a shared-counter problem into
embarrassingly-parallel form: each thread accumulates locally, then
partial results are merged once. Word counting with per-thread
Counters is the canonical example: contention is one merge per thread, not one per word.counter_sharded.pyiscounter_race.py’s problem restructured this way. - Coarse-grained locking. When sharing can’t be eliminated, hold the lock for a chunk of work instead of an item. If each acquisition covers 1000 ops, lock overhead is negligible.
- Read-mostly shared state. Caches, configuration, lookup tables. Concurrent dict reads are mostly lock-free in FT; the cost only appears on writes.
- Pipeline parallelism (CSP-style). Stages connected by queues. See
counter_csp.py: the workers and counter run in parallel, with the queue as the only shared point. If each worker did real CPU work before sending, the speedup would be real.
The general rule from PEP 703: minimize shared mutable state, and hold locks for as little time as possible. The lock cost matters in proportion to:
$$\frac{\text{acquisitions} \times \text{cost per acquisition}}{\text{useful work between acquisitions}}$$
the_camels_nose.py is the worst case because the denominator is
essentially zero: every iteration acquires the lock, and there is no work
outside it. A version that did even 100µs of independent work between
increments would already show FT speedup.
Summary
The GIL is what you get when you choose refcounting, expose it through a direct extension API, add threads for I/O, and then need to make refcounts thread-safe without breaking anything. Every alternative at that point was worse: atomics alone weren’t enough, fine-grained locks were 2× slower, tracing GC would break the ecosystem, and removing threads would break existing users.
For thirty years that trade-off favored single-threaded performance and ecosystem compatibility over multi-core scaling. PEP 703 is the first approach that preserves both while removing the GIL, and it only works because biased refcounting and immortal objects finally made refcount arithmetic cheap enough to be thread-safe by default.
The GIL and Context Switching
A Python interpreter executes a stream of opcodes: small instructions
that the bytecode compiler produces from your source. a + b becomes
several opcodes (load a, load b, perform the addition, store the
result), and the interpreter runs them one at a time. The GIL controls
how the opcode streams from different threads interleave.
How many opcodes does Python have?
The count depends on how you measure and which Python version you’re running.
Base opcodes (the ones you see in dis output) number roughly 100–130
in Python 3.13/3.14. You can check exactly:
# examples/opmap_contents.py
import opcode
print(f"opmap entries: {len(opcode.opmap)}")
print(f"opname entries: {len(opcode.opname)}")
for num, name in enumerate(opcode.opname):
print(f" {num:3d} {name}")
opmap holds the “public” opcodes visible in dis output; opname
is larger and also includes <N> reserved slots, INSTRUMENTED_*
debugger opcodes, and pseudo-instructions used during compilation.
On top of that, Python 3.11+ added specialized/adaptive opcodes: internal
variants like LOAD_FAST_CHECK and BINARY_OP_ADD_INT that the interpreter
substitutes at runtime for frequently-executed code paths. These add another
~50–60, bringing the total to roughly 180–220 entries in the opcode table.
Why this matters for the GIL demo
The race condition in counter += 1 comes from the fact that it is not
atomic. An operation is atomic if no other thread can observe it
half-done: it has either not started or has finished, never an
in-between state. You can see the opcodes counter += 1 compiles to
with dis:
# examples/dis_increment.py
import dis
def increment():
counter += 1 # pyright: ignore[reportUnboundVariable]
dis.dis(increment)
Output:
4 RESUME 0
5 LOAD_FAST_CHECK 0 (counter)
LOAD_SMALL_INT 1
BINARY_OP 13 (+=)
STORE_FAST 0 (counter)
The columns are: source line number, byte offset, opcode name, numeric argument, and a human-readable annotation of the argument in parentheses.
That’s three separate opcodes doing the work:
| Opcode | What it does |
|---|---|
LOAD_FAST | Push counter’s value onto the stack |
BINARY_OP | Compute counter + 1 |
STORE_FAST | Write the result back to counter |
The GIL can release between any of these. If two threads both execute LOAD
before either executes STORE, they both see the same starting value and one
increment is silently lost.
The old model: 100 opcodes (Python 1.0 – 3.1)
For most of Python’s history, the GIL released every 100 opcodes,
controlled by sys.getcheckinterval(). This was a round number chosen for
simplicity, not calibrated to any particular latency target.
On 1990s hardware (millions of simple operations per second), 100 opcodes may have accidentally approximated a few milliseconds. But as hardware got faster, 100 opcodes shrank to microseconds, and by the time Python 3.2 shipped in 2011, threads were fighting over the GIL far more often than intended. The coordination overhead from constant acquire/release cycles hurt performance even on single-threaded programs, since the check fired regardless of how many threads were running.
The current model: 5ms (Python 3.2+)
Python 3.2 replaced the opcode counter with a time-based mechanism,
defaulting to 5ms (sys.getswitchinterval()). A background watchdog thread sets
an eval_breaker flag every 5ms; the running thread checks that flag and yields
the GIL when it fires.
In a tight arithmetic loop, roughly 50,000–200,000 opcodes might execute in that 5ms window, wildly more than 100, which illustrates how broken the old model had become on modern hardware.
When exactly does the GIL release?
The 5ms timer doesn’t release the GIL directly. It sets the eval_breaker flag,
and the running thread releases the GIL the next time it checks that flag. Where
those checks happen has changed:
- Python 3.2–3.10:
eval_breakerwas checked at the top of every opcode dispatch loop iteration, so the GIL released after at most one more opcode. - Python 3.11+: As part of the specializing adaptive interpreter, the check
was moved to backward jumps and function calls only, a performance
optimization that avoids the overhead of checking on every single opcode.
A backward jump (
JUMP_BACKWARD) is the opcode that closes a loop. It fires once per iteration of anyfororwhileloop, when control returns to the top. Straight-line code (if/else, sequential statements) only jumps forward and never triggers a check.
The practical implication: in Python 3.11+, a straight-line sequence of
opcodes with no loop back-edge or function call will not be interrupted by the
timer. The LOAD / BINARY_OP / STORE sequence for counter += 1 contains none
of those check points, which is a significant reason why naïve race-condition
demos almost never fail with the GIL active.
Note that in a tight loop with a short body, JUMP_BACKWARD fires on every
iteration, but the GIL only actually releases when the 5ms timer has also
elapsed. The check point and the timer work together: the check point is where
the GIL can release, and the timer controls when.
What “releasing the GIL” actually means
“The GIL is released” is shorthand for the running thread relinquishing the lock so another waiting thread can acquire it and start running Python bytecode. The release is the mechanism that makes a thread switch possible; whether a switch actually happens depends on whether other Python threads are waiting and what the OS scheduler decides.
Two layers of switching coincide here but aren’t the same thing:
- GIL handoff at the interpreter level: which thread is allowed to execute Python bytecode right now.
- OS context switch at the kernel level: which thread the CPU is actually running.
A GIL release lets a different Python thread take over the interpreter, and the OS typically performs a context switch to actually put that thread on a CPU. Since Python 3.2, the GIL implementation deliberately waits for another thread to grab the lock after release, rather than letting the releaser re-take it immediately. This was added to prevent starvation on multicore machines, where the releasing thread would often win the re-acquisition race against threads waking up on other cores.
In short, “the GIL is released” means that another Python thread now has the opportunity to run, and if one is waiting, it will. Under the free-threaded build the whole mechanism is gone. Threads execute Python bytecode in parallel without any handoff, and only the OS-level context switching remains.
Cooperative vs. preemptive switching
The most familiar form of context switching is cooperative: a lock is acquired on entry to a critical section and released on exit. The programmer controls exactly where switches can occur. The downside is that a thread that never yields can starve everything else.
Preemptive switching (whether by instruction count or by time) hands that
decision to the scheduler. No thread can starve others, but switches can happen
anywhere, including places the programmer never considered. That is precisely
the source of the race in counter += 1: no one requested a switch between
LOAD and STORE, but the scheduler has no knowledge of that boundary.
The GIL is a hybrid
The GIL sits between these two models:
- Preemptive at the scheduling level: the 5ms timer fires regardless of what the code is doing.
- Cooperative at the opcode level: the running thread only actually yields at the next check point (backward jump or function call in 3.11+).
What also makes the GIL unusual is that it is a single global lock covering the entire interpreter, not a fine-grained lock around specific data. “Entering a critical section” in CPython effectively means “holding the GIL,” which every thread already does whenever it runs Python code. Preemptive scheduling then becomes: which thread next holds the single lock.
Making the race visible: forcing a context switch
Because the 5ms timer almost never fires in the ~3 opcodes of counter += 1,
demos based on counter += 1 in a tight loop rarely fail with the GIL active.
The standard fix is to split the operation manually and force a GIL release in
the middle using time.sleep(0):
# from examples/context_switch.py
import time
import constants as c
from utils import report, run_threads
counter: int = 0
def increment(iterations: int) -> None:
global counter
for _ in range(iterations):
temp = counter # LOAD
time.sleep(0) # force context switch
counter = temp + 1 # STORE (may overwrite another thread's write)
if __name__ == "__main__":
iters = 50
run_threads(increment, (iters,))
report("threaded", counter, c.NUM_THREADS * iters)
time.sleep() is a blocking call, and all blocking calls release the GIL. This
guarantees a context switch occurs between every LOAD and STORE, making
lost increments a certainty rather than a rare event, even with the GIL active.
With 8 threads and 50 iterations each, the expected result is 400. A typical run produces something in the 40–100 range.
Why this still matters with the free-threaded build
In Python’s free-threaded build (3.14t, no GIL), context_switch.py fails
without the sleep(0) for a more fundamental reason: there is no longer any
implicit mutual exclusion to accidentally rely on. The sleep(0) demo is useful
precisely because it shows the race with the GIL active, making the point that
the GIL does not protect you from race conditions; it only makes them unlikely
by serializing opcode execution.
Summary
| Topic | Key point |
|---|---|
| Opcode count | ~100–130 named; ~180–220 including adaptive variants |
| Old switch interval | Every 100 opcodes (Python 1.0–3.1) |
| Current switch interval | Every 5ms via eval_breaker flag (Python 3.2+) |
| Check point location | Every opcode (3.2–3.10); backward jumps + calls only (3.11+) |
| GIL model | Preemptive scheduling, cooperative yield points |
| Forcing a race | time.sleep(0) releases the GIL between LOAD and STORE |
Reference Counts and External Modules
How CPython Manages Memory
Every Python object carries a reference count: an integer field (ob_refcnt) that tracks how many things point to it. When the count reaches zero, the object is freed immediately (no garbage-collection pause, no tracing phase).
object created ob_refcnt = 1
assigned to x ob_refcnt = 2
x goes out of scope ob_refcnt = 1
last reference gone ob_refcnt = 0 → freed
Incrementing and decrementing ob_refcnt is not a single instruction. It is a read-modify-write sequence:
old = obj.ob_refcnt # LOAD
obj.ob_refcnt = old - 1 # STORE
if obj.ob_refcnt == 0:
free(obj)
A thread switch between LOAD and STORE corrupts the count. This is exactly what refcount_race.py demonstrates.
Cycles and the gc Module
Reference counting alone cannot reclaim cyclic garbage. Two objects that point at each other (a parent and child node, any doubly-linked structure, or any cycle of references) keep each other’s refcount above zero forever, even when no outside reference exists. Pure refcounted code leaks cycles.
CPython did not address this until Python 2.0 (October 2000), which shipped a cycle-detecting collector as the gc module. Neil Schemenauer led the implementation. The algorithm is a standard technique for hybrid refcount-plus-tracing systems, and its shape has not changed materially in 25 years.
Tracked vs. untracked objects
Only some objects participate in cycle collection. Immutable objects that cannot reference other tracked objects (int, float, str, bytes) are skipped entirely. The tracked set is containers and instances: list, dict, set, tuple (when it holds at least one tracked element), user-class instances, frames, generators. Each tracked object is linked into a per-generation doubly-linked list inside the runtime.
This selectivity matters: most allocated objects are immutable scalars, and walking them during collection would be wasted work.
The three generations
Tracked objects start in generation 0. Survive a collection, get promoted to generation 1; survive again, generation 2. Generation 0 is collected often; generation 1 less often; generation 2 rarely. Newly created objects are the most likely to be garbage (the generational hypothesis), so this concentrates work where it matters.
Defaults are visible and tunable through gc.get_threshold() / gc.set_threshold(). The gen 0 threshold is the number of allocations minus deallocations that triggers a collection; the gen 1 and gen 2 thresholds count gen 0 (and gen 1) collections, respectively.
When and how the collector runs
The collector has no dedicated thread. It runs synchronously, in three situations:
Automatically, during container allocation. Every time a tracked object is allocated (through PyObject_GC_New in the C API, or implicitly when Python code creates a list, dict, set, instance, etc.), CPython increments a per-generation counter. Every tracked deallocation decrements it. After the allocation completes, the runtime checks the counter: if counter > threshold[0] (default 700), it runs a gen 0 collection right then, on the same thread that did the allocation, before returning the new object to the caller.
After each gen 0 collection, a separate counter is bumped. If it crosses threshold[1] (default 10), gen 1 is collected too. Same for gen 1 → gen 2 via threshold[2] (default 10). A full gen 2 sweep therefore happens roughly every 700 × 10 × 10 ≈ 70 000 net container allocations.
The check is post-allocation, not pre-, so a single large allocation never gets scheduled specially. The trigger is the steady drumbeat of container creates.
Manually, via gc.collect(). Forces a full collection of all generations immediately. Returns the number of unreachable objects found. Useful after dropping a large structure known to contain cycles, for benchmarking, or as a hint before shutdown. You can also call gc.disable() to suppress automatic triggering and drive collection yourself, which long-running services sometimes do to control pause timing.
At interpreter shutdown. Final cleanup runs collections to free as many objects as possible and surface lingering finalizers. Not perfect: some C state lives outside Python’s tracking and may leak across shutdown.
A few details worth knowing:
- The pause is paid by whichever thread happens to do the allocation that crosses the threshold. There is no separate GC thread to amortize this.
- Function calls, imports, attribute lookups, and bytecode dispatch do not trigger the collector directly. Only allocations of tracked objects do.
- C extensions that allocate tracked objects must use
PyObject_GC_Newand callPyObject_GC_Trackso the new object joins the generation list. Forgetting this is a silent leak of any cycle the object participates in. - In the free-threaded build, counter updates are atomic and the collection itself is stop-the-world: the triggering thread asks every other Python thread to pause at the next safe point before the walk begins. See chapter 9.
The cycle-detection algorithm
For a generation being collected:
- Take a snapshot of each tracked object’s
ob_refcnt. Call this its GC refcount. - Walk every tracked-to-tracked reference within the generation. For each such reference, decrement the target’s GC refcount.
- After the walk, any object whose GC refcount is still > 0 has at least one reference from outside the generation (the Python stack, module globals, an older generation, a C extension). It is reachable.
- Propagate reachability transitively: anything reachable from a still-positive-count object is also reachable.
- The remainder is unreachable cyclic garbage. Run finalizers (
__del__), then free it.
The GC refcount is a scratch field; the real ob_refcnt is untouched. The collector never moves objects; pointers stay valid throughout. The cost is proportional to the generation’s size, not to the heap.
Why this complements rather than replaces refcounting
The deterministic refcount path still does the bulk of the work and frees objects immediately when their count hits zero. Files close when the last reference drops, sockets release, with blocks behave predictably. The cycle collector handles only the edge case refcounting cannot, on a schedule, and only over the tracked subset.
This split is why CPython has the destruction guarantees scripting users expect and still reclaims arbitrary object graphs. Pure tracing GCs (Java, C#, JVM-based Jython) cannot promise the first; pure refcounting cannot deliver the second.
Free-threading does not introduce a new collector. It changes how this existing one runs: collections become stop-the-world pauses at safe points, so the algorithm sees a consistent object graph without the GIL. That story is in chapter 9.
What the GIL Provides
The GIL serializes all Python bytecode execution. Only one thread runs Python at a time, so no two threads can interleave their LOAD/STORE sequences on the same object. Reference counts are always consistent.
This guarantee is invisible to Python programmers; it is simply assumed. It is also what makes writing C extensions straightforward: you can manipulate ob_refcnt with plain integer arithmetic and nothing goes wrong.
Releasing the GIL in Extensions
External modules written in C, Rust, or any other language can release the GIL while doing CPU-bound or I/O-bound work. This is desirable: it lets other Python threads run in parallel during, for example, a long numpy computation or a disk read.
The standard pattern in a C extension:
Py_BEGIN_ALLOW_THREADS
// GIL is released here: do not touch Python objects
do_expensive_work();
Py_END_ALLOW_THREADS
// GIL reacquired: safe to touch Python objects again
The contract is strict: between Py_BEGIN_ALLOW_THREADS and Py_END_ALLOW_THREADS, the extension must not read or write any Python object, including its own arguments. Any touch of a Python object without holding the GIL is a data race.
The Rust Case: PyO3
PyO3 is the standard crate for writing Python extensions in Rust. It encodes the GIL contract in the type system using a lifetime token:
#![allow(unused)]
fn main() {
#[pyfunction]
fn process(py: Python<'_>, data: &PyList) -> PyResult<()> {
// py token proves we hold the GIL
// data is a Python object: safe to use here
py.allow_threads(|| {
// GIL released inside this closure
// data is NOT accessible here; borrow checker enforces this
do_expensive_work();
});
// GIL reacquired automatically when closure returns
Ok(())
}
}
The Python<'py> token is not constructible by user code; PyO3 hands it to you only when you genuinely hold the GIL. Rust’s borrow checker then prevents you from using any &PyAny (or similar) reference inside allow_threads, because those references require the token’s lifetime. The memory-safety guarantee is enforced at compile time.
Free-Threading Changes the Equation
With the GIL removed (Python 3.13+t), the serialization guarantee is gone. Multiple threads can now run Python simultaneously, which means:
ob_refcntincrements and decrements must be atomic operations, not plain integer reads and writes.- CPython’s free-threaded build replaces
ob_refcntwith an atomic integer and uses CPU-level atomic instructions for everyPy_INCREF/Py_DECREF.
Extensions that release the GIL and then reacquire it are largely unaffected; they already respected the contract. Extensions that assumed the GIL was always held, or that did clever things with refcounts outside the normal macros, now have data races.
PyO3 tracks free-threading support explicitly. A Rust extension that declares:
[package.metadata.maturin]
requires-python = ">=3.13"
must also audit every allow_threads boundary and ensure no Python objects leak across it (the same rule as before), but now the consequences of getting it wrong are immediate and observable rather than occasional and mysterious.
What Developers Must Do
| Scenario | GIL build | Free-threaded build |
|---|---|---|
| Pure Python extension logic | Safe by default | May need locks for shared state |
C extension, respects Py_BEGIN/END_ALLOW_THREADS | Safe | Safe |
| C extension, touches objects without holding GIL | Crashes rarely (lucky) | Crashes reliably |
PyO3 extension, uses allow_threads correctly | Safe | Safe |
PyO3 extension, leaks Py<T> across thread boundary | Compile error | Compile error |
| Hand-rolled refcount manipulation | Unsafe | Definitely unsafe |
The core lesson: the GIL did not make extensions safe by magic. It made certain races unlikely by serializing execution. Free-threading reveals the races that were always latent.
What Gets Refcounted in an Extension
Any PyObject* the extension touches. The entire Python object model in C
is PyObject*, and every Python value (ints, strings, lists, dicts,
user-defined instances, function objects, modules, types, everything) has
ob_refcnt as the first field of its C struct, exposed via the
PyObject_HEAD macro.
Concretely, the refcount manipulation happens on:
- Arguments coming in. A C function receives its args as
PyObject*. Whether it needs toPy_INCREFthem depends on “borrowed vs. owned” semantics it has to track. - Return values going out.
PyLong_FromLong(42)returns a new reference (refcount 1). The caller owns it; whoever eventually receives it mustPy_DECREFwhen done. - Items fetched from containers.
PyDict_GetItemreturns a borrowed reference; if the extension wants to hold onto it past the dict’s lifetime, it mustPy_INCREF.PyList_GetItemis the same.PyList_SetItemsteals a reference to the value being inserted, so the caller must notPy_DECREFafter. - Cached or stored objects. Anything the extension stashes in a C
static variable, a struct field, or its module state needs a
Py_INCREFto keep it alive, and a matchingPy_DECREFat teardown. - Intermediate objects. Temporaries created during the function body (e.g., a list being built up to return) need their refcounts balanced before exit.
Py_INCREF is a C macro, not a function. It expands inline to
((PyObject*)(op))->ob_refcnt++. Every compiled extension has
ob_refcnt++ written directly into its machine code against the current
struct layout. That’s why this is an ABI issue rather than just an API
issue: CPython can’t change how refcounting works (atomicize it, add a
bias field, make it deferred) without every already-compiled .so or
.pyd on users’ machines executing the wrong machine instruction against
the new layout.
There is no stack allocation for Python objects. Every PyObject lives on
the heap, and refcounting is the only mechanism that ever frees it. If an
extension creates a temporary and doesn’t Py_DECREF it before returning,
it leaks, even if no Python code or C code outside that function ever saw
it.
A concrete C example:
static PyObject* add_them(PyObject *self, PyObject *args) {
PyObject *x = PyLong_FromLong(10); // new reference, refcount = 1
PyObject *y = PyLong_FromLong(20); // new reference, refcount = 1
PyObject *sum = PyNumber_Add(x, y); // new reference, refcount = 1
Py_DECREF(x); // refcount -> 0, freed immediately
Py_DECREF(y); // refcount -> 0, freed immediately
return sum; // ownership transferred to caller
}
x and y never leave the function. They still need explicit Py_DECREF
calls, or they leak. The Python integers 10 and 20 are heap objects
so they are not managed via a “local variable” stack lifetime.
Note:
- Borrowed vs. new references. Objects you receive (function
arguments, results of
PyDict_GetItem,PyList_GetItem) are usually borrowed: you do notPy_DECREFthem. Objects you create (anything withFrom,New, orPy_BuildValuein its name) are new references: you mustPy_DECREFeventually, or transfer ownership. This distinction isn’t visible in the C type system; it’s documented per-function and the extension author has to track it mentally. - Immortal objects in 3.12+ (PEP 683).
None,True,False, small integers, and interned strings now carry a sentinel refcount that never changes.Py_INCREF(Py_None)is a no-op at runtime. But the extension author still writes the macro in source, and still reasons as if it were a normal refcount, because the macro is the contract and the optimization is invisible below it.
The net effect: the C extension author is essentially hand-rolling garbage
collection, one Py_INCREF/Py_DECREF pair at a time, for every
PyObject* that passes through their code. This is the cost of exposing
reference counting directly in the C API, and it’s what makes the
ecosystem so sensitive to any change in how refcounts work.
The Broken Contract
For thirty years, writing a CPython C extension meant writing against a set of assumptions that were never formally labeled as a contract. They were just how things worked. Most of them follow from “the GIL is held whenever my code runs.” Free-threading invalidates them one by one.
What Extension Authors Used to Assume
- My function is called with the GIL held. No other Python thread is executing bytecode or C extension code simultaneously. I don’t need to think about interleavings until I explicitly release the GIL.
- Refcount manipulation is just integer arithmetic.
Py_INCREF(obj)expands toobj->ob_refcnt++. It compiles to a load, an add, and a store. No lock prefix, no memory barrier. - Direct reads of
ob->ob_refcntare coherent. I can log it, branch on it, use it to decide whether to cache something. - Module-level C statics and globals need no locks. My extension’s internal state (caches, counters, lazy-initialized tables) is implicitly serialized because Python code that reaches my module is serialized.
- Module initialization runs exactly once, on one thread. I can populate
lookup tables, register types, and open handles in
PyInit_mymod()without synchronization. - Borrowed references stay valid.
PyDict_GetItemreturns a borrowed reference. As long as I don’t release the GIL or call back into Python, nothing can free the object underneath me. - Iterating a container is safe if I don’t mutate it. No other thread can resize the dict or list I’m walking, because no other thread is running.
- Type slots, method tables, and class hierarchies are read-mostly and
stable. I can cache a pointer to a type’s
tp_getattroslot and reuse it. - Memory ordering is not my problem. The GIL acquire/release pair acts as a full memory barrier. Writes one thread performs before releasing the GIL are visible to the next thread that acquires it.
What Free-Threading Forces
- Concurrent entry is real. Two Python threads can call into my extension at the same instant. Anything I touch that is shared must be protected.
- Refcount macros now expand to atomics.
Py_INCREF/Py_DECREFstill work, but they’re no longer cheap integer ops; they’relock xadd(or equivalent) under the hood. Extensions that bypassed the macros with directobj->ob_refcnt++are broken: the write is not atomic and the value is no longer stored in a plainPy_ssize_t. - Module state needs explicit locking. That static cache, that lazy initializer, that “I’ll just remember the last value” optimization: all of them need a mutex, or a redesign to avoid sharing.
- Borrowed references are dangerous. Another thread can delete the dict
entry and free the object between
PyDict_GetItemreturning and my code using the result. Several APIs have gained strong-reference variants (PyDict_GetItemRef, etc.) for this reason. - Iterating a container while another thread mutates it can fail. The built-in containers have internal locks that keep the interpreter from crashing, but the logical race (reading a dict that’s being written) is now a real concern, not a theoretical one.
- Type mutation is no longer a quiet operation. Another thread can assign
to
SomeClass.methodwhile my code is doing attribute lookup on an instance. The interpreter handles this correctly, but any pointer I cached into a type’s slot table is no longer safe. - Memory ordering can matter. Without the GIL providing implicit barriers, writes to shared structures need explicit atomics or locks to be visible in a defined order across threads.
The Opt-In Mechanism
PEP 703 understood that breaking every extension silently would be disastrous. So the free-threaded build ships a negotiation mechanism:
- A module declares itself free-thread-safe by setting
Py_MOD_GIL_NOT_USEDin its module definition (C) or equivalent flag (PyO3, Cython). - When the interpreter loads a module that does not declare itself safe, it re-enables the GIL at runtime. A single unaudited extension drags the whole process back into GIL-held mode.
- Users can override this with
PYTHON_GIL=0, accepting the risk.
This is an explicit acknowledgment that the contract has changed, that most existing extensions have not been audited, and that correctness is preserved by falling back to the old behavior rather than by trusting extensions to behave.
What the Audit Actually Looks Like
For an extension author, “free-threading support” is not a flag to flip. It is:
- Find every static/global variable. Decide whether it’s read-only (fine), thread-local (fine), or shared mutable (needs a lock).
- Find every borrowed reference. Decide whether concurrent mutation is possible. If so, switch to a strong-reference API or hold a critical section.
- Find every cached pointer into a Python object’s internals. Verify the invariants that made the cache safe still hold.
- Find every direct refcount manipulation. Replace with the macros, or with the atomic-aware API.
- Find every place you assumed “I’m the only thread here.” This is the hardest step, because the assumption is usually implicit.
- Add tests that actually run the extension from multiple threads. The GIL build cannot detect races that free-threading exposes.
NumPy, for example, took two years and multiple releases to reach provisional free-threading support. It is one of the best-resourced extensions in the ecosystem. Smaller projects will take longer, and many will never be audited at all.
This is what “changes the contract” means in practice: not a subtle reinterpretation of semantics, but a decades-long backlog of hidden assumptions that every extension author now has to find and either justify or fix.
Inside Free-Threaded Python
This document explains, in implementation terms, what changed inside CPython
to enable the free-threaded build (3.13t, 3.14t, etc.). It assumes you
have read 05-HistoryOfTheGIL.md and 07-RefcountsAndExtensions.md, which
cover why the GIL existed and how the C extension API depends on
reference counts.
The questions answered here:
- How are reference counts kept consistent without a global lock?
- Is there a cycle-detecting garbage collector now? (Yes, and there always was.)
- How can existing C extensions, which assume the GIL, keep working unmodified?
- What did the interpreter itself have to change?
The Problem Restated
The GIL existed to serialize three things at once:
- Reference count updates on every Python object.
- Mutations to the interpreter’s own data structures (the import system, type slots, the bytecode dispatcher’s bookkeeping).
- Mutations to built-in mutable containers (dict, list, set).
Removing the GIL means each of these needs its own thread-safety story. None can fall back on “the GIL will sort it out.” And the cost of the new mechanisms must be small enough that single-threaded programs do not regress.
PEP 703 attacks each of the three independently. The result is not “the GIL with finer granularity.” It is a coordinated set of techniques, each chosen to keep the common case (one thread, no contention) close to free.
Reference Counting: Four Mechanisms
The hot path in CPython is Py_INCREF and Py_DECREF. They run millions
of times per second. Replacing them with naive atomic adds would cost
roughly 30 percent on single-threaded code, well past the bar Guido set
in 1996. PEP 703 avoids that by recognizing that most objects do not
actually need atomic refcounting. Four mechanisms cooperate to make this
work.
1. Immortal Objects
Some objects live forever: None, True, False, the small integers
in [-5, 256], interned strings, type objects for built-in types,
common exception classes. Their refcounts have no meaningful upper
bound, and they are never freed.
Free-threaded CPython gives these objects a sentinel refcount value (a
specific high bit pattern). Py_INCREF and Py_DECREF check for the
sentinel and return immediately. No atomic operation, no cache line
write, no contention. Across threads, an immortal object is effectively
read-only.
This was actually shipped in 3.12 as PEP 683, predating free-threading.
It pays off most under free-threading, where every avoided atomic on
None is real performance.
2. Biased Reference Counting
Most objects, even in multi-threaded programs, are touched by exactly one thread for their entire lifetime: a temporary list inside a function, an intermediate string, a small dict used to format an error message. For these, atomic refcount updates are pure overhead.
Biased refcounting (Choi et al., 2018) splits each object’s refcount into two fields:
- A local refcount owned by the thread that created the object. Updated with plain non-atomic instructions, fast.
- A shared refcount for all other threads. Updated atomically.
The owning thread reads and writes its local count freely. Any other thread that increments or decrements goes through the atomic shared count. The object is freed when both counts indicate no references exist, which requires a small reconciliation protocol.
The asymmetry is the point. The owning thread, which does the vast majority of refcount updates for short-lived objects, pays nothing extra. The cost of atomics is paid only when a second thread genuinely starts sharing the object.
When an object becomes shared frequently, ownership can be relinquished and both threads use the shared (atomic) path going forward. The bias exists to optimize the common case, not to lock objects to threads.
3. Deferred Reference Counting
A handful of object kinds are referenced very frequently from many threads but rarely deallocated: top-level functions, modules, classes that have been imported across the program. For these, even atomic refcount updates would create cache-line contention as multiple cores write the same memory.
Deferred refcounting marks these objects so that the interpreter skips most refcount updates on them during normal execution. The omitted increments are tracked implicitly (typically through the interpreter’s own bookkeeping, such as the value stack). At a safe point, usually a GC cycle, the deferred references are reconciled and the true refcount is computed.
The trade-off: an object with deferred refcounting cannot be freed promptly when its last reference drops, because the “true” count is not known until reconciliation. For modules and top-level functions, this is fine; they are expected to live until interpreter shutdown.
4. Atomic Operations as the Fallback
When none of the above apply (a normal heap object that has been seen
by more than one thread, with no special annotation), refcount updates
fall back to atomic CPU instructions: lock xadd on x86, LDADD on
ARMv8.1, equivalent primitives elsewhere.
This is the slowest path, but it is also the rarest. The first three mechanisms together cover the overwhelming majority of refcount operations in a typical program.
The Garbage Collector: Yes, There Is One
Reference counting alone cannot reclaim cyclic garbage. Two objects
that point at each other (a parent and child node, or any cycle of
references) keep each other’s refcount above zero forever, even when
no outside reference exists. CPython has shipped a cycle-detecting
garbage collector in the gc module since Python 2.0 (2000) for
exactly this reason. Free-threading does not introduce a garbage
collector. It changes how the existing one runs.
What the Cycle Collector Does
Periodically, the collector walks objects that opt into tracking (containers like dict, list, set, instances of user classes). It computes effective refcounts after temporarily subtracting internal references between tracked objects. Any object whose effective count reaches zero is part of an unreachable cycle and is freed.
Under the GIL, the collector ran with the lock held. No other thread could mutate the object graph during a collection, so the algorithm saw a consistent snapshot.
How It Runs Without the GIL
The free-threaded build uses stop-the-world garbage collection. When a collection starts, the runtime asks every other Python thread to pause at the next safe point. Once all threads have stopped, the collector runs as before. Then the world resumes.
This is the first time CPython has had stop-the-world pauses in its
mainline execution model. The pauses are short (cycle collections
were already infrequent and scoped to tracked objects), and they
happen at thread-safe checkpoints rather than arbitrary instructions.
Cooperative pausing is necessary because a thread holding internal
state, mid-Py_INCREF, cannot be preempted safely.
Stop-the-world is also the moment when deferred reference counts are reconciled. The interpreter walks the value stacks of all paused threads and adds up the deferred contributions to each deferred-counted object. After reconciliation, an object whose true count is zero can finally be freed.
Quiescent State Based Reclamation (QSBR)
Removing the GIL exposes a new hazard: a thread can read an object through a borrowed reference while another thread frees it. Even with correct refcounting, the gap between “I obtained this pointer” and “I incremented the refcount” is no longer protected by the global lock.
Free-threaded CPython uses QSBR to bound this hazard for certain internal data structures (notably the dict/list resize machinery). Memory is not freed immediately when its refcount drops; it is queued. The actual free happens once every thread has passed through a quiescent state, a point where it is known to hold no pointers into the queued memory. Quiescent states coincide with the same safe points the GC uses.
QSBR is invisible to Python code and to most extension code. It is the mechanism that lets borrowed-reference patterns inside the interpreter remain correct under free-threading without paying for an atomic increment on every single read.
Per-Object Locks for Mutable Containers
Dicts, lists, and sets are mutated in-place. Two threads writing to the same dict can corrupt the hash table; two threads, one writing and one resizing, can produce a use-after-free even with correct refcounting. Previously, the GIL made these operations safe by accident.
In the free-threaded build, each mutable container carries its own lightweight mutex. Operations that mutate the container acquire it; operations that only read can often avoid it through careful use of atomics and QSBR.
The locks are designed for the uncontended case. Acquiring a per-dict mutex when no other thread wants it costs roughly the same as a single atomic compare-and-swap. The cost only grows when two threads genuinely race for the same container.
Critically, these locks are per object, not interpreter-wide. Two threads working on two different dicts do not contend with each other. This is the unlock-the-cores property the GIL never had.
Memory Allocator: mimalloc
CPython’s old object allocator (obmalloc) used arenas with no
internal synchronization, relying on the GIL for safety. This approach cannot
work without the GIL.
The free-threaded build replaces the small-object allocator with mimalloc, a thread-aware allocator from Microsoft Research. Each thread gets its own heap segments and allocates from them without contention. Cross-thread frees (thread A frees memory thread B allocated) are handled through a small lock-free hand-off.
mimalloc also gives the GC something it needs: the ability to enumerate live objects by walking heap pages. Several free-threaded operations rely on this, including the cycle collector and certain debugging tools.
The standard malloc (or whatever the platform provides) is still
used for large allocations. mimalloc is the small-object fast path.
Interpreter State Cleanup
PEP 684 (per-interpreter GIL, shipped in 3.12) had already done much of the work of moving runtime state off C globals and onto per-interpreter structs. Free-threading extends that further: state that used to be shared across threads of a single interpreter, on the assumption that the GIL would serialize access, has been audited and either:
- Made truly thread-local (one copy per thread).
- Protected by a fine-grained lock.
- Made atomic (when read frequently and written rarely).
- Made immutable after initialization (the most common outcome where possible).
Examples that needed work:
- The free lists for common object types (small ints, frames, tuples) used to be unsynchronized arena-style caches. They are now per-thread.
- The import system’s module table needed locking, and the import lock itself was rebuilt as a per-module lock to avoid serializing unrelated imports.
- The bytecode interpreter’s adaptive specialization machinery (PEP 659, “specializing adaptive interpreter”) writes to inline caches as the program runs. These writes are now atomic, with the read path tolerating a partially written cache through careful ordering.
- Type objects’ method resolution order (MRO) caches, attribute
lookup caches, and
tp_version_taguse atomic updates with version counters so a stale read is detectable and recoverable.
Most of these changes are invisible at the Python level. They are expensive in audit time (PEP 703 took years), but each individual change is small.
Accommodating Existing C Extensions
The hardest constraint on PEP 703 was not technical. It was social: hundreds of thousands of compiled C extensions exist in the wild, and none of them were written with free-threading in mind. Breaking them silently would have made the free-threaded build unusable for any real workload.
The solution has three parts.
1. The Refcount Macros Still Work
Py_INCREF and Py_DECREF are still macros (or inline functions),
and they still take a PyObject*. An extension compiled against the
free-threaded headers gets the new implementation: the macros now
expand to code that checks for immortality, then for biased ownership,
then falls back to atomics. An extension compiled against the
GIL-build headers and re-linked against python3t.dll (or
equivalent) does not magically become safe; it must be rebuilt.
The ob_refcnt field still exists on PyObject, but its layout has
changed (it now holds the local count and bias bits, with the shared
count elsewhere). Code that touched ob_refcnt directly, bypassing
the macros, is broken. This was always discouraged but was never
prevented at the API level.
2. The Py_MOD_GIL_NOT_USED Opt-In
A C extension declares itself free-thread-safe by setting a flag in its module definition:
static PyModuleDef_Slot mymodule_slots[] = {
{Py_mod_exec, mymodule_exec},
{Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
{0, NULL}
};
When the free-threaded interpreter loads a module, it checks this
flag. If the module declares Py_MOD_GIL_NOT_USED, the runtime
assumes the module’s author has audited it. If the flag is missing
(the default for any extension built before free-threading existed),
the runtime re-enables the GIL at process scope and emits a
runtime warning naming the offending module.
The re-enable is dynamic: the GIL is created and acquired on the fly, all threads start using it, and refcount paths shift back to their GIL-compatible behavior (the immortal and biased optimizations remain, but the cycle GC and per-object locks coexist with a single serialized executor).
This is the central compatibility lever. It means:
- Existing extensions keep working unchanged. Performance reverts to GIL-build behavior, but correctness is preserved.
- New extensions opt in only after audit.
- Users who know their stack is safe can override with
PYTHON_GIL=0and skip the auto-enable.
3. Strong-Reference API Extensions
Several borrowed-reference APIs have been augmented with strong-reference variants. The classic example:
// Borrowed reference: the dict still owns it, may be freed under us.
PyObject *value = PyDict_GetItem(dict, key);
// New reference: caller owns a fresh refcount; safe across thread races.
PyObject *value = NULL;
int rc = PyDict_GetItemRef(dict, key, &value);
Borrowed references were never required to be borrowed; they were an optimization. The new APIs let extension authors trade a refcount update for safety in code paths where the dict could be mutated by another thread. The borrowed-reference APIs still work but require the caller to hold a critical section.
Other-Language Extensions
The compatibility story for Rust, Cython, and other languages follows the same pattern: the runtime checks for an opt-in flag, and the binding layer is responsible for providing safe primitives.
PyO3 (Rust)
PyO3 propagates the free-threading declaration to Rust extensions through a crate-level attribute:
#![allow(unused)]
fn main() {
#[pymodule(gil_used = false)]
fn my_extension(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(process, m)?)?;
Ok(())
}
}
This sets Py_MOD_GIL_NOT_USED in the underlying module definition.
Rust’s borrow checker continues to enforce the Python<'py> token
contract described in 07-RefcountsAndExtensions.md, so the same
patterns that were safe under the GIL remain safe under free-threading,
provided the author has not relied on implicit serialization for
shared mutable state.
A PyO3 extension that uses static Rust globals or lazy_static
caches still has to add Mutex or RwLock around them; the borrow
checker does not know about Python threading.
Cython
Cython 3.1+ supports free-threading through a directive at the top
of a .pyx file:
# cython: freethreading_compatible=True
This sets the same module flag. Cython-generated C code uses the
standard refcount macros, so the underlying refcounting upgrade is
automatic. Cython does not (yet) statically check for the patterns
that break under free-threading; the developer has to audit
cdef globals, nogil blocks, and any direct C state.
Other Languages
Languages that bind to the C API through their own FFI (Julia’s
PyCall, Haskell’s cpython, Go’s go-python) inherit the
compatibility model. They must:
- Set the
Py_mod_gilslot in their generated module definition, if they want to skip the auto-enable. - Audit any state they cache outside Python.
- Use the new strong-reference APIs in any borrowed-reference pattern that crosses a thread boundary.
The runtime treats them identically to a hand-written C extension.
Summary of the Compatibility Matrix
| Extension | Built against | Loaded into 3.14t | Result |
|---|---|---|---|
| Pure Python | n/a | 3.14t | Works. Races may appear in shared state. |
| C, GIL-only headers | 3.12 or earlier | 3.14t | Loads, GIL re-enables, warning emitted. |
C, FT headers, no Py_MOD_GIL_NOT_USED | 3.13t+ | 3.14t | Loads, GIL re-enables, warning emitted. |
C, FT headers, Py_MOD_GIL_NOT_USED set | 3.13t+ | 3.14t | Runs free-threaded. Extension author asserts safety. |
| PyO3 default | recent | 3.14t | Loads, GIL re-enables. |
PyO3 with gil_used = false | recent | 3.14t | Runs free-threaded. |
| Cython, no directive | 3.1+ | 3.14t | Loads, GIL re-enables. |
Cython, freethreading_compatible=True | 3.1+ | 3.14t | Runs free-threaded. |
The pattern is uniform: opt-in, with the GIL as the safety net.
What This Costs on Single-Threaded Code
PEP 703’s headline claim is that the free-threaded build runs single-threaded code within a few percent of the GIL build. The mechanisms above are why:
- Immortal objects pay zero per-operation cost.
- Biased refcounting on owner threads is plain integer ops.
- Deferred refcounting moves work to GC time, which is rare.
- Per-object locks are uncontended in single-threaded use.
- mimalloc’s per-thread heaps avoid synchronization on alloc/free.
- The cycle collector runs with the same frequency as before, just with a new stop-the-world pause that, for a single thread, is a no-op (there is no other thread to wait for).
Measured single-threaded slowdown in 3.13t was around 5-10 percent versus 3.13. The 3.14t build narrowed that further. The original 2× cost of Greg Stein’s 1996 patch is gone, primarily because biased refcounting and immortal objects together remove almost all of the per-operation atomic cost.
What Free-Threading Does Not Provide
Worth stating explicitly:
- It does not eliminate races in Python code. A Python program
with two threads incrementing a shared counter without a lock will
lose updates. The interpreter is thread-safe; arbitrary Python
code is not. The examples in this repository (
counter_race.py,two_counters.py,stats_race.py) exist to demonstrate exactly this. - It does not provide a memory model for Python. Python has never specified one. The free-threaded build documents the guarantees the interpreter itself provides (reference counts are consistent, container internals do not corrupt) but not the visibility ordering of writes to user-level objects.
- It does not make all C extensions safe. It only makes them
runnable. The
Py_MOD_GIL_NOT_USEDflag is an assertion by the extension author, not a verification by the interpreter. - It does not deprecate the GIL. The standard CPython build
(
python3.14) still ships with the GIL and is the default. The free-threaded build (python3.14t) is parallel, and per-interpreter GIL (PEP 684) is yet another concurrency model inside the same process. All three coexist.
Further Reading
- PEP 703: Making the Global Interpreter Lock Optional in CPython.
- PEP 683: Immortal Objects, Using a Fixed Refcount.
- PEP 684: A Per-Interpreter GIL.
- Choi et al., “Biased Reference Counting: Minimizing Atomic Operations in Garbage Collection” (PACT 2018).
- mimalloc: Daan Leijen et al., “Mimalloc: Free List Sharding in Action” (Microsoft Research, 2019).
- Sam Gross’s nogil prototype write-up (the precursor to PEP 703).
Appendix: Python and the OS
Python threads, the GIL, and free-threading all sit on top of the operating system’s process and thread model. This appendix collects the OS-level facts that affect how Python concurrency actually behaves, including a few questions the rest of the book skips over.
Python Threads Are OS Threads
threading.Thread is not a Python abstraction with a separate
scheduler. It is a thin wrapper over the platform’s native thread
API: pthread_create on Linux and macOS, _beginthreadex on
Windows. Every Thread you create produces a real OS thread, owned
and scheduled by the kernel, with its own stack and its own entry in
the kernel’s run queue.
The OS does not know or care about the GIL. It sees a process with N threads and schedules them according to its own policies. Whether those threads can run Python bytecode in parallel is a separate question, determined by the interpreter rather than the kernel.
Under the standard GIL build, all N OS threads exist but only one can hold the GIL at a time, so only one runs Python bytecode at once. The OS may still preempt the thread that holds the GIL, and on a multicore machine the other threads can still be running C code outside the interpreter (a NumPy operation, a blocking I/O call, an extension that released the GIL). The model is “N OS threads, one bytecode runner.”
Under the free-threaded build the GIL is gone. The same N OS threads now run Python bytecode simultaneously on different cores. Same threads, same OS scheduler, different gating.
What is not an OS thread:
- Coroutines (
async def/await). All multiplexed onto a single OS thread by the asyncio event loop. The kernel sees one thread; the loop sees thousands of coroutines. - Green threads (gevent, eventlet). User-space schedulers built on coroutines or stack-switching. Same single-OS-thread picture.
- Subinterpreters (PEP 684, 734). Distinct Python interpreter
contexts inside one process. Each one runs on an OS thread, but
they have separate
sys.modulesand separate object worlds. - Processes (
multiprocessing). Separate OS processes, each with its own OS threads and its own Python interpreter.
A threading.Thread from CPython is the same kind of OS-level
object as a pthread_t from C. The difference is purely in what
Python does on top of it.
The Main Thread
A process always has a primary thread, the one that exists when the
process starts. In Python this is the thread that imports the
script and runs the top-level module code. threading.main_thread()
returns it, and threading.current_thread() is threading.main_thread()
is the usual check.
The main thread is not a GIL concept. The GIL did not create it, and removing the GIL does not remove it. It is a property of the OS process model and of CPython’s startup and shutdown sequence. Its privileges all come from outside the GIL:
- Signal handlers run only on the main thread. This is a POSIX rule: signals are delivered to the main thread by the kernel. Python therefore queues signal handler invocations and runs them the next time the main thread is at a safe point. No other thread ever sees a signal directly.
atexithandlers run on the main thread when it exits.- Interpreter lifecycle. When the main thread returns from its
top-level code, CPython calls
Py_Finalize, which tears down the interpreter. Other threads still alive at that point are either joined (non-daemon) or abruptly stopped (daemon). The interpreter does not stay up just because other threads are running unless they are non-daemon and the main thread explicitly waits on them. - GUI toolkits. Tkinter, Cocoa via PyObjC, and Qt under certain configurations all require their event loop on the main thread. This is not a Python rule; the OS-level windowing systems require it. Python’s threading model has no choice in the matter.
- Some debug hooks.
sys.settracesemantics around the main thread, andsignalmodule interactions, continue to treat the main thread specially.
The shift from GIL to free-threading does not change any of this. Under FT, the main thread is just one of several threads that can run bytecode in parallel, but it remains the only one allowed to handle signals or to drive a GUI event loop.
How Blocking Calls Release the GIL
The original 1992 motivation for Python threads was I/O concurrency:
let one thread block on read() while another keeps doing work.
This works under the GIL because the convention in the C API is
that blocking calls release the GIL before they block and
re-acquire it afterward.
The pattern in extension code:
Py_BEGIN_ALLOW_THREADS
// blocking syscall, e.g. read(), select(), poll(), recv()
Py_END_ALLOW_THREADS
Py_BEGIN_ALLOW_THREADS releases the GIL, so other Python threads
can run while this one waits in the kernel. Py_END_ALLOW_THREADS
re-acquires it before the function returns to Python code.
Every stdlib I/O call follows this convention. time.sleep,
socket.recv, subprocess.Popen.wait, file I/O on regular file
handles: all release the GIL around their blocking points. This is
why Python can do meaningful I/O concurrency despite the GIL: the
GIL is released exactly when it would otherwise stall progress.
Under free-threading the dance is unnecessary for parallelism (all threads can run bytecode simultaneously anyway), but the macros still exist. Extension code shouldn’t run for long stretches without yielding, and existing extensions that wrap blocking calls should continue to work without modification.
Memory and Stack
A process has one address space. Every thread in the process sees the same heap, the same globals, the same loaded modules. This is what makes threads attractive (shared data with no inter-process communication) and what makes them dangerous (any thread can overwrite any other thread’s data).
Each thread has its own stack. Default stack size is platform dependent:
- Linux: 8 MB by default, configurable per thread.
- macOS: 8 MB for the main thread, 512 KB for others by default.
- Windows: 1 MB by default.
Python’s threading.stack_size() lets you change the size for new
threads. The default usually fits Python recursion plus a generous
margin, but deep recursion can blow the stack faster on platforms
with smaller defaults.
Practical consequence: creating thousands of threads is expensive in address space and kernel memory, not just in CPU time. This is one of the reasons asyncio is preferred for very high concurrency counts (tens of thousands of connections): coroutines share one thread’s stack via continuations, with no per-coroutine OS cost.
Thread-Local Storage
threading.local() provides per-thread storage. Attributes set on
a threading.local() instance from one thread are invisible to
other threads. This works under both the GIL and free-threading
because the implementation uses a per-thread dictionary keyed on
the thread’s identity.
Useful for thread-bound resources: database connections held by a worker thread, request context in a server, scratch buffers reused across calls in the same thread. The pattern lets you avoid locks entirely for state that doesn’t need to be shared.
Thread Scheduling
The OS scheduler is preemptive: it can interrupt any thread at any instruction boundary and run a different thread, on the same core or a different one. Python has no say in this. The GIL changed which thread could run Python bytecode at a given moment; it did not change whether threads were preemptively scheduled.
A few related details:
- CPU affinity (
sched_setaffinityon Linux,SetThreadAffinityMaskon Windows) can pin a thread to specific cores.os.sched_setaffinityexists on Linux;psutilexposes it portably. - Thread priority. Nice values on Unix, priority classes on Windows. The stdlib does not expose a portable way to change thread priority.
- The check interval (
sys.setswitchinterval) controls how often the interpreter yields the GIL, not how often the OS schedules. Under FT this setting still exists (it affects cooperative yield points in some interpreter paths) but matters much less, because threads no longer queue on a single lock.
Process Model: fork, spawn, forkserver
The multiprocessing module exposes three start methods:
fork(Linux default until 3.14): clone the current process, including all of its memory. Fast and avoids re-import, but unsafe in the presence of threads. A thread holding a lock in the parent leaves that lock held in the child, and other threads simply vanish. Many libraries, including some C extensions, cannot survive aforkfrom a multi-threaded program.spawn(default on Windows and macOS, and on Linux from 3.14): start a fresh Python process and re-import everything. Slower but safe with threads. The child does not inherit file descriptors or state implicitly.forkserver(Linux and macOS): a single-threaded helper process forks workers on demand. Combinesfork’s speed with thread safety, at the cost of more complex setup.
The interaction with Python’s GIL is indirect: under both GIL and
FT builds, each process gets its own interpreter and its own GIL
state (or absence thereof). multiprocessing was originally a way
to get multi-core CPU parallelism around the GIL. With FT, threads
can do that too, and the choice between processes and threads is
now governed by whether you need isolation (processes) or shared
memory (threads).
Subinterpreters: An Orthogonal Direction
PEP 684 (3.12) and PEP 734 (3.14) introduced per-interpreter GIL
and a concurrent.interpreters stdlib module. A subinterpreter is
a Python interpreter instance running inside one OS process. Each
subinterpreter has its own sys.modules, its own type objects, its
own GIL (in the standard build) or its own runtime state (in the FT
build), and communicates with other subinterpreters only through
explicit channels.
From the OS’s perspective, a subinterpreter is still hosted by an OS thread. From Python’s perspective, the subinterpreter is a separate world that cannot reach into other subinterpreters’ state.
Subinterpreters and free-threading solve different problems. Free-threading lets shared-memory threads run Python in parallel. Subinterpreters provide isolation between concurrent Python contexts in one process, closer to Erlang processes than to traditional threads. Both can coexist: the FT build supports subinterpreters, and a subinterpreter under FT can spawn threads that run its code in parallel.
Summary
- A Python thread is an OS thread. The GIL was a property of the interpreter that ran on top, not of the thread object itself.
- The main thread is an OS and interpreter concept, not a GIL concept. It retains its signal-handling, lifecycle, and GUI-driving privileges under both GIL and FT builds.
- Blocking C calls release the GIL by convention, which is why threads have always provided real I/O concurrency.
- Each thread has its own stack but shares the process’s address space. This is what makes shared-memory races possible at all.
- Free-threading removes the GIL but does not change the OS process model, the main-thread concept, or any of the conventions built on top.
- Subinterpreters are an orthogonal path that gives isolation rather than shared-memory parallelism.
The recurring theme: free-threading is a change to the Python interpreter, not to the operating system underneath. Everything the OS does (scheduling, signals, memory layout, process model) keeps doing what it always did. The only thing that changes is which thread is allowed to run Python bytecode when.