Asyncio vs Threads
Concurrency Concepts
Asyncio vs Threading
Threads use preemptive scheduling; asyncio uses cooperative coroutines. Same problem, two models — here's how to pick.
One problem, both models
Both threads and asyncio solve the same problem: overlapping many waits. While one request is in flight, start the others instead of queuing behind it. Here is the identical workload — ten "fetches" of half a second each — written both ways. (The latency is simulated with a sleep so you can run it without a network; a real urlopen behaves the same way.)
With threads, each blocking call runs in its own OS thread, and the OS decides when to switch:
import time
from concurrent.futures import ThreadPoolExecutor
URLS = [f"https://example.com/page/{n}" for n in range(10)]
def fetch(url):
time.sleep(0.5) # simulated network latency
return f"<html>{url}</html>"
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
pages = list(pool.map(fetch, URLS))
print(f"{len(pages)} pages in {time.perf_counter() - start:.1f}s")
# 10 pages in 0.5s
With asyncio, everything runs in one thread; coroutines volunteer control at each await, and the event loop runs whichever one is ready:
import asyncio
import time
URLS = [f"https://example.com/page/{n}" for n in range(10)]
async def fetch(url):
await asyncio.sleep(0.5) # simulated network latency
return f"<html>{url}</html>"
async def main():
start = time.perf_counter()
pages = await asyncio.gather(*(fetch(url) for url in URLS))
print(f"{len(pages)} pages in {time.perf_counter() - start:.1f}s")
# 10 pages in 0.5s
asyncio.run(main())
Same speedup, same shape. The differences show up at the edges: how many concurrent operations you need, and whether your libraries cooperate.
Blocking libraries and asyncio can meet in the middle: asyncio.to_thread (Python 3.9+) hands a blocking call to a worker thread and gives you back an awaitable. This is how you use urllib from async code without freezing the loop:
import asyncio
import urllib.request
async def fetch(url):
return await asyncio.to_thread(urllib.request.urlopen, url)
async def main():
responses = await asyncio.gather(
fetch("https://example.com"),
fetch("https://www.python.org"),
)
print([r.status for r in responses]) # [200, 200]
asyncio.run(main())
Decision tree
Threads win when:
- Your libraries block.
requests, most database drivers, anything wrapping a blocking C API — threads run them concurrently as-is. Async needs either async-native replacements (aiohttp, asyncpg) or executor wrappers around every call. - Concurrency is moderate. Tens to a few hundred simultaneous operations fit comfortably in a thread pool. Each thread costs stack memory (megabytes) and scheduler overhead, but at this scale it doesn't matter.
- You're adding concurrency to existing code.
ThreadPoolExecutor.maparound an existing function is a three-line change. Going async rewrites the call chain (see below).
Asyncio wins when:
- Connection counts are very high. Thousands of websockets or long-polling clients would need thousands of threads; the event loop multiplexes them all on one thread with a small object per task.
- You need fine-grained cancellation and timeouts.
asyncio.wait_for,asyncio.timeout, and task cancellation are first-class and reliable. Killing a thread mid-blocking-call, by contrast, is essentially impossible in Python. - You're doing streaming or websockets. Long-lived, mostly-idle connections are the event loop's home turf — that's why every websocket framework is async.
Neither wins when the work is CPU-bound. Threads serialize on the GIL, and a coroutine crunching numbers blocks the whole event loop. Use ProcessPoolExecutor or a native extension for compute.
The cost of async: colored functions
async is contagious. A regular function cannot await, so the moment one operation deep in your code becomes async, every caller up the chain must become async def too — and be called from a running event loop. People call this the "colored functions" problem: sync and async functions are two colors that don't mix freely, and converting a codebase means repainting whole call chains, not one function.
The trap runs the other way as well: one ordinary blocking call inside a coroutine — time.sleep, requests.get, a sync DB query — stalls the entire loop, freezing every other task until it returns. Threads are forgiving here (the OS preempts a blocked thread and runs the rest); async is not. That's why the ecosystem split matters: in async code you commit to async-native libraries from day one, or wrap the stragglers in asyncio.to_thread.
Practical corollaries:
- Async code still runs in a single OS thread; use
asyncio.Semaphoreto cap concurrency instead of sizing a pool. - Mixing is fine and normal: an async web app calling one legacy blocking library via
asyncio.to_threadbeats rewriting the library. - If you're starting a project that will live on I/O (an API gateway, a scraper, a chat server), pick async libraries (aiohttp, asyncpg) up front to avoid painful rewrites.
Frequently Asked Questions
Can I mix asyncio and threads in one program?
Yes, and real applications usually do. asyncio.to_thread (or loop.run_in_executor) runs a blocking function in a worker thread and returns an awaitable, so the event loop keeps serving other tasks while the thread waits. Going the other direction, asyncio.run_coroutine_threadsafe lets a thread submit work to a running loop.
import asyncio, time
async def main():
# blocks a worker thread for 1s; the loop stays free
await asyncio.to_thread(time.sleep, 1)
print("done") # done
asyncio.run(main())
Is asyncio faster than threads?
Not per operation — a single request takes the same network round-trip either way. Asyncio is cheaper per concurrent operation: a task is a small Python object, while a thread costs a stack and OS scheduling. Below a few hundred concurrent operations the difference is noise; at thousands, threads run out of road and asyncio keeps scaling.
Don't Python threads run one at a time because of the GIL?
For Python bytecode, yes — but the GIL is released during blocking I/O, so twenty threads can all be waiting on the network simultaneously. That is exactly the workload threads are good at. The GIL only makes threads useless for CPU-bound pure-Python work, where you need processes instead.