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…
🎯 What you'll learn
async defCreates a Coroutine Functionasyncio.run()— the Entry Pointawait— Suspending Until a Coroutine Finishesasyncio.sleep()Is Nottime.sleep()- Running Things Concurrently with
asyncio.gather()
1async def Creates a Coroutine Function
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
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
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()
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()
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
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" RuntimeWarningForgetting 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
- Calling an
async deffunction and expecting it to run immediately — it returns a coroutine object; something mustawaitit or schedule it on the loop. - 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. - 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()(orasyncio.create_task, Part 2) when they're independent. - Forgetting
awaiton a coroutine call — silently does nothing except produce an unawaited-coroutine warning; the work never happens. - Calling
asyncio.run()from inside code that's already running on an event loop — raisesRuntimeError;asyncio.run()is for the single top-level entry point only.
1Tasks vs. Coroutines — Scheduling Eagerly
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
┌───────────────────────────────────┐
│ 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) │
└───────────┘ └───────────┘ └───────────┘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
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 loopBecause 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
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
- 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.
- Assuming
task.cancel()stops execution immediately — it only raisesCancelledErrorat the task's next suspension point; a task not currently at anawaitkeeps running until it reaches one. - Swallowing
CancelledErrorwithout re-raising it — silently converts a cancelled task into a successfully completed one from the caller's perspective, hiding the cancellation. - 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. - 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
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
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.
- 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.
- 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.
- Example offender?
requests.getortime.sleepinside 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.
- 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.
- 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.