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:
| OS | System Call | Max FDs | Edge/Level |
|---|---|---|---|
| Linux | epoll | ~1M | Both |
| macOS | kqueue | ~100K | Both |
| Windows | select | 512 default | Level 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
- Batch
create_taskcalls — creating thousands of tasks in a tight loop can bloat_ready. Useasyncio.gather()orasyncio.TaskGroupfor structured concurrency. - Keep callbacks lean — any work in a callback delays the entire loop iteration.
- Use uvloop in production — near-zero effort for significant throughput gains.
- Monitor loop lag — periodically schedule a callback and measure how late it fires relative to its target time.
- Avoid nested
asyncio.run()— it creates a new loop. Useawaitorcreate_taskinside 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.
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.