Engineering Notes
Home/Languages/Python/Asyncio Basics
Languages · Python

Asyncio Basics

Calling an async def function does not run its body. It returns a coroutine object — a paused, not-yet-started computation, conceptually similar to how calling a generator function returns a generator without running any of its code (see the Generators and Iterators topic). Something has to actually drive that coroutine…

💻 Languages● Advanced⏱ 10 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • async def Creates a Coroutine Function
  • asyncio.run() — the Entry Point
  • await — Suspending Until a Coroutine Finishes
  • asyncio.sleep() Is Not time.sleep()
  • Running Things Concurrently with asyncio.gather()
Part 1Coroutines, `await`, and Running Them

1async def Creates a Coroutine Function

python
async def greet(name):
    return f"hello, {name}"

result = greet("ana")
print(result)   # <coroutine object greet at 0x...> — NOT "hello, ana"

Calling an async def function does not run its body. It returns a coroutine object — a paused, not-yet-started computation, conceptually similar to how calling a generator function returns a generator without running any of its code (see the Generators and Iterators topic). Something has to actually drive that coroutine forward for its body to execute at all.

2asyncio.run() — the Entry Point

python
import asyncio

async def greet(name):
    return f"hello, {name}"

result = asyncio.run(greet("ana"))
print(result)   # "hello, ana"

asyncio.run(coro) creates a fresh event loop, drives the given coroutine to completion, returns its result, and tears the loop down. It's meant to be called once, at the top level of a program, to kick off the whole async call stack — never called from inside an already-running coroutine (that raises RuntimeError: asyncio.run() cannot be called from a running event loop).

3await — Suspending Until a Coroutine Finishes

python
import asyncio

async def fetch_user(user_id):
    await asyncio.sleep(1)          # simulates a network call — yields control for 1s
    return {"id": user_id, "name": "Ana"}

async def main():
    user = await fetch_user(1)       # suspends main() here until fetch_user finishes
    print(user)

asyncio.run(main())

await can only appear inside an async def function. It hands control back to the event loop while the awaited coroutine is pending, letting the loop run other ready work in the meantime, then resumes exactly where it left off once the awaited thing completes — with its return value as the expression's result.

4asyncio.sleep() Is Not time.sleep()

python
import asyncio, time

async def bad():
    time.sleep(1)          # BLOCKS the entire event loop — nothing else can run for 1s
    return "done (blocking)"

async def good():
    await asyncio.sleep(1)  # yields control — other coroutines can run during this second
    return "done (non-blocking)"

time.sleep() blocks the operating system thread the event loop runs on — since asyncio is single-threaded cooperative concurrency (Part 2 covers this model in depth), one blocking call stalls everything scheduled on that loop, not just the coroutine that called it. asyncio.sleep() instead tells the event loop "wake me up after N seconds" and immediately yields control, letting other pending coroutines make progress during the wait.

5Running Things Concurrently with asyncio.gather()

python
import asyncio, time

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

async def sequential():
    start = time.perf_counter()
    a = await fetch("a", 1)
    b = await fetch("b", 1)
    print(time.perf_counter() - start)   # ~2.0s — one after another

async def concurrent():
    start = time.perf_counter()
    a, b = await asyncio.gather(fetch("a", 1), fetch("b", 1))
    print(time.perf_counter() - start)   # ~1.0s — both run "at the same time"

Awaiting two coroutines back-to-back (sequential) runs them one after another — the second doesn't even start until the first fully finishes. asyncio.gather(*coros) instead schedules all of them onto the event loop up front and waits for all to finish, returning their results in the same order they were passed in. This is where asyncio's concurrency actually pays off: many I/O-bound waits overlap instead of stacking.

6Coroutines Must Be Awaited (or Scheduled) — Never Just Called

python
async def fetch_user(user_id):
    await asyncio.sleep(1)
    return {"id": user_id}

async def main():
    fetch_user(1)   # BUG: creates a coroutine object and immediately discards it —
                     # the body never runs, and Python prints a
                     # "coroutine 'fetch_user' was never awaited" RuntimeWarning

Forgetting await (or asyncio.gather/asyncio.create_task, covered in Part 2) is one of the most common asyncio mistakes: the call looks like it does something, silently returns a coroutine object instead, and the intended work never runs. Python detects this specific pattern and emits a RuntimeWarning at garbage-collection time as a safety net — but it's easy to miss in noisy output.

