codejump

AcademyPython

The GIL and Concurrency in Python

Why threading does not speed up CPU-bound Python code, what the GIL actually serialises, and when to reach for multiprocessing or asyncio instead.

3 min readUpdated

3 exercises

Waiting at the end of this page.

What the GIL actually locks

The Global Interpreter Lock is a single mutex around executing Python bytecode: at any instant, only one thread in a CPython process is running Python instructions, no matter how many threads exist or how many CPU cores are available. Threads still run concurrently in the sense of interleaving — the interpreter switches between them regularly — but never in the sense of two of them executing Python at the exact same moment.

python
import threading

def cpu_bound(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

threads = [threading.Thread(target=cpu_bound, args=(10_000_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

Four threads here do not finish in roughly a quarter of the single-threaded time. They finish in roughly the same time as one thread doing four times the work, plus the overhead of switching between them — because the GIL guarantees only one of the four is ever actually executing at once.

Where threads still help

python
import threading

def download(url):
    response = requests.get(url)   # blocks on network I/O
    save(response.content)

A thread blocked on requests.get is not executing Python bytecode — it is waiting on a socket, and CPython releases the GIL for the duration of that wait so another thread can run. This is why threading genuinely helps I/O-bound work — downloading files, querying a database, reading from disk — and does nothing for CPU-bound work like the sum of squares above, where the bottleneck is Python instructions actually running, not waiting.

Sidestepping the GIL with separate processes

python
from multiprocessing import Pool

def cpu_bound(n):
    return sum(i * i for i in range(n))

with Pool(4) as pool:
    results = pool.map(cpu_bound, [10_000_000] * 4)

Each worker in a Pool is a separate operating system process, with its own interpreter and its own GIL — four processes really do use four cores. The cost is that nothing is shared automatically: arguments and return values are pickled and sent between processes, which has real overhead and rules out sharing mutable objects directly the way threads can. Multiprocessing is the right tool specifically when the work is CPU-bound and the data crossing the process boundary is small relative to the computation.

asyncio is neither of the above

python
import asyncio

async def fetch(url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    results = await asyncio.gather(*(fetch(u) for u in urls))

asyncio runs everything on a single thread, cooperatively: an await is the point where a coroutine explicitly hands control back to the event loop, which then runs whatever else is ready. There is no GIL contention to speak of, because there is only ever one thread — but that single thread means one coroutine that never awaits (a CPU-bound loop with no await in it) blocks every other coroutine in the program, since nothing preempts it. asyncio.gather runs many I/O-bound coroutines concurrently with far less overhead than one OS thread per task, which is why it has become the default choice for a server handling thousands of simultaneous connections, each mostly waiting on the network.

Picking between the three

I/O-bound with many concurrent operations favours asyncio for the lowest overhead, or threads when the libraries involved are not async-aware. CPU-bound work that needs real parallelism requires multiprocessing, or a C-extension library that releases the GIL internally, like NumPy does for its array operations — which is why NumPy code, unlike a pure-Python loop, does benefit from threads even though the GIL is still there. Reaching for threads to speed up a CPU-bound Python loop is the mistake this page exists to prevent.

Now practice it

1 challenge · 2 quizzes

Reading this page is the cheap half. These are the exercises that make you use it.