The GIL and Python's Three Concurrency Models: Threading, Multiprocessing, Asyncio
Concept
CPython's Global Interpreter Lock (GIL) allows only one thread to execute Python bytecode at a time, even on a multi-core machine. This single fact is why Python ended up with three separate, non-interchangeable concurrency tools instead of one — each sidesteps the GIL's limitation in a different way, and each is a poor fit for the workload the other two handle well:
threading— real OS threads, but they still share one GIL. The interpreter releases the GIL during blocking I/O calls (a socket read, a file write,time.sleep) and during calls into C extensions that explicitly drop it (NumPy's inner loops, for example), so threads genuinely overlap while waiting on I/O. They never overlap while running pure Python bytecode.multiprocessing— spawns separate OS processes, each with its own interpreter and its own GIL. This buys true parallelism across CPU cores, at the cost of no shared memory by default: data crossing a process boundary must be pickled and copied (or placed in explicit shared memory), and process startup itself is expensive relative to a thread.asyncio— single-threaded cooperative concurrency. Coroutines run on one thread and one GIL, but explicitly yield control atawaitpoints instead of being preemptively switched by the OS. There is no parallelism at all here — the win is avoiding both the GIL contention of threads and the process overhead of multiprocessing when the workload is overwhelmingly "waiting on the network," where a single thread can juggle thousands of pending operations far more cheaply than thousands of OS threads could.
Tradeoffs
| Model | Best for | Cost |
|---|---|---|
threading |
I/O-bound work using libraries that don't support async (blocking DB drivers, legacy SDKs) — moderate concurrency, roughly 10-100 simultaneous operations |
GIL means zero benefit for CPU-bound code; thread-safety bugs (races, needing locks) return despite the GIL, since it only guarantees atomic bytecode instructions, not atomic multi-step operations |
multiprocessing |
CPU-bound work — number crunching, image/data transforms, hashing large volumes — where true parallelism across cores is the actual goal | Process startup and IPC (pickling) overhead makes it a poor fit for fine-grained or short-lived tasks; shared mutable state requires explicit multiprocessing.Manager/shared memory instead of just sharing a Python object |
asyncio |
I/O-bound work at high concurrency (hundreds to thousands of simultaneous HTTP requests, WebSocket connections) where an async-native library exists |
Every library in the call chain must be async-aware or awaited work blocks the whole event loop; a single accidental blocking call (a synchronous requests.get, unbounded CPU work) stalls every other coroutine, not just its caller |
When to use / when not to
- Use
asynciowhen the bottleneck is waiting on many concurrent I/O operations and the libraries involved (HTTP clients, DB drivers, message queues) have async support — this is the highest-throughput option per unit of memory/OS resources for that specific shape of work. - Use
threadingwhen the I/O-bound work is real but the library doing it is synchronous-only and can't be swapped for an async equivalent; aThreadPoolExecutorwrapping blocking calls is the standard bridge into an otherwise-async codebase. - Use
multiprocessing(or aProcessPoolExecutor) specifically when the work is CPU-bound — transforming a large in-memory dataset, running a batch of independent computations — since only separate interpreters get around the GIL for pure-Python compute. - Don't reach for
multiprocessingto fix an I/O-bound bottleneck — the processes will spend their time idle waiting on the network exactly like threads would, but pay process-creation and pickling overhead that threads and coroutines don't. - Don't assume
threadinggives CPU speedup for pure-Python loops — it won't, because the GIL serializes bytecode execution across threads regardless of core count.
Common pitfall
Mixing paradigms incorrectly — most often, calling a blocking (synchronous, CPU-bound, or blocking-I/O) function directly inside an async def coroutine without offloading it. Because asyncio is single-threaded, that one blocking call freezes the entire event loop: every other coroutine, including ones handling unrelated requests, stalls until it returns. The fix is to run blocking work in a thread or process pool (loop.run_in_executor) rather than inline, but this failure mode is easy to introduce accidentally — a synchronous logging call, a synchronous DB driver used "just this once," or an unbounded CPU-heavy computation dropped into otherwise-async code.
Engineering Lens
The decision of which concurrency model to reach for should be driven by profiling the actual bottleneck, not by a general preference for "using async" or "using more cores." A production system's request path is frequently a mix — an API endpoint might use asyncio to juggle concurrent outbound HTTP calls, while a background worker uses multiprocessing to CPU-crunch a batch job, and a thin compatibility layer uses threading to wrap one legacy blocking dependency neither of the other two models fits. Python 3.13+'s experimental free-threaded (--disable-gil) build changes the threading tradeoff for CPU-bound work going forward, but as of today it's not the default build most production deployments run, so the three-model split above remains the operative reality for anyone shipping Python services now.
Sources
- asyncio vs threading vs multiprocessing: When to Use Each in Python — BSWEN
- Python's GIL and Asyncio: Understanding the Relationship
- Python Concurrency in 2026: AsyncIO vs Threads vs Processes — Loc Truong
Related
- ConsistencyConcurrency Index (general-purpose concurrency/threading theory)
- Concurrency and Scalability Checklist for Async Python Series
- Languages Index