โš–๏ธ Choosing the Model Under Load

Threading, multiprocessing, or asyncio? The right answer is dictated by your workload โ€” not by fashion. Match the model to the bottleneck.

Step Zero: CPU-Bound or I/O-Bound?

Before you type a single import, classify the workload. Everything downstream flows from this one question, and getting it wrong means the fastest model you can pick will still be the slowest thing in your stack.

Quick test: run the workload single-threaded and watch a core. If one core is pinned at 100%, you're CPU-bound โ†’ you need processes. If CPU is low but throughput is bad, you're waiting on I/O โ†’ you need concurrency (threads or async), not parallelism.

The Big Comparison

DimensionThreadingMultiprocessingAsyncio
Real parallelismNo (GIL serializes Python bytecode)Yes โ€” one interpreter per coreNo โ€” single thread, single core
GIL impactSevere for CPU; irrelevant for I/O (GIL released on blocking calls)None โ€” each process has its own GILN/A โ€” cooperative, one task runs at a time
Memory modelShared heap โ€” cheap sharing, needs locksSeparate address spaces โ€” copy/pickle to share (IPC)Shared heap, single thread โ€” no locks needed
Per-unit cost~1โ€“8 MB stack + OS thread (~ยตsโ€“ms to spawn)Whole interpreter, ~10โ€“50 MB (~10s of ms to spawn)A coroutine ~few KB (spawn ~nsโ€“ยตs)
Ideal scaletens โ†’ low hundreds of concurrent blocking ops~= number of CPU coresthousands โ†’ tens of thousands of I/O tasks
Best workloadI/O-bound with blocking/sync librariesCPU-bound, parallelizableI/O-bound at very high concurrency, greenfield
Library ecosystemAny sync lib works as-is (requests, psycopg)Any lib, but args/results must pickleNeeds async libs (httpx, asyncpg, aiokafka)
The one-liner heuristic: CPU-bound โ†’ multiprocessing. I/O-bound + you can rewrite to async โ†’ asyncio. I/O-bound + stuck with sync libraries โ†’ threading. Everything else is nuance.

The GIL Under Load: Threads Don't Parallelize CPU

CPython's Global Interpreter Lock allows only one thread to execute Python bytecode at a time. For CPU-bound work this means 8 threads on 8 cores run no faster than 1 thread โ€” they just take turns holding the GIL, and the lock hand-off adds overhead, so it can be slower.

# โŒ Threads for CPU-bound work โ€” the GIL serializes them
import time
from concurrent.futures import ThreadPoolExecutor

def crunch(n):
    total = 0
    for i in range(n):
        total += i * i        # pure Python bytecode โ†’ holds the GIL
    return total

N = 20_000_000
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
    list(pool.map(crunch, [N] * 8))
print(f"8 threads: {time.perf_counter() - t0:.2f}s")   # โ‰ˆ same as running 8x serially
Why it hurts under load: a CPU-bound endpoint served by a thread pool will pin one core at 100% while 7 cores idle. Add more threads and latency rises (GIL contention + context switching) while throughput stays flat. The fix is not "more threads" โ€” it's another interpreter.

Two important caveats: (1) the GIL is released around blocking I/O syscalls and inside many C extensions (NumPy, hashlib, compression), so threads do overlap I/O and native compute. (2) Free-threaded CPython (the experimental 3.13t no-GIL build) changes this story โ€” but on standard CPython in production today, assume the GIL is there.

The Decision Flowchart

