Python Concurrency: GIL, Threading, Multiprocessing, Asyncio
The GIL — Why This Question Exists
The 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's concurrency story has three genuinely different tools instead of one, and picking the right one for a given workload is the actual interview signal, not reciting what the GIL is.
import threading
def cpu_heavy():
total = 0
for i in range(50_000_000):
total += i
return total
# Two threads running cpu_heavy() do NOT run in parallel on separate cores —
# the GIL means only one executes Python bytecode at any instant, so this
# gives no speedup over running the same work sequentially, and can even be slightly slower.
The Three Tools, and When Each Is Correct
| Tool | What it actually parallelizes | Best for | Why |
|---|---|---|---|
threading | I/O waits (not CPU) | I/O-bound: network calls, file reads, DB queries | The GIL is released while a thread is blocked on I/O (waiting for a socket, disk, etc.) — so other threads can run Python bytecode during that wait. Multiple threads genuinely overlap their waiting time. |
multiprocessing | Actual CPU work | CPU-bound: heavy computation, image/data processing | Each process gets its own Python interpreter and its own GIL — real parallelism across cores, at the cost of separate memory spaces (data must be explicitly passed between processes, not shared directly). |
asyncio | I/O waits, cooperatively, single-threaded | High-volume I/O-bound: thousands of concurrent network calls | One thread, one event loop — coroutines voluntarily yield control at await points instead of relying on OS thread preemption. Handles far more concurrent I/O operations than threading can, with much lower per-unit overhead (no OS thread per connection), but is not parallel and gets no benefit at all for CPU-bound work — a CPU-bound coroutine blocks the entire event loop, stalling everything else. |
The one-sentence version, worth having ready verbatim: "asyncio and threading both solve I/O concurrency, not CPU parallelism, because the GIL only gets out of the way during I/O waits — for genuine CPU parallelism you need multiprocessing, which sidesteps the GIL entirely by using separate interpreter processes."
Why the GIL Doesn't Matter for asyncio Specifically
This is a common point of confusion worth resolving precisely: asyncio is single-threaded by design — there's only ever one thread, so the GIL (a lock about multiple threads contending for the interpreter) is simply never in contention in the first place. asyncio's concurrency comes entirely from cooperative yielding at await points, not from bypassing or being blocked by the GIL — it's a different axis of the problem, not a GIL workaround.
Running Blocking or CPU-Bound Code From Async Code
A common practical need: you're in an async def function, but need to call a blocking library (a sync DB driver) or run genuinely CPU-heavy work, without stalling the whole event loop.
import asyncio
from concurrent.futures import ProcessPoolExecutor
async def fetch_sync_data():
loop = asyncio.get_event_loop()
# runs the blocking call in a THREAD pool — doesn't block the event loop while waiting
result = await loop.run_in_executor(None, blocking_db_call, arg1)
return result
async def compute():
loop = asyncio.get_event_loop()
with ProcessPoolExecutor() as pool:
# runs in a separate PROCESS — genuine parallelism for CPU-bound work, sidesteps the GIL
result = await loop.run_in_executor(pool, cpu_heavy_fn, data)
return result
The distinction matters: run_in_executor(None, ...) defaults to a thread pool (right for blocking I/O — the GIL releases during the blocking call, so other coroutines still make progress), while explicitly passing a ProcessPoolExecutor is required for CPU-bound work (a thread pool alone would still serialize CPU-bound Python bytecode execution under the GIL).
Deep Dive: asyncio Mechanics
The event loop, gather/wait/TaskGroup, semaphore-based concurrency capping, async context managers/iterators, and the producer/consumer asyncio.Queue pattern are covered in full depth, with production FastAPI examples, in [[Asyncio]] — this page is the decision framework for which concurrency tool to reach for; that page is the deep mechanics of the asyncio tool once you've decided it's the right one.
Interview Drills
- Why does adding more threads not speed up a CPU-bound Python function? — The GIL allows only one thread to execute Python bytecode at any instant; CPU-bound work keeps the interpreter continuously busy (no I/O wait to release the GIL during), so additional threads just take turns rather than running in parallel — no speedup, and the added context-switching overhead can even make it slightly slower.
- You need to make 10,000 concurrent HTTP requests. Would you reach for
threadingorasyncio, and why? —asyncio— 10,000 OS threads would be prohibitively expensive (thread creation/context-switch overhead, memory per thread), while asyncio's single-threaded event loop handles thousands of concurrent I/O-bound coroutines with far lower per-unit overhead, since there's no OS thread per connection at all. - A CPU-heavy function is accidentally called directly (not via
run_in_executor) inside anasync defroute handler. What happens to the rest of the application during that call? — The entire event loop stalls — since asyncio is single-threaded and cooperative, a CPU-bound call that never hits anawaitpoint holds the only thread hostage until it returns, meaning every other coroutine (every other in-flight request) is blocked for that entire duration, not just the one handling that request.
Cross-links
[[Asyncio]] · [[Go Concurrency]] · [[Python OOP & Data Model]]