🐍 Python Course · Stage 3 · Lesson 37 of 89 · asyncio — Async I/O
Course Overview →

🔵 Read Beginner and Intermediate tabs. The Expert tab has the internals — worth reading once you finish Stage 3.

← sys — System Params logging — Diagnostics →
thecodex.expert · The Codex Family of Knowledge
Python stdlib

Python asyncio module

Concurrent I/O on a single thread — write code that waits for many things at once without creating a thread per task.

Python 3.13 Standard Library Last verified:
Canonical Definition

The Python asyncio module provides an event-loop-based framework for writing concurrent I/O-bound code using async/await syntax — running many coroutines on a single thread by suspending them when waiting for I/O.

🟩 Beginner

Writing concurrent code with async/await

What you'll learn: The async/await syntax, how to run a coroutine with asyncio.run(), and how to run multiple coroutines concurrently with asyncio.gather().

How to read this tab: Write an async function that simulates waiting (await asyncio.sleep(1)) and run 3 of them concurrently with gather(). Compare the time to running them sequentially.

⏱ 35 min 📄 2 sections 🔶 Prerequisite: Concurrency lesson
One sentence

asyncio lets you write code that does multiple things at once — downloading files, querying databases, waiting for network responses — without creating threads, by pausing tasks when they're waiting and resuming them when ready.

The mental model for await: when a coroutine hits "await something", it pauses and returns control to the event loop. The event loop can then run other coroutines. When "something" completes, the event loop resumes your coroutine from where it paused. This is cooperative — your code must yield control explicitly with await. A coroutine that never awaits blocks the entire event loop.

async / await basics

Pythonasyncio_basics.py
import asyncio

# async def defines a coroutine function
# Calling it returns a coroutine object — nothing runs yet
async def greet(name, delay):
    await asyncio.sleep(delay)    # pause THIS coroutine, yield control
    print(f"Hello, {name}!")
    return f"greeted {name}"

# asyncio.run() — entry point; creates and runs the event loop
async def main():
    # Sequential — total 3 seconds
    await greet("Priya", 1)
    await greet("Rahul", 2)

asyncio.run(main())

# Run concurrently with asyncio.gather
async def main_concurrent():
    # Both start immediately, total ~2 seconds (not 3)
    results = await asyncio.gather(
        greet("Priya", 1),
        greet("Rahul", 2),
    )
    print(results)   # ['greeted Priya', 'greeted Rahul']

asyncio.run(main_concurrent())
💡

gather() vs create_task(): asyncio.gather(c1, c2, c3) starts all three and waits for all to complete. asyncio.create_task(c) schedules a coroutine to run and returns a Task object immediately — you can let it run in the background and await it later. Use gather() when you want all results together; create_task() when you want fire-and-forget.

Tasks

Pythonasyncio_tasks.py
import asyncio

async def fetch_data(url, delay):
    print(f"Fetching {url}...")
    await asyncio.sleep(delay)   # simulate network I/O
    return f"data from {url}"

async def main():
    # Create tasks — schedule coroutines to run concurrently
    task1 = asyncio.create_task(fetch_data("api.example.com/users", 1))
    task2 = asyncio.create_task(fetch_data("api.example.com/posts", 2))
    task3 = asyncio.create_task(fetch_data("api.example.com/comments", 0.5))

    # Tasks run concurrently from here; await to get results
    result1 = await task1
    result2 = await task2
    result3 = await task3
    print(result1, result2, result3)

    # Timeout — cancel if too slow
    try:
        result = await asyncio.wait_for(fetch_data("slow.com", 10), timeout=2.0)
    except asyncio.TimeoutError:
        print("Request timed out")

asyncio.run(main())
📎

asyncio's power shows with real I/O libraries. The standard requests library is synchronous — calling it from async code blocks the event loop. Use aiohttp (async HTTP) or httpx (both sync and async) for truly concurrent HTTP requests. The pattern: async with aiohttp.ClientSession() as session: async with session.get(url) as response: data = await response.json().

