The idea: one thread, and tasks that give it back

asyncio runs all your coroutines on one thread. Nothing is preempted. A coroutine runs until it reaches an await on something that is not ready yet, a timer or a socket. Then it gives the thread back to the event loop, and the loop runs the next piece of work that is ready. When nothing is ready, the loop sleeps in epoll_wait and uses no CPU.

One iteration of BaseEventLoop._run_once as a cycle: step 1 selector.select(timeout) with timeout 0 if _ready has handles, else the time to the next timer, else None; ready fds put their callbacks on _ready. Step 2 timers whose when has passed move from the _scheduled heap to _ready. Step 3 the loop runs exactly the handles that were in _ready, ntodo of them; handles they add wait for the next iteration.
Every iteration does the same three things: wait for I/O (or not), move due timers, run what is ready.

One iteration: _run_once

The loop is BaseEventLoop._run_once called over and over. It keeps two queues: _ready, a deque of Handles (callbacks to run soon, first in, first out), and _scheduled, a heap of TimerHandles sorted by when. Each iteration:

  1. select: call selector.select(timeout). The timeout is 0 if _ready has handles, otherwise the time until the earliest timer, otherwise None (wait forever). Every readable fd puts its reader callback on _ready.
  2. due timers: every timer with when <= loop.time() moves to the back of _ready.
  3. run: take ntodo = len(_ready) and run exactly that many handles. A handle added while they run waits for the next iteration, so a callback that keeps re-scheduling itself cannot starve the selector.

Press 1 iteration to step through one _run_once at a time; the three boxes under running now show the phase.

Tasks, futures and the two-hop wakeup

A Task wraps a coroutine. Task.__step runs the coroutine until it awaits a future that is not done. The task then adds its __wakeup as a done callback of that future and returns. The coroutine's frame stays suspended; no thread waits for it.

When the future gets its result, it does not run the task at once. It calls loop.call_soon() for each done callback. So waking up is always two hops through _ready: first the event's callback (the timer's set_result, the socket's _read_ready) sets the future's result, then, one iteration later, Task.__wakeup resumes the coroutine. Each task has its own colour on the canvas; its Handles carry that colour too (Demo: gather, three sleeps on one thread).

asyncio.gather() makes a Task for each coroutine and returns an outer future. Each child's done callback counts it; the last one sets the outer result, which wakes main. Three sleeps of 0.3, 0.1 and 0.2 s finish after 0.3 s, not 0.6 s.

Awaiting a coroutine directly (await fetch()) does not go through the loop at all: it is a plain call into the inner frame. Only awaiting a future that is not done yields the thread.

Timers and the select timeout

await asyncio.sleep(0.1) creates a future and calls loop.call_later(0.1, futures._set_result_unless_cancelled, …). That puts a TimerHandle on _scheduled. With nothing in _ready, the loop passes the time left until the earliest when as the epoll_wait timeout, so it wakes exactly when the timer is due, unless I/O comes first.

Sockets and the selector

On Linux the selector is selectors.EpollSelector. A connected stream's transport registers its socket once with loop.add_reader(fd, _read_ready). await reader.read(100) on an empty buffer makes a _waiter future and yields. When data arrives, epoll_wait reports the fd, _process_events puts _read_ready on _ready, and it calls the non-blocking recv(), protocol.data_received() and reader.feed_data(), which sets the waiter's result (Demo: sockets, epoll wakes the reader). The client whose data comes first is served first. See select, poll and epoll for what the selector does in the kernel.

Blocking the loop, and to_thread

Because there is one thread, any call that does not await holds every task. time.sleep(0.35), a slow requests.get(), or 350 ms of pure CPU all freeze the loop: timers fire late and sockets are not read (Demo: time.sleep blocks the loop). In debug mode (PYTHONASYNCIODEBUG=1 or asyncio.run(main(), debug=True)) asyncio logs Executing <Task …> took 0.350 seconds for any callback slower than loop.slow_callback_duration (0.1 s).

The fix is to move the blocking call off the loop thread. await asyncio.to_thread(time.sleep, 0.35) runs it in the default ThreadPoolExecutor through loop.run_in_executor(). When the worker finishes, it cannot touch _ready safely by itself, so it calls loop.call_soon_threadsafe(): that appends a Handle and writes one byte to the loop's self-pipe, which makes epoll_wait return at once (Demo: asyncio.to_thread keeps the loop free). For CPU-heavy Python code a thread does not help much because of the GIL; use a ProcessPoolExecutor instead.

Two timelines from 0 to 0.6 seconds. Top: main calls time.sleep(0.35) on the loop thread; the ticker's timers due at 0.1 s fire at 0.35 s, then ticks follow at 0.45 and 0.55 s, and the program ends at 0.55 s. Bottom: main calls asyncio.to_thread; a worker thread sleeps from 0 to 0.35 s while the loop thread prints ticks at 0.1, 0.2 and 0.3 s, and the program ends at 0.35 s.
Same program, two ways to wait 0.35 s: on the loop thread every tick is late; on a worker thread every tick is on time.

sleep(0) and taking turns

await asyncio.sleep(0) is a bare yield: __step sees no future and calls call_soon(self.__step), putting the task at the back of _ready. Two CPU-bound tasks that do this after each chunk of work take turns, A B A B A B (Demo: sleep(0), two tasks take turns). Without it, A would run all its chunks before B starts. This is cooperative scheduling: fairness is the code's job, not the loop's.

asyncio compared with the Node.js loop

Python asyncioNode.js
looppure Python _run_once (or uvloop)libuv, in C
startyou call asyncio.run(main())always running after the main script
queuesone _ready deque plus the timer heapsix phases plus nextTick and promise microtasks
resuming a coroutinea __wakeup Handle in _readya promise microtask, before the next phase
blocking workto_thread, run_in_executorlibuv thread pool, worker_threads

See The Node.js Event Loop for the libuv phases, Coroutines: Stackful vs Stackless for how a suspended frame is kept, and Callbacks vs Promises vs Async/Await for the same idea in JavaScript.

What the page leaves out

Real Python code takes microseconds between awaits; the page counts it as 0 ms and draws it as thin bars. It leaves out cancellation (Task.cancel(), CancelledError, cancelled timers left in the heap), exceptions and return_exceptions, TaskGroup and timeouts, writes and flow control (drain()), accept() of new connections, the clock resolution added to timer checks, the Windows proactor loop, uvloop, signal handlers, context variables, and the cleanup asyncio.run() does at the end (cancelling leftover tasks, shutting down async generators and the default executor). In debug mode the slow-callback warning is real; the page shows it even though debug mode is off by default.

On the mind map

This page on the Servers & Event Loops mind map · 26 pages in 5 branches · open it to see the whole map and test yourself in recall mode.