Hermes Wiki
Developer/Languages/Python/Concurrency/Fundamentals/gil-threading-multiprocessing-and-asyncio

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 at await points 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 asyncio when 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 threading when the I/O-bound work is real but the library doing it is synchronous-only and can't be swapped for an async equivalent; a ThreadPoolExecutor wrapping blocking calls is the standard bridge into an otherwise-async codebase.
  • Use multiprocessing (or a ProcessPoolExecutor) 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 multiprocessing to 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 threading gives 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

Hermes Wiki