Python

The GIL, threading, and multiprocessing

Why adding threads to a CPU-heavy Python program often doesn't make it faster — and why the fix is a different tool entirely, not a smarter use of threads.

Advanced

4 min read

The claim that surprises people coming from other languages

import threading
 
def count_to_100_million():
    n = 0
    while n < 100_000_000:
        n += 1
 
t1 = threading.Thread(target=count_to_100_million)
t2 = threading.Thread(target=count_to_100_million)
t1.start(); t2.start()
t1.join(); t2.join()

In many languages, running this on two threads on a multi-core machine would roughly halve the wall-clock time versus running it twice sequentially. In CPython (the standard Python implementation), it doesn't — running two CPU-bound threads like this takes about the same total time as running them one after another, not in parallel. The reason is a specific piece of CPython's implementation: the Global Interpreter Lock (GIL).

What the GIL actually is

The GIL is a single lock inside the CPython interpreter that only one thread can hold at a time, and holding it is required to execute Python bytecode. Multiple threads can exist — they really are separate OS-level threads — but only one of them can actually be running Python code at any given instant; the others are waiting for the GIL to become available. The GIL exists for a specific reason: CPython's internal memory management (reference counting, covered by how every Python object tracks how many references point to it) isn't thread-safe on its own, and the GIL is the mechanism that avoids the far harder problem of making every single object's reference count updates individually safe across threads.

Why threading still genuinely helps for I/O

import threading
import requests
 
def fetch(url):
    requests.get(url)   # waiting on the network — not running Python bytecode
 
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]

The GIL is released specifically while a thread is waiting on something outside the interpreter — a network response, a disk read, time.sleep() — not while it's actively running Python bytecode. This is why threading is genuinely effective for I/O-bound work: while one thread is blocked waiting on a slow network response, the GIL is free, and another thread can run. Ten threads each waiting on a slow API call can overlap their waiting time almost completely, even though only one of them is ever executing actual Python code at once.

Why threading doesn't help for CPU-bound work

def cpu_heavy_work():
    return sum(i * i for i in range(10_000_000))   # never waits on anything — pure computation

A thread running cpu_heavy_work() never releases the GIL voluntarily (aside from periodic forced switches CPython does to stay fair between threads), because it's never waiting on anything external — it's just computing, continuously. Two threads both running CPU-heavy work end up taking turns holding the GIL rather than running simultaneously, which is exactly why the counting example at the top doesn't get faster with more threads: the actual computational work still only ever happens on one CPU core at a time, no matter how many threads are started.

The fix for CPU-bound work: separate processes, not more threads

from multiprocessing import Pool
 
def cpu_heavy_work(n):
    return sum(i * i for i in range(n))
 
with Pool(processes=4) as pool:
    results = pool.map(cpu_heavy_work, [10_000_000, 10_000_000, 10_000_000, 10_000_000])

multiprocessing sidesteps the GIL entirely by using separate operating system processes instead of threads — each process gets its own Python interpreter, its own memory space, and critically, its own GIL. Four processes can genuinely run on four CPU cores simultaneously, actually parallelizing CPU-bound work in a way threading structurally cannot in CPython. The cost: processes don't share memory the way threads do, so passing data between them requires actual serialization (pickling objects to send them across process boundaries) rather than just referencing the same objects directly — real overhead that threading doesn't have.

The decision, stated directly

Reaching for threading on CPU-bound work is one of the most common real performance mistakes in Python — it looks like it should help (more threads, more work happening "at once"), and doesn't, because the GIL means the actual CPU-bound computation still serializes onto one core regardless of thread count. Recognizing which category a workload falls into — I/O-bound versus CPU-bound — is the entire decision, and it has to be made correctly before reaching for either tool.

A note on newer developments

Recent CPython work (an optional "free-threaded" build starting around Python 3.13) is aimed specifically at removing the GIL, precisely because this exact limitation has been a long-standing, well-known cost of the current design — but as of general availability, it remains experimental and not the default build most projects run. The underlying lesson — check whether work is I/O-bound or CPU-bound before picking threading or multiprocessing — remains the practical reality for the vast majority of Python code running today.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What does the GIL actually prevent, mechanically, inside CPython?

2. Why does starting ten threads that each fetch a URL over the network actually speed things up, despite the GIL?

3. Why does running two CPU-bound counting loops on two threads take about the same time as running them sequentially?

4. Why does multiprocessing achieve genuine parallel CPU usage where threading can't, in CPython?