๐Python asyncio
A staff interview guide to Python's asyncio โ the mental models and trade-offs an interviewer probes, for a strong Python engineer who hasn't written async before. Not an API tour: the event loop, the coroutine/task/future trio, structured concurrency, and the one rule that decides whether your service falls over.
TL;DR
asynciois single-threaded cooperative concurrency for I/O-bound work. One thread runs an event loop that juggles thousands of tasks by letting each run until itawaits, then switching to another.awaitis a suspension point, not a thread. It marks the only places a task can pause and yield control. Between twoawaits your code runs uninterrupted โ which makes reasoning easy and makes one bad line catastrophic.- Know the trio: coroutine (the recipe) โ Task (scheduled to run concurrently) โ Future (a result, later). Awaiting a bare coroutine is sequential; wrapping it in a Task is what makes work overlap.
- The cardinal rule: never block the loop. There is one thread, and no preemption. A CPU-bound loop or a synchronous call (
time.sleep,requests.get) freezes every task at once. It is not parallelism โ under the GIL you get concurrency, not extra cores.
Why asyncio exists
Picture a web service that, per request, waits on a database, then an upstream API, then writes a response. It spends almost all of its wall-clock time waiting โ the CPU is idle, parked on a socket. The classic way to handle many such requests at once is one OS thread per connection. That works until it doesn't: each thread carries a stack measured in megabytes, and the kernel pays a context-switch cost every time it swaps them. At ten thousand mostly-idle connections โ the famous C10k problem โ the machine spends more effort scheduling threads than doing work.
asyncio makes a different bet: if the work is waiting, you don't need a thread for each wait. One thread runs an event loop. Each in-flight operation is a cheap coroutine โ a heap object, not an OS stack. When a coroutine would block on I/O, it registers interest with the loop's selector and yields; the loop moves on to whatever is ready now. Concurrency scales to tens of thousands of connections on a single core, because idle connections cost almost nothing.
So the scope is narrow and worth stating out loud in an interview: asyncio buys concurrency, not parallelism. It shines when you have many operations that spend their time waiting on the network or disk. It does nothing for a workload that keeps a CPU busy โ that needs multiple processes (see the comparison below).
This is the same event-loop idea behind nginx, Node.js, and libuv, ported into Python's standard library (PEP 3156, 2012). It began on generator-based coroutines and got dedicated async/await syntax in Python 3.5 (PEP 492).
Mental model in 60 seconds
The event loop is a single thread running one job: keep a queue of ready-to-run tasks, take one, run it until it awaits something not yet ready, then set it aside and take the next. When an awaited operation completes โ a timer fires, a socket becomes readable โ its task goes back on the ready queue. Repeat until nothing is left to do.
await; while it waits, the loop runs someone else.Three properties fall out of this, and they explain almost everything about how async code behaves:
- Single-threaded. Your coroutine code never runs concurrently with itself. Within one uninterrupted run there are no data races on ordinary variables โ a relief compared to threads.
- Cooperative, not preemptive. The loop can only switch tasks at an
await. Between two awaits your code runs to completion, uninterrupted. That is the double edge of asyncio: you always know exactly where a switch can happen, but a task that never awaits (a busy CPU loop) never yields and starves the loop. - One loop, started with
asyncio.run. It creates the loop, drives your top-level coroutine to completion, and closes the loop. One event loop per thread.
import asyncio
async def main():
await asyncio.gather(worker("a"), worker("b"))
asyncio.run(main()) # create the loop, run main() to completion, close the loop
asyncio.run() (3.7+) is the entry point for new code. You will see older tutorials call get_event_loop() / run_until_complete() by hand โ avoid that; it is easy to get loop lifecycle wrong, and a common source of "there is no current event loop" errors.
Coroutines & await
An async def defines a coroutine function. The thing to internalize: calling it does not run it. It returns a coroutine object โ inert โ exactly the way calling a generator function returns a generator without executing the body. Nothing happens until you await it (or schedule it as a Task, or hand it to asyncio.run).
async def fetch(url): ...
fetch(url) # returns a coroutine object โ the body never runs (RuntimeWarning)
await fetch(url) # now it actually runs
await X does two things at once. It drives X toward its result, and โ the important part โ if X is not ready, it suspends the current coroutine and hands control back to the event loop. await is the only place a coroutine can pause, and you can only write it inside an async def. What you can await is any awaitable โ a coroutine, a Task, or a Future.
await is the handoff.This is why await asyncio.sleep(1) and time.sleep(1) are worlds apart. asyncio.sleep suspends the coroutine for a second and lets the loop run everyone else; time.sleep blocks the whole thread โ and with it the loop and every other task. Hold that thought; it becomes the cardinal rule below.
await
Forget the await and you create a coroutine that never runs โ silently, unless you notice the warning.
async def main():
save(record) # BUG: coroutine created, never awaited โ the save never happens
await save(record) # fix
Python emits RuntimeWarning: coroutine '...' was never awaited. In an interview, "did you await that?" is a fast, credible catch.
Coroutine vs Task vs Future
Interviewers love this one because the three are easy to conflate and the difference is where concurrency actually comes from. Three related things:
- Coroutine โ the recipe. What
async defreturns. Inert until driven. Awaiting a coroutine directly runs it inline, in the current task: sequential. - Task โ a coroutine the loop runs concurrently.
asyncio.create_task(coro())wraps the coroutine, schedules it on the loop, and returns immediately with a handle. The task advances whenever the current task awaits. Youawaitthe handle later to collect its result. - Future โ a result that will exist later. A low-level placeholder with a state (pending โ done) and eventually a value or an exception. Libraries hand you Futures to bridge callback-style code; you rarely build one yourself. Crucially,
Taskis a subclass ofFutureโ a Future whose value is produced by running a coroutine.
The operational punchline โ worth being able to write on a whiteboard:
import asyncio
async def slow(n):
await asyncio.sleep(n)
return n
# Sequential โ await one, then the next. total โ 1 + 2 = 3s
a = await slow(1)
b = await slow(2)
# Concurrent โ schedule both, then await. total โ max(1, 2) = 2s
ta = asyncio.create_task(slow(1)) # both are running now
tb = asyncio.create_task(slow(2))
a, b = await ta, await tb # they progressed while we awaited
Same coroutines, very different timing. Awaiting a coroutine is not the same as scheduling a Task. create_task is the verb that makes work overlap โ remember it and the rest of concurrency is ergonomics.
Running things concurrently
You rarely hand-roll create_task pairs. Two ergonomic tools cover almost everything.
asyncio.gather โ fan out, collect in order
Schedules every coroutine, waits for all of them, and returns their results positionally.
results = await asyncio.gather(fetch(a), fetch(b), fetch(c)) # [ra, rb, rc]
Watch the failure semantics โ a favorite follow-up. By default, if one coroutine raises, gather propagates that first exception to the caller but the others keep running (and if you never await them, they can leak). Pass return_exceptions=True and it instead returns exceptions as results rather than raising โ convenient, but it silently turns failures into return values, so you must inspect them.
asyncio.TaskGroup โ the modern default (3.11+)
A context manager that owns its child tasks. This is the recommended way to run concurrent work today.
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch(a))
t2 = tg.create_task(fetch(b))
# the block exits only after both finish;
# if either raises, the other is cancelled and errors surface together
results = t1.result(), t2.result()
It is structured: control does not leave the async with block until every child is done, and if one fails, the siblings are cancelled and the errors are raised together as an ExceptionGroup. No orphaned tasks, no swallowed errors โ the two things gather makes easy to get wrong.
create_task hands the loop only a weak reference to the task. If you don't keep the handle yourself, the task can be garbage-collected mid-flight and vanish silently. Keep a reference (or just use a TaskGroup):
_background = set()
def spawn(coro):
t = asyncio.create_task(coro)
_background.add(t) # keep a strong ref...
t.add_done_callback(_background.discard) # ...drop it when finished
return t
Structured concurrency & cancellation
Structured concurrency means a task's lifetime is bounded by a scope, so you can't leak tasks and errors can't disappear. TaskGroup is asyncio's implementation of the idea (borrowed from Trio's nurseries). Cancellation is the machinery that makes it work.
Cancellation is cooperative too
task.cancel() does not kill anything on the spot. It schedules a CancelledError to be raised inside the coroutine at its next await. The coroutine may catch it to clean up โ but should re-raise, or cancellation silently fails to happen.
try:
await do_work()
except asyncio.CancelledError:
await rollback() # clean up...
raise # ...then let the cancellation propagate
Put cleanup in finally. And note the type hierarchy is deliberate: CancelledError derives from BaseException, not Exception, precisely so a broad except Exception: doesn't accidentally eat a cancellation.
Timeouts
asyncio.timeout() (3.11+) is a context manager that cancels whatever runs inside it once the clock runs out โ built directly on cancellation.
async with asyncio.timeout(5):
await slow_operation() # raises TimeoutError if it runs > 5s
# older, still fine โ wraps a single coroutine:
await asyncio.wait_for(slow_operation(), timeout=5)
Both work by cancelling the inner operation, so the same rules apply: clean up in finally, don't swallow the CancelledError. When you have an operation that must not be interrupted by an outer cancel โ a commit, say โ wrap it in asyncio.shield(...), sparingly.
Never block the event loop
If you remember one operational fact about asyncio, make it this โ and expect an interviewer to push on it. There is one thread. Any code that runs without awaiting holds the loop hostage; nothing else โ not another request, not a health-check heartbeat โ makes progress until it returns. Two ways to commit this sin:
- CPU-bound work โ a tight loop, hashing, parsing a huge payload, resizing an image. It never awaits, so nothing else runs until it finishes.
- Synchronous I/O โ
time.sleep,requests.get, a blocking database driver, a plain file read. These block the OS thread, and the loop is on that thread.
The symptom is distinctive: latency spikes across unrelated requests, all stalling in lockstep, because they share the one thread that is stuck.
The fix is to get the blocking work off the loop thread.
# Blocking I/O call โ run it on a worker thread, await the result
row = await asyncio.to_thread(blocking_db_query, sql) # 3.9+
# CPU-bound work โ a process pool, which sidesteps the GIL
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(process_pool, heavy_cpu, data)
asyncio.to_thread hands the call to a ThreadPoolExecutor and gives you back an awaitable; the loop keeps serving other tasks while the worker thread sits in the blocking call.
Threads help for blocking I/O, because the GIL is released while a thread waits on I/O (and inside many C extensions). They do not give you parallel CPU โ the GIL still serializes Python bytecode, so CPU-bound threads just take turns. For CPU work you need separate processes (ProcessPoolExecutor or multiprocessing), which run independent interpreters. Rule of thumb: I/O that blocks โ thread; CPU that grinds โ process; a native async library exists โ use it and stay on the loop.
Prefer async-native libraries so there's nothing to offload โ asyncpg for Postgres, httpx or aiohttp for HTTP, aiofiles for disk. to_thread is the escape hatch for libraries that only ship a synchronous API.
Coordinating tasks
Even on one thread you need coordination, for a subtle reason: state can change across an await. Between awaits your code is atomic, but at an await another task may run and mutate shared state. That is where async races live โ a check-then-act split by an await.
# NOT atomic: the await lets another task run between check and act
async def get(key):
if key not in cache: # check
cache[key] = await load(key) # two tasks can both miss and both load
return cache[key]
Guard the critical section with an asyncio.Lock so the check-then-act runs to completion for one task at a time:
lock = asyncio.Lock()
async def get(key):
async with lock: # serialize the whole check-then-act
if key not in cache:
cache[key] = await load(key)
return cache[key]
Bounded concurrency with Semaphore
Fanning out ten thousand requests at once will exhaust sockets or file descriptors, or get you rate-limited. A semaphore caps how many run at a time while still letting you schedule them all.
sem = asyncio.Semaphore(20) # at most 20 in flight
async def fetch(url):
async with sem: # waits here if 20 are already out
return await client.get(url)
await asyncio.gather(*(fetch(u) for u in urls)) # 10k scheduled, 20 at a time
Producer / consumer with Queue
An asyncio.Queue decouples producers from consumers, and a maxsize gives you backpressure for free โ put suspends when the queue is full, throttling producers to the consumers' pace.
q = asyncio.Queue(maxsize=100) # bounded โ backpressure
async def producer():
for item in source:
await q.put(item) # suspends when full
async def consumer():
while True:
item = await q.get()
await handle(item)
q.task_done()
asyncio.Lock, Semaphore, Event, and Queue coordinate coroutines on one event loop โ they are not thread-safe like their threading namesakes. To poke the loop from another thread, use loop.call_soon_threadsafe(...) or asyncio.run_coroutine_threadsafe(coro, loop).
asyncio vs threads vs processes
The architecture question, and the one where a crisp answer signals seniority. Choose by what the work is waiting on.
| Dimension | asyncio | threading | multiprocessing |
|---|---|---|---|
| Best for | I/O-bound, very high concurrency | I/O-bound with blocking libraries | CPU-bound work |
| Unit of work | Coroutine (~KB, on the heap) | OS thread (~MB stack) | Process (own interpreter) |
| Scheduling | Cooperative, 1 thread | Preemptive, N threads | Preemptive, N processes |
| Parallel CPU? | No โ one thread | No โ GIL serializes bytecode | Yes โ separate interpreters |
| Switch cost | Cheap (a function return) | Kernel context switch | High (IPC + pickling) |
| Sharing data | Shared; safe within a step | Shared; needs locks | Serialize / IPC |
| Main hazard | One blocking call stalls all | Races & deadlocks | IPC & memory overhead |
| Scales to | 10โดโ10โต tasks | 10ยฒโ10ยณ threads | ~ number of cores |
The decision, in one breath: waiting on the network or disk with an async library available โ asyncio. Waiting on a blocking library you can't replace โ threads (or asyncio plus to_thread). Crunching numbers โ processes. And they compose โ a common production shape is an asyncio server that offloads occasional CPU chunks to a ProcessPoolExecutor and stubborn blocking calls to a thread pool.
The free-threaded (no-GIL) build introduced experimentally in 3.13 could eventually change the threading calculus for CPU work. It's opt-in and maturing; the GIL-based reasoning above is still the safe default answer.
Interview gotchas
A tight checklist of the mistakes and "gotcha" questions that come up most. Being able to name and fix each is most of what "knows asyncio" means at this level.
- Forgot the
await. A coroutine created but never awaited never runs โ Python only whispers aRuntimeWarning. - Blocking the loop.
time.sleep,requests, or a CPU loop freezes every task. Offload withto_thread/ a process pool, or use an async-native library. - Sequential when you meant concurrent.
await a(); await b()runs one after the other. Overlap them withgather, aTaskGroup, or explicitcreate_task. - Orphaned tasks. The result of
create_taskmust be kept alive, or the task can be GC'd and silently dropped. Hold the handle or use a TaskGroup. - Swallowing
CancelledError. It's aBaseException, soexcept Exceptionmisses it โ but catching it explicitly and not re-raising breaks cancellation. - Trusting
gather(return_exceptions=True). It converts failures into ordinary return values; if you don't inspect them, errors vanish. - Expecting parallelism. asyncio is one thread โ it will not speed up CPU-bound code. That's what processes are for.
- Mixing sync and async. You can't
awaitin a plain function, and calling anasync defwithout awaiting (or a running loop) just makes an inert coroutine. - Racing across an
await. Check-then-act split by anawaitisn't atomic; another task can slip in. Guard it with aLock. - Cross-thread misuse. asyncio primitives aren't thread-safe; bridge threads and the loop with
run_coroutine_threadsafe/call_soon_threadsafe.
Further reading
- CPython
asynciodocs โ start with Developing with asyncio and the Coroutines & Tasks page (TaskGroup, timeouts, cancellation). The authoritative reference for the current API. - PEPs that shaped it โ 3156 (asyncio), 492 (
async/await), 525 (async generators), 654 (exception groups /except*). - Structured concurrency โ Nathaniel J. Smith's Notes on structured concurrency and the Trio docs, the design that TaskGroup borrows from.
- Talks โ David Beazley's Build Your Own Async and Die Threads build the event loop from scratch โ the fastest way to make the model click.
- Ecosystem โ uvloop (a faster libuv-backed loop), AnyIO (a structured-concurrency layer that runs on asyncio or Trio), and async-native drivers like asyncpg and httpx.