Deep Engineering
Advanced·Published·3.12 · 3.13 · 3.14·30 MIN

The event loop: one thread, one queue, and six steps on repeat

The event loop is a plain while True with six steps inside it. How many times it repeats is set by suspensions, not by tasks: swap ten coroutines for a thousand and the count does not move. That is where the classic trap comes from: one time.sleep stalls the whole service.

Full technical treatment

TL;DR

The event loop is a while True with six steps inside it. The fifth queues everything whose deadline has arrived; the sixth is the only place where anything is called at all. The length of the queue is fixed before the pass, so a callback added during a turn only runs on the next one.

Everything else follows. A thousand coroutines doing sleep(0) produce as many turns as ten do: turns count suspensions, not tasks. An empty asyncio.run program makes six turns, four of which are shutdown procedures. And fifty tasks with time.sleep(0.1) inside produce not fifty turns but two, one of which lasts five seconds.

Why bother?

Everybody writes async Python; few can say what happens between await and the line after it. That usually does no harm — until one of three things happens: a service inexplicably freezes for a second, asyncio.sleep(0.01) somehow sleeps for 30 ms, or adding one more task ruins the responsiveness of the whole application.

All three come from one mechanism, and it fits in a single eighty-line function. This article takes that function apart in full — and checks every claim by measuring it on a live interpreter.

The model to hold in your head

Picture one person behind a counter. In front of them is a stack of slips — the queue of work that is ready to be done. Next to them is an alarm clock that can be set for any time. And there is a hatch through which new requests are pushed.

They work by a single rule: take the stack, do everything that was in it at the moment they picked it up, and only then look at the clock and the hatch. Slips added while they worked go into the next round.

While the stack is empty and the alarm has not rung, they sleep — not "spin idle", but genuinely sleep, waking either to the alarm or to something arriving through the hatch.

Everything else in this article sharpens that picture until it can be checked against code. One detail matters right away: there is one person. While they are busy with one slip, they look neither at the clock nor at the hatch. Not out of stubbornness — there is simply only one of them.

What the loop is made of

Before taking apart what the loop does, it is worth seeing what it is built from. It is literally an object with three containers, and everything that happens in an async program is work being moved between them.

PYTHON
# base_events.py, BaseEventLoop.__init__ — tag v3.13.7
class BaseEventLoop(events.AbstractEventLoop):
    def __init__(self):                                    # :419
        self._timer_cancelled_count = 0                    # :420
        self._ready = collections.deque()                  # :423
        self._scheduled = []                               # :424
        self._clock_resolution = time.get_clock_info('monotonic').resolution  # :430
PYTHON
# selector_events.py, BaseSelectorEventLoop.__init__ — same tag
        if selector is None:
            selector = selectors.DefaultSelector()         # :63
        self._selector = selector                          # :65

Five lines, and that is the whole inventory.

_ready — the queue of what is due right now. A plain deque. It is the single entrance to execution: a fired timer, a readable socket, a call_soon — all of them land here first and are only then run.

_scheduled — the heap of timers, ordered by deadline (an ordinary list, but worked through heapq, so the nearest deadline is always at the head). Everything asked for "later" goes here: call_later, call_at, and the insides of asyncio.sleep.

_selector — a wrapper around epoll (or kqueue, or select, depending on the system). The only thing that knows about sockets, and the only place where the process actually sleeps.

Above these three sits the layer of tasks and futures, and below them is exactly one function that calls anything. And note that the loop knows nothing about tasks: Task and Future live on top of it and talk to it only through call_soon and call_later. As far as the loop is concerned, everything that exists is a callback.

The "What it is made of" tab below shows that diagram whole, with the real field names and arrows for what ends up where. Switch it to "Turn, step by step" and the same picture starts highlighting the parts that do the work at each step.

Six steps of a single turn

The event loop in CPython is the _run_once method in Lib/asyncio/base_events.py. run_forever calls it, and the whole construction is literally this:

PYTHON
# base_events.py:678–687, tag v3.13.7
def run_forever(self):
    ...
    self._run_forever_setup()
    try:
        while True:
            self._run_once()
            if self._stopping:
                break
    finally:
        self._run_forever_cleanup()

Inside _run_once are six steps, and the rest of this article refers to them by number:

  1. Sweep cancelled timers (lines 1978–1999).
  2. How long we may sleep (2001–2010) — the timeout for the next step.
  3. selector.select(timeout) (2012) — the only place the process actually blocks.
  4. Process ready I/O (2013) — ready descriptors become callbacks and go into _ready; nothing runs yet.
  5. Pop expired timers (2017–2025) — also into _ready.
  6. The only place callbacks are called (2027–2051).