⚠️ Common Pitfalls

  1. Calling an async def function and expecting it to run immediately — it returns a coroutine object; something must await it or schedule it on the loop.
  2. Using time.sleep() (or any blocking call) inside a coroutine — blocks the entire single-threaded event loop, defeating the whole point of using asyncio; use the non-blocking equivalent (asyncio.sleep, an async HTTP client, etc.) instead.
  3. Awaiting coroutines one at a time when they don't depend on each other — turns what could be concurrent I/O-bound waits into a slower sequential chain; use asyncio.gather() (or asyncio.create_task, Part 2) when they're independent.
  4. Forgetting await on a coroutine call — silently does nothing except produce an unawaited-coroutine warning; the work never happens.
  5. Calling asyncio.run() from inside code that's already running on an event loop — raises RuntimeError; asyncio.run() is for the single top-level entry point only.
Part 2the Event Loop, Tasks, and Cancellation

1Tasks vs. Coroutines — Scheduling Eagerly

python
import asyncio

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

async def main():
    task_a = asyncio.create_task(fetch("a", 1))   # scheduled NOW, starts running immediately
    task_b = asyncio.create_task(fetch("b", 1))   # also scheduled now — both run concurrently
    result_a = await task_a   # just waits for something already in progress
    result_b = await task_b
    print(result_a, result_b)

asyncio.create_task(coro) wraps a coroutine in a Task and immediately schedules it on the event loop — the coroutine starts making progress the moment the current coroutine next yields control (e.g. at its own next await), not only once someone awaits the task. This is the key difference from a bare coroutine object, which does nothing until awaited. asyncio.gather() (Part 1) is actually built on top of this same task-scheduling mechanism internally.

2The Event Loop Is a Single-Threaded Scheduler

text
                 ┌───────────────────────────────────┐
                 │           EVENT LOOP               │
                 │  (one OS thread, one call stack)   │
                 └───────────────────────────────────┘
                       │ picks the next READY task
                       ▼
        ┌───────────┐     ┌───────────┐     ┌───────────┐
        │  Task A   │     │  Task B   │     │  Task C   │
        │ (paused   │     │ (running  │     │ (paused,  │
        │  at await)│     │  now)     │     │  waiting) │
        └───────────┘     └───────────┘     └───────────┘
Three tasks interleave on one loop thread: each runs until its next await, the loop waits in the OS selector when nothing is ready, and a blocking call would freeze every task
Three tasks interleave on one loop thread: each runs until its next await, the loop waits in the OS selector when nothing is ready, and a blocking call would freeze every task

There is exactly one event loop, running on exactly one OS thread, executing exactly one piece of Python code at any instant — asyncio concurrency is cooperative, not parallel. A running coroutine keeps the CPU until it hits an await on something not yet ready (I/O, a timer, another task), at which point it voluntarily yields control back to the loop, which then picks another ready task to run. Nothing preempts a coroutine mid-statement the way an OS thread scheduler can preempt a thread — control only changes hands at await points.

This is fundamentally different from the threading module: threads achieve concurrency via OS-level preemptive context switching across (potentially) multiple cores for I/O-bound work, or contend for the GIL for CPU-bound work; asyncio achieves concurrency on a single thread by having coroutines explicitly yield at await points. Neither approach gives true CPU parallelism in CPython for pure-Python code (the GIL prevents that for threads too) — asyncio's win is specifically for I/O-bound work with many concurrent waits, where cooperative switching has far lower overhead than OS threads.

3Why a Blocking Call Anywhere Stalls Everything

python
import asyncio, time

async def worker(n):
    print(f"worker {n} starting")
    time.sleep(2)   # BLOCKS the one and only OS thread the loop runs on
    print(f"worker {n} done")

async def main():
    await asyncio.gather(worker(1), worker(2), worker(3))
    # prints all three "starting" lines, then a 2s pause, then all three "done" lines —
    # NOT interleaved concurrently, because time.sleep() never yields to the loop

Because there is only one thread and control only changes hands at await, any call that blocks without awaiting (a synchronous time.sleep(), a synchronous DB driver, CPU-heavy pure-Python work) freezes the entire loop — every other task, no matter how "concurrent" it looks in the code, simply cannot run until that blocking call returns. This is the single most common asyncio production bug: mixing a synchronous, blocking library into async code and silently losing all the concurrency the code appears to have.

4Cancellation

python
import asyncio

async def long_task():
    try:
        await asyncio.sleep(10)
        return "finished"
    except asyncio.CancelledError:
        print("cleanup: long_task was cancelled")
        raise   # re-raising is the convention — swallowing it can break the caller

async def main():
    task = asyncio.create_task(long_task())
    await asyncio.sleep(0.1)
    task.cancel()                 # requests cancellation — does NOT stop it instantly
    try:
        await task
    except asyncio.CancelledError:
        print("main: confirmed task was cancelled")

