Python `async`/`await` and the Event Loop: Coroutines, Tasks, Cancellation, and I/O
A coroutine that never runs is not a syntax error. It is an object you built and never drove.

Key topics
A coroutine that never runs is not a syntax error. It is an object you built and never drove.
I have watched this failure mode for twenty years across threads, callbacks, and now coroutines. The service hangs. The task vanishes. The connection stays half-open after a timeout. In almost every case, the syntax was correct. What was missing was a mental model of who owns the work and where control can leave your code.
async def does not start anything. It builds a coroutine object — a suspended computation waiting for a driver. await is the only place control can leave your frame. Everything else in this article follows from those two facts.
A Coroutine Is a Suspended Computation, Not a Running Job
Call an async def function and Python executes no body code. You get back a coroutine object:
import asyncio
async def fetch_user(user_id: int) -> dict:
print(f"fetching {user_id}") # does not run yet
await asyncio.sleep(0.1)
return {"id": user_id}
coro = fetch_user(1)
print(type(coro)) # <class 'coroutine'>
Nothing printed fetching 1. The function body is frozen at the top of its frame. To make it move, something must drive it.
The coroutine protocol is the generator protocol
A coroutine implements send(), throw(), and close() — the same interface generators expose. await compiles to a yield point that hands a future-like object outward. Here is a minimal driver that proves the mechanism without asyncio:
async def g():
print("g start")
await asyncio.sleep(0) # suspension point
print("g end")
return 42
coro = g()
try:
fut = coro.send(None) # runs until the first await
print("yielded:", fut)
except StopIteration as e:
print("done:", e.value)
The first send(None) runs g up to the await, yields a future, and suspends. The driver would then wait for that future and call coro.send(result) to resume.
This is a conceptual model, not the literal runtime. The object yielded through the coroutine machinery is an implementation-level awaitable, and asyncio's Task drives the coroutine through its internal protocol rather than a simple selector loop that sends I/O results directly. The lesson is the handshake: the coroutine yields control outward, the driver decides when to send a result back in. asyncio wraps that handshake in a selector, a ready queue, and a timer heap.
Why await is the whole safety story
Because control can only leave your frame at an await, any span of code with no await in it is atomic with respect to the event loop. No other task can interleave. That is the property threads do not give you, and it is why most async code needs no locks.
Invariant: between two
awaitexpressions, your coroutine runs to completion without interruption. The moment you add anawait, you have created a cancellation point and a potential interleaving point.
The tradeoff is symmetric. You get cooperative scheduling and no preemption, but you also get no automatic parallelism. A CPU-bound loop inside a coroutine blocks every other task on the loop.
Knowledge check
Check your understanding
Answer this question before you continue.
The Event Loop: Selector, Ready Queue, and Timer Heap
The event loop is a callback scheduler that coroutines ride on. Each iteration does roughly three things:
- Run every callback currently in the ready queue.
- Poll the I/O selector with a timeout derived from the nearest scheduled timer.
- Schedule callbacks for file descriptors that became ready.
Callbacks enter the queue through call_soon, call_later, and call_at. A Task is a callback that resumes a coroutine. A Future is a callback target that a coroutine is waiting on. The loop does not know what a coroutine is; it knows how to run callbacks and how to wait on file descriptors.
asyncio.run(main()) owns the full lifecycle: it creates a loop, runs main to completion via run_until_complete, then cancels any tasks still pending, drains the loop, and shuts down the default executor. That shutdown behavior is why a fire-and-forget task can disappear without an error — the loop cancels it on the way out.
Blocking the loop is a correctness problem, not a performance problem
The loop is single-threaded. If you call time.sleep(1), open(...).read(), or a CPU-heavy function inside a coroutine, every other task waits. Not slower — stalled. There is no preemption to save you.
| Work type | Tool | Cost |
|---|---|---|
| Blocking I/O (file, legacy DB driver) | loop.run_in_executor(None, fn) | Thread hop, GIL contention on CPU work |
| CPU-bound work | ProcessPoolExecutor via run_in_executor | Pickling arguments and results |
| Truly async I/O (sockets, HTTP) | Native async library | None — this is the intended path |
loop = asyncio.get_running_loop()
data = await loop.run_in_executor(None, blocking_read, path)
The default executor is a thread pool. It is fine for occasional blocking calls. It is not a substitute for an async driver when you are doing thousands of concurrent requests.
Knowledge check
Check your understanding
Answer this question before you continue.
Tasks vs Coroutines: Ownership, Scheduling, and the Fire-and-Forget Trap
A coroutine is a value you must drive. A task is a scheduled unit the loop owns and will cancel on shutdown. The distinction is operational, not cosmetic.
await cororuns the coroutine inline in the current task's frame. The caller pauses; nothing else about the caller's task changes.asyncio.create_task(coro)schedules the coroutine immediately and returns aTaskhandle. The caller keeps running.
async def main():
asyncio.create_task(fetch_user(1)) # scheduled, handle discarded
# main returns; loop cancels the task on shutdown
That task may never complete. It may never even start. The loop holds only a weak reference to tasks, so a task with no strong reference can be garbage-collected mid-flight. This is the classic silent data loss.
Rule: if you call
create_task, you own the handle. Store it, await it, or cancel it explicitly. Otherwise useTaskGroup.
Structured concurrency with TaskGroup
Python 3.11 added asyncio.TaskGroup, which enforces ownership:
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch_user(1))
t2 = tg.create_task(fetch_user(2))
# scope does not exit until both finish
print(t1.result(), t2.result())
If a child raises, the group cancels its siblings and raises an ExceptionGroup when the scope exits. The parent cannot leak past the block.
gather vs TaskGroup
asyncio.gather predates structured concurrency and behaves differently on failure. With the default return_exceptions=False, the first exception propagates to the caller, but sibling tasks keep running. They are not cancelled. If those siblings hold connections, semaphore slots, or file handles, you have a resource leak that only shows up under load.
| API | On child failure | Ownership |
|---|---|---|
gather(..., return_exceptions=False) | Raises first error; siblings keep running | Weak |
gather(..., return_exceptions=True) | Returns exceptions as values | Weak |
TaskGroup | Cancels siblings, raises ExceptionGroup | Strong |
My decision rule: use TaskGroup for owned fan-out where partial failure should stop the batch. Use gather when you genuinely want all results regardless of individual failures. Use bare create_task only when you also own the handle and its lifetime.
Knowledge check
Check your understanding
Answer this question before you continue.
Cancellation Is a Request, Not a Kill
task.cancel() does not stop the coroutine. It schedules a CancelledError to be thrown into the coroutine at its next await. Synchronous code between awaits runs to completion first. If the coroutine never awaits again, it never sees the cancellation.
async def worker():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("cleaning up")
raise # re-raise — do not swallow
CancelledError inherits from BaseException since Python 3.8. A bare except Exception will not catch it. A bare except: will — and that is a bug, because it breaks the caller's contract and can hang TaskGroup shutdown indefinitely.
If you catch
CancelledError, you must re-raise after cleanup. Suppressing it is not defensive programming; it is a broken cancellation protocol.
shield, timeout, and wait_for
asyncio.shield(inner) protects an inner awaitable from outer cancellation. It does not cancel the inner work. The shielded coroutine keeps running after the outer task is cancelled, and you are still responsible for awaiting or tracking it. Shield is for "this write must complete even if the caller goes away," not for "ignore cancellation."
asyncio.timeout (3.11+) is a context manager that cancels the enclosed block on expiry and raises TimeoutError. asyncio.wait_for wraps a single awaitable and cancels it on timeout. Both cancel the inner task; they differ in how the timeout surfaces and in whether the inner task is a fresh one.
uncancel() and the cancellation count exist for libraries that must distinguish their own cancellation from an outer one — for example, a retry loop that wants to know whether the cancellation came from its own timeout or from a parent shutdown.
Cancellation-Safe I/O: Cleanup, Timeouts, and Partial Failure
Network and model-serving code is where cancellation bugs become production incidents. A cancelled request that leaves a connection open, a semaphore slot held, or a stream half-read will eventually exhaust a pool.
async with is a structured cleanup hook, not a guarantee
async with guarantees __aexit__ runs on cancellation, including CancelledError. Connection and session teardown belongs there:
async with httpx.AsyncClient() as client:
async with client.stream("POST", url, json=payload) as resp:
async for chunk in resp.aiter_bytes():
yield chunk
If the consumer is cancelled mid-stream, __aexit__ closes the response and releases the connection. But async with only guarantees the hook runs. Whether cleanup is actually safe depends on what __aexit__ does. If it awaits something that can itself be cancelled, or if a second cancellation arrives while cleanup is in progress, the cleanup may not complete.
A finally block that awaits is even more exposed: it can be interrupted by a second cancellation. The safer pattern is to retain the child task, await it during cleanup, and deliberately decide whether a narrowly scoped cleanup operation should be shielded. Shield is not a blanket recommendation — it is a targeted tool for the one write or close that must finish.
Ordering matters
When a request is cancelled mid-flight, the release order should be:
- Cancel or await the in-flight request.
- Close the transport.
- Release the semaphore.
Reversing this can leak capacity: a semaphore released before the transport closes lets a new request start while the old one still holds a socket.
Bound concurrency with a semaphore in one scope
sem = asyncio.Semaphore(20)
async def call_model(payload):
async with sem: # acquire and release in one scope
async with client.stream(...) as resp:
...
If acquisition and release live in the same async with, a cancellation anywhere inside releases the slot. If you acquire in one function and release in another, a cancellation between them leaks the slot permanently.
Retries must be cancellation-aware
A retry loop that catches broad exceptions will retry after cancellation and delay shutdown:
for attempt in range(3):
try:
return await call_model(payload)
except asyncio.CancelledError:
raise # never retry a cancellation
except (httpx.TimeoutException, httpx.ConnectError):
await asyncio.sleep(backoff(attempt))
The CancelledError clause must come first and must re-raise. Otherwise the loop treats shutdown as a transient failure.
Knowledge check
Check your understanding
Answer this question before you continue.
Observability
Log the task name, the cancellation origin, and elapsed time. Without it, cancellation bugs look like random latency spikes. asyncio.current_task().get_name() and task.set_name() give you correlation across fan-out.
Tracing a Cancellation End to End
The rules above are easy to memorize and hard to apply. Here is one instrumented trace that connects task state, cancellation propagation, resource ownership, and shutdown. The example is a semaphore-bounded streaming call.
import asyncio
sem = asyncio.Semaphore(2)
events = []
async def stream_request(name: str):
events.append(f"{name}: acquire")
async with sem:
events.append(f"{name}: acquired")
try:
async with client.stream("POST", url, json=payload) as resp:
events.append(f"{name}: stream open")
async for chunk in resp.aiter_bytes():
events.append(f"{name}: chunk")
await asyncio.sleep(0.05)
finally:
events.append(f"{name}: release")
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(stream_request("a"))
tg.create_task(stream_request("b"))
tg.create_task(stream_request("c"))
Now cancel the parent mid-flight and read the event log. The expected order for the cancelled task is:
acquire— the task entered the semaphore scope.acquired— the slot was taken.stream open— the transport was established.chunk— at least one chunk was read.- Cancellation is injected at the next
await. release— thefinallyruns, andasync with semreleases the slot.- The parent
TaskGroupsees the cancellation, cancels siblings, and exits.
The invariant this trace proves: every resource acquired across an await must be released in a scope that runs on cancellation. The semaphore is released because async with sem wraps the entire body. The transport is closed because async with client.stream(...) wraps the read loop. If either scope were split across functions, the trace would show a missing release event and a permanently held slot.
If cleanup itself awaits and receives a second cancellation, the trace will show release starting but not finishing. That is the boundary where you decide whether to shield the close operation or restructure so cleanup is synchronous.
Debugging Async Code: Traces, Warnings, and Failure Modes
The loop has a debug mode that surfaces the failures that otherwise stay silent.
PYTHONASYNCIODEBUG=1 python app.py
python -X dev app.py
asyncio.run(main(), debug=True)
Debug mode logs callbacks that block the loop for too long and warns about coroutines that were never awaited. -X dev catches unclosed transports and unawaited coroutines at shutdown.
| Symptom | Likely mechanism |
|---|---|
| "Nothing happens" | Coroutine created but never awaited or scheduled |
| "Task disappeared" | No strong reference, or loop shut down before completion |
| "Hangs on exit" | CancelledError suppressed, or transport never closed |
| "Everything is slow" | Blocking call on the loop |
| "Random latency spikes" | Cancellation retried as a transient failure |
When not to use asyncio
Async is not a default. It is a fit for I/O-bound concurrency where you need many in-flight operations on one thread. It is the wrong tool for:
- CPU-bound pipelines — use processes.
- Code dominated by synchronous C extensions that release the GIL poorly.
- A single sequential request — threads or plain sync code are simpler and often faster.
The overhead of coroutine scheduling is real, and the debugging cost is higher than sync code. Pay it only when concurrency is the point.
What to Do Next
Every coroutine you create must have a named owner. Every await is a cancellation point. Every resource acquired across an await must be released in a scope that runs on cancellation.
Take one existing fan-out function in your codebase — the one that fires N requests with gather and hopes for the best — and convert it to a TaskGroup. Add an asyncio.timeout around the whole scope. Then build a minimal verification harness:
- Replace the real client with a fake that blocks on an
asyncio.Eventyou control. - Start the fan-out, let it acquire all semaphore slots, then cancel the parent task.
- Await the parent's completion and assert it raised
CancelledError. - Assert
sem._value == sem._initial_value— every slot was released. - Assert your fake client recorded a
closecall for every opened connection. - Assert the event log shows
releaseafterstream openfor every task.
If any assertion fails, you have found the bug that would have surfaced at 3 a.m. under load. Fix it there, in the test, before it finds you in production.
The adjacent concept worth studying next is exception boundaries: how to aggregate the failures that structured concurrency now surfaces as ExceptionGroup, and how to separate domain failures from infrastructure failures without losing the original cause.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