flowchart TB START["What's the
bottleneck?"] --> CPU{"CPU-bound?
(cores pinned)"} CPU -->|yes| MP["๐Ÿงฉ multiprocessing
(real parallelism,
~1 worker per core)"] CPU -->|no,
it waits on I/O| SCALE{"Many thousands
of concurrent ops
AND rewritable to async?"} SCALE -->|yes| ASYNC["โšก asyncio
(cheap coroutines,
one core)"] SCALE -->|no
(sync libs / modest scale)| THREADS["๐Ÿงต threading
(overlap blocking I/O,
use existing libs)"]
Combine when needed: real systems mix models. A common shape is an async web layer that offloads CPU-heavy chunks to a process pool (loop.run_in_executor(ProcessPoolExecutor(), ...)), keeping the event loop responsive while CPU work runs on other cores.

One API, Three Models: concurrent.futures

The stdlib concurrent.futures module hides threads and processes behind the same Executor interface โ€” submit(), map(), as_completed(). Swapping ThreadPoolExecutor โ†” ProcessPoolExecutor is often a one-line change, which makes it the perfect harness for A/B benchmarking a workload across models.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def run(executor_cls, fn, items, workers):
    with executor_cls(max_workers=workers) as ex:
        return list(ex.map(fn, items))    # identical call site for threads OR processes

# threads โ†’ run(ThreadPoolExecutor, task, items, 32)
# procs   โ†’ run(ProcessPoolExecutor, task, items, 8)
Gotcha: with ProcessPoolExecutor, the function and its arguments must be picklable and importable at module top level. Lambdas, closures, and locally-defined functions will raise PicklingError. Threads have no such constraint (shared memory).

Benchmark: The Same Workload, Three Ways

Talk is cheap โ€” time it. Below we run one I/O-bound task and one CPU-bound task under all three models. Fill in a real endpoint / real numbers, but the relative results below are what you should expect on standard CPython.

In Threading

import time
from concurrent.futures import ThreadPoolExecutor

def io_task(_):
    import time
    time.sleep(0.1)          # stand-in for a network/DB call (GIL released while sleeping)
    return 1

def cpu_task(_):
    total = 0
    for i in range(5_000_000):
        total += i * i       # pure Python โ†’ holds the GIL
    return total

def timed(label, fn, executor, workers, n):
    t0 = time.perf_counter()
    with executor(max_workers=workers) as ex:
        list(ex.map(fn, range(n)))
    print(f"{label:>14}: {time.perf_counter() - t0:.2f}s")

timed("threads I/O", io_task,  ThreadPoolExecutor, workers=100, n=100)   # โ‰ˆ 0.1s  โœ… overlaps
timed("threads CPU", cpu_task, ThreadPoolExecutor, workers=8,   n=8)     # โ‰ˆ serial โŒ GIL

In Multiprocessing

from concurrent.futures import ProcessPoolExecutor
# reuse the SAME io_task / cpu_task / timed defined above (must be module-level to pickle)

timed("procs   CPU", cpu_task, ProcessPoolExecutor, workers=8,  n=8)     # โ‰ˆ 8x faster โœ… real cores
timed("procs   I/O", io_task,  ProcessPoolExecutor, workers=8,  n=100)   # ok, but wasteful for I/O
# CPU work now spreads across 8 interpreters โ†’ wall time โ‰ˆ single-task time, not 8x it.
Why processes win the CPU case: each worker is a separate interpreter with its own GIL, so 8 workers truly compute on 8 cores in parallel. The cost is spawn time and pickling I/O โ€” for the tiny I/O task, that overhead makes processes a poor fit vs. threads/async.

In Asyncio

import asyncio, time

async def io_task():
    await asyncio.sleep(0.1)     # non-blocking wait โ€” the loop runs other tasks meanwhile
    return 1

async def main(n):
    t0 = time.perf_counter()
    await asyncio.gather(*(io_task() for _ in range(n)))
    print(f"   async I/O: {time.perf_counter() - t0:.2f}s")

asyncio.run(main(10_000))        # โ‰ˆ 0.1s for 10,000 tasks โ€” one thread, tiny memory footprint
Expected relative results:
  • I/O-bound: async โ‰ˆ threads โ‰ˆ ~0.1s (both overlap the wait). But async does it with one thread and KBs per task, so at 10k+ concurrency it wins on memory and scheduling overhead. Threads run out of stack RAM and scheduler headroom first.
  • CPU-bound: processes โ‰ˆ Nร— faster (N cores); threads โ‰ˆ serial (GIL); async โ‰ˆ serial and blocks the whole event loop โ€” never put CPU work on the loop.
Rule: parallelism (processes) beats CPU work; concurrency (threads/async) beats I/O wait. Never confuse the two.

Where To Go Next

Once you've picked a model, the rest of this collection shows how to make it fast and safe under load โ€” and most techniques apply across all three. Dive deeper into each model:

Reminder: none of these three fixes a slow database or a chatty downstream. If the bottleneck is external, the model barely matters โ€” pool, batch, and cache first. Measure, then choose.