How does asyncio differ from threading, and when would you choose one over the other?
asyncio is cooperative, single-threaded concurrency: coroutines yield control explicitly at await points, so there is no GIL contention and no shared-state races. Threads are preemptive OS-level concurrency: the scheduler can switch at any bytecode boundary, which requires explicit locking. Choose asyncio for high-fan-out I/O (thousands of connections); choose threads when you need to call blocking APIs you cannot rewrite.
How to think about it
Both asyncio and threading target I/O-bound work. The difference is who controls the switch — and that single difference cascades into very different mental models for writing safe concurrent code.
The core distinction
With threads, the OS scheduler decides when to pause one thread and resume another. Your code doesn’t get a say. Any shared variable can be mid-update when the switch happens, so you need locks.
With asyncio, your code decides when to yield control — at every await. Between await points, you are the only thing running. No locks needed for local state, because nothing can interrupt you.
asyncio — cooperative, single-threaded
An async def function is a coroutine. When it hits await, it suspends and hands control back to the event loop, which picks another coroutine to run. No OS scheduler, no GIL contention, no locks needed for coroutine-local state.
The practical win: a single event loop can juggle tens of thousands of connections at very low overhead — no thread stacks, no kernel context switches.
import asyncio
async def fetch(session, url):
async with session.get(url) as r:
return await r.text()
async def main():
import aiohttp
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, "https://example.com") for _ in range(100)]
pages = await asyncio.gather(*tasks)
asyncio.run(main())
threading — preemptive, multi-threaded
The OS can context-switch a thread at any point. This makes threads work transparently with blocking libraries (plain requests, synchronous database drivers), but shared mutable state requires explicit synchronisation.
import threading
import requests
results = []
lock = threading.Lock()
def fetch(url):
r = requests.get(url)
with lock:
results.append(r.text)
threads = [threading.Thread(target=fetch, args=("https://example.com",)) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
When to choose which
| Factor | asyncio | threading |
|---|---|---|
| Concurrency count | Thousands | Hundreds (stack RAM is ~8 MB/thread) |
| Library ecosystem | Needs async-native libs | Works with any blocking lib |
| Shared state safety | Implicit (single thread) | Requires locks |
| Blocking CPU work | loop.run_in_executor | Natural |
You can bridge the two worlds: loop.run_in_executor offloads a blocking call into a thread pool from async code.