The GIL and Python Threading
The Global Interpreter Lock lets only one thread run Python bytecode at a time, so threading in Python speeds up waiting-on-I/O work but not CPU-bound number crunching — for that you need separate processes or code outside the GIL.
Spin up four threads in Python to speed up a CPU-heavy calculation, run it on a four-core machine, and it doesn't get four times faster. Often it barely gets faster at all — sometimes it gets slower. This surprises almost everyone who learns threading in another language first, and the reason is a single piece of CPython (the standard Python implementation) machinery called the Global Interpreter Lock, or GIL.
The idea: one microphone, many speakers
Picture a meeting room with several people (threads) who each want to speak (execute Python bytecode), but there's only one microphone (the GIL) and the room's rule is that no one may speak without holding it. A thread that wants to run Python code must first acquire the microphone; only one thread can hold it at any instant, no matter how many CPU cores the machine has. The GIL exists because CPython's memory management — specifically reference counting, the technique it uses to track when an object can be freed — is not safe if two threads can modify the same counter at the same instant; the GIL is the simplest fix, serializing all Python-level execution to avoid that race.
This means Python threads never truly run Python code in parallel. They interleave: the interpreter periodically forces the currently-running thread to release the microphone (roughly every few milliseconds, or explicitly whenever it does I/O) so another thread gets a turn. On a multi-core machine, only one core is ever executing Python bytecode at a given instant, regardless of how many threads or cores exist.
Why threading still helps sometimes
The GIL is released automatically whenever a thread is waiting on something outside the interpreter — reading from a network socket, waiting for a disk read, sleeping. During that wait, the thread isn't using the microphone anyway, so another thread can pick it up and make progress. This is why Python threading is genuinely useful for I/O-bound work — a market-data client waiting on network packets, a script downloading several files — but not for CPU-bound work, where the thread is actively computing and therefore actively holding the GIL the entire time.
Worked example: timing CPU-bound work with threads
Consider a pure-Python loop that sums squares up to 20 million, split across two threads doing 10 million each.
import threading, time
def cpu_task(n):
total = 0
for i in range(n):
total += i * i
return total
# single thread, 20,000,000 total iterations
t0 = time.perf_counter()
cpu_task(20_000_000)
print("single:", time.perf_counter() - t0) # ~1.6s
# two threads, 10,000,000 iterations each
t0 = time.perf_counter()
threads = [threading.Thread(target=cpu_task, args=(10_000_000,)) for _ in range(2)]
[t.start() for t in threads]
[t.join() for t in threads]
print("two threads:", time.perf_counter() - t0) # ~1.6-1.8s, NOT ~0.8s
Trace why the two-thread version isn't roughly twice as fast. Each thread holds the GIL while executing total += i * i, releases it briefly when CPython's periodic switch interval elapses, and the other thread grabs it. The total amount of Python bytecode executed is unchanged — 20 million iterations either way — and since only one core is ever active on that bytecode, the wall-clock time is close to the single-threaded time, sometimes slightly worse because of the overhead of constantly handing the GIL back and forth between threads.
Worked example: I/O-bound work does parallelize
Now suppose each "task" is fetching a market-data snapshot over the network, where each request spends 200ms waiting on the network and almost no time on actual Python computation.
import threading, time
def fetch(latency_s):
time.sleep(latency_s) # stands in for a network wait -- GIL is released here
t0 = time.perf_counter()
for _ in range(8):
fetch(0.2)
print("sequential:", time.perf_counter() - t0) # ~1.6s
t0 = time.perf_counter()
threads = [threading.Thread(target=fetch, args=(0.2,)) for _ in range(8)]
[t.start() for t in threads]
[t.join() for t in threads]
print("threaded:", time.perf_counter() - t0) # ~0.2s
Here the eight threads spend nearly all their time in time.sleep, which releases the GIL for the duration of the wait. Since none of them need the microphone while waiting, all eight waits overlap, and the threaded version finishes in roughly the time of one wait rather than eight — an 8x speedup, the mirror image of the CPU-bound case.
The GIL lets only one thread execute Python bytecode at a time, so Python threading parallelizes waiting (network, disk, sleep) but not computing. For CPU-bound speedups you need separate processes (each with its own interpreter and GIL, via multiprocessing), or code that drops out of the interpreter and releases the GIL explicitly, such as NumPy's compiled inner loops (Vectorization vs Loops) or a C/C++ extension.
Where this shows up
Interviewers use this to catch candidates who assume Python threading behaves like Java or C++ threading — the expected instinct is "is this task CPU-bound or I/O-bound" before reaching for threads at all. In production, this shapes real architecture: a market-data handler juggling many simultaneous socket connections is a natural fit for threads (or async I/O) precisely because it's I/O-bound; a research pipeline crunching numbers across cores instead uses multiprocessing, a process pool, or pushes the hot loop into NumPy/C++/Numba where the GIL is released during the actual computation. NumPy releases the GIL around most of its compiled operations specifically so that a background thread doing I/O can make progress while a NumPy call runs, which is part of why "NumPy plus threads" is a workable combination even though "pure Python plus threads" for CPU work is not.
multiprocessing sidesteps the GIL by giving each process its own interpreter, but that means no shared memory by default — objects passed between processes are pickled (serialized) and copied, which has its own real cost and is easy to underestimate when moving a CPU-bound job from threads to processes.
Practice in interviews
Further reading
- Beazley, Understanding the Python GIL (PyCon 2010)
- Python documentation, sys.setswitchinterval and the Global Interpreter Lock