The Event Loop, Stepped Through

One thread, one queue of ready work, and coroutines that step aside at every await. Everything else about async Python is a consequence of this.

Overview

The whole machine, in one sentence

Asynchronous Python runs on a single thread. There is no parallelism here and no second core doing work behind your back. What there is instead is an event loop: a plain loop that keeps a queue of coroutines that are ready to run, takes one, runs it until it voluntarily steps aside, and moves to the next.

That "steps aside" is the entire trick, and it happens at exactly one kind of place: an await. Between two awaits a coroutine has the thread to itself and nothing can interrupt it. At an await it hands control back to the loop, which is then free to run something else while the first coroutine is waiting for whatever it awaited.

The Event Loop, Stepped Through

One thread, one queue of ready work, and coroutines that step aside at every await. Everything else about async Python is a consequence of this.

Watch two coroutines take turns

The clearest way to see the loop switching is to make two coroutines that yield control on purpose, with await asyncio.sleep(0) — a sleep of zero seconds whose only effect is to return to the loop.

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

The output is not A A A B B B. It is interleaved:

A step 0
B step 0
A step 1
B step 1
A step 2
B step 2

A runs until its first await and stops. The loop then runs B until *its* first await. Then A again, because its sleep(0) has resolved and it is back on the ready queue. The two advance in lockstep, one step each per turn, and the point where a turn ends is always an await. Delete the await asyncio.sleep(0) and A runs all three steps before B starts at all, because without an await it never gives the loop a chance to switch.

Where the concurrency actually comes from

Nothing above ran in parallel. A and B took turns on one thread, and the total work was the same as doing them one after another. So what is the loop for?

The answer is that real awaits are not instant. When a coroutine does await on a network read, a timer, or a database reply, it is saying "I have nothing to do until this arrives — run someone else." The loop does exactly that. While A waits 200 ms for a server, B is not blocked; it is running, or waiting on its own reply. A thousand coroutines can all be waiting at once on one thread, which is the thing threads are too expensive to do at that scale.

The loop is therefore an overlap machine for waiting, not a way to do two computations at once. That distinction decides everything about when async helps — it is covered in running work concurrently, and its most common failure, a coroutine that does not step aside, is the whole subject of the blocking call that freezes the loop.

The three states a coroutine is in

At any moment the loop sees each coroutine in one of three states, and following them is enough to predict any async program's behaviour:

StateMeaning
Runningit has the thread right now, between two awaits
Readyit is waiting in the queue for its turn
Waitingit is suspended at an await, until that await resolves

Exactly one coroutine is ever *running*. When it hits an await it becomes *waiting*, and the loop promotes the next *ready* one to running. When an await resolves — a timer fires, a reply arrives — its coroutine moves from *waiting* back to *ready*, and will run again when its turn comes.

Why "cooperative" is the word that matters

This scheduling is cooperative: the loop can only switch when the running coroutine chooses to await. It cannot preempt. A thread-based system is different — the operating system can pause a thread mid-instruction and run another — but an event loop has no such power, because there is only one thread and it is currently executing your code.

The upside is that between awaits your code is atomic: no other coroutine can observe a half-finished update, which removes a whole category of race condition that threads suffer from. The downside is the mirror image: if a coroutine runs for a long time *without* awaiting, the entire loop is frozen for that whole time, because nothing can take the thread away from it. Cooperation only works if everyone cooperates.

Where it goes wrong

Expecting parallelism. The loop runs one thing at a time. For work that is genuinely CPU-bound, async does nothing; you need processes.

A coroutine that never awaits. It monopolises the single thread, and every other coroutine stalls until it finishes. This is the most common async bug and has its own article.

Assuming order across awaits. Between two awaits your code is safe. Across an await, anything else may have run, so state you read before the await may have changed after it.

Confusing "started" with "running". Creating a coroutine object does not run it, and neither does the loop until you hand it over — which is what coroutines, tasks and await is about.

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 “The whole machine, in one sentence”?

  2. What does this module say about “Watch two coroutines take turns”?

  3. What does this module say about “Where the concurrency actually comes from”?

Cheat sheet

The Event Loop, Stepped Through

One thread, one queue of ready work, and coroutines that step aside at every await. Everything else about async Python is a consequence of this.

ASYNC PYTHON · vizlearn.in/async_python/event_loop_stepped_through.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.