How does Python's Global Interpreter Lock (GIL) actually limit concurrency, and how do you work around it?
The GIL is the single most misunderstood line item on a Python backend's performance profile — it blocks CPU parallelism but barely touches I/O concurrency.
LEARN BY DOINGPut your intuition to work3 steps
How does Python's Global Interpreter Lock (GIL) actually limit concurrency, and how do you work around it?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What the GIL actually is
The Global Interpreter Lock is a single mutex inside CPython — the reference implementation almost everyone means when they say "Python" — that must be held by a thread before it can execute Python bytecode. It exists because CPython's memory management uses simple, non-atomic reference counting on every object. Without a global lock, two threads incrementing and decrementing the same object's refcount concurrently could race, corrupt the count, and either free an object still in use or leak it forever. The GIL sidesteps that entire class of bugs by making sure only one thread is ever running Python-level code at any instant, in any process.
This is a CPython implementation detail, not a property of the Python language. Jython, IronPython, and PyPy's STM variant do not have one, and as of Python 3.13 there is an official experimental "free-threaded" build (PEP 703) that removes it entirely by switching to per-object atomic reference counting and biased locking — with a real, if still-maturing, single-threaded performance cost. For the interview, assume standard CPython unless told otherwise, because that is what the overwhelming majority of production Python runs on.
#Why threading still "sort of" works
The GIL does not serialize your whole program — it serializes execution of Python bytecode. Any thread that calls into a C-level blocking operation — a socket read, a file read, time.sleep, most of requests and psycopg2 under the hood — releases the GIL for the duration of that call. While one thread is blocked in the kernel waiting on a socket, the GIL is free, and another Python thread can acquire it and run. That is precisely why threading remains a perfectly good tool for I/O-bound workloads in Python: fetching ten URLs on ten threads genuinely overlaps their wait time, even though only one thread is ever executing Python bytecode at once.
The failure mode is CPU-bound work: a tight Python loop doing arithmetic, string manipulation, or hashing never calls into a GIL-releasing C function, so it holds the lock for its entire slice. CPython periodically forces a hand-off (by default, checking every 5 milliseconds of wall-clock time, tunable via sys.setswitchinterval), but that hand-off is round-robin between whichever threads want the GIL — it does not create parallelism, it just interleaves. Two CPU-bound threads on two cores still execute one at a time, and you pay extra for the interleaving itself: context switches, cache invalidation, and — notoriously, in a well-known CPython bug pattern — a CPU-bound thread can even starve an I/O-bound thread of the GIL under some scheduler interactions, making threading actively worse than a single thread for that mix.
#Proving it: threading does nothing for CPU-bound work
import threadingimport timedef count_down(n: int) -> None:while n > 0:n -= 1N = 30_000_000# Baseline: single-threaded, does the full 2N units of work serially.start = time.perf_counter()count_down(N)count_down(N)print(f"single-threaded: {time.perf_counter() - start:.2f}s")# Two threads splitting the same total work.start = time.perf_counter()t1 = threading.Thread(target=count_down, args=(N,))t2 = threading.Thread(target=count_down, args=(N,))t1.start(); t2.start()t1.join(); t2.join()print(f"two threads: {time.perf_counter() - start:.2f}s")
Run this on any multi-core machine under standard CPython and the "two threads" line does not come out anywhere near half the single-threaded time — it typically lands at roughly the same wall-clock time, sometimes slightly worse, because the GIL only ever lets one of those threads execute Python bytecode at once. Both threads are fighting over the same lock, taking turns on a single core's worth of actual work while adding hand-off overhead on top.
#Fix 1: multiprocessing for CPU-bound work
Each process spawned by multiprocessing gets its own Python interpreter and, critically, its own GIL. There is no shared lock to contend over because there is no shared interpreter state — the OS scheduler can run each process's interpreter on a separate core, achieving real parallelism.
import multiprocessing as mpimport timedef count_down(n: int) -> None:while n > 0:n -= 1if __name__ == "__main__":N = 30_000_000start = time.perf_counter()p1 = mp.Process(target=count_down, args=(N,))p2 = mp.Process(target=count_down, args=(N,))p1.start(); p2.start()p1.join(); p2.join()print(f"two processes: {time.perf_counter() - start:.2f}s")
This version scales close to linearly with available cores, because count_down never needs to share mutable Python objects between the two runs. The cost is real, though: spawning a process is heavier than spawning a thread, and passing data in or out means serializing it (pickling) across a pipe or shared memory segment, which is why multiprocessing shines for coarse-grained, largely independent chunks of CPU work (batch image resizing, parsing large files, scientific computation) and struggles when tasks need to share a lot of state or communicate frequently.
#Fix 2: asyncio for I/O-bound work
For I/O-bound workloads, threading already works because blocking calls release the GIL — but asyncio does it with far less overhead, because it uses a single thread and cooperative scheduling instead of OS threads and preemptive time-slicing. There is no GIL hand-off to pay for at all, because there is only ever one thread; concurrency comes from a coroutine yielding control at every await point while the event loop polls the underlying sockets for readiness.
import asyncioimport aiohttpimport timeasync def fetch(session: aiohttp.ClientSession, url: str) -> int:async with session.get(url) as resp:return resp.statusasync def main() -> None:urls = ["https://example.com"] * 20start = time.perf_counter()async with aiohttp.ClientSession() as session:results = await asyncio.gather(*(fetch(session, u) for u in urls))print(f"20 requests, concurrently: {time.perf_counter() - start:.2f}s")print(results[:3])asyncio.run(main())
Twenty requests issued concurrently via asyncio.gather finish in roughly the time of the single slowest request, not the sum of all twenty, because the event loop is juggling all of them while each waits on the network — none of them are competing for CPU, so the single-threaded, GIL-bound model is not a bottleneck here at all.
#Putting it together
The interview answer worth giving is a decision rule, not a lecture on CPython internals: profile first to find out whether the workload is CPU-bound or I/O-bound, then reach for threading/asyncio for I/O-bound work (asyncio if you are building it from scratch and want the lowest overhead per concurrent unit; threading if you are gluing together blocking libraries that were never written to be async), and reach for multiprocessing (or handing the CPU-heavy part off to a native extension, or to a separate service written in a language without a GIL) when the bottleneck is genuinely computation, not waiting.
🧠 Check your understanding
0/2 · 0/2 answered1. A Python service spends 95% of its request-handling time waiting on a downstream database and an external HTTP API. Which concurrency approach is most appropriate given the GIL?
2. Why doesn't spawning two Python threads to run a pure computational loop (no I/O) cut the wall-clock time roughly in half on a multi-core machine?