Lesson 6 · Python Concurrency / Runtime

Why more threads don't make Python code faster

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.

What the GIL actually is

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 tradeoff, named

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.

Where threading still helps: I/O-bound work

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.

Where threading stops helping: CPU-bound work

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.

The three tools, and what each actually buys you

ToolModelGood forCost
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

The decision rule

Check yourself

A Python service does heavy JSON parsing and regex processing (CPU-bound) on every request. Would adding more worker threads to handle requests concurrently make it faster?
Right — parsing and regex work is pure CPU work with no blocking I/O calls to release the GIL. The threads never really run in parallel; they hand the GIL back and forth, so more threads just adds handoff overhead without more throughput. Not quite — the key question is whether the work ever blocks on I/O. JSON parsing and regex processing don't; they just keep the GIL. Reread the "Where threading stops helping" section above.

Try it yourself

Hands-on

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.

Looking forward: free-threaded Python

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.

Primary sources

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.