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.

Overview

A coroutine is a plan, not an action

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.

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, which starts a loop, runs the coroutine, and closes it. The two are the same program with different entry points.

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.)

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.

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 at the same time, you wrap each in a task. asyncio.create_task schedules a coroutine on the loop immediately 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 identical 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 immediately 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.

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 immediately 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.

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

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.

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.