Why this matters
A chat server with 10,000 connected clients, a crawler fetching 500 pages, a bot listening on several sockets: each spends nearly all its time waiting for the network. Threads can overlap that waiting, but 10,000 OS threads cost a lot of memory and switching, and shared state needs locks. asyncio handles many waits in a single thread by switching between tasks only at points you mark with await. It powers modern Python web frameworks (FastAPI, aiohttp) and network clients, so you will meet async and await in real code soon.
The idea: cooperative multitasking
Threads are preemptive: the OS can interrupt one at any instruction. asyncio is cooperative: a task runs until it voluntarily says "I am waiting for something, let someone else run". The coordinator is the event loop:
event loop (one thread)
ready queue: [task A, task B, task C]
run A until it awaits a network reply -> A parked, waiting on socket 5
run B until it awaits sleep(0.1) -> B parked, timer at t+0.1
run C until it finishes -> C done
nothing ready: ask the OS (epoll on Linux) "wake me when socket 5 has data
or in 0.1 s, whichever comes first"
socket 5 ready -> A back in the ready queue ...
Because switches happen only at await, code between two awaits runs without interruption. That removes most race conditions without locks. The flip side: one task that computes for a long time, or makes a blocking call, freezes everyone.
Coroutines: async def and await
A function defined with async def is a coroutine function. Calling it does not run it; it returns a coroutine object, a suspended computation:
import asyncio
async def greet(name):
print("start", name)
await asyncio.sleep(0.1)
print("end", name)
return name.upper()
coro = greet("ada")
print(type(coro)) # <class 'coroutine'>
result = asyncio.run(coro) # now it runs
print(result)
<class 'coroutine'>
start ada
end ada
ADA
asyncio.run(coro)creates an event loop, runs the coroutine to completion, closes the loop, and returns the result. Call it once, at the top of your program, usually asasyncio.run(main()).await xmay only appear insideasync def. It means: runx(a coroutine, task or future); if it has to wait, suspend me and let the loop run others; resume me with its result.asyncio.sleep(seconds)is the asynchronous sleep: it parks the task on a timer instead of blocking the thread.
Under the hood, coroutines are built on the same machinery as generators (previous module): a frame that can be suspended and resumed. An await that must wait passes control all the way back up to the event loop, which later resumes the chain.
Awaiting is not concurrency
This is the most common surprise:
async def main():
await greet("a")
await greet("b")
start a
end a
start b
end b
Each await waits for that coroutine to finish before the next line runs: sequential, just with extra syntax. Concurrency needs several tasks alive at once.
Running things concurrently
asyncio.gather runs several awaitables concurrently and returns their results as a list in argument order:
import asyncio, time
async def work(name, delay):
await asyncio.sleep(delay)
return f"{name} done"
async def main():
start = time.perf_counter()
results = await asyncio.gather(work("a", 0.3), work("b", 0.1), work("c", 0.2))
print(results)
print(f"took {time.perf_counter() - start:.1f} s")
asyncio.run(main())
['a done', 'b done', 'c done']
took 0.3 s
Total time is the longest wait, not the sum. b finished first, but results still come in argument order. If a coroutine raises, gather raises that exception (pass return_exceptions=True to get exceptions in the list instead).
Tasks start a coroutine running in the background right away:
async def main():
task = asyncio.create_task(work("bg", 0.2))
print("task started, doing other things")
await asyncio.sleep(0.1)
print(await task) # wait for its result
Keep a reference to every task you create (the loop holds only a weak reference; an unreferenced task can be garbage collected mid-run). Python 3.11 added asyncio.TaskGroup, a structured way to run tasks that cancels the rest if one fails:
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(work("x", 0.1))
t2 = tg.create_task(work("y", 0.2))
print(t1.result(), t2.result()) # all done when the block exits
Useful tools
- Timeouts:
async with asyncio.timeout(2):orawait asyncio.wait_for(coro, 2)raiseTimeoutErrorif the work takes too long. - Limiting concurrency:
asyncio.Semaphore(n)withasync with sem:lets at mostntasks into a block, for example to respect a server's rate limit. - Queues:
asyncio.Queuefor producer/consumer pipelines between tasks. - Blocking code:
await asyncio.to_thread(func, *args)runs a blocking function in a worker thread without freezing the loop. - Streams:
asyncio.open_connection(host, port)returns reader/writer objects for TCP, the basis of network clients.
async with and async for are the asynchronous versions of with and for, for context managers and iterators whose setup or next step involves waiting.
When to use what
| Situation | Tool |
|---|---|
| CPU-bound pure Python | processes (ProcessPoolExecutor) |
| A few blocking I/O calls, existing synchronous libraries | threads (ThreadPoolExecutor) |
| Many concurrent network connections, async libraries available | asyncio |
asyncio is "viral": to benefit, the libraries you call must be async too (an async HTTP client, an async database driver). A synchronous library call inside a coroutine blocks the loop.
Common mistakes
- Forgetting
await:result = fetch()inside a coroutine gives you a coroutine object, not the result, andRuntimeWarning: coroutine 'fetch' was never awaited. awaitoutside async def:SyntaxError: 'await' outside async function.- Calling
asyncio.runinside a running loop (for example in Jupyter, or from a coroutine):RuntimeError: asyncio.run() cannot be called from a running event loop. Inside a coroutine, justawait. - Blocking the loop with
time.sleep,requests.get, or heavy computation. Everything stalls; use the async equivalent orasyncio.to_thread. - Awaiting in a loop when you meant concurrency: use
gatheror aTaskGroup.
Recap
- asyncio runs many tasks in one thread; the event loop switches tasks only at
awaits that must wait (cooperative multitasking). async defdefines a coroutine function; calling it creates a coroutine object;asyncio.run(main())drives the top level;awaitruns and waits.- Sequential awaits are not concurrent. Use
asyncio.gather(results in argument order),create_task, orTaskGroup. - Never block the loop; use
asyncio.sleep, async libraries, orasyncio.to_thread. - Pick processes for CPU-bound work, threads or asyncio for I/O-bound work.
Going further: read the "Coroutines and Tasks" page of the asyncio documentation, then write a tiny TCP echo server with asyncio.start_server and connect to it with nc localhost 8888 from two terminals at once.