Python asyncio, explained
asyncio is Python's answer to I/O-bound concurrency — handling thousands of network
connections, file reads, or API calls without spawning a thread per task. It trips people
up because it introduces three new ideas at once: coroutines, await, and an event loop.
This guide separates them so you can reason about what actually runs when.
What a coroutine actually is
Defining a function with async def doesn't run anything — it creates a coroutine
function. Calling it returns a coroutine object, a paused computation that does
nothing until it's driven by an event loop.
async def fetch(name):
print(f"start {name}")
return name.upper()
coro = fetch("a") # nothing printed yet — just a coroutine object
print(coro) # <coroutine object fetch at 0x...>
That last line even warns you the coroutine was never awaited. A coroutine is inert; it needs something to drive it.
await suspends, it doesn't block
await does two things: it runs another awaitable to completion, and — crucially — it
yields control back to the event loop while waiting. That's the whole point. While one
coroutine is parked on a slow network read, the loop runs others.
import asyncio
async def task(name, delay):
await asyncio.sleep(delay) # yields to the loop; doesn't block the thread
print(f"{name} done after {delay}s")
return name
async def main():
result = await task("a", 1) # runs to completion before continuing
print(result)
asyncio.run(main())
Note await asyncio.sleep(1) is non-blocking, whereas time.sleep(1) would freeze the
entire loop. Mixing blocking calls into async code is the #1 asyncio mistake.
The event loop is a single thread
There is one thread and one event loop. It keeps a queue of ready tasks and runs them one
at a time; each task runs until it hits an await that can't complete immediately, then
the loop switches to another ready task. This is cooperative multitasking — tasks must
yield via await for anything else to run.
Because it's single-threaded, you don't need locks for ordinary in-memory state, and there
are no race conditions between await points. But CPU-heavy code still blocks everything.
Running things concurrently with tasks
Awaiting coroutines one after another is sequential. To overlap them, wrap each in a
task (scheduled on the loop) and await them together with asyncio.gather.
import asyncio, time
async def work(n):
await asyncio.sleep(1)
return n * 2
async def main():
start = time.perf_counter()
results = await asyncio.gather(work(1), work(2), work(3))
print(results, f"in {time.perf_counter() - start:.1f}s") # [2,4,6] in ~1.0s
asyncio.run(main())
Three one-second sleeps finish in ~1 second, not 3, because they overlap. gather returns
results in argument order regardless of which finished first.
Tasks vs coroutines
asyncio.create_task(coro) schedules a coroutine to run right away in the background
and returns a Task handle. A bare coroutine only runs when awaited. Use tasks when you
want work to start before you await its result.
async def main():
t = asyncio.create_task(work(10)) # starts running now
await asyncio.sleep(0.5) # do other things meanwhile
print(await t) # collect the result later
asyncio.run(main())
Modern code often prefers asyncio.TaskGroup (3.11+), which awaits a set of tasks and
cancels the rest if one fails — safer structured concurrency than raw gather.
Running blocking code without freezing the loop
When you must call a blocking function (a sync DB driver, heavy CPU work), offload it to a thread or process pool so the loop stays responsive.
import asyncio
def blocking_io():
with open("big.log") as f:
return len(f.read())
async def main():
size = await asyncio.to_thread(blocking_io) # runs in a worker thread
print(size)
asyncio.run(main())
asyncio.to_thread is the clean way to bridge sync and async. For CPU-bound work, use
loop.run_in_executor with a ProcessPoolExecutor to escape the GIL.
Where asyncio fits
asyncio shines for I/O-bound, high-concurrency workloads — web servers, scrapers, chat backends, anything waiting on many sockets at once. It is not a speedup for CPU-bound work (use multiprocessing) and adds real complexity, so don't reach for it when a handful of threads or a simple sequential script would do.
Recap
async def creates coroutines — inert until driven by the event loop, which runs
on a single thread. await runs an awaitable and yields control while waiting, letting
other tasks run cooperatively. To overlap work, schedule coroutines as tasks (or use
gather/TaskGroup); awaiting them one by one stays sequential. Never call blocking
functions directly inside async code — use asyncio.to_thread or an executor. asyncio is
the right tool for massive I/O concurrency, and the wrong one for CPU-bound speed.