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.

Contents

  1. What is a Coroutine?
  2. The await Keyword
  3. The Event Loop (Core Concept)
  4. Running Coroutines Concurrently
  5. Async Context Managers & Iterators
  6. Real-World Example: Fetching Multiple URLs
  7. Coroutines vs Threads
  8. Best Practices
  9. Conclusion

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 coroutine greet.
  • 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?

  1. You create coroutines (using async def).
  2. You schedule them with the event loop (e.g., via asyncio.run() or asyncio.gather()).
  3. The event loop starts running:
    • Picks a coroutine and starts it.
    • When the coroutine hits an await (like await asyncio.sleep(1) or await 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:

  1. You order food → the waiter (event loop) takes it to the kitchen.
  2. 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).
  3. When your food is ready (I/O completes), the kitchen signals the waiter.
  4. 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
Notice that Task B finishes first even though A and C started earlier. This is true concurrency for I/O-bound tasks.

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.

Install dependency: pip install 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
Here, requests run concurrently—much faster than making them sequentially.

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 🙂