Go back to the visualisation above and switch it to "Turn, step by step": it will highlight which part works at each step. In "Recorded run" the same steps are shown against a real program.

The step worth stopping at is the last one. In the sources it carries a comment that explains the scheduler better than any retelling could:

This is the only place where callbacks are actually called. All other places just add them to ready.

—

And right after it, the part that makes the scheduler fair:

PYTHON
# base_events.py:2033–2035
ntodo = len(self._ready)
for i in range(ntodo):
    handle = self._ready.popleft()

ntodo is computed before the loop. A callback added to _ready during this pass will not make it in — it waits for the next turn. Without that line, a task that endlessly schedules its own continuation through call_soon would own the loop forever.

How many turns your program makes

This is where the measurements start. Turns can be counted exactly: replace BaseEventLoop._run_once with a counting wrapper before the loop is created. This is not an estimate — these are precisely the iterations that happened.

Program3.12.33.13.73.14.7
async def main(): pass666
one await asyncio.sleep(0)777
ten × await asyncio.sleep(0)161616
three nested awaits, none suspending666
gather of 10 coroutines, none suspending999
gather of 10 coroutines with sleep(0)101010
gather of 100 coroutines with sleep(0)101010
gather of 1000 coroutines with sleep(0)101010

Three rows in this table deserve a second reading.

An empty program makes six turns, not one. The reason is not the loop but asyncio.run: it is three consecutive run_until_complete calls, and only the first runs your code. The second closes async generators (shutdown_asyncgens), the third the default thread pool (shutdown_default_executor). Each contributes a couple of turns. In "Recorded run" above, those four turns are visible by name at the end.

Three nested awaits cost the same as an empty program. That is, nothing. await on its own never touches the loop — more on that below.

A thousand coroutines produce as many turns as ten. This is probably the single thing to carry away from the article: the number of turns is set by the depth of suspension, not by the number of tasks. Every task that is ready to continue is consumed by one pass over _ready.

What await actually is

A coroutine is a generator with a different protocol. You drive it by calling .send(None), and return comes out as StopIteration.value. This can be checked by hand, with no event loop at all:

PYTHON
async def inner():
    return "the value from return"
 
coro = inner()
try:
    coro.send(None)          # first step — and immediately the last
except StopIteration as e:
    print(e.value)           # the value from return

Now the same thing, but with a suspension. The whole protocol fits in the five lines of Future.__await__:

PYTHON
# futures.py:283–289, tag v3.13.7
def __await__(self):
    if not self.done():
        self._asyncio_future_blocking = True
        yield self  # This tells Task to wait for completion.
    if not self.done():
        raise RuntimeError("await wasn't used with future")
    return self.result()  # May raise too.

yield self is passed up through the entire await chain and emerges as the result of coro.send(None). The _asyncio_future_blocking flag is the agreed signal for "I am a real awaitable, park the task"; the task checks it in tasks.py:324–325.

From which follows the thing that explains the "three nested awaits" row of the table: if no suspending Future is met along the way, the whole await chain collapses without a single trip into the loop. It costs exactly what it costs to create the coroutine objects.

In nanoseconds:

Operation3.13.73.14.7vs a plain call (3.13.7)
plain function call44.5 ns36.1 ns×1.0
await a coroutine that does not suspend90.7 ns91.0 ns×2.0
create a coroutine object and close it, no await68.9 ns76.6 ns×1.5
await an already-finished Future370.2 ns402.4 ns×8.3
await asyncio.sleep(0) — exactly one turn2087 ns2007 ns×46.9

Read this by rows, not by columns: the ratios in the last column were measured within a single run of 3.13.7 and so hold, whereas the two columns are two different builds.

The second and third rows are close, and that is the answer to "is await expensive". Of the 90.8 ns, 69.0 is creating the coroutine object itself — three quarters of it. The await machinery proper costs around twenty nanoseconds. What is expensive is a real suspension with a return into the loop: 2.0 µs, twenty-odd times more.

How a future wakes a task

This is where people go wrong most often, and the mistake sounds innocent: "set_result resumes the coroutine". It does not.

PYTHON
# futures.py:158–170, tag v3.13.7
def __schedule_callbacks(self):
    callbacks = self._callbacks[:]
    if not callbacks:
        return
    self._callbacks[:] = []
    for callback, ctx in callbacks:
        self._loop.call_soon(callback, self, context=ctx)

