Backend / Async & concurrency / 06_event_loop_internals.md

Event loop internals

Updated 6 interview angles 8 min read source
On this page13
  1. The loop’s state
  2. One turn of the loop
  3. How a Task drives a coroutine
  4. Scheduling callbacks
  5. Readiness and completion: the two loop types
  6. Blocking code and the executor
  7. Getting a loop
  8. uvloop
  9. Python’s loop and Node’s
  10. Where it breaks in production
  11. The trade not taken
  12. Related
  13. Interview angle

Event loop internals

Article.

The asyncio event loop is a small program: a queue of callbacks to run now, a heap of callbacks to run later, and one call into the operating system that sleeps until a socket is ready or the next timer is due. Coroutines do not appear in it at all; a Task turns each step of a coroutine into a callback.

What does the loop actually do on each turn, and how does a coroutine get resumed? That explains why blocking calls freeze everything, why cross-thread calls need a special method, and what uvloop changes. Checked on CPython 3.14.5.

The coroutine object itself, and what await compiles to, are in Coroutines and async def.

The loop’s state

python
loop = asyncio.get_running_loop()
loop._ready        # collections.deque of Handle: callbacks to run this turn
loop._scheduled    # a heap (list + heapq) of TimerHandle, earliest first
loop._selector     # the OS readiness API: epoll, kqueue or select
loop.time()        # time.monotonic(): timers are immune to clock changes

Each Handle is a callback plus its arguments plus the contextvars.Context that was current when it was scheduled, which is how a request ID set in one task is still visible when its callbacks run.

One turn of the loop

text
1. drop cancelled timers from the head of the heap
2. timeout = 0 if _ready has callbacks,
             else until the earliest timer (max 24 h)
3. selector.select(timeout)       the only place it sleeps
4. for each ready socket: queue its callback in _ready
5. move every timer that is now due into _ready
6. run the callbacks in _ready - only the ones that
   were there when this step began

Two consequences matter. The loop sleeps only in step 3, so anything that keeps step 6 busy (a long callback, a coroutine that never yields) stops sockets and timers from being serviced at all. And step 6 runs a snapshot of the queue: callbacks scheduled during it wait for the next turn, so a task that keeps rescheduling itself cannot starve the selector.

How a Task drives a coroutine

asyncio.create_task(coro) wraps the coroutine in a Task and schedules its first step with call_soon. A step is one coro.send(None) (or coro.throw(CancelledError()) when cancelled), and what comes out decides what happens next:

  • A Future comes out. Future.__await__ sets a “blocking” flag and yields itself, all the way up the await chain to the Task. The Task adds its wake-up as a done-callback on that future and is not scheduled again until the future completes.
  • None comes out. That is a bare yield, as in asyncio.sleep(0). The Task reschedules itself with call_soon: “let everyone else run once”.
  • StopIteration comes out. The coroutine returned; the Task sets its result, and its own done-callbacks are scheduled.
python
fut = loop.create_future()

async def waiter():
    return await fut

c = waiter()
y = c.send(None)
type(y), y._asyncio_future_blocking     # (Future, True): what the Task sees

Completing a future never resumes anything inline: set_result schedules the done-callbacks with call_soon, so every resumption costs one trip through the queue. asyncio.sleep(d) is exactly a future plus call_later(d, ...) that completes it.

Lazy and eager tasks

By default create_task only schedules the first step; nothing runs until the current task yields. Since 3.12 an eager task factory (loop.set_task_factory(asyncio.eager_task_factory)) runs the coroutine immediately inside create_task until its first real suspension.

A task that never blocks (a cache hit, say) completes before create_task returns and skips the loop entirely, which matters for fan-out over mostly cached calls. Code that assumed the task had not started yet can break.

Scheduling callbacks

Method What it does
call_soon(cb) append to _ready: run on the next turn
call_later(d, cb) call_at(time() + d, cb)
call_at(t, cb) push onto the timer heap
call_soon_threadsafe(cb) call_soon plus a byte written to wake the loop

The last row matters. The loop is not thread-safe, and a loop sleeping in select() with no timers due does not notice a callback appended from another thread. call_soon_threadsafe writes a byte to an internal socket pair that the selector watches, so the sleep ends and the callback runs. asyncio.run_coroutine_threadsafe() is built on it.

Timer cancellation is lazy: TimerHandle.cancel() only marks the handle, and it stays in the heap until it reaches the head, or until the heap is rebuilt (once there are more than 100 timers and over half are cancelled). A service that sets and cancels a timeout per request carries those dead handles in memory until then.

Readiness and completion: the two loop types

SelectorEventLoop ProactorEventLoop
OS mechanism readiness: epoll, kqueue, select completion: IOCP
Model “this socket is readable; read it now” “this read has finished”
Default on Linux, macOS Windows (since 3.8)

The proactor issues overlapped reads and writes and is told when they complete, so it supports pipes and subprocesses on Windows. It cannot offer add_reader/add_writer, which raise NotImplementedError there; libraries built on them (psycopg’s async mode, for example) need the selector loop on Windows: asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop).