Real I/O: aiohttp pattern

Pythonasyncio_pattern.py
# asyncio itself doesn't do HTTP — use aiohttp (pip install aiohttp)
# This shows the correct pattern for concurrent HTTP requests

import asyncio

async def fetch(session, url):
    # async with: async context manager
    async with session.get(url) as response:
        return await response.json()

async def fetch_all(urls):
    import aiohttp
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        return await asyncio.gather(*tasks)   # all concurrent

urls = [
    "https://jsonplaceholder.typicode.com/posts/1",
    "https://jsonplaceholder.typicode.com/posts/2",
    "https://jsonplaceholder.typicode.com/posts/3",
]
results = asyncio.run(fetch_all(urls))
print(len(results))   # 3 responses fetched concurrently

✅ Beginner tab complete — check your understanding

  • I can define a coroutine with "async def" and await another coroutine with "await"
  • I can run a coroutine as the entry point with asyncio.run()
  • I can run multiple coroutines concurrently with await asyncio.gather()
  • I understand that await yields control — it does NOT block the event loop

Continue to logging →

🔵 Intermediate

Tasks, timeouts, and running blocking code

What you'll learn: asyncio.create_task() for fire-and-forget concurrent execution. wait_for() for timeouts. run_in_executor() for running synchronous blocking code without blocking the event loop.

How to read this tab: The run_in_executor pattern is essential for mixing async and sync code. Try calling a synchronous function (like requests.get) from an async context using run_in_executor.

⏱ 35 min 📄 2 sections 🔶 Prerequisite: Beginner tab
📎

The event loop runs forever, checking for ready coroutines. It uses the OS's selector mechanism (select/poll/epoll on Unix, IOCP on Windows) to monitor I/O events. When a file descriptor becomes readable or writable, the event loop wakes up the coroutine that was waiting for it. This is how thousands of connections can be handled on one thread.

Event loop, coroutines, and the execution model

asyncio runs on a single thread. The event loop maintains a queue of coroutines. When a coroutine hits an await that blocks (network I/O, sleep), the event loop suspends it and runs another. When the I/O completes (signalled by the OS via epoll/kqueue/IOCP), the event loop resumes the waiting coroutine. This is cooperative multitasking — coroutines must voluntarily yield with await. CPU-bound code never yields and starves other coroutines.

Pythonasyncio_advanced.py
import asyncio

# asyncio.TaskGroup (Python 3.11+) — structured concurrency
async def main():
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(some_coro())
        task2 = tg.create_task(another_coro())
    # Both tasks complete (or raise) before continuing
    # If either raises, the other is cancelled

# Semaphore — limit concurrent operations
async def limited_fetch(semaphore, url):
    async with semaphore:   # max 5 concurrent fetches
        return await fetch(url)

async def main_limited():
    sem = asyncio.Semaphore(5)
    tasks = [limited_fetch(sem, url) for url in large_url_list]
    return await asyncio.gather(*tasks)

# Queue — producer/consumer pattern
async def producer(queue):
    for i in range(10):
        await queue.put(i)
        await asyncio.sleep(0.1)
    await queue.put(None)   # sentinel

async def consumer(queue):
    while True:
        item = await queue.get()
        if item is None: break
        print(f"Processing {item}")

async def main_queue():
    q = asyncio.Queue()
    await asyncio.gather(producer(q), consumer(q))

The bridge between async and sync worlds. loop.run_in_executor(None, blocking_function, arg1, arg2) runs the blocking function in a thread pool and returns an awaitable. This is how you integrate legacy synchronous libraries (database drivers, file operations) into async code without blocking the event loop.

Running sync code in executor

Pythonasyncio_executor.py
import asyncio
from concurrent.futures import ThreadPoolExecutor

# CPU-bound or blocking sync code must run in an executor
# to avoid blocking the event loop
async def main():
    loop = asyncio.get_event_loop()

    # Run blocking I/O in thread pool
    def blocking_read():
        with open("large_file.txt") as f:
            return f.read()

    content = await loop.run_in_executor(None, blocking_read)

    # Run CPU-bound work in process pool
    from concurrent.futures import ProcessPoolExecutor
    with ProcessPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, cpu_heavy_function, data)