set_result, set_exception and cancel all end in this function, and it queues a callback. The coroutine continues not here, but in step six of the next turn.

The full loop looks like this:

  1. await fut → Future.__await__ does yield self;
  2. the task sees the flag, attaches add_done_callback(self.__wakeup) and remembers the future in _fut_waiter (tasks.py:341–343) — from this moment the loop knows nothing about the task;
  3. some time later somebody calls fut.set_result(v);
  4. __schedule_callbacks puts Task.__wakeup into _ready via call_soon;
  5. step six of a turn calls __wakeup, which calls __step, which calls coro.send(None);
  6. __await__ returns self.result(), and the function continues on the next line.

asyncio.sleep(delay) is the shortest complete example of that loop:

PYTHON
# tasks.py:713–720, tag v3.13.7
future = loop.create_future()
h = loop.call_later(delay, futures._set_result_unless_cancelled, future, result)
try:
    return await future
finally:
    h.cancel()

A future, a timer, an await — that is all. There is no "sleep magic" in asyncio: there is a timer that after delay seconds puts a result into a future, and that wakes the task through the loop above.

Why one blocking call stops everything

The mechanism is assembled now, so the main consequence can be shown rather than asserted.

Take N tasks, each busy for 100 ms. In one variant that is time.sleep(0.1), in the other await asyncio.sleep(0.1). What is measured is total time, the number of turns, and the duration of the longest turn.

The thing to look at is not the time but the number of turns. In the blocking variant it does not grow at all: one task and fifty tasks both give two turns. What grows is the duration of a single turn — up to 5011 ms.

This is exactly what ntodo = len(self._ready) predicts. All fifty tasks were ready at once, landed in the queue, and were consumed by one pass of step six. The stretches they spent blocked added up.

Note the cooperative variant too: it also has a turn 100 ms long. The difference is not the duration but what happens inside it: in the first case the thread is working and can react to nothing; in the second it sleeps in select() and will wake on any event. On a time axis those two rectangles look identical, which is why they are coloured differently.

The "Responsiveness" tab translates this into something you can feel: a background task asks to be woken every 10 ms while a worker does ten 100 ms chunks of work, yielding between chunks. Both variants yield; the only difference is whether the worker blocks inside a chunk. With a blocking worker the pulse ticked 4 times in a second, with a median interval of 300.9 ms. With a cooperative one — 99 times, median 10.2 ms.

Why 300 and not 100 is a question worth answering, because the answer follows entirely from step six. The pulse loses a turn twice over: a timer that expired during the block is only collected at step 5 of the next turn, and a callback that landed in _ready during a turn only runs a turn later — because of ntodo. So between two ticks of the pulse there is not one turn of the worker but three: its own and the two it lost. Three turns of 100 ms make 300. Confirmed by tracing: the recording shows TaskStepMethWrapper in three consecutive turns before the pulse's Task.task_wakeup appears.

How to see this in a log rather than a profiler

asyncio has a built-in way to catch blocking: debug mode. It is off by default, and the threshold beyond which a callback counts as slow is 100 milliseconds (Lib/asyncio/base_events.py:437).

Callbacks taking longer than 100 milliseconds are logged. The loop.slow_callback_duration attribute can be used to set the minimum execution duration in seconds that is considered "slow".

— asyncio — Debug Mode

The mode and the threshold are two independent knobs, and that is what trips people up most. Verified on a 0.25 s block (bench/cancellation/06_debug_slow_callback.py):

modethresholdwhat lands in the log
offdefaultnothing
on0.1 sWARNING:asyncio:Executing <Handle …> took <N> seconds
on1.0 snothing: the block is shorter than the threshold
on0.01 sthe same warning

The line has one format, but the object inside it changes — and that object tells you what blocked the loop:

  • <Handle …> — an ordinary callback scheduled through call_soon;
  • <TimerHandle when=… …> — a delayed one, through call_later;
  • <Task … coro=<…>> — a coroutine step, which is exactly the case taken apart above: a task that did not yield control.

It is switched on from outside, without touching the code: PYTHONASYNCIODEBUG=1, or python -X dev, or asyncio.run(main(), debug=True). The threshold is set separately and only matters together with the mode: loop.slow_callback_duration = 0.05.

Debug mode also turns on coroutine origin tracking: the "coroutine … was never awaited" warning gains a Coroutine created at <file:line> line, and a forgotten coroutine stops being a mystery.

What changed in 3.12, 3.13 and 3.14

The core of the loop did not change. The body of _run_once is byte-identical between tags v3.13.7 and v3.14.0, all 83 lines; only the first line number moved, 1970 → 1966. The turn counts in the table above are the same across all three versions — the scheduler counts identically. Speed is not compared across versions by these measurements: the three builds differ by more than the language version.

