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:

  1. Process scheduled callbacks — timers set with call_later() or call_at() that have expired get moved to the ready queue.
  2. 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.
  3. 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) — puts fn in the ready queue for the next cycle
  • call_later(delay, fn) — puts fn in a heap sorted by expiration time
  • add_reader(fd, fn) — tells the selector to call fn when fd is 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:

  1. The transport registers the socket’s file descriptor with add_reader()
  2. The selector adds it to the epoll interest list
  3. On the next _run_once(), the poll step asks epoll for ready descriptors
  4. 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.

pythonconcurrencyasynciointernals

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.