Commonly confused
asyncio does not make CPU-bound code faster. asyncio is for I/O-bound concurrency — tasks that spend time waiting for network, disk, or timers. If your code is CPU-bound (number crunching, image processing), use multiprocessing or concurrent.futures.ProcessPoolExecutor instead. Running CPU-bound code in a single asyncio event loop starves all other coroutines.
A coroutine function is not a coroutine. async def greet() is a coroutine function. Calling greet() returns a coroutine object — nothing runs. The coroutine only runs when awaited or scheduled as a task. Forgetting to await a coroutine is a common bug that produces no error — the coroutine just never executes.
asyncio is single-threaded — it is not thread-safe by default. Do not use asyncio objects from multiple threads without asyncio.run_coroutine_threadsafe(). If you need asyncio + threads, use loop.call_soon_threadsafe() to schedule from a thread into the event loop.

✅ Intermediate tab complete — check your understanding

  • I know the difference between asyncio.gather() and asyncio.create_task()
  • I can use asyncio.wait_for(coro, timeout=5) to cancel if it takes too long
  • I can use loop.run_in_executor(None, blocking_func, arg) to run synchronous code

Continue to logging →

🔴 Expert

The event loop, execution model, and PEP history

What you'll learn: The event loop's internal execution model — how it decides which coroutine to resume and when. The history of async in Python (PEP 3156 → PEP 492). The event loop implementation.

How to read this tab: Read after you've built at least one real async application and have questions about why certain things behave the way they do.

⏱ 25 min 📄 2 sections 🔶 Prerequisite: After Stage 3

PEP 492, PEP 3156, and the async/await history

Python's async history: PEP 342 (Python 2.5, 2005) gave generators send() — enabling coroutines. PEP 3156 (Python 3.4, 2014) introduced asyncio as the standard event loop and the @asyncio.coroutine / yield from syntax. PEP 492 (Python 3.5, 2015) introduced async def, await, async for, and async with as first-class syntax, replacing the decorator-based approach. PEP 525 (Python 3.6) added asynchronous generators. PEP 654 (Python 3.11) introduced TaskGroup and ExceptionGroup for structured concurrency.

The event loop implementation

CPython's asyncio event loop uses the OS I/O polling mechanism: select.epoll() on Linux, select.kqueue() on macOS, and IOCP on Windows (via the ProactorEventLoop). The loop maintains a ready queue (callbacks to call immediately) and a scheduled queue (callbacks with a deadline). Each iteration: run all ready callbacks, then poll the OS for I/O events with a timeout matching the next scheduled deadline, then process I/O callbacks. Third-party loops (uvloop) replace the event loop with a libuv-based implementation, achieving 2–4× higher throughput for I/O-bound servers.

✅ Expert tab complete

  • I can explain what the event loop's selector mechanism does
  • I know that PEP 492 (Python 3.5) introduced native async/await syntax
  • I know when to use asyncio.shield() to prevent a coroutine from being cancelled

Continue to logging →

Sources

1
Python Standard Library — asyncio. docs.python.org/3/library/asyncio.html.
2
PEP 492 — Coroutines with async and await syntax. peps.python.org/pep-0492/.
3
PEP 3156 — Asynchronous IO Support Rebooted. peps.python.org/pep-3156/.
4
PEP 654 — Exception Groups and except*. peps.python.org/pep-0654/.
Source confidence: High Last verified: Primary source: docs.python.org/3/library/asyncio.html

Sources

1
Python Standard Library — asyncio. docs.python.org/3/library/asyncio.html.
2
PEP 492 — Coroutines with async and await. peps.python.org/pep-0492/.
3
PEP 3156 — Asynchronous IO Support Rebooted. peps.python.org/pep-3156.
4
PEP 654 — Exception Groups. peps.python.org/pep-0654.