Python Event Loop Internals — Core Concepts
The Heart of asyncio
The event loop isn’t magic — it’s a while True loop with a very specific structure. Every iteration goes through the same phases, and understanding those phases is the key to writing efficient async code.
The _run_once Cycle
At the core of asyncio.SelectorEventLoop is a method called _run_once(). Each call does three things in order:
- Process scheduled callbacks — timers set with
call_later()orcall_at()that have expired get moved to the ready queue. - Poll for I/O — the loop asks the OS selector (epoll on Linux, kqueue on macOS) which file descriptors are ready. If nothing is ready and no callbacks are pending, the loop blocks here until something happens or a timer expires.
- Execute ready callbacks — every callback in the ready queue runs one at a time, in FIFO order.
This three-phase cycle repeats until loop.stop() is called or all tasks complete.
Callbacks vs. Coroutines
Under the hood, everything becomes a callback. When you await something, the event loop wraps the coroutine’s next step as a callback and schedules it. The difference matters:
call_soon(fn)— putsfnin the ready queue for the next cyclecall_later(delay, fn)— putsfnin a heap sorted by expiration timeadd_reader(fd, fn)— tells the selector to callfnwhenfdis readable
Coroutines built with async/await are layered on top. A Task object wraps a coroutine and uses call_soon() to schedule each step after an await resolves.
The Selector Under the Hood
The event loop delegates I/O watching to the operating system through Python’s selectors module. On Linux, this typically means epoll — an efficient mechanism that watches thousands of sockets without scanning each one individually.
When you call await reader.read(1024), the chain of events is:
- The transport registers the socket’s file descriptor with
add_reader() - The selector adds it to the epoll interest list
- On the next
_run_once(), the poll step asks epoll for ready descriptors - If the socket is ready, the reader callback fires and the coroutine resumes
Common Misconception: “The Event Loop Is Always Running”
It’s not — it blocks during the poll phase when there’s nothing to do. This is intentional. An idle event loop consumes near-zero CPU because the operating system wakes it only when I/O arrives or a timer expires. That blocking poll is what makes event-driven code efficient.
Why CPU-Bound Work Breaks the Loop
If a callback takes 500ms to compute a Fibonacci number, nothing else runs during those 500ms. The ready queue stacks up, I/O goes unpolled, and your application freezes. This is why you offload heavy computation to loop.run_in_executor() — it pushes work to a thread pool so the loop stays responsive.
Debug Mode
Running asyncio.run(main(), debug=True) or setting PYTHONASYNCIODEBUG=1 enables slow-callback detection. The loop measures how long each callback takes and logs a warning if it exceeds 100ms (configurable via loop.slow_callback_duration). It also tracks coroutines that were created but never awaited.
One thing to remember: The event loop is a single-threaded scheduler that alternates between running ready callbacks and polling the OS for I/O — understanding this two-beat rhythm explains most async behavior.
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.