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.