task.cancel() doesn't interrupt the task immediately — it arranges for a CancelledError to be raised at the task's next suspension point (its current or next await). This means a task deep inside a long CPU-bound stretch between awaits won't actually be cancelled until it reaches one. Code that needs to clean up on cancellation (closing a file, releasing a lock) should catch CancelledError, do the cleanup, and re-raise it — swallowing it silently marks the task as successfully completed instead of cancelled, which usually surprises whatever is awaiting that task.

Under the HoodWhat await Actually Does to a Coroutine▾

A coroutine object, like a generator, has its own internal frame (locals, instruction pointer) that persists across suspensions — this is the same suspend/resume machinery covered in the Generators and Iterators topic; async def/await were in fact built on top of generator-based coroutines historically and still share the core mechanism. When code hits an await some_coroutine, the event loop doesn't "wait" in the sense of blocking — it registers a callback to resume the current coroutine's frame once some_coroutine (or, at the bottom of the chain, an actual OS-level event like a socket becoming readable) is ready, then returns control all the way up to the loop's own run-loop, which is now free to pick literally any other ready task's frame to resume next. Every await boundary is a checkpoint where the loop's scheduler gets to choose what runs next — this is precisely why blocking synchronous code is so damaging: it holds the one thread through what should have been a checkpoint, denying the scheduler any opportunity to switch.

⚠️ Common Pitfalls

  1. Mixing a synchronous blocking call into async code — stalls the entire event loop for every task, not just the one making the call (see section 3); use an async-native library or run blocking calls in a thread pool executor instead.
  2. Assuming task.cancel() stops execution immediately — it only raises CancelledError at the task's next suspension point; a task not currently at an await keeps running until it reaches one.
  3. Swallowing CancelledError without re-raising it — silently converts a cancelled task into a successfully completed one from the caller's perspective, hiding the cancellation.
  4. Creating a task with asyncio.create_task() and never awaiting or storing it — the task can be garbage-collected mid-flight with no warning beyond a logged message; always keep a reference and eventually await it.
  5. Expecting asyncio concurrency to speed up CPU-bound work — cooperative single-threaded scheduling helps overlap I/O waits, not CPU computation; CPU-bound work still needs multiprocessing or a thread pool (see the Concurrency-adjacent topics) to actually parallelize.

📋 Quick Reference

python
async def f(): ...                      # defining a coroutine function
f()                                       # calling it returns a coroutine object — does NOT run the body

asyncio.run(main())                        # top-level entry point, creates+drives+tears down the loop
await coro                                   # suspend until coro finishes, get its return value
await asyncio.sleep(n)                         # non-blocking wait — yields control to the loop

asyncio.gather(c1, c2, ...)                       # run several coroutines concurrently, wait for all
asyncio.create_task(coro)                           # schedule NOW, eagerly, independent of await timing

task.cancel()                                         # request cancellation at next suspension point
except asyncio.CancelledError: ... ; raise               # correct cleanup pattern — always re-raise

# single OS thread, cooperative scheduling — a blocking call anywhere stalls every task

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is asyncio and when should you use it?core▾

Python's framework for cooperative concurrency with an event loop and async/await coroutines. Ideal for many concurrent I/O-bound tasks (HTTP calls, websockets, DB queries with async drivers); not for CPU-bound work.

Follow-ups interviewers ask
  • CPU-bound alternative? multiprocessing or ProcessPoolExecutor.
Q2How do you run tasks concurrently?core▾

asyncio.gather(*coros) or asyncio.TaskGroup (3.11+, structured concurrency with automatic cancellation on failure); limit concurrency with asyncio.Semaphore.

Follow-ups interviewers ask
  • Why TaskGroup? Errors cancel sibling tasks and surface as ExceptionGroups.
Q3What happens if you call blocking code inside async functions?core▾

It blocks the event loop, freezing all other tasks. Use async libraries (httpx, asyncpg) or offload blocking calls with asyncio.to_thread / run_in_executor.

Follow-ups interviewers ask
  • Example offender? requests.get or time.sleep inside a coroutine.
Q4How do timeouts and cancellation work?senior▾

async with asyncio.timeout(5): or asyncio.wait_for cancels the inner task by raising CancelledError inside it; code should clean up in finally and not swallow CancelledError.

Follow-ups interviewers ask
  • Why not swallow it? It breaks cancellation semantics.
Q5How does the GIL relate to asyncio and threads?core▾

The GIL lets only one thread execute Python bytecode at a time, so threads don't speed up CPU-bound Python, but I/O releases it. Asyncio uses one thread anyway. Free-threaded Python builds (3.13+) remove the GIL experimentally.

Follow-ups interviewers ask
  • When are threads fine? I/O-bound work with blocking libraries.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.