βš–οΈ Processes vs Threads vs Async

Three tools for doing more than one thing at once. This is the decision guide, told from the multiprocessing side β€” 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 the CPU β†’ processes (a GIL each is the only escape β€” see the GIL deep dive). Waiting on the network/disk β†’ threads or async. Everything else β€” memory, startup cost, complexity β€” is a tie-breaker, not the deciding factor.

The Big Comparison

The process column is the one this site cares about β€” read it first, then contrast.

multiprocessingthreadingasyncio
True CPU parallelismβœ… Yes β€” a GIL each❌ No (one GIL)❌ No (one thread)
MemoryIsolated; pickle/IPC to shareShared by defaultShared (same thread)
Data-passing costSerialization per call (the tax)Free (shared objects)Free (shared objects)
Per-unit costHeaviest β€” full process + startupHeavy β€” OS thread, ~MBs of stackTiny β€” a coroutine object
Realistic scale~ number of CPU corestens–low hundredstens of thousands
SwitchingPreemptive (OS, separate)Preemptive (OS, anywhere)Cooperative (only at await)
Race conditionsRare β€” no shared memoryEverywhere β€” need locksRare β€” switches are predictable
Best workloadCPU-bound number crunchingBlocking I/O w/ sync librariesMassive concurrent I/O
Library requirementArgs/results must be picklableAny (works w/ requests)Needs async libs (httpx, asyncpg)
ComplexityMedium (serialization, start method, guard)Medium (locks, races)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 inference, parsing, math, compression) β†’ processes. Threads cannot help; the GIL serializes them. This is the default answer for this site.
  • A moderate pile of blocking I/O with libraries you can't or won't rewrite (requests, a sync DB driver) β†’ threads. Shared memory, no pickling, and the GIL frees on the wait.
  • Thousands of concurrent I/O tasks and you can commit to async libraries β†’ async, which scales that far for almost no memory.
Note the first branch is a hard yes/no: if adding cores would make it faster, nothing but processes (or C that releases the GIL) will do.

Same Task, Three Ways

concurrent.futures is the great unifier: the same code becomes threads or processes by swapping one class name. That makes it trivial to A/B a workload and let the numbers tell you which model your problem actually wants. See Pattern 2 for the full ProcessPoolExecutor treatment.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

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

if __name__ == "__main__":            # REQUIRED for the process pool (spawn re-imports)
    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))

if __name__ == "__main__":
    asyncio.run(main(["https://example.com"] * 1000))   # 1000 tasks, one thread
The process pool's edge here: because ThreadPoolExecutor and ProcessPoolExecutor share an API, moving CPU work from "accidentally serialized on the GIL" to "truly parallel across cores" is a one-line change β€” no rewrite. The async version, by contrast, is a different program: every call in the path must be awaitable.

The Subtleties That Bite (Especially From the Process Side)

Rule of thumb for the tax: if a task takes less time to compute than to pickle its inputs and outputs, processes will make it slower. Batch tiny items into larger chunks (chunksize) or don't use processes at all.

Hybrid Is Normal β€” Pick the Model Per Workload

Real systems mix all three. The classic shape: an asyncio web server that handles thousands of connections on one thread, but offloads each CPU-bound job to a ProcessPoolExecutor so the heavy work runs on other cores without freezing the event loop. Another: a thread pool that shells expensive work out to worker processes. You are not choosing one model for the whole program β€” you are choosing one per kind of work.

The bridge from an async server to real cores is loop.run_in_executor. Await a CPU-bound function as if it were async; under the hood it runs in a separate process and the event loop keeps serving other requests:

import asyncio
from concurrent.futures import ProcessPoolExecutor

def crunch(n):                     # top-level + picklable β†’ safe for a process pool
    total = 0
    for i in range(n):
        total += i * i
    return total

async def handle_request(loop, pool, n):
    # offload CPU work to a worker process; the event loop stays free meanwhile
    result = await loop.run_in_executor(pool, crunch, n)
    return result

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:                 # a GIL per worker β†’ real parallelism
        results = await asyncio.gather(
            *(handle_request(loop, pool, 30_000_000) for _ in range(4))
        )
    print(results)

if __name__ == "__main__":         # REQUIRED β€” the pool spawns worker processes
    asyncio.run(main())

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
An async server with CPU jobshybridrun_in_executor + process pool: loop stays free, work goes parallel
Going deeper on the other two models? This is the process-side view; the full treatments of the alternatives live in the sibling threading guides and async guides. For why processes are the only CPU escape in the first place, back up to the GIL & True Parallelism deep dive.