Python Threads vs Processes vs Asyncio: Same Code, 28x Faster or Slower
You have work to speed up, and Python hands you three tools with confident names: threading, multiprocessing, asyncio. Reach for the wrong one and you'll write more code to run slower than the plain loop you started with.
Here's the trap, in two numbers from the same machine. A batch of CPU work: the plain loop takes 4.9 seconds, and moving it to threads takes 5.1 — threading made it slower. A batch of I/O work: the plain loop takes 3.2 seconds, and moving it to threads takes 0.11 — threading made it 28× faster. Same tool. Same machine. Opposite verdicts.
The choice isn't about which library is best. It's about one property of your work — is it CPU-bound or I/O-bound — and the ranking of the tools inverts between the two. This post measures that inversion, explains the one mechanism (the GIL) that causes it, adds the fourth option nobody lists, and ends on the 2024 change that quietly rewrites the oldest rule of all.
The rig
Two workloads, because one number can only ever tell you half the story:
- CPU-bound — sum
sin(i)² + cos(i)²over a 20-million-integer range. Pure-Python floating-point, one iteration at a time. (sin² + cos² = 1, so the answer is just the count — a built-in correctness check. The trig calls are the actual work.) - I/O-bound — 64 calls that each wait 50 ms.
time.sleepstands in for an external call — a database query, an HTTP request, a disk read — where the latency isn't your CPU's fault and your core sits idle waiting.
Run each across every tool. CPython 3.12.10, 16 logical cores, wall clock, best of three. Every method returns the identical answer.
The results
CPU-bound (20M iterations):
| Method | Time | vs. sequential |
|---|---|---|
| sequential | 4.86 s | 1.0× |
| threading (16) | 5.15 s | 0.9× |
| multiprocessing (16) | 1.44 s | 3.4× |
| vectorized — numpy | 0.50 s | 9.8× |
I/O-bound (64 calls × 50 ms):
| Method | Time | vs. sequential |
|---|---|---|
| sequential | 3.22 s | 1.0× |
| threading (32) | 0.11 s | 28.5× |
| multiprocessing (16) | 1.04 s | 3.1× |
| asyncio | 0.056 s | 57.2× |
Read those two tables side by side and the inversion jumps out. Threading: 0.9× on CPU, 28.5× on I/O. Multiprocessing wins the CPU table among the concurrency tools and nearly loses the I/O one. asyncio owns I/O and can't touch CPU at all. One mechanism explains every cell.
Why: the GIL
CPython has a Global Interpreter Lock — one mutex that must be held to execute Python bytecode. A process running CPython has exactly one, so no matter how many threads you start, only one runs Python code at any instant. The others wait for the lock.
That single sentence explains the entire CPU-bound column. Your sixteen threads don't run in parallel; they take turns holding the GIL, one at a time, plus they now pay for lock hand-off and scheduling on top. That's why threading came in at 0.9× — slightly slower than doing nothing, not the 16× the core count dangles in front of you.
So how does I/O escape it? The GIL is released whenever a thread is waiting — during time.sleep, a socket read, a disk call, any blocking syscall. A thread parked on a 50 ms wait isn't running bytecode, so it drops the lock and lets another thread run. Sixteen — or thirty-two — threads can all be waiting at the same time, which is the whole job. The work was never CPU; it was waiting, and waiting parallelizes for free. That's the 28.5×.
The rule falls straight out:
CPU-bound -> threads share ONE GIL -> they serialize -> no speedup
I/O-bound -> waiting releases the GIL -> waits overlap -> big speedup
The four tools, and when each wins
multiprocessing — real CPU parallelism, bought with overhead. Each process is a separate Python interpreter with its own GIL, so N processes genuinely run on N cores. That's the 3.4× on the CPU workload. But note it's 3.4×, not 16×: every process must be spawned, and every argument and result pickled across the process boundary. On the I/O workload that overhead is pure loss — 3.1×, worse than threads and far worse than async — because you paid to start sixteen interpreters just to have them sleep. Processes are for CPU work that dwarfs their startup cost.
threading — for waiting, not computing. Shared memory, cheap to start, and the GIL steps out of the way exactly when threads block. Ideal for a few dozen concurrent I/O calls. Useless for CPU-bound Python, and — because memory is shared — the place where you actually have to think about locks and races.
asyncio — waiting at the largest scale. One thread, one event loop, cooperative multitasking: each await yields control while a call is in flight. No thread stack per task, no OS context switches, so it scales to thousands of concurrent waits where threads would choke on memory. It won the I/O table at 57×. The catch: it's all-or-nothing — one blocking (non-await) call, or one heavy CPU function, freezes the entire loop, because there's only one thread. It buys the most I/O concurrency and demands the most discipline.
Vectorization — the option nobody lists, and it beat all three. Look at the CPU table again: numpy, 9.8× — nearly three times faster than sixteen processes, from zero concurrency. The trick is to stop running a Python loop at all. np.sin(a)**2 + np.cos(a)**2 hands the whole array to a C routine that runs over contiguous memory with SIMD and without the interpreter in the loop. No GIL to fight, no processes to spawn, no data to pickle. For CPU-bound numeric work, the correct move is usually not "add cores" but "delete the loop." This is the same lesson the 117-GB Parquet case study landed the hard way — there, one vectorized pandas line beat a ten-process pool by 14×, because the pool was busy parallelizing work that shouldn't have existed.
The decision, compressed:
Is the work waiting on something external?
yes -> a lot of it? -> asyncio (thousands of waits, one thread)
a little? -> threading (a few dozen, simplest)
no (it's CPU) -> is it numeric/array?
yes -> vectorize (numpy) (no concurrency — push into C)
no -> multiprocessing (real cores, if work > overhead)
The 2024 change: free-threading, and the end of "threads can't do CPU"
Every number above rests on a premise that is, as of Python 3.13, optional. PEP 703 added a build of CPython with the GIL removed — the free-threaded build (python3.13t). No GIL means threads can finally run Python bytecode in parallel.
I ran the identical CPU-bound threading workload with the GIL on and off, on both 3.13 and 3.14, changing nothing but the interpreter. Same 16 threads:
| Build | GIL | sequential | threaded (16) | threading speedup |
|---|---|---|---|---|
| 3.13 regular | on | 3.71 s | 4.03 s | 0.92× |
| 3.13t free-threaded | off | 5.58 s | 0.74 s | 7.55× |
| 3.14 regular | on | 3.92 s | 4.27 s | 0.92× |
| 3.14t free-threaded | off | 4.56 s | 0.57 s | 7.97× |
The free-threaded rows are the headline: with the GIL gone, threading jumps from ~0.9× to ~8× on the exact workload where threads have been useless for thirty years. And that beats the 3.4× multiprocessing got on this same box — threads share memory, so there's no pickling and no interpreter startup. When the GIL stops being the bottleneck, most of the reason to prefer processes over threads for CPU work evaporates.
Which build am I even on? One call tells you, and the free-threaded interpreter is a separate binary you opt into — never your system default:
import sys
sys._is_gil_enabled() # True on a normal build; False on free-threaded with the GIL off
python3.14t # free-threaded build — GIL off by default
python3.14t -X gil=1 # force the GIL back on, to compare
PYTHON_GIL=0 python3.14t # env-var form of the same switch
But free-threading is not free — and the most important 3.14 news is exactly how much less it costs than 3.13's debut:
- A single-thread tax, shrinking fast. Replacing one big lock with fine-grained locking everywhere costs you on a single thread. On 3.13 the free-threaded build ran the sequential workload in 5.71 s against the regular build's 3.71 s — a 1.54× tax. On 3.14 that same comparison is 4.60 s against 3.92 s — 1.17×. The tax nearly halved in one release, which is the biggest reason 3.14 — where free-threading graduated from experimental to officially supported under PEP 779 — matters more than 3.13's first cut. And end to end it's already a rout: 3.14's free-threaded threaded run (0.57 s) beats the regular build's sequential (3.92 s) by ~7×, tax and all.
- The C-extension problem. A C extension written assuming the GIL protected its global state can now be entered by two threads at once. Extensions must be rebuilt and audited to declare they're safe; until the ecosystem catches up, a free-threaded interpreter may fall back to a GIL or simply refuse a module.
- Thread-safety becomes your problem. The GIL was accidentally guarding a lot of "obviously atomic" Python operations. Remove it and races that were previously impossible become possible — you write the locks now.
And note the one tool free-threading doesn't change: vectorization already sidesteps the GIL (numpy releases it during the C computation), so numpy's 9.8× owes nothing to free-threading. The fastest option in the CPU table was never GIL-bound to begin with.
Honest caveats
time.sleepmodels I/O; it doesn't do I/O. It reproduces the essential property — the CPU is idle, waiting on something external — which is exactly what governs the thread/async result. Real sockets add DNS, connection setup, and bandwidth limits on top, but they don't change the ranking.- Multiprocessing's 3.4× is sub-linear on purpose. Sixteen logical cores are eight physical ones with hyperthreading, and each chunk's work was small relative to Windows process-spawn cost. Heavier per-task work rides closer to linear — the hub's pool hit 8.3× because each task did real file I/O and parsing.
- Free-threading is young. These are 3.13.14 and 3.14.6 numbers on one machine; the single-thread tax and extension compatibility are both moving targets — and the 1.54× → 1.17× drop across one release is exactly how fast they're moving. Treat the direction as settled and the exact percentages as a snapshot.
- One machine, warm, best of three. Rankings are structural and won't move; absolute times will.
Takeaways
- Classify the work before you pick a tool. CPU-bound and I/O-bound want opposite answers, and the single most common Python performance mistake is threading CPU-bound code and watching it not get faster.
- Threads are for waiting; processes are for computing; async is for waiting a lot. The GIL serializes Python bytecode, so threads only win when the work is spent blocked (I/O), where the GIL is released anyway. Processes get real cores at the price of startup and pickling. asyncio scales waiting to thousands with one thread and no per-task stack.
- The fastest concurrency is often no concurrency. For CPU-bound numeric work, vectorizing into C beat sixteen processes here by 3× and shows up nowhere on a "threads vs processes" chart. Ask whether the loop should exist before you ask how many cores to throw at it.
- Free-threading changes the CPU rule, not the I/O one. With the GIL removed, threads finally parallelize CPU-bound Python (~0.9× to ~8×) and can beat processes by skipping IPC — at a single-thread cost that fell from 1.54× on 3.13 to 1.17× on 3.14 as it became officially supported. It doesn't touch async or vectorization, which were never GIL-bound.
The one-line version, and the thread that ties this series together: the win is in matching the tool to the shape of the work — and the first question is never "how many cores," it's "is this code even doing work worth parallelizing?"
Rig: benchmarks/parallel/ — two workloads (CPU-bound trig sum over 20M integers; 64 I/O calls of 50 ms each) across sequential / threading / multiprocessing / asyncio / numpy, plus a free-threaded experiment running the CPU/threading workload on CPython 3.13.14 and 3.14.6's standard and free-threaded builds with the GIL forced on and off. CPython 3.12.10 for the headline table, 16 logical cores, wall clock, best of three; every method returns the same answer as a correctness check. Free-threaded builds installed side-by-side via uv python install 3.13t 3.14t, never as the system default. Part of the Python-at-scale series; see also row vs column storage and file formats.
0 Comments