Python Asyncio Event Loop — Deep Dive

Architecture of the Default Event Loop

CPython ships with SelectorEventLoop (all platforms) and ProactorEventLoop (Windows IOCP). Since Python 3.10, SelectorEventLoop is the default on Unix; on Windows, ProactorEventLoop became the default in 3.8.

The selector loop wraps Python’s selectors module, which itself wraps OS primitives:

OSSystem CallMax FDsEdge/Level
Linuxepoll~1MBoth
macOSkqueue~100KBoth
Windowsselect512 defaultLevel only

ProactorEventLoop uses Windows I/O Completion Ports, which support true async I/O (the OS performs the read/write and notifies on completion, rather than signaling readiness).

The Loop Iteration in Detail

Each pass through _run_once() — the heart of the CPython event loop — does:

1. Calculate timeout:
   - If _ready queue is non-empty → timeout = 0 (don't block)
   - Else → timeout = time until next scheduled callback
   
2. Poll selector with calculated timeout
   - Returns list of (key, events) for ready file descriptors
   - Each key carries a callback (reader or writer)
   
3. Process I/O events → add callbacks to _ready queue

4. Process scheduled callbacks whose deadline has passed → move to _ready queue

5. Drain _ready queue:
   - Pop each Handle, call handle._run()
   - Each run invokes one step of a coroutine or a plain callback

The _ready queue is a collections.deque. Scheduled callbacks live in a list maintained as a min-heap (sorted by deadline), so finding expired timers is O(log n) per insertion and O(1) for the earliest.

Task Lifecycle

A Task wraps a coroutine and drives it via __step():

# Simplified version of Task.__step
def __step(self, exc=None):
    try:
        if exc is None:
            result = self._coro.send(None)
        else:
            result = self._coro.throw(exc)
    except StopIteration as e:
        self.set_result(e.value)
    except Exception as e:
        self.set_exception(e)
    else:
        # result is a Future the coroutine is waiting on
        result.add_done_callback(self.__wakeup)

def __wakeup(self, future):
    self._loop.call_soon(self.__step)

When a coroutine awaits a Future, __step hooks __wakeup onto that Future’s done-callbacks. When the Future resolves (I/O complete, another task finished), __wakeup schedules __step back onto _ready. This is the mechanism behind cooperative multitasking.

Custom Event Loop Policies

asyncio.set_event_loop_policy() lets you swap the loop implementation globally. The most notable third-party loop is uvloop, which wraps libuv (the engine behind Node.js):

import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())

Benchmarks show uvloop delivering 2-4x throughput improvement for I/O-heavy applications compared to the default selector loop, because libuv’s C implementation eliminates Python-level overhead in the hot path.

Debugging the Event Loop

Enable debug mode to catch common issues:

asyncio.run(main(), debug=True)
# or
PYTHONASYNCIODEBUG=1 python app.py

In debug mode the loop:

  • Logs coroutines that take longer than 100ms without yielding
  • Warns about unawaited coroutines
  • Tracks creation stack traces for tasks (useful for “where was this created?” debugging)
  • Raises warnings for callbacks scheduled from wrong threads

The slow-callback threshold is configurable:

loop = asyncio.get_running_loop()
loop.slow_callback_duration = 0.05  # 50ms threshold

Thread Safety

The event loop is not thread-safe. If you need to schedule a coroutine from another thread:

# From a worker thread:
asyncio.run_coroutine_threadsafe(some_coro(), loop)

# For plain callbacks:
loop.call_soon_threadsafe(callback, *args)

call_soon_threadsafe writes to a self-pipe (Unix) or a socket pair (Windows), which wakes the selector from its blocking poll. This is the only safe way to cross the thread boundary.

Running Blocking Code Without Freezing

loop.run_in_executor() offloads CPU-bound or legacy synchronous code to a thread pool (default) or process pool:

import asyncio
from concurrent.futures import ProcessPoolExecutor

def heavy_math(n):
    return sum(i * i for i in range(n))

async def main():
    loop = asyncio.get_running_loop()
    # Thread pool (default)
    result = await loop.run_in_executor(None, heavy_math, 10_000_000)
    
    # Process pool for true parallelism
    with ProcessPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, heavy_math, 10_000_000)

The executor returns a concurrent.futures.Future, which asyncio wraps in an asyncio.Future so you can await it seamlessly.

Signal Handling in the Loop

On Unix, the loop can handle OS signals directly:

import signal

async def main():
    loop = asyncio.get_running_loop()
    loop.add_signal_handler(signal.SIGTERM, handle_shutdown)
    loop.add_signal_handler(signal.SIGINT, handle_shutdown)

This is preferable to signal.signal() in async applications because the event loop’s signal handlers are integrated with the callback queue — they don’t interrupt a running coroutine mid-execution.

Subinterpreters and the Future

Python 3.12 introduced per-interpreter GILs, and the asyncio event loop is being adapted to work in subinterpreter contexts. The long-term vision is multiple event loops in separate subinterpreters, each with their own GIL, achieving true parallelism for I/O-bound Python code without multiprocessing overhead.

Performance Tuning Checklist

  1. Batch create_task calls — creating thousands of tasks in a tight loop can bloat _ready. Use asyncio.gather() or asyncio.TaskGroup for structured concurrency.
  2. Keep callbacks lean — any work in a callback delays the entire loop iteration.
  3. Use uvloop in production — near-zero effort for significant throughput gains.
  4. Monitor loop lag — periodically schedule a callback and measure how late it fires relative to its target time.
  5. Avoid nested asyncio.run() — it creates a new loop. Use await or create_task inside an existing loop.

One thing to remember: The event loop’s power comes from one simple trick — it never blocks on I/O. It asks the OS to watch all the sockets at once and only runs code for the ones that are ready right now. Understanding _run_once() means understanding everything asyncio does.

pythonconcurrencyasyncio

See Also

  • Python Actor Model Why treating each piece of your program like a person with their own mailbox makes concurrency way less scary.
  • Python Aiocache Caching aiocache remembers expensive answers so your async Python app doesn't waste time asking the same question twice.
  • Python Aiofiles Async Io aiofiles lets your async Python program read and write files without freezing — because normal file operations secretly block everything.
  • Python Aiohttp Understand Aiohttp through an everyday analogy so Python behavior feels intuitive, not random.
  • Python Anyio Portability AnyIO lets your async Python code work with any async library — write once, run on asyncio or Trio without changes.