asyncio vs Threads vs Multiprocessing in Python: Pick by Workload
9 min readBytePatterns
asyncio vs threads vs multiprocessing in Python, decided by the workload: async for many idle connections, threads for blocking I/O, processes for CPU work.
asyncio vs threads vs multiprocessing in Python in one line: the workload decides. Work that mostly waits on the network or disk and has many connections in flight belongs on asyncio, one thread taking turns at every await. Work that waits but calls libraries that block belongs on threads, because CPython releases the GIL while a thread waits. Work that computes belongs on processes, because each process has its own interpreter and its own GIL, so four processes use four cores. The measurements below are from one machine and are approximate; the ratios are what matter.
| Workload | Pick | Why |
|---|---|---|
| Many connections, mostly waiting, async libraries available | asyncio | one thread, thousands of cheap tasks, no locks |
Waiting, but the library blocks (drivers, requests, files) | threads | the GIL is released during the wait |
| Computing: parsing, hashing, numeric loops in pure Python | processes | one GIL per process, so cores add up |
The problem it solves
"Make this faster with concurrency" has three tools in the standard library and the wrong one makes nothing faster. Threads on a CPU loop finish no sooner than the loop alone. asyncio around a blocking driver freezes every other task. Processes for ten HTTP calls pay more to start than the calls cost. What each tool shares, who schedules it and what a crash takes down is covered in process vs thread vs coroutine, and why the GIL exists is in threads vs processes and the GIL. This article asks only one question: given this workload, which tool, and how do I prove it with a clock?
The intuition
Ask two questions about the work. Does it wait or compute? And if it waits, does the library know how to wait cooperatively?
- Waits, and the library is async.
asyncio. A coroutine runs until it hits anawaiton something that is not ready, hands the one thread back to the event loop, and the loop runs whichever task is ready. Ten thousand idle connections are ten thousand small objects, not ten thousand stacks. Scheduling is cooperative, so there are no data races between awaits and no locks to hold. The condition is strict: every slow call must be awaitable. Onetime.sleepor one synchronous database call holds the thread and every other task stops, which is the subject of don't block the event loop. - Waits, but the library blocks. Threads, usually through
concurrent.futures.ThreadPoolExecutor. CPython's interpreter lock is released around blocking system calls, so while one thread waits on a socket another runs Python. For I/O the GIL is nearly irrelevant and ten threads finish ten waits in the time of one. The costs are the ones the GIL does not remove: a stack per thread, preemption at any bytecode, and locks around shared state. - Computes. Processes, through
ProcessPoolExecutorormultiprocessing. A pure-Python loop holds the GIL the whole time, so threads take turns on one core and the total does not shrink; it often grows, from switching. Each process has its own interpreter and GIL, so four processes on four cores finish four jobs in roughly the time of one. The costs are start-up, which on Windows and macOS means spawning a fresh interpreter that imports your module, and pickling: arguments and results cross process boundaries as bytes, so passing a large array back and forth can cost more than the computation.
Mixing is normal. An asyncio server that must call a blocking driver hands that call to a thread with asyncio.to_thread, and hands a CPU job to a process pool with loop.run_in_executor; the loop awaits both without freezing. As of October 2026, CPython also ships an optional free-threaded build without the GIL, which changes the CPU row for programs built against it; the default build still behaves as described here.
Watch it run
The animation starts from the rule: async runs on one thread, and gather schedules both coroutines and hands that thread to the loop. A prints step 1, hits await and gives the thread back; the loop resumes B; B awaits, so A runs step 2. The order is A, B, A, B on every run, because nothing preempts. Then a rude task arrives: time.sleep(5) never awaits, keeps the thread, and A, ready the whole time, cannot run. That frozen frame is the line between the first two rows of the table: a wait the loop can see is cheap, a wait it cannot see stops everything, and the fix is to move it to a thread.
Async and the Event Loop
Step 1 of 10
Async runs on one thread. gather schedules both coroutines and hands that thread to the loop.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's worker, yielding at await asyncio.sleep(0), becomes a fake network call here: a 0.2 s wait, written once as a blocking function and once as a coroutine. Ten of them, run three ways. The sequential version pays ten waits; threads and asyncio pay one, because all ten waits overlap:
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def fetch(name): # stands in for a blocking network call
time.sleep(0.2) # the thread waits; the GIL is released while it does
return name
async def fetch_async(name): # the same call, written for the event loop
await asyncio.sleep(0.2) # the coroutine yields; the one thread runs others
return name
def timed(label, fn):
start = time.perf_counter()
fn()
print(f"{label:28} {time.perf_counter() - start:5.2f} s")
async def gather_all(names):
return await asyncio.gather(*(fetch_async(n) for n in names))
def io_benchmark():
names = [f"page-{i}" for i in range(10)]
timed("I/O sequential", lambda: [fetch(n) for n in names])
with ThreadPoolExecutor(max_workers=10) as pool:
timed("I/O threads (10)", lambda: list(pool.map(fetch, names)))
timed("I/O asyncio", lambda: asyncio.run(gather_all(names)))
if __name__ == "__main__": # see the note below the next block
io_benchmark()
# I/O sequential 2.05 s
# I/O threads (10) 0.20 s
# I/O asyncio 0.20 s
Threads and asyncio tie on ten waits. They part company at ten thousand: ten thousand threads are ten thousand stacks and a scheduler juggling them, ten thousand coroutines are ten thousand small objects in one loop.
Now the work computes. Summing four million squares holds the GIL from start to finish, so four threads take turns on one core and finish no sooner than the loop alone, slightly later from the switching. Four processes each bring their own interpreter and finish sooner, in roughly half to three-quarters of the time across runs, including the cost of starting them:
def crunch(n): # pure CPU work: never releases the GIL
return sum(i * i for i in range(n))
def cpu_benchmark():
jobs = [4_000_000] * 4
timed("CPU sequential", lambda: [crunch(n) for n in jobs])
with ThreadPoolExecutor(max_workers=4) as pool:
timed("CPU threads (4)", lambda: list(pool.map(crunch, jobs)))
with ProcessPoolExecutor(max_workers=4) as pool:
timed("CPU processes (4)", lambda: list(pool.map(crunch, jobs)))
if __name__ == "__main__": # required: spawned workers import this file
cpu_benchmark()
# CPU sequential 0.81 s
# CPU threads (4) 0.84 s
# CPU processes (4) 0.47 s
The if __name__ == "__main__": guard is not decoration. With the default spawn start method on Windows and macOS, every worker process imports the main module to find crunch; without the guard the import would run the benchmark again inside each worker, which then spawns more workers. Any function sent to a process pool must also be importable at module level, which is why crunch is not a lambda. On one machine, roughly, processes won by less than four times because four jobs of 0.2 s each do not fully hide the spawn cost; make the jobs ten times longer and the ratio approaches the core count.
Finally the mixed case, the shape of a real async service: one event loop awaiting a native async call, a blocking call moved to a thread with asyncio.to_thread, and a CPU job moved to a process with run_in_executor, all at once. The loop stays responsive and the total is the longest of the three, not their sum:
async def mixed():
"""One loop that farms out a blocking call and a CPU job without freezing."""
loop = asyncio.get_running_loop()
with ProcessPoolExecutor(max_workers=2) as pool:
start = time.perf_counter()
results = await asyncio.gather(
fetch_async("page-1"), # native async I/O
asyncio.to_thread(fetch, "page-2"), # blocking I/O, moved to a thread
loop.run_in_executor(pool, crunch, 4_000_000), # CPU work, moved to a process
)
print(results[:2], results[2] > 0, f"{time.perf_counter() - start:4.2f} s")
if __name__ == "__main__":
asyncio.run(mixed())
# ['page-1', 'page-2'] True 0.39 s
The complexity
Count what each unit of concurrency costs:
- A coroutine is a Python object and a frame: kilobytes, created and switched in microseconds, switched only at
await. Scaling to tens of thousands is routine; the limit is the one thread's CPU. - A thread is an OS thread with its own stack, typically megabytes reserved, switched by the kernel at any point. Hundreds are fine; tens of thousands are not. For I/O it gives the same overlap as
asyncio; for CPU it gives nothing, because the GIL serialises the Python. - A process is an interpreter: tens of megabytes, tens to hundreds of milliseconds to spawn, plus pickling on every argument and result. For CPU it is the only one of the three that uses more than one core in the default build, and the ideal speed-up is the core count.
Where it goes wrong
- Threads for a CPU loop. The wall-clock time stays the same or grows. Use processes, or move the hot loop into a C extension that releases the GIL.
- A blocking call inside a coroutine. One
requests.getortime.sleepstops every task on the loop. Use an async client, orasyncio.to_thread. - Processes for tiny jobs. Spawning and pickling cost more than the work. Batch the jobs so each worker gets enough to do.
- No
__main__guard withspawn. Each worker re-runs the script on import; the symptom is a flood of processes or aRuntimeErrorabout bootstrapping. - Shared state across threads without a lock. The GIL makes single bytecodes atomic, not your read-modify-write.
When it shows up in interviews
As "you have ten thousand URLs to fetch, how do you do it in Python?" followed by "and if you then have to parse ten thousand large files?" The interviewer is listening for wait versus compute, the GIL's role in each, and the start-up and pickling cost of processes. A strong answer also mentions to_thread and run_in_executor for the mixed case. The event loop itself is the async and the event loop lesson, and the pool-sizing follow-up is in thread pool size: CPU-bound vs I/O-bound.
How to say it in an interview
"I ask whether the work waits or computes. If it waits and the libraries are async, I use asyncio: one thread, cooperative switching at each await, cheap enough for thousands of connections, as long as nothing blocks the loop. If it waits but the library blocks, I use a thread pool, because CPython releases the GIL during I/O. If it computes, I use a process pool, because each process has its own GIL and so uses its own core, accepting the spawn and pickling cost. In an async service I mix them: to_thread for blocking calls, run_in_executor with a process pool for CPU work."
Frequently asked questions
When should I use asyncio instead of threads in Python?
When the work is mostly waiting, the number of concurrent connections is large, and async versions of your libraries exist. One loop handles thousands of idle connections with no stacks and no locks. If a library you depend on only blocks, threads give the same overlap for I/O with no rewrite.
Does multithreading speed up CPU-bound Python code?
Not in the default build. A pure-Python loop holds the GIL, so threads take turns on one core and the total time stays the same or grows. Use processes, which each have their own GIL, or push the loop into a library that releases the GIL, or the optional free-threaded build.
Why does multiprocessing need if name == "main"?
With the spawn start method, used on Windows and macOS, each worker starts a fresh interpreter and imports the main module to find the function. Code at module level runs again during that import; without the guard the workers would re-run the pool creation and spawn more workers.
Sources
- asyncio — Asynchronous I/O — Python Documentation
- asyncio: Running in Threads (to_thread) and Executors — Python Documentation
- concurrent.futures — ThreadPoolExecutor and ProcessPoolExecutor — Python Documentation
- multiprocessing — Programming guidelines: safe importing of the main module — Python Documentation
- Glossary: global interpreter lock — Python Documentation