TRT
Sign in
easyCoredebugging

How does the `asyncio` event loop actually work under the hood (coroutines, `Future`, `Task._step()`, `epoll`/`kqueue`), when do you still need `asyncio.Lock`.

Trace `Task._step()` driving `coro.send(None)`, explain read-modify-write races across `await` points, and use `loop.set_debug(True)` / lag monitors.

TRT practice prompt — not a verified question from a named employer.

The Runtime Theory Team2 min read

A strong answer

An asyncio event loop runs on a single OS thread around a tight loop (_run_once): (1) Poll the OS kernel I/O multiplexer (epoll on Linux, kqueue on macOS, IOCP on Windows) for sockets whose kernel buffers are ready to read/write, plus a min-heap of scheduled timer handles. (2) Move every ready callback into the _ready collections.deque. (3) Pop and execute each handle in _ready one by one (task._step()). A Coroutine (async def) is just a suspended frame object; wrapping it in an asyncio.Task drives it by calling coro.send(None). When the coroutine hits await future, it yields that Future object all the way up to Task._step(), which attaches self._wakeup to the Future and returns control to the event loop immediately! Because there is only 1 OS thread, any synchronous blocking call (requests.get, time.sleep, or 500 ms of pure CPU math) freezes _run_once so zero other tasks can advance until that call returns!

At most one Task executes Python bytecode on the event-loop thread at any instant, and cooperative context switches occur only at explicit await, async for, or async with suspension points—never preemptively between normal instructions. Because Task._step() runs synchronously on the event-loop thread until the coroutine yields a pending Future via await, two lines of code without an intervening await execute atomically with respect to all other coroutines on that loop—and any blocking C/OS call inside Task._step() stalls the entire loop.

An asyncio.Task + coroutine frame occupies only ~2–4 KB of heap memory (vs 8 MB of virtual stack for an OS pthread), allowing a single Python process to hold 50,000 concurrent idle network connections in ~150 MB of RAM. In production systems (uvloop (libuv Cython event loop), Uvicorn, FastAPI, aiohttp, httpx, and asyncpg all multiplex socket file descriptors through this exact Selector -> Future -> Task._step() state machine.), engineers monitor these exact trade-offs to keep p99 latency predictable and prevent resource exhaustion under peak load.

What the interviewer is listening for

  • Traces how the asyncio event loop (Selector, Future, Task, and coroutine suspension) operates in memory step by step rather than giving a vague textbook definition.
  • States the core correctness invariant and explains why edge cases do not violate it.
  • Quantifies time, space, and hardware costs (OS Thread (threading.Thread), asyncio.Task + Coroutine) with concrete numbers.
  • Identifies real-world failure modes (Calling requests, urllib, time.sleep, or sync DB drivers inside async def) and explains how production systems mitigate them.

Common mistakes

  • Treating the asyncio event loop (Selector, Future, Task, and coroutine suspension) as a black box without explaining the underlying memory or state transitions.
  • Ignoring worst-case tail latency or memory overhead (Assuming single-threaded asyncio means race conditions are impossible).
  • Forgetting boundary conditions and error recovery (Calling coro_fn() without await or create_task()).

This answer walks

Practice follow-ups

  1. 01Why do you still need an `asyncio.Lock` around `bal = await db.get(); await db.set(bal + 10)` even though `asyncio` is single-threaded?
  2. 02How do you measure 'event loop lag' in production (scheduling a 10ms timer or `call_soon` and measuring how late it actually fires)?

Not started

Sign in to save your learning progress.

Sign in to save