What changed is everything around it.

3.12 — eager tasks. asyncio.eager_task_factory makes a coroutine start executing synchronously inside the task constructor. The documentation is exact here: "Tasks are only scheduled on the event loop if they block". Hence a property worth knowing before switching it on: the speed-up goes only to tasks that finish without suspending once. If the body suspends, there is no gain — the task gets registered with the loop anyway.

3.13 — correctness of cancellation. A case where two nested TaskGroups with simultaneous exceptions could hang was fixed; groups also began keeping a cancellation count (Task.cancelling()). asyncio.Queue.shutdown and Server.close_clients() appeared.

3.14 — introspection and free threading. python -m asyncio ps PID and pstree PID arrived — a view of the task tree of a live process — along with the asyncio.graph module and print_call_graph(). The optimisation claimed in the release notes is "improved by 10-20%", from a new per-thread doubly linked list of tasks. Free-threading builds got "first class support for free-threading builds… scaling linearly with the number of threads". And the change most likely to break existing code: asyncio.get_event_loop now raises RuntimeError when there is no loop instead of silently creating one.

VersionChangeOrdering status
3.12asyncio.eager_task_factory; C implementation of current_task; loop_factory for asyncio.run
3.13Fixed hang in nested TaskGroups; Queue.shutdown; as_completed yields the original tasks
3.14python -m asyncio ps/pstree and asyncio.graph; get_event_loop no longer creates a loop; free-threaded build support

What to do about it

Do not call blocking code in a coroutine. This is the one rule the others follow from, and now it is clear why: there is one thread, and step six is an ordinary for with no preemption.

If blocking is unavoidable — await loop.run_in_executor(...) or asyncio.to_thread(...). The work moves to a thread and the loop goes back into select().

Yielding between chunks of work is not enough. In the measurement above the worker honestly did await asyncio.sleep(0) after every 100 ms chunk — and the pulse still lost 95 of its 99 ticks. One yield per 100 ms of work does not save you.

asyncio.run in a loop is an anti-pattern. Measured: one asyncio.run(noop()) costs 71 µs, while the same coroutine inside an already-running loop costs 0.12 µs. That is 99.8% of the budget spent creating and closing a loop, plus those four shutdown turns. For repeated runs there is asyncio.Runner: it reuses one loop and comes to 10 µs per run — seven times cheaper.

Do not optimise await. It costs 91 ns, and three quarters of that budget goes on creating the coroutine object. What is worth optimising is the number of suspensions, not the number of awaits.

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Take to work

  • Do not call time.sleep inside a coroutine: it stops the whole service rather than one task — fifty such tasks are five seconds in which nothing at all runs.
  • Do not economise on await: it costs about ninety nanoseconds, most of it creating the coroutine object. What costs is the waiting itself.
  • Do not picture the loop polling tasks: readiness is delivered by the selector rather than by walking a list, and the number of turns does not grow with the number of tasks.
  • Do not expect a speed-up from eager_task_factory without measuring: the gain is not there in every case.

Common misconceptions

Claim

“The event loop constantly polls the tasks looking for ready ones.”

Actually

It sleeps. Step three of a turn is selector.select(timeout), the only place where the process actually blocks; the timeout is computed as the time until the nearest timer. In the recorded run of gather(sleep(0.02), sleep(0.05)) this is visible directly: turn 3 sleeps 0.019983 s, turn 6 sleeps 0.029687 s. Not a fixed tick, but exactly up to the nearest deadline.

Claim

“fut.set_result(x) resumes the coroutine that is awaiting that future.”

Actually

It does not. set_result calls __schedule_callbacks (futures.py:158–170), which puts Task.__wakeup into the queue via call_soon. The coroutine continues in step six of the NEXT turn. The difference shows up in the pulse measurement: a callback that lands in the queue during a turn waits for the next one — which is where the pulse loses an extra 100 ms.

Claim

“The more tasks there are, the more turns the loop makes.”

Actually

Turns count suspensions, not tasks. A gather of 10, 100 and 1000 coroutines each doing await asyncio.sleep(0) gives the same number of turns — 10 — and it is the same on 3.12.3, 3.13.7 and 3.14.7. Every ready task is consumed by one pass over _ready, because ntodo is captured before the loop.

Claim

“await is expensive, use it sparingly.”

Actually

