βš–οΈ Threads vs Processes vs Async

Three tools for doing more than one thing at once. This is the decision guide β€” pick the right one and the code writes itself.

The Three Models in One Breath

Key Insight: the choice is driven almost entirely by what your work is waiting on. Waiting on a CPU β†’ processes. Waiting on the network/disk β†’ threads or async. Everything else (memory, overhead, complexity) is a tie-breaker.

The Big Comparison

threadingmultiprocessingasyncio
True CPU parallelism❌ No (one GIL)βœ… Yes (a GIL each)❌ No (one thread)
MemoryShared by defaultIsolated; pickle/IPC to shareShared (same thread)
Data-passing costFree (shared objects)Serialization overhead per callFree (shared objects)
Per-unit costHeavy β€” OS thread, ~MBs of stackHeaviest β€” full process + startupTiny β€” a coroutine object
Realistic scaletens–low hundreds~ number of CPU corestens of thousands
SwitchingPreemptive (OS, anywhere)Preemptive (OS, separate)Cooperative (only at await)
Race conditionsEverywhere β€” need locksRare β€” no shared memoryRare β€” switches are predictable
Best workloadBlocking I/O w/ sync librariesCPU-bound number crunchingMassive concurrent I/O
Library requirementAny (works w/ requests)Args/results must be picklableNeeds async libs (httpx, asyncpg)
ComplexityMedium (locks, races)Medium (serialization, spawn)High (async all the way down)

The Decision, as a Flowchart

flowchart TD START["What is your work
mostly doing?"] --> CPU{"CPU-bound?
(crunching numbers
in pure Python)"} CPU -->|Yes| PROC["multiprocessing /
ProcessPoolExecutor
(or NumPy/C)"] CPU -->|"No β€” it's I/O-bound"| SCALE{"Thousands of tasks
AND able to rewrite
with async libraries?"} SCALE -->|Yes| ASYNC["asyncio"] SCALE -->|"No / stuck with
sync libraries"| THREADS["threading /
ThreadPoolExecutor"]
Read it top-down:
  • CPU-bound (image processing, ML, parsing, math) β†’ processes. Threads can't help; the GIL blocks them.
  • A moderate pile of blocking I/O with libraries you can't or won't rewrite (requests, a sync DB driver) β†’ threads.
  • Thousands of concurrent I/O tasks and you can commit to async libraries β†’ async, which scales to that many for almost no memory.

Same Task, Three Ways

concurrent.futures is the great unifier: the same code becomes threads or processes by swapping one class. That makes it trivial to A/B a workload and see which model your problem actually wants.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def work(item):
    ...   # do something with item
    return item

items = range(100)

# I/O-bound β†’ threads (shared memory, cheap, works with sync libs)
with ThreadPoolExecutor(max_workers=16) as pool:
    results = list(pool.map(work, items))

# CPU-bound β†’ processes (true parallelism; `work` & args must pickle)
with ProcessPoolExecutor() as pool:            # same API, one word changed
    results = list(pool.map(work, items))

And the async equivalent, which lives in its own world with await:

import asyncio, httpx   # note: an ASYNC http client, not `requests`

async def fetch(client, url):
    r = await client.get(url)          # yields control here β€” loop runs others
    return r.status_code

async def main(urls):
    async with httpx.AsyncClient() as client:
        return await asyncio.gather(*(fetch(client, u) for u in urls))

asyncio.run(main(["https://example.com"] * 1000))   # 1000 tasks, one thread
The catch with async: it's all-or-nothing. A single blocking call (a sync requests.get, a heavy CPU loop, time.sleep) freezes the entire event loop and every task on it. Threads tolerate blocking calls; async does not.

The Subtleties That Bite

Hybrid is normal. Real systems mix them: an async web server that offloads a CPU-bound job to a ProcessPoolExecutor via loop.run_in_executor, or a thread pool that shells work out to worker processes. Pick the model per workload, not per program.

Key Takeaways

If your work is…UseBecause
CPU-bound (pure Python)processesOnly way past the GIL for real parallelism
A handful of blocking I/O tasks, sync libsthreadsSimple, shares memory, GIL frees on I/O
Thousands of I/O tasks you can rewriteasyncScales huge for almost no memory
Threads vs processes, same codeconcurrent.futuresSwap Thread↔Process pool executor, one line
Going deeper on async? There's a sibling guide covering the event loop, coroutines, and await in detail β€” see the async guides.