← All blogs

Python Threads vs Processes vs Asyncio: Same Code, 28x Faster or Slower

BrevFeed Jul 25, 2026 11 min read 0 reads
PythonConcurrencyPerformanceAsyncioGIL

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:

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:

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

Takeaways

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