Threads Won't Fix CPU-Bound Python
Threads will not make your CPU-bound Python faster. Ever. On CPython, the global interpreter lock (GIL) lets only one thread execute Python bytecode at a time — so ten threads crunching numbers take turns on one core while the other cores idle. Yet ask an AI assistant to "parallelize this" and it reaches for ThreadPoolExecutor by default, whatever the workload. The fix is one decision: name the bound, then pick the model.
The one table that settles it
| Your work is… | Examples | Use | Why it works |
|---|---|---|---|
| I/O-bound, a handful of calls | a few HTTP requests, file reads | ThreadPoolExecutor | The GIL is always released during I/O, so threads genuinely overlap waits |
| I/O-bound, thousands of connections | scrapers, websockets, API fan-out | asyncio | One thread, an event loop; the docs call it "a perfect fit for IO-bound and high-level structured network code" |
| CPU-bound | image processing, hashing loops, heavy math in pure Python | ProcessPoolExecutor | Separate processes each have their own interpreter — it "side-steps the Global Interpreter Lock" |
The stdlib itself encodes this split in the default worker counts: ThreadPoolExecutor defaults to min(32, os.process_cpu_count() + 4) — more workers than cores, because they're expected to be waiting — while ProcessPoolExecutor defaults to exactly os.process_cpu_count(), one busy process per core.
The two-line swap
Both executors share the concurrent.futures interface, so fixing a wrong choice is often a one-word edit:
from concurrent.futures import ProcessPoolExecutor # was ThreadPoolExecutor
with ProcessPoolExecutor() as pool: # one process per core
results = list(pool.map(hash_block, blocks))
Same submit(), same map(), same futures. The caveat: process pools pickle arguments and return values across process boundaries, so pass plain data (bytes, dicts, dataclasses), not open sockets or lambdas.
How to know which bound you're fighting
Don't guess — measure. Run the serial version once:
- Wall time ≫ CPU time (the process sat waiting) → I/O-bound → threads or asyncio.
- Wall time ≈ CPU time (a core was pinned the whole run) → CPU-bound → processes.
time.process_time() vs time.perf_counter() around the hot loop answers this in four lines. This is also the review question to ask when an assistant hands you threaded code: "what did the profiler say the bound was?"
Power-user notes
- Mixing sync into asyncio: a blocking call inside a coroutine freezes the whole event loop. Wrap it with
await asyncio.to_thread(blocking_fn, arg)— it runs in a worker thread while the loop keeps serving. - CPU work from asyncio:
await loop.run_in_executor(process_pool, cpu_fn, arg)lets an async server offload heavy math to a process pool without blocking. - C extensions bend the rule: NumPy, compression, and hashing routines release the GIL during their C-level work, so threads can speed those up. The rule is about pure-Python bytecode.
- The GIL's days are numbered: since Python 3.13 a free-threaded build (PEP 703) can disable the GIL entirely (
--disable-gilbuild, run withPYTHON_GIL=0). Until that's your production interpreter, plan around the GIL.
Resources
- concurrent.futures — ThreadPoolExecutor & ProcessPoolExecutor
- asyncio — the event loop for IO-bound network code
- Global interpreter lock — Python glossary
- asyncio.to_thread — run blocking code without freezing the loop
- PEP 703 — making the GIL optional
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.