The GIL: I/O-bound vs CPU-bound

threading & multiprocessing · Essentials · 10 min

Choosing threads vs processes vs asyncio starts with this one distinction.

Standard CPython has a Global Interpreter Lock (GIL): only one thread executes Python bytecode at any moment. Threads take turns.

That sounds like threads are useless, but the GIL is released whenever a thread waits for I/O (sockets, files, time.sleep, subprocess waits) and inside many C extensions (NumPy operations, hashlib on large inputs, compression). So:

  • I/O-bound work (HTTP calls, database queries, reading files): threads overlap the waiting and give big speedups. 20 threads doing 20 downloads finish in roughly the time of one.
  • CPU-bound pure-Python work (loops, parsing, math in Python): threads take turns on one core, so you get no speedup, sometimes a slowdown from switching.

For CPU-bound work, the options are:

  • processes (ProcessPoolExecutor, multiprocessing): each has its own interpreter and GIL, so they use all cores,
  • C extensions or vectorised libraries that release the GIL,
  • the free-threaded build of Python 3.13 (experimental), which removes the GIL.

asyncio is another option for I/O-bound concurrency, using one thread and an event loop instead of many threads.

Measure: time.perf_counter() around the sequential and threaded versions tells you quickly which kind of workload you have.

Example

import time
from concurrent.futures import ThreadPoolExecutor

def io_task(_):
    time.sleep(0.2)                     # releases the GIL while waiting

def cpu_task(_):
    return sum(i * i for i in range(1_000_000))   # pure Python: holds the GIL

def timed(label, fn, workers):
    t = time.perf_counter()
    with ThreadPoolExecutor(workers) as pool:
        list(pool.map(fn, range(4)))
    print(f"{label:4} {workers} thread(s): {time.perf_counter() - t:.2f}s")

timed("io", io_task, 1)    # io 1 thread(s): 0.80s
timed("io", io_task, 4)    # io 4 thread(s): 0.20s   -> 4x faster
timed("cpu", cpu_task, 1)  # cpu 1 thread(s): e.g. 0.30s
timed("cpu", cpu_task, 4)  # cpu 4 thread(s): about the same (GIL build)

Remember

  • The GIL lets only one thread run Python bytecode at a time
  • It is released during I/O and in many C extensions, so threads help I/O-bound work
  • Use processes (or free-threading) for CPU-bound pure Python

Watch out for

  • Adding threads to speed up a pure-Python number-crunching loop on a standard build.

Check yourself

  1. Which workload benefits most from threads on a standard (GIL) CPython?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/threading.html