The event-loop policy API, including WindowsSelectorEventLoopPolicy, is deprecated in 3.14 and slated for removal in 3.16.

Blocking code and the executor

loop.run_in_executor() submits a function to a thread pool and returns an asyncio future. When the thread finishes, the result is delivered back with call_soon_threadsafe, which is how a worker thread wakes a sleeping loop. Cancelling the awaiting task cancels only that future: the thread runs on. run_in_executor submits at once; asyncio.to_thread submits only when awaited (run_in_executor and asyncio.to_thread).

Getting a loop

Call Use
asyncio.run(main()) the entry point: create a loop, run, close
asyncio.Runner() several run() calls on one loop
asyncio.get_running_loop() inside a coroutine; raises if none is running
asyncio.get_event_loop() legacy: raises RuntimeError since 3.14 if no loop

get_event_loop() used to create a loop when none existed, which hid bugs; since 3.14 it raises RuntimeError: There is no current event loop. There is one running loop per thread; other threads reach it through run_coroutine_threadsafe.

uvloop

uvloop replaces the loop with one built on libuv, in C, on Linux and macOS. Its “2-4x faster” figure comes from its own echo-server benchmark: the gain in a real application is the share of time spent in the loop itself, often much smaller once handlers, serialization and the database are counted. Start it with uvloop.run(main()); uvloop.install() goes through the deprecated policy API. uvicorn uses it automatically when it is installed.

Python’s loop and Node’s

Both are cooperative and single-threaded, so the blocking-call failure is the same. The difference interviewers ask about: Node’s loop is always running, part of the runtime, while Python’s is started explicitly with asyncio.run(), because Python supports threads, processes and asyncio and does not presume which one a program wants.

Where it breaks in production

A blocked loop

A synchronous call or a long computation in a coroutine keeps step 6 busy, so sockets and timers wait: latency rises on every endpoint at once while CPU may be low. Debug mode (asyncio.run(main(), debug=True) or PYTHONASYNCIODEBUG=1) logs every callback slower than loop.slow_callback_duration, 100 ms by default.

Since 3.14, python -m asyncio ps <pid> and pstree <pid> show what each task in a running process is awaiting.

Calling the loop from another thread

A worker thread that calls loop.call_soon() or future.set_result() directly races with the loop and may not wake it: the callback runs late or the state is corrupted. Cross-thread calls go through the threadsafe methods, always:

python
def on_message(msg):                       # called on a library's own thread
    loop.call_soon_threadsafe(queue.put_nowait, msg)          # not call_soon
    fut = asyncio.run_coroutine_threadsafe(handle(msg), loop)
    fut.result(timeout=5)                  # a concurrent.futures.Future

The Windows surprise

Code that works on Linux fails on Windows with NotImplementedError from add_reader, because the default Windows loop is the proactor. Choose the loop explicitly with loop_factory where a library needs readiness-based I/O.

The trade not taken

A loop is only a callback scheduler; coroutines are an ergonomic layer over it. Callback-style frameworks (Twisted’s Deferreds, early Node) expose that layer directly: no colouring of functions, but control flow broken into chained callbacks and error handling scattered across them. asyncio keeps the callbacks internal and gives each task a readable, sequential body, at the cost of async/await spreading through the call graph.

Interview angle 6

  • “What does the event loop do while a coroutine awaits I/O?” — it sleeps in one OS call (epoll_wait, kqueue or IOCP) with a timeout set to the earliest timer, wakes when a socket is ready or a timer is due, queues the matching callbacks, and runs them.
    • Follow-up: “How does the awaiting coroutine get resumed?” — it yielded a Future up to its Task, which added a wake-up callback to that future. When the I/O completes the future’s result is set, the wake-up is scheduled with call_soon, and the Task calls send(None) on the coroutine again.
  • “Coroutine vs Task?” — a coroutine is the suspended function; a Task is the loop’s driver for it, a Future subclass that runs one step per callback. Only Tasks are scheduled.
    • Follow-up: “Does create_task run anything?” — by default no: it schedules the first step for the next turn. With the eager task factory (3.12) it runs the coroutine up to its first suspension at once, and a task that never blocks finishes before create_task returns.
  • “Why does loop.call_soon() from another thread sometimes never run?” — the loop may be asleep in select() with nothing to wake it. call_soon_threadsafe also writes to a socket the selector watches, so it wakes the loop.
  • “get_event_loop vs get_running_loop vs asyncio.run?” — run is the entry point; get_running_loop is the accessor inside async code; get_event_loop is legacy and since 3.14 raises when no loop is running.
  • “What does uvloop buy you?” — a faster loop in C; a large win in loop overhead benchmarks, a smaller one in real applications where handlers dominate. Measure before claiming 2-4x.
  • “Why doesn’t Python start a loop automatically like Node?” — Python has several concurrency models and does not presume asyncio; Node has one.