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.
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.
This explorer needs JavaScript: every time, depth and lag on it is computed in the page rather than downloaded as an image.
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.
Asynchronous Python runs on a single thread. There is no parallelism here and no second core doing work behind your back.
This page steps through the loop one coroutine switch at a time, shows what blocking calls do to the queue, and builds up to the producer-consumer pattern that most real async programs use.
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. This starts a loop, runs the coroutine, and closes it. The two are the same program with different entry points.
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 2A 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.
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.
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:
| State | Meaning |
|---|---|
| Running | it has the thread right now, between two awaits |
| Ready | it is waiting in the queue for its turn |
| Waiting | it 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.
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.
The loop is not a metaphor. It is a while loop over three data structures, and knowing them is enough to predict any scheduling question:
One iteration does four things in order. Run everything currently on the ready queue. Work out how long until the earliest timer is due. Ask the selector for I/O events, using that as its timeout. Then move whatever fired — sockets that became readable, timers that came due — onto the ready queue for the next pass.
await asyncio.sleep(0) is the smallest possible interaction with that machinery: it puts the current coroutine straight back on the ready queue and returns to the loop, so anything already queued runs before your next line. loop.call_soon appends a plain callback to the same queue, and loop.call_later puts one on the timer heap instead.
after one turn: ['soon A', 'soon B']
after the timer: ['soon A', 'soon B', 'timer 50ms']After a single await asyncio.sleep(0) both call_soon callbacks have already run, because one trip through the loop drains the ready queue. The 50 ms timer has not, because it is not due yet — it is sitting on the heap until an iteration whose selector timeout expires moves it across. That is the entire scheduler: a queue for now, a heap for later, and one poll that waits for whichever arrives first.
asyncio.run is not a way of calling a coroutine. It creates a fresh event loop, runs the coroutine until it finishes, and then closes the loop. That is why it belongs at the top of a program and nowhere else — and why calling it while a loop is already running is an error rather than a nested run:
RuntimeError: asyncio.run() cannot be called from a running event loop
there is a running loop here: TrueThat error is exactly why every editor on this track finishes with await main(). The page already has a loop — the browser's — and from inside a running loop a coroutine can simply be awaited. A standalone script has no loop yet, so there asyncio.run(main()) is how you get one.
Two related functions come up constantly. asyncio.get_running_loop() returns the loop that is running and raises if there is none, which is what you want inside a coroutine. The older asyncio.get_event_loop() would *create* one when none existed, which made starting a second loop by accident easy; that is why it is deprecated. If you need the loop, ask for the running one.
The loop is also per-thread. A second thread has no loop until something creates one there, and an object belonging to one loop cannot be awaited on another. That constraint is what asyncio.run_coroutine_threadsafe exists for — it is how synchronous code on another thread hands work to a loop.
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.
Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.
What does this module say about “The whole machine, in one sentence”?
What does this module say about “Watch two coroutines take turns”?
What does this module say about “Where the concurrency actually comes from”?
Step through Python's event loop tick by tick. See how one thread, one ready queue and cooperative yields run thousands of coroutines.