Tied to Mission: Backend Depth — the Python concurrency/runtime gap, building on the OS-level picture of blocking I/O and context switching.
Last lesson's mental model: a thread blocked on I/O (waiting on a socket, a disk read) gets parked by the OS, and the OS runs something else on that core in the meantime. That's true for any language, at the OS level. Python adds a wrinkle on top of it that catches people off guard: even when the OS is perfectly happy to run several of your threads at once on several cores, CPython won't let them run Python code at the same time.
CPython — the reference implementation you're almost certainly running — has a single lock called the Global Interpreter Lock (GIL). A thread must hold the GIL to execute Python bytecode. Only one thread in the whole process can hold it at a time. So no matter how many threads you spin up, at any given instant exactly one of them is actually running Python.
This isn't an oversight — it's a simplification that made CPython's memory management easy to get right. CPython manages memory largely through reference counting: every object tracks how many references point to it, and gets freed when that count hits zero. Reference counts get incremented and decremented constantly, on nearly every operation. Without a global lock, two threads updating the same object's refcount at the same time could race and corrupt it — a use-after-free or a leak. The GIL sidesteps that entire class of bug by making refcount updates safe by construction: only one thread is ever touching Python objects at once.
The GIL buys memory-management simplicity (and fast single-threaded performance, since there's no per-object locking overhead) at the cost of true parallel execution of Python code within one process. David Beazley's "Understanding the Python GIL" talk is the canonical walkthrough of what the lock actually protects and how threads take turns holding it — see the primary source below.
Here's the part that connects back to the OS lesson. When a thread calls something that blocks on I/O — a socket read, a file read, time.sleep — CPython releases the GIL for the duration of that call. The thread parks itself with the OS exactly like any blocked thread would, and another Python thread is free to pick up the GIL and run. When the I/O completes, the thread re-acquires the GIL to keep going.
So for a backend service handling many concurrent requests that spend most of their time waiting on a database, an upstream API, or a file — threading works. The threads aren't fighting over the GIL because they're mostly not holding it; they're parked, waiting on the OS, just like the previous lesson described.
Now swap the workload: a thread doing heavy computation — parsing, regex matching, number crunching — never calls anything that blocks on I/O. It just wants to run Python bytecode, continuously. It holds the GIL for as long as it can before CPython's interpreter forces a switch to give another thread a turn.
Add a second thread doing the same kind of CPU-bound work, and the two threads don't run alongside each other — they take turns holding the GIL, each running in short bursts. The total amount of Python bytecode executed per second doesn't go up just because you added a thread; it's still bounded by one core's worth of GIL-holding time, sliced between threads. Worse, each handoff of the GIL has overhead (saving state, waking the next thread, contending for the lock), so past a certain point adding more CPU-bound threads can make the whole thing slower, not faster.
| Tool | Model | Good for | Cost |
|---|---|---|---|
threading |
OS threads, one process, shared memory, GIL serializes Python execution | I/O-bound work, especially with libraries that already block (no async rewrite needed) | No parallelism for CPU-bound work; thread overhead; shared-state bugs still possible |
asyncio |
Single thread, cooperative multitasking via an event loop; code yields control at await points |
I/O-bound work at high concurrency (thousands of connections) without per-connection OS thread cost | Every I/O call in the path must be async-aware (blocking calls stall the whole event loop) |
multiprocessing |
Separate OS processes, each with its own interpreter and its own GIL | CPU-bound work — genuine parallelism across cores | No shared memory by default; data crosses process boundaries via pickling/IPC, which costs time and memory |
Write a small CPU-bound loop (e.g. summing squares up to some large N, or a regex over a big string). Run it N times sequentially, then across N threading.Threads, then across N multiprocessing.Processes. Time each with Python's time module and compare wall-clock time as N grows. Watch for the threaded version failing to beat the sequential version, and the process version scaling with your core count.
PEP 703 introduced an experimental "free-threaded" build of CPython (no GIL) that shipped as an opt-in build starting with Python 3.13. It's a real removal of the lock, not a workaround — but as of now it's still experimental, requires a special build, and much of the ecosystem (C extensions especially) hasn't caught up. Worth knowing it exists; not something to design a production system around yet.
David Beazley — "Understanding the Python GIL" is the canonical deep explanation of what the GIL protects and how CPython schedules threads across it. Pair it with the official docs for threading and asyncio for the API-level detail this lesson didn't cover.
Questions on how multiprocessing actually passes data between processes, or where C extensions fit into the GIL story? Ask directly — that's what this curriculum is for.