Coroutines, Tasks and await

Why calling an async function runs nothing, why a bare await is still sequential, and the one word that turns waiting into overlap.

Awaited in turn, or run as tasks

This explorer needs JavaScript: every time, depth and lag on it is computed in the page rather than downloaded as an image.

Overview

These editors run inside a browser event loop that is already going. So the examples finish with await main(). In a standalone .py script — run with python file.py — you write asyncio.run(main()) instead. This starts a loop, runs the coroutine, and closes it. The two are the same program with different entry points.

Define a function with async def and calling it does not run it. It hands you back a coroutine object — a description of work that has not started.

A coroutine is a plan, not an action

example_01.pyPython
Output
type: coroutine
is coroutine: True
running it needs the loop, not a call

Calling greet() returned an object, not "hi". This trips up everyone once: an async def looks like a function and is really a factory for coroutine objects, and the object does nothing until something drives it. The thing that drives it is the event loop, reached with asyncio.run at the top of a program, or with await from inside another coroutine.

(The c.close() is only there to avoid a "coroutine was never awaited" warning — a coroutine you create and then abandon is itself a bug, covered at the end.)

await runs it, and waits for it

await does two things at once. It starts the coroutine, and it suspends the current one until that coroutine finishes, handing control back to the loop in the meantime. The value of the expression is whatever the coroutine returned.

The catch is the "waits for it" half. Awaiting one coroutine, then awaiting another, is strictly sequential — the second cannot start until the first has returned, because await blocked on it. Overlap does not come from await alone.

Tasks are what run concurrently

To make two coroutines make progress also, you wrap each in a task. asyncio.create_task schedules a coroutine on the loop at once and returns a handle; the coroutine starts running at the next await, whether or not you have awaited the handle yet.

example_02.pyPython
Output
sequential: 0.40s
concurrent: 0.20s

Two the same waits of 0.2 seconds. Awaited one after the other they take 0.40; wrapped in tasks they take 0.20, because both timers were counting down at once. Nothing was faster — the waiting overlapped. That is the entire value proposition of async, and it is visible in this one four-hundredth-versus-two- hundredth difference.

The reason is scheduling order. await fetch(...) creates the coroutine and at once blocks on it, so the second one is not created until the first returns. create_task puts both on the loop *before* either is awaited, so by the time you await a, task b is already running and its timer is already ticking.

The rule that follows

await when you need the result before you can continue; wrap in a task when you want the work happening while you do something else. Two awaits in a row are two things done in sequence. Two tasks are two things done at once. The mistake that quietly costs all the performance is a loop of await do_one(x) over a list — correct, and exactly as slow as a plain synchronous loop, because each await finishes before the next begins.

Managing tasks by hand, as above, is also easy to get wrong: a task you forget to await can be discarded before it finishes, and an exception inside a task you never await can vanish silently. Both are why running work concurrently reaches for gather and TaskGroup instead of loose create_task calls.

await needs something awaitable

You can only await an awaitable: a coroutine, a task, or a future. Awaiting a plain value is a TypeError, and — more commonly — forgetting to await something that *is* awaitable is a silent bug. result = fetch(2) without an await binds the coroutine object, not its result; the arithmetic you do on it next fails in a way whose cause is three lines up.

Driving a coroutine by hand

"A coroutine is a plan, not an action" is literally true. And you can prove it without a loop at all. A coroutine object is a *resumable* object: it supports send, and driving it is exactly what the event loop does.

example_03.pyPython
Output
type: coroutine
returned: hi

send(None) starts the coroutine and runs it until it either suspends or finishes. This one never awaits, so it finishes at once, and a finished coroutine signals its result by raising StopIteration with the return value attached — which is the same protocol a generator uses, because that is what coroutines were built on.

Now the whole mechanism is visible. The loop holds a coroutine and calls send. If the coroutine suspends at an await, control comes back to the loop, which goes and does something else and calls send again once whatever was awaited is ready. When StopIteration finally arrives, the loop takes .value as the result and hands it to whoever was awaiting. "The loop drives it" is not a metaphor for something more complicated; it is this three-line pattern in a queue.

You never write this in real code — await is the readable form of it. It is worth seeing once because it makes the rest of asyncio stop being magical: a task is an object that owns a coroutine and knows how to keep calling send on it.

Cancelling a task

Creating tasks is half of managing them; stopping them is the other half, and the half that is usually missing. A task can be cancelled, and cancellation works by raising CancelledError *inside* the coroutine at whatever await it is suspended on.

example_04.pyPython
Output
slow: saw CancelledError
main: task.cancelled() is True
wait_for gave up and raised TimeoutError

Three things in that output are worth naming. t.cancel() does not stop the task on the spot — it *requests* cancellation, and the error is delivered the next time the loop gets to that task. Awaiting a cancelled task re-raises CancelledError in the awaiter, which is why the try around await t is there rather than optional. And asyncio.wait_for is cancellation with a deadline attached: it cancels the inner coroutine on timeout and raises TimeoutError, so a timeout is not a separate mechanism but the same one on a timer. On 3.11 and later asyncio.timeout() is the context- manager form and reads better around several statements.

The raise in the except block is the part to copy. Catching CancelledError to run cleanup is correct and normal; catching it and *not* re-raising tells the loop the task declined to be cancelled, so the thing that asked — a TaskGroup, a wait_for, a shutdown handler — hangs waiting for a task that is still running.

Where it goes wrong

Calling instead of awaiting. fetch(2) builds a coroutine and runs nothing; you meant await fetch(2). Python warns "coroutine was never awaited" — a warning worth treating as an error.

A loop of awaits, expecting overlap. Sequential every time. Use tasks or gather.

Awaiting create_task too early. await asyncio.create_task(f()) on one line is just a slower await f() — you scheduled it and at once blocked on it, gaining nothing.

An orphaned task. A task nobody holds a reference to can be garbage-collected mid-flight, and an unhandled exception inside it may never surface. Keep the handle, and await it.

Sources and next steps

Recall check

0 of 3

Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.

  1. What does this module say about “A coroutine is a plan, not an action”?

  2. What does this module say about “await runs it, and waits for it”?

  3. What does this module say about “Tasks are what run concurrently”?

Cheat sheet

Coroutines, Tasks and await

Learn why calling an async function returns a coroutine, not a result, and how create_task turns sequential awaits into real overlap.

ASYNC PYTHON · vizlearn.in/async_python/coroutines_tasks_and_await.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.