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
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 changesEach 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
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 beganTwo 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
Futurecomes out.Future.__await__sets a “blocking” flag and yields itself, all the way up theawaitchain 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. Nonecomes out. That is a bareyield, as inasyncio.sleep(0). The Task reschedules itself withcall_soon: “let everyone else run once”.StopIterationcomes out. The coroutine returned; the Task sets its result, and its own done-callbacks are scheduled.
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 seesCompleting 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:
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.FutureThe 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.
Related
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
Futureup to itsTask, 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 withcall_soon, and the Task callssend(None)on the coroutine again.
- Follow-up: “How does the awaiting coroutine get resumed?” — it yielded
a
- “Coroutine vs Task?” — a coroutine is the suspended function; a Task is
the loop’s driver for it, a
Futuresubclass that runs one step per callback. Only Tasks are scheduled.- Follow-up: “Does
create_taskrun 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 beforecreate_taskreturns.
- Follow-up: “Does
- “Why does
loop.call_soon()from another thread sometimes never run?” — the loop may be asleep inselect()with nothing to wake it.call_soon_threadsafealso writes to a socket the selector watches, so it wakes the loop. - “
get_event_loopvsget_running_loopvsasyncio.run?” —runis the entry point;get_running_loopis the accessor inside async code;get_event_loopis 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.