A switch happens only on a real suspension. 90.8 ns on 3.13.7 — against 44.6 ns for a plain function call, and 69.0 of those 90.8 go on creating the coroutine object, not on the waiting machinery. What is expensive is a real suspension: await asyncio.sleep(0) costs 2033 ns, twenty-odd times more. Cut the number of suspensions, not the number of awaits.

Claim

“An empty asyncio.run(main()) program makes one turn of the loop.”

Actually

Six. asyncio.run is three consecutive run_until_complete calls: your code, then shutdown_asyncgens(), then shutdown_default_executor(). Each contributes a couple of turns. Hence the practical conclusion too: asyncio.run in a loop is an anti-pattern — for repeated runs there is asyncio.Runner.

Claim

“eager_task_factory from 3.12 speeds up asyncio.”

Actually

It speeds up exactly one shape of workload, and the documentation says so directly: “Tasks are only scheduled on the event loop if they block”. A task that finishes without suspending once saves one call_soon and one turn. A task that suspends saves nothing — it gets registered with the loop anyway.

Claim

“Yielding control between chunks of work preserves responsiveness.”

Actually

It depends on the chunk size. In the measurement the worker honestly did await asyncio.sleep(0) after every hundred-millisecond chunk — and the background task, which had asked to be woken every 10 ms, ticked 4 times instead of 99, with a median interval of 300.9 ms. Yielding helps only when the chunk is comparable to the responsiveness you need.

Check your understanding

Question 1 of 5

An async service answers quickly, but every few seconds it freezes for a second. One CPU core is busy while this happens. What do you check first?

Sources & further reading

10 SOURCES

  1. PEP 3156 — Asynchronous IO Support Rebooted: the “asyncio” ModulePEP. The document that put asyncio into the language. Final, Python 3.3. The model itself comes from here: loop, callbacks, futures, transports.https://peps.python.org/pep-3156/
  2. PEP 492 — Coroutines with async and await syntaxPEP. Final, Python 3.5. Introduces async def and await as a protocol of its own on top of generators — which is why await can be explained through send.https://peps.python.org/pep-0492/
  3. Lib/asyncio/base_events.py — _run_onceCPython source code. Lines 1970–2051: the six steps of a single turn. The same file holds _MIN_SCHEDULED_TIMER_HANDLES = 100 (line 58), _MIN_CANCELLED_TIMER_HANDLES_FRACTION = 0.5 (62) and MAXIMUM_SELECT_TIMEOUT = 24 * 3600 (68). CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/base_events.py
  4. Lib/asyncio/futures.py — Future.__await__ and __schedule_callbacksCPython source code. Lines 283–289 — five lines in which yield self and the _asyncio_future_blocking flag make up the entire suspension protocol. Lines 158–170 — why set_result does not resume a coroutine but only queues a callback. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/futures.py
  5. Lib/asyncio/tasks.py — Task.__step_run_and_handle_result and asyncio.sleepCPython source code. Lines 298–371: what happens to whatever coro.send(None) returned. Lines 703–720: sleep as the smallest complete example of the future — timer — wake-up loop. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/tasks.py
  6. Developing with asyncio — blocking code and multithreadingOfficial documentation. “If a function performs a CPU-intensive calculation for 1 second, all concurrent asyncio Tasks and IO operations would be delayed by 1 second” — and next to it: “While a Task is running in the event loop, no other Tasks can run in the same thread”.https://docs.python.org/3.13/library/asyncio-dev.html
  7. Event Loop — call_soon and call_laterOfficial documentation. “Callbacks are called in the order in which they are registered”, and the caveat about timers: “may run up to one clock-resolution early” — exactly what the code implements by adding _clock_resolution.https://docs.python.org/3.13/library/asyncio-eventloop.html
  8. asyncio — Tasks: eager_task_factoryOfficial documentation. “Coroutines begin execution synchronously during Task construction. Tasks are only scheduled on the event loop if they block”. Added in 3.12 — which explains why this optimisation helps exactly one shape of workload.https://docs.python.org/3.13/library/asyncio-task.html
  9. selectors — BaseSelector.selectOfficial documentation. The definition of the one call in which an async program actually sleeps: “Wait until some registered file objects become ready, or the timeout expires”.https://docs.python.org/3.13/library/selectors.html
  10. What's new in Python 3.14 — asyncioOfficial documentation. Two claims the version section leans on: “improved by 10-20% following the implementation of a new per-thread doubly linked list for native tasks” and “first class support for free-threading builds… scaling linearly with the number of threads”. Same page: get_event_loop now raises RuntimeError instead of creating a loop.https://docs.python.org/3.14/whatsnew/3.14.html