Skip to content
C Codeloom
Python

Python asyncio Event Loop Guide

Understand how Python's asyncio event loop schedules coroutines, what await actually does, and how to avoid the classic mistakes that turn async code into a tangle of bugs.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How the asyncio event loop schedules tasks
  • What await actually does at the bytecode level
  • Difference between coroutines, tasks, and futures
  • How to run blocking code without freezing the loop
  • Patterns for cancellation, timeouts, and gather

Prerequisites

  • Basic Python
  • Familiarity with functions and exceptions

What and why

asyncio lets a single thread do many things concurrently by cooperatively switching between tasks whenever one of them is waiting on I/O. It is not threading and it is not multiprocessing. It is one thread, one stack at a time, and a scheduler that keeps a queue of ready coroutines.

You want asyncio when your workload is I/O-bound: HTTP requests, database queries, message brokers, websockets. You do not want it for CPU-bound work, because while a coroutine is computing, nothing else in the loop runs.

Mental model

The event loop is a while True loop. Each iteration it picks a ready task, runs it until that task hits an await on something not yet ready, suspends it, and moves to the next ready task. When external events (a socket becomes readable, a timer fires) happen, the loop wakes the relevant task.

+--------------------- Event Loop ---------------------+
|                                                      |
|   Ready Queue: [TaskA, TaskC]                        |
|        |                                             |
|        v                                             |
|   Run TaskA until 'await socket.read()'              |
|        |                                             |
|        v                                             |
|   Register TaskA on selector, suspend                |
|        |                                             |
|        v                                             |
|   Run TaskC until 'await asyncio.sleep(0.5)'         |
|        |                                             |
|        v                                             |
|   Poll OS for ready FDs and expired timers           |
|        |                                             |
|        v                                             |
|   Wake matching tasks -> push to Ready Queue         |
+------------------------------------------------------+
Event loop scheduling one iteration at a time

Hands-on example

A coroutine is a function defined with async def. Calling it returns a coroutine object; nothing runs until you await it or wrap it in a task.

import asyncio
import time

async def fetch(name, delay):
    print(f"{name} start")
    await asyncio.sleep(delay)
    print(f"{name} done")
    return name

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("a", 1.0),
        fetch("b", 1.0),
        fetch("c", 1.0),
    )
    print(results, f"in {time.perf_counter()-start:.2f}s")

asyncio.run(main())

This completes in about one second, not three, because all three asyncio.sleep calls are pending concurrently. asyncio.run creates a fresh loop, schedules main, and tears the loop down at the end.

await x does two things. If x is already done, it returns the value immediately. Otherwise, it suspends the current task and registers a callback so the loop resumes it when x completes. The actual mechanism uses generators under the hood, but you do not need to think at that level day to day.

A Task is a coroutine that the loop is already tracking. asyncio.create_task(coro) schedules it immediately; you can await it later or let it run in the background.

async def background_writer(queue):
    while True:
        item = await queue.get()
        if item is None:
            return
        await write_to_disk(item)

async def main():
    queue = asyncio.Queue()
    worker = asyncio.create_task(background_writer(queue))
    for i in range(100):
        await queue.put(i)
    await queue.put(None)
    await worker

Common pitfalls

The number one bug is calling a blocking function inside a coroutine. requests.get, time.sleep, psycopg2, and CPU-heavy loops all stall the entire loop. Use the async equivalents (httpx.AsyncClient, asyncio.sleep, asyncpg) or push the work into a thread pool with asyncio.to_thread.

result = await asyncio.to_thread(cpu_heavy_function, big_input)

Another classic is forgetting to await a coroutine. fetch("a", 1) on its own returns a coroutine and does nothing. Python will warn at shutdown that a coroutine was never awaited, but the bug is silent until then.

Cancellation is cooperative. When you cancel a task, a CancelledError is raised at the next await. If your code catches Exception broadly, you may swallow it. Always re-raise CancelledError or let it propagate.

try:
    await do_work()
except asyncio.CancelledError:
    await cleanup()
    raise
except Exception as exc:
    log.exception(exc)

Mixing asyncio.run with already-running loops (notably in Jupyter or under uvicorn) raises RuntimeError: asyncio.run() cannot be called from a running event loop. Use await directly, or use nest_asyncio in notebooks.

Production tips

Use asyncio.timeout for bounded waits. It is cleaner than wait_for and integrates with structured concurrency.

async with asyncio.timeout(5):
    data = await client.get("/slow")

Prefer asyncio.TaskGroup (Python 3.11+) over loose gather calls. Task groups cancel siblings on the first error, which is almost always what you want.

async with asyncio.TaskGroup() as tg:
    tg.create_task(refresh_cache())
    tg.create_task(replicate_to_peer())

Bound concurrency. Spawning ten thousand tasks against one upstream is a great way to take it down. Use a Semaphore:

sem = asyncio.Semaphore(50)
async def bounded_fetch(url):
    async with sem:
        return await client.get(url)

Observe your loop. loop.set_debug(True) in development logs slow callbacks. In production, export metrics on task counts, queue depths, and time spent in to_thread.

Wrap-up

The event loop is a single-threaded scheduler that runs coroutines until they await something pending. Tasks are scheduled coroutines, await suspends, and blocking calls break everything. Use TaskGroup and timeout for structure, to_thread for blocking work, and semaphores to keep concurrency under control. With those four primitives, most production asyncio code falls into place.