Shared State and Isolation Boundaries
Python offers threads, processes, concurrent.futures, subinterpreters, and now a free-threaded build - which can look like an unusually large toolbox for one problem.
It is actually a small number of tools sitting on one spectrum: how much memory two pieces of concurrent code are allowed to share, and what mechanism keeps that shared memory from being corrupted.
Every tool in this section is a specific answer to that same question, not an unrelated feature.
Concurrency Basics introduces the concurrency-versus-parallelism distinction, and Choosing a Concurrency Model gives a practical decision checklist; threading, multiprocessing, The GIL & Free-Threaded Python, and Subinterpreters each cover one specific point on the spectrum in depth.
This page is the layer underneath all of them: the one axis they're all different positions on, and why that axis is what actually decides which tool fits a given workload.
Summary
- Every Python concurrency mechanism trades off along one axis - how much memory is shared between concurrent units of work, and what enforces safety over that shared memory.
- Insight: Picking the wrong point on that spectrum either leaves real parallelism on the table (using threads for CPU-bound work) or pays unnecessary isolation cost (using processes for I/O-bound work that threads handle fine).
- Key Concepts: shared memory, isolation boundary, the GIL, process, interpreter state, free-threading.
- When to Use: Choosing between threads, processes, subinterpreters, or async for a given workload, and understanding why the same task ("run this concurrently") needs different tools depending on whether it's CPU-bound or I/O-bound.
- Limitations/Trade-offs: More isolation buys more real parallelism but costs more overhead - a process is heavier than a thread, and cross-boundary communication (pickling, sockets, IPC) is never as cheap as passing a plain object by reference.
- Related Topics: the Global Interpreter Lock,
multiprocessing,concurrent.futuresexecutors, subinterpreters, free-threaded Python builds.
Foundations
Two or more pieces of code running "at the same time" always have to answer one underlying question: what happens if both try to read or modify the same piece of memory at once?
A language's concurrency model is, at its core, a set of answers to that question, and Python offers several because different workloads want different answers.
At one extreme sits full sharing with a single lock: a thread runs inside the same process as every other thread, sees the exact same objects in memory, and in the default CPython build, only one thread executes Python bytecode at any instant because the Global Interpreter Lock (GIL) enforces that rule.
At the other extreme sits no sharing at all: a process (via multiprocessing) has its own private memory space, its own interpreter, and its own GIL, so two processes can run Python bytecode genuinely simultaneously on separate cores - the price is that they don't automatically see each other's objects, and anything passed between them has to be serialized (pickled) across the boundary.
A useful analogy: threads are coworkers in one shared office, able to reach the same filing cabinet instantly but only one at a time by house rule; processes are separate offices in separate buildings, each with its own filing cabinet, able to work simultaneously but needing a courier (serialization) to exchange any paperwork at all.
# threads: same memory, same objects, one GIL - safe by serialization, not isolation
counter = {"n": 0}
# every thread sees this exact dict; a lock is needed to increment it safely
# processes: separate memory, separate objects - safe by isolation, not locking
# a child process gets its own copy; changes don't appear in the parent automaticallyMechanics & Interactions
The GIL is the mechanism that makes CPython's default, fully-shared-memory model safe: it guarantees that only one thread executes Python bytecode at a time, which means most single operations on Python objects don't tear or corrupt even without an explicit lock, at the cost of threads never running Python code truly in parallel with each other.
That single fact explains the split this section keeps returning to: threads help I/O-bound work, because I/O operations release the GIL while waiting on the network or disk, letting another thread run during that wait - but threads do not help CPU-bound work, because a tight Python loop never releases the GIL, so N threads doing pure computation run no faster than one.
multiprocessing sidesteps the GIL entirely by giving each worker its own interpreter and its own GIL, which is why it's the default answer for CPU-bound parallelism in ordinary CPython - the isolation that makes serialization necessary is the exact same isolation that makes true parallel execution possible.
concurrent.futures doesn't introduce a new point on this spectrum; it's a uniform Executor interface (submit, map) over two existing ones - ThreadPoolExecutor for the shared-memory side, ProcessPoolExecutor for the isolated side - so switching between them is a one-line change once code is written against the Executor API rather than against threads or processes directly.
Shared memory, one GIL Isolated memory, one GIL each
------------------------- ------------------------------
threading.Thread multiprocessing.Process
ThreadPoolExecutor ProcessPoolExecutor
cheap to start, cheap to share expensive to start, must serialize to share
I/O-bound work CPU-bound work
Queues (queue.Queue for threads, multiprocessing.Queue for processes) exist because "isolated" and "shared" both still need a safe way to hand data across the boundary - a thread-safe queue coordinates access within shared memory, while a process-safe queue actually serializes each item across the process boundary, doing more work per item precisely because there's no memory to share in the first place.
Advanced Considerations & Applications
Two newer developments in CPython relax the "one GIL, fully shared" default without moving all the way to separate processes, and both are best understood as new points on the same spectrum rather than entirely new concepts.
Subinterpreters (usable from Python via PEP 734-related APIs, 3.12+/3.13+) give each interpreter its own GIL and, by default, its own isolated state, while still living inside a single OS process - a middle point between "one shared GIL" and "fully separate processes," trading some of multiprocessing's startup and IPC cost for lighter-weight isolation than a full process, at the cost of most objects still not being directly shareable across the boundary.
Free-threaded CPython (3.13+, experimental) takes the opposite approach: instead of isolating memory to avoid needing one global lock, it replaces the single GIL with finer-grained, per-object locking, so threads really can execute Python bytecode in parallel while continuing to share memory directly - the same shared-memory model as ordinary threading, but with the safety previously provided by one big lock now distributed across many smaller ones.
That single change is significant enough to warrant real caution: C extensions written assuming a GIL protects them implicitly may not be safe under a free-threaded build unless they're updated to declare and enforce their own thread safety, which is why adopting a no-GIL build in production means auditing the dependency tree, not just flipping a build flag.
| Model | Memory | Safety mechanism | Best Fit |
|---|---|---|---|
| Threads (default GIL) | Fully shared | One GIL serializes bytecode execution | I/O-bound work; sharing objects cheaply matters more than CPU parallelism |
Processes (multiprocessing) | Isolated per worker | OS-level process boundary; no shared state by default | CPU-bound work needing real parallel execution across cores |
| Subinterpreters | Isolated per interpreter, one process | Per-interpreter GIL | Isolating untrusted or plugin-style code without full process overhead |
| Free-threaded build | Fully shared | Per-object locking instead of one global lock | CPU-bound threaded workloads once the dependency tree is verified thread-safe |
The practical upshot is that "which concurrency tool should I use" is really "how much isolation does this workload need, and how much can I afford to pay for it" - Choosing a Concurrency Model turns that into a concrete checklist, but the checklist only makes sense once this shared-memory-versus-isolation trade-off is the lens you're viewing it through.
Common Misconceptions
- "Threads in Python don't actually run concurrently at all." They do run concurrently - the GIL only prevents two threads from executing Python bytecode at the exact same instant, but I/O waits, C extensions that release the GIL, and OS-level scheduling all still let threads make real overlapping progress.
- "Multiprocessing is just threading but heavier, with no other benefit." The overhead buys something threading structurally cannot under the default GIL: genuine simultaneous execution of Python bytecode across CPU cores.
- "Subinterpreters are the same thing as multiprocessing, just inside one process." They're isolated like processes in terms of default state, but they share the OS process and its memory space in a way multiprocessing's separate processes do not, which changes both the startup cost and what can eventually be shared safely between them.
- "Free-threaded Python removes the need to think about thread safety." It removes the single GIL as the safety mechanism, but replaces it with the need for genuinely thread-safe code and extensions - the safety burden moves, it doesn't disappear.
- "
concurrent.futuresis a third concurrency model, separate from threads and processes." It's an interface, not a model -ThreadPoolExecutorandProcessPoolExecutorare the same threads-vs-processes trade-off described here, wrapped in one consistent API.
FAQs
What's the one underlying question every Python concurrency tool is answering?
How much memory is shared between concurrently running pieces of code, and what mechanism keeps that shared memory safe - full sharing under one lock, or full isolation with no lock needed at all.
Is the GIL a performance feature or a safety feature?
Safety. It guarantees only one thread executes Python bytecode at a time, which keeps CPython's internal object structures consistent without every operation needing its own explicit lock - the performance trade-off (no CPU parallelism across threads) is the cost of that safety guarantee, not its purpose.
Why do threads help with network calls but not CPU-heavy loops?
I/O operations release the GIL while waiting, letting another thread run during that wait - a tight Python computation loop never hits a point where it releases the GIL, so other threads simply wait their turn instead of running in parallel.
Why does multiprocessing require serializing (pickling) data between processes?
Because each process has its own separate memory space and its own interpreter - there's no shared memory a child process could read directly from the parent, so any data crossing that boundary has to be converted to a transportable form and reconstructed on the other side.
Is `concurrent.futures` a different concurrency model from threading and multiprocessing?
No - it's a uniform Executor interface over the same two underlying models. ThreadPoolExecutor uses threads and shared memory; ProcessPoolExecutor uses processes and isolated memory; switching between them is mostly a one-line change.
What problem do subinterpreters actually solve?
They offer isolation similar to separate processes - each with its own GIL and, by default, its own state - while staying inside a single OS process, which is lighter-weight than spawning full processes for workloads that mainly need isolation rather than shared-memory access.
Does free-threaded Python mean threads no longer need locks?
No - it removes the single global lock and replaces it with finer-grained, per-object locking, so genuinely concurrent access to shared data is still possible and code (and C extensions) still needs to be written to be thread-safe under that finer-grained model.
Why can't I just always use multiprocessing to be safe, since it avoids the GIL entirely?
Processes are heavier to start and require serializing data across the boundary, which adds real overhead - for I/O-bound work, where the GIL isn't the bottleneck in the first place, that overhead buys nothing and threads (or async) are the cheaper fit.
Are queues different between threading and multiprocessing?
Functionally similar on the surface, but mechanically different: queue.Queue coordinates safe access to shared, in-memory objects between threads, while multiprocessing.Queue actually serializes each item across the process boundary, since there's no memory to share directly.
Is adopting a free-threaded build a drop-in change for an existing codebase?
Not safely, without auditing first - C extensions written assuming the GIL implicitly protected them may not be thread-safe under a build where that single lock no longer exists, so verifying the dependency tree matters before relying on it in production.
How should I decide between threads and processes for a given task?
Ask whether the workload is I/O-bound or CPU-bound: I/O-bound work benefits from threads' cheap sharing since the GIL isn't the constraint, while CPU-bound work needs the true parallelism only isolated processes (or a verified free-threaded build) can provide.
Do subinterpreters and free-threaded Python solve the same problem?
They address the same underlying limitation - one shared GIL serializing everything - from opposite directions: subinterpreters add isolation to sidestep sharing the lock, while free-threading removes the single lock but keeps memory fully shared.
Related
- Concurrency Basics - concurrency vs. parallelism and picking a starting tool
- The GIL & Free-Threaded Python - the GIL mechanism and the no-GIL build in depth
- multiprocessing - isolated processes for CPU-bound parallelism
- threading - shared-memory concurrency for I/O-bound work
- Subinterpreters - per-interpreter isolation inside one process
- Choosing a Concurrency Model - turning this trade-off into a decision checklist
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.