Inside the event loop

asyncio · Expert · 15 min

Knowing the machinery explains starvation, ordering surprises and how to integrate other event sources.

An event loop is a loop over two things: a queue of ready callbacks and an I/O selector (epoll, kqueue or IOCP). Each iteration it computes a timeout from the nearest scheduled timer, polls the selector, turns ready I/O events and due timers into callbacks, then runs every ready callback once.

Everything is callbacks. loop.call_soon(cb) queues one; call_later / call_at schedule timers.

A Future is a placeholder for a result with a list of done-callbacks. A Task drives a coroutine: its __step method calls coro.send(None), which runs the coroutine until it hits await on something not ready. A not-ready Future yields itself up the await chain to the Task. The Task registers a done-callback on that Future and returns to the loop. When the Future gets a result (for example because the selector reported the socket readable), the callback schedules __step again, and the coroutine resumes.

Consequences:

  • await coro() on an already-ready path never yields: no other task runs.
  • A long synchronous stretch delays every callback: starvation.
  • asyncio.sleep(0) yields exactly once, letting queued callbacks run.

loop.create_future() plus set_result lets you bridge callback APIs into awaitables.

Example

import asyncio

async def main():
    loop = asyncio.get_running_loop()
    order = []
    loop.call_soon(order.append, "call_soon")
    loop.call_later(0.01, order.append, "call_later")

    fut = loop.create_future()            # bridge a callback into await
    loop.call_later(0.02, fut.set_result, "future value")

    async def child():
        order.append("child task")
    asyncio.create_task(child())

    order.append("main before await")
    print(await fut)                      # future value
    print(order)

asyncio.run(main())
# ['main before await', 'call_soon', 'child task', 'call_later']

Remember

  • The loop runs ready callbacks, then polls I/O with a timeout from the next timer
  • Tasks step coroutines with send(); awaiting a pending Future suspends the task
  • Code between awaits runs without interruption

Watch out for

  • Assuming every await yields to other tasks; awaiting a coroutine that finishes immediately never switches.

Check yourself

  1. Why does a task doing a 2-second pure-Python computation delay a 10 ms timer elsewhere?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/asyncio-eventloop.html