Python 3.5 introduced native coroutines via PEP 492, adding the async
and await
keywords to the language. This modernized Python’s approach to I/O-bound concurrency, replacing callback-heavy or generator-based styles with a clean, readable model.
When to use async? If your program waits on the network, files, databases, or timers, async/await can let other tasks run while one is waiting.
For CPU-bound work, prefer multiprocessing or native extensions.
What is a Coroutine?
A coroutine is a special function that can pause execution and resume later.
- Defined with
async def
- Returns a coroutine object (not executed until awaited)
- Uses
await
to yield control to the event loop
Example: A Simple Coroutine
import asyncio
async def greet():
print("Hello")
await asyncio.sleep(1) # non-blocking delay
print("World")
asyncio.run(greet())
Output:
Hello
World
Here:
async def
creates a coroutinegreet
.await asyncio.sleep(1)
suspends execution for 1 second without blocking the program.- The event loop resumes and prints “World”.
The await
Keyword
The await
keyword can only be used inside async def
. It tells Python: “pause here until the awaited coroutine finishes”.
Example: Sequential Awaits
import asyncio
async def say(message: str, delay: float):
await asyncio.sleep(delay)
print(message)
async def main():
await say("First", 1)
await say("Second", 2)
asyncio.run(main())
Output:
First
Second
Execution is sequential because each await
waits for the coroutine to finish before moving on.
What is an Event Loop?
Think of the event loop as a traffic controller for your program. Instead of letting one car (task) block the entire road while it waits at a red light, the event loop lets other cars (tasks) move ahead until the first one is ready to go again.
The event loop is part of the asyncio
module.
- It manages when coroutines run.
- It pauses coroutines that are waiting (e.g., for I/O, network, timers).
- It resumes them when the awaited task is done.
How Does It Function?
- You create coroutines (using
async def
). - You schedule them with the event loop (e.g., via
asyncio.run()
orasyncio.gather()
). - The event loop starts running:
-
- Picks a coroutine and starts it.
- When the coroutine hits an
await
(likeawait asyncio.sleep(1)
orawait session.get(url)
), it pausesthat coroutine. - While waiting, the loop looks for other tasks that can run.
- When the awaited operation finishes, the loop resumes the coroutine where it left off.
This cycle continues until all scheduled coroutines are completed.
Another Real world Analogy:
Imagine you’re at a busy restaurant.
- You (the customer) = a coroutine
You place an order (start running some code). - The waiter = the event loop
He takes your order, but instead of waiting idly at your table, he keeps moving. - The kitchen = an I/O operation
Preparing food takes time, just like fetching data from a database or making a web request.
Here’s how it plays out:
- You order food → the waiter (event loop) takes it to the kitchen.
- While the kitchen works (I/O in progress), the waiter doesn’t stand still.
He goes to other tables, taking orders and delivering drinks (running other coroutines). - When your food is ready (I/O completes), the kitchen signals the waiter.
- The waiter returns to your table and resumes service right where he left off.
Why This Analogy Works
- One waiter (event loop) can handle dozens of tables (coroutines) efficiently.
- If the waiter waited idly at each table until food arrived, the restaurant would need one waiter per table → this is like using threads, which are heavier and costlier.
- Instead, the waiter juggles multiple tables smoothly, making the restaurant (your program) far more efficient.
This analogy helps explain:
await
= “I’m waiting for the kitchen to finish; meanwhile, please serve others.”- Event loop = “the smart waiter who keeps everyone moving.”
- Coroutines = “customers being served in turns.”
The event loop is the waiter; each customer is a coroutine. When a customer orders (hits await
), the waiter doesn’t stand idle—he serves other tables.
When the kitchen is ready, the waiter returns and continues service. One waiter, many tables, little waiting.
Event Loop in Action
import asyncio
async def task(name: str, delay: float):
print(f"{name} started")
await asyncio.sleep(delay) # yield to the loop
print(f"{name} finished after {delay} seconds")
async def main():
await asyncio.gather(
task("Task A", 2),
task("Task B", 1),
task("Task C", 3),
)
asyncio.run(main())
Output:
Task A started
Task B started
Task C started
Task B finished after 1 seconds
Task A finished after 2 seconds
Task C finished after 3 seconds
Notice:
- The event loop started all tasks.
- When each task hit
await asyncio.sleep()
, the event loop paused it. - Instead of sitting idle, it resumed other tasks.
- As a result, Task B (shortest delay) finished first.
Running Coroutines Concurrently
We can run multiple coroutines at once using asyncio.gather
.
import asyncio
async def task(name, delay):
print(f"Task {name} started")
await asyncio.sleep(delay)
print(f"Task {name} finished")
async def main():
await asyncio.gather(
task("A", 2),
task("B", 1),
task("C", 3)
)
asyncio.run(main())
Output:
Task A started
Task B started
Task C started
Task B finished
Task A finished
Task C finished
Async Context Managers & Iterators
PEP 492 also introduced async with
and async for
to manage asynchronous resources and streams.
Async Context Manager
import asyncio
class AsyncResource:
async def __aenter__(self):
print("Acquire resource")
await asyncio.sleep(0.2)
return self
async def __aexit__(self, exc_type, exc, tb):
print("Release resource")
async def main():
async with AsyncResource() as r:
print("Using resource")
asyncio.run(main())
Output:
Acquire resource
Using resource
Release resource
A Real Use case: Fetching Multiple URLs
Network I/O is where async shines. Below we fetch several pages concurrently with aiohttp
.
import asyncio
import aiohttp # pip install aiohttp
URLS = [
"https://example.com",
"https://httpbin.org/get",
"https://jsonplaceholder.typicode.com/todos/1",
]
async def fetch(session: aiohttp.ClientSession, url: str) -> str:
async with session.get(url) as resp:
print(f"Fetched {url} with status {resp.status}")
return await resp.text()
async def main():
async with aiohttp.ClientSession() as session:
pages = await asyncio.gather(*(fetch(session, u) for u in URLS))
print(f"Downloaded {len(pages)} pages")
asyncio.run(main())
Output:
Fetched https://example.com with status 200
Fetched https://httpbin.org/get with status 200
Fetched https://jsonplaceholder.typicode.com/todos/1 with status 200
Downloaded 3 pages
Coroutines vs Threads
Aspect | Coroutines (async/await) | Threads |
---|---|---|
Best for | I/O-bound tasks (network, DB, file I/O) | CPU-bound tasks |
Scheduling | Cooperative via event loop | OS-level preemptive |
Overhead | Lightweight | Heavier (stacks, context switches) |
Scalability | Thousands of tasks | Dozens–hundreds (practical) |
Best Practices
- Use
async def
for coroutines - Use
await
only inside async functions - Use
asyncio.gather
for concurrency - Don’t block the event loop with CPU-heavy code—use multiprocessing instead
- Start with small async functions before scaling up
Conclusion
Coroutines with async and await (PEP 492) changed how Python handles asynchronous programming. They provide a clean, intuitive way to write concurrent code—ideal for networking, web servers, and other I/O-heavy applications.
By combining async def
, await
, and asyncio
, you can write programs that are both efficient and readable.
Async/await has now become a core skill for modern Python developers.
Happy Learning 🙂