Asynchronous Python
Do thousands of I/O operations concurrently on a single thread. Master the event loop, coroutines, async/await, asyncio, and the patterns that power modern Python servers.
Why Async? Blocking vs Non-Blocking
Async lets a single thread juggle many tasks that spend most of their time waiting — network calls, disk reads, database queries. Instead of sitting idle during a wait, the program switches to other work. This is concurrency, not parallelism: one worker, never idle, not many workers at once.
Synchronous (blocking)
Each call must finish before the next starts. Download 3 pages at 1s each → 3 seconds. The CPU sleeps while the network works.
Asynchronous (non-blocking)
Start all 3 downloads, await them together. Total ≈ 1 second. While one waits on I/O, the loop runs the others.
When to reach for async
I/O-bound work (APIs, sockets, DB, files) → asyncio shines. CPU-bound work (number crunching, image processing) → use multiprocessing instead; async won't help because there's no waiting to overlap.
Coroutines & the await Keyword
A coroutine is a function defined with async def. Calling it does not run it — it returns a coroutine object. You must await it (or schedule it) to actually execute. await hands control back to the event loop until the awaited thing is ready.
import asyncio async def greet(name): print(f"Hello {name}") await asyncio.sleep(1) # non-blocking pause — loop runs others print(f"Bye {name}") return f"done: {name}" greet("Ada") # returns a coroutine — does NOT run asyncio.run(greet("Ada")) # runs it on the event loop
Task, or Future — anything you can await.await is only legal inside an async def.The Event Loop
The event loop is the engine. It maintains a queue of ready tasks, runs one until it hits an await that isn't ready, parks it, and runs the next. When I/O completes, the parked task is re-queued. asyncio.run() creates the loop, runs your main coroutine to completion, then closes it.
import asyncio async def main(): print("start") await asyncio.sleep(1) print("end") # Python 3.7+ — the modern, only entry point you need asyncio.run(main()) # Inside a running loop, grab it with: # loop = asyncio.get_running_loop()
One loop, one thread
There is normally one event loop per thread. Never call asyncio.run() from inside a coroutine — it's a top-level entry point. Don't run blocking code (like time.sleep or heavy CPU) on the loop; it freezes everything.
Tasks & asyncio.gather
Awaiting coroutines one by one is still sequential. To run them concurrently, wrap them in Tasks (which schedule them on the loop immediately) or use asyncio.gather() to launch and collect many at once.
import asyncio, time async def work(n): await asyncio.sleep(1) return n * n async def main(): # Sequential — ~3s total a = await work(1); b = await work(2); c = await work(3) # Concurrent — ~1s total results = await asyncio.gather(work(1), work(2), work(3)) print(results) # [1, 4, 9] — order preserved # Or schedule explicit Tasks t1 = asyncio.create_task(work(4)) t2 = asyncio.create_task(work(5)) print(await t1, await t2) # 16 25 asyncio.run(main())
| Tool | What it does | Use when |
|---|---|---|
create_task() | Schedules a coroutine to run now, returns a Task | You need a handle to await/cancel later |
gather(*aws) | Runs all concurrently, returns a list of results in order | You want all results together |
as_completed() | Yields results as each finishes | You want to process whichever finishes first |
TaskGroup() | Structured concurrency (3.11+), auto-cancels siblings on error | Modern, safest grouping |
Concurrency Patterns
Timeouts, racing for the first result, and limiting how many run at once are the everyday tools of async code.
import asyncio # 1. Timeout — cancel if it takes too long try: await asyncio.wait_for(slow_call(), timeout=2.0) except asyncio.TimeoutError: print("too slow!") # 2. Process results as they arrive for fut in asyncio.as_completed([work(1), work(2)]): print(await fut) # 3. Limit concurrency — at most 5 in flight sem = asyncio.Semaphore(5) async def guarded(url): async with sem: return await fetch(url)
Async HTTP with aiohttp
The requests library is blocking — using it in async code stalls the loop. aiohttp is the async HTTP client. Fetch hundreds of URLs concurrently with one shared session.
import asyncio, aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(urls): async with aiohttp.ClientSession() as session: tasks = [fetch(session, u) for u in urls] pages = await asyncio.gather(*tasks) print(f"fetched {len(pages)} pages") asyncio.run(main(["https://example.com"] * 50))
Reuse the session
Create one ClientSession and share it across requests — it pools connections. Creating a session per request is a classic performance bug.
Real-World Pattern — Producer / Consumer
An asyncio.Queue decouples producers from consumers — the backbone of crawlers, job runners, and pipelines. Workers pull items concurrently while producers push.
import asyncio async def worker(name, queue): while True: job = await queue.get() await asyncio.sleep(0.5) # pretend to work print(f"{name} did {job}") queue.task_done() async def main(): queue = asyncio.Queue() for i in range(10): queue.put_nowait(f"job-{i}") workers = [asyncio.create_task(worker(f"w{n}", queue)) for n in range(3)] await queue.join() # wait until every job is done for w in workers: w.cancel() # stop idle workers asyncio.run(main())
Common Pitfalls
greet("x") creates a coroutine that never runs and emits a "coroutine was never awaited" warning. Always await it or wrap in a task.
Calling time.sleep(), requests.get(), or heavy CPU work freezes every coroutine. Use asyncio.sleep, aiohttp, or offload with loop.run_in_executor().
for u in urls: await fetch(u) runs one at a time. Build a list of tasks and gather them for real concurrency.
It's single-threaded cooperative multitasking. For CPU-bound work, reach for multiprocessing or concurrent.futures.ProcessPoolExecutor.
Quick Quiz
async def function directly returns…