Python has three built-in concurrency tools β asyncio, threading, and multiprocessing β and they solve different problems. For I/O-bound work (network calls, file reads, database queries), both asyncio and threading give roughly a 10x speedup over serial code. For CPU-bound work, only multiprocessing helps, because Python's Global Interpreter Lock (GIL) prevents threads from running Python bytecode in parallel.
The core difference
The three tools operate at different levels:
- asyncio β cooperative concurrency on a single thread. Code yields control with
await, and the event loop switches between tasks while one waits. Zero thread overhead, but every call in the path must beasync. - threading β OS threads sharing the same Python interpreter. The GIL prevents true parallel execution of Python bytecode, but threads are switched during I/O, so they work well for I/O-bound work. The main downside: shared mutable state requires locks.
- multiprocessing β separate OS processes, each with its own Python interpreter and memory space. Bypasses the GIL entirely. The tradeoff: higher spawn overhead (~100ms per process) and no shared memory by default.
Benchmark: I/O-bound work
We ran 10 tasks that each sleep for 200ms (simulating a network call or file read):
import asyncio
import threading
import time
N = 10
SLEEP = 0.2 # 200ms per task
# serial β one after another
def serial():
t0 = time.perf_counter()
for _ in range(N):
time.sleep(SLEEP)
return time.perf_counter() - t0
# threading β all tasks run concurrently in OS threads
def threaded():
results = [None] * N
t0 = time.perf_counter()
threads = [threading.Thread(target=lambda i=i: time.sleep(SLEEP)) for i in range(N)]
for t in threads: t.start()
for t in threads: t.join()
return time.perf_counter() - t0
# asyncio β all tasks run concurrently on the event loop
async def async_main():
t0 = time.perf_counter()
await asyncio.gather(*[asyncio.sleep(SLEEP) for _ in range(N)])
return time.perf_counter() - t0
Results on Python 3.12.3 (Ubuntu 24.04):
I/O-bound simulation: 10 tasks Γ 200ms sleep each
serial... 2.002s
threading... 0.202s (9.9x faster than serial)
asyncio... 0.201s (9.9x faster than serial)
Both threading and asyncio complete in roughly the time of one task instead of ten. That's the whole point: while one task is waiting for I/O, the others run.
Benchmark: CPU-bound work
Now a task that actually uses the CPU β counting to 5 million:
import asyncio
import threading
import multiprocessing
import time
def cpu_task(n=5_000_000):
x = 0
for i in range(n):
x += i
return x
N = 4 # four parallel tasks
CPU-bound benchmark: 4 tasks counting to 5,000,000
serial... 2.124s
threading... 2.719s (GIL prevents true parallelism β actually slower)
asyncio... 2.545s (blocks the event loop β no parallelism)
multiprocessing.. 2.575s (separate processes, bypasses GIL)
Note: This benchmark ran on a single-core machine (1 CPU). That's why multiprocessing shows no improvement over serial β there's only one physical core to schedule on. On a 4-core machine, multiprocessing typically completes in roughly 1/N_cores of the serial time.
The important takeaways here are threading and asyncio: both are slower than serial for CPU work. Threading adds locking overhead on top of the GIL; asyncio blocks the entire event loop while a CPU-bound coroutine runs.
Why threading doesn't help for CPU work: the GIL
Python's Global Interpreter Lock ensures only one thread executes Python bytecode at a time. Here's a concrete demonstration:
import threading
import time
def count(n):
x = 0
for i in range(n):
x += i
N = 10_000_000
# Two threads, each counting to N
t0 = time.perf_counter()
t1 = threading.Thread(target=count, args=(N,))
t2 = threading.Thread(target=count, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"2 threads: {time.perf_counter() - t0:.3f}s")
# One thread doing double the work
t0 = time.perf_counter()
count(2 * N)
print(f"1 thread: {time.perf_counter() - t0:.3f}s")
2 threads each counting to 10,000,000: 2.686s
1 thread counting to 20,000,000: 2.931s
Two threads doing half the work each take nearly the same time as one thread doing all of it. They can't overlap. (The slight variation is scheduling noise.)
The GIL is released during I/O waits β that's why threading works well there. It is not released during Python bytecode execution.
Using asyncio with blocking libraries
A common problem: you're in an async codebase but need to call a library that's synchronous (e.g., requests, sqlite3, a blocking database driver). Calling it directly blocks the event loop and freezes all other coroutines.
The fix is loop.run_in_executor(), which offloads the blocking call to a thread pool:
import asyncio
import time
def blocking_io(task_id):
"""Simulates a blocking call β e.g. requests.get()"""
time.sleep(0.2)
return f"task {task_id} done"
async def main():
loop = asyncio.get_event_loop()
t0 = time.perf_counter()
tasks = [
loop.run_in_executor(None, blocking_io, i)
for i in range(10)
]
results = await asyncio.gather(*tasks)
print(f"Elapsed: {time.perf_counter() - t0:.3f}s")
print(results[0])
asyncio.run(main())
Elapsed: 0.404s
task 0 done
run_in_executor(None, ...) uses the default ThreadPoolExecutor. You can pass a ProcessPoolExecutor instead for CPU-bound work that needs to escape the GIL. Pass None as the first argument to use the default thread pool.
When to use each
| Situation | Best tool | Why |
|---|---|---|
| Many concurrent network requests, API calls, or DB queries | asyncio | Lowest overhead; scales to thousands of concurrent tasks |
| Integrating a blocking library into async code | asyncio + run_in_executor | Keeps the event loop unblocked |
| I/O-bound work with a synchronous (non-async) design | threading | Simpler than async; works well for moderate task counts |
| CPU-heavy work: image processing, parsing, ML inference | multiprocessing | Only option that bypasses the GIL |
| Mixing CPU and I/O (e.g. fetch then process) | asyncio + ProcessPoolExecutor | I/O stays async; CPU work offloaded to processes |
| Firing off a few background tasks with no coordination needed | threading | Lower setup cost than asyncio when there's no event loop already running |
Practical notes
asyncio scales better than threading for large concurrency. Each OS thread consumes ~8MB of stack by default. A thousand threads means ~8GB just in stack memory. asyncio tasks are lightweight coroutines β you can run tens of thousands without issue.
threading requires care with shared state. If multiple threads read and write the same data, you need locks (threading.Lock). Asyncio avoids this because only one coroutine runs at a time (no preemption between await points).
multiprocessing has a startup cost. Spawning a process takes roughly 50β200ms depending on the OS. Use a persistent Pool for repeated work rather than creating new processes per task.
Python 3.13+ is experimenting with a per-thread GIL (PEP 703 "free-threaded" CPython). If you enable the experimental free-threaded build, threads can run Python bytecode in parallel. As of 3.12, this is not the default.
Quick decision tree
Is the work I/O-bound (waiting on network, disk, database)?
βββ Yes β asyncio (preferred) or threading
β βββ Does the library support async/await?
β βββ Yes β asyncio directly
β βββ No β asyncio + run_in_executor OR threading
βββ No (CPU-bound: computation, parsing, encoding) β multiprocessing
Tested on Ubuntu 24.04 with Python 3.12.3.