Deep Engineering
Search
Advanced·Published·3.12 · 3.13 · 3.14·25 MIN

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

An empty async program makes six turns of the loop, and a thousand coroutines make exactly as many turns as ten. The number of turns is set by how often you suspend, not by how many tasks you have — and everything else follows from that, including why a single time.sleep stops 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.

BaseEventLoop, v3.13.7

The event loop is an object with three containers. Everything that happens in an async program is work being moved between them.

WHO ASKS
Tasks and futures
Task / Future
call_later ↓
call_at
call_soon ↓
add_done_callback
WHERE IT WAITS
_scheduled
list + heapq
step 5
_ready
collections.deque
step 4
_selector
selectors.DefaultSelector
step 6 ↓ ntodo captured before the pass
WHERE IT RUNS — THE ONLY PLACE
Handle._run()
events.py:87–105
↑ the callback resumes a task, which schedules more — and the circle closes
Tasks and futures · Task / Future · tasks.py, futures.py
The ones asking. A task that hits await parks itself and hangs a callback on a future. The loop knows nothing about tasks — only about callbacks.
_scheduled · list + heapq · base_events.py:424
A heap of timers ordered by deadline. Everything asked for “later” lands here: call_later, call_at, the innards of asyncio.sleep.
_ready · collections.deque · base_events.py:423
The queue of what to do right now. The single entrance to execution: timers, ready I/O and call_soon all land here first.
_selector · selectors.DefaultSelector · selector_events.py:63–65
A wrapper over epoll (or kqueue, or select, by platform). The only place the process really sleeps, and the only part that knows about sockets.
Handle._run() · events.py:87–105 · base_events.py:2050
The actual call. Each callback runs in its own copy of contextvars.Context; an exception goes to the loop's handler instead of killing it.
The fields are real: _ready and _scheduled are created in BaseEventLoop.__init__ (base_events.py:423 and :424); the selector in BaseSelectorEventLoop.__init__ (selector_events.py:63–65). Tag v3.13.7.

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. 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.0rc2
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.0rc2vs a plain function call
plain function call20.3 ns20.3 ns×1.0
await a coroutine that does not suspend56.6 ns59.8 ns×2.8
create a coroutine object and close it, no await53.8 ns52.1 ns×2.7
await an already-finished Future270.4 ns315.8 ns×13.3
await asyncio.sleep(0) — exactly one turn1698 ns1591 ns×83.7

The second and third rows nearly coincide, and that is the answer to "is await expensive". Of the 56.6 ns, about 54 is creating the coroutine object itself. The await machinery proper costs single-digit nanoseconds. What is expensive is a real suspension with a return into the loop: 1.7 µs, thirty times more.

Claim

await is a context switch, so it should be used sparingly.”

Actually

A switch happens only on a real suspension. await on a coroutine that waits for nothing costs 56.6 ns, of which 54 is creating the coroutine object; the event loop is never reached at all.

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 futFuture.__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.

CPython 3.13.7
thread is busy — nothing can be answeredthread sleeps in select() — any event wakes it
Blocking: time.sleep(0.1)
N=1
0.100 s2 turns
N=10
1.002 s2 turns
N=50
5.012 s2 turns
Yielding: await asyncio.sleep(0.1)
N=1
0.100 s4 turns
N=10
0.101 s4 turns
N=50
0.101 s4 turns
N=1000
0.107 s10 turns

Fifty blocking tasks: five seconds, and still two turns. Fifty cooperative ones: a tenth of a second and four turns. A thousand cooperative ones: 107 ms — the extra seven went on creating and stepping the tasks themselves.

Measurement: N tasks, each busy for 100 ms. A wrapper around _run_once counted the turns and timed each one. Look at the “turns” column: in the blocking variant it does not grow at all — the duration of a single turn does.

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. Their blocks 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. With a blocking worker it 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 the start of the next turn (step 5), and a callback that landed in _ready during a turn only runs a turn later — because of ntodo. 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.

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, only the speed differs.

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. And the most breaking change: 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. Of the ~53 µs it costs, 6 is useful work: the rest is creating and closing a loop plus those four shutdown turns. For repeated runs there is asyncio.Runner.

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

Common misconceptions

Claim

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

Actually

It sleeps. Step two 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 work the loop has.”

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.0rc2. 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

56.6 ns on 3.13.7 — against 20.3 ns for a plain function call. And 53.8 of those 56.6 go on creating the coroutine object, not on the waiting machinery. What is expensive is a real suspension: await asyncio.sleep(0) costs 1698 ns, thirty times more. Economise on 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