Operating systems and concurrency
Event loops
An event loop runs a queue of ready callbacks and waits on the kernel for more. Why one slow callback stalls everything, and how to keep the loop responsive.
An event loop is a single thread that repeats one cycle: run the ready callbacks, then wait for new events. The events come from the kernel, such as "socket readable" or "timer fired". The callbacks are your code. The loop never blocks on one socket, so it can serve many connections, as long as every callback returns quickly.
That last condition is the whole discipline. The loop is only as responsive as its slowest callback.
Walking through it
Each pass of the loop goes through a few steps. The details differ by runtime, but the shape is the same:
- Timers. Run callbacks whose delay has passed.
- I/O callbacks. Run the callbacks for sockets the kernel reported as ready.
- Pending work. Run deferred callbacks from the previous pass.
- Poll. Ask the kernel for new events, sleeping only if nothing is ready and no timer is due.
Node.js's libuv follows a close variant of this loop. Python's asyncio uses the same model, with a ready queue of tasks instead of raw callbacks.
Walking through it: one slow callback
This example runs three tasks on one loop. Each one awaits a delay, which yields control back to the loop while it waits. The loop runs them together.
import asyncio, time
async def fetch(name, delay):
await asyncio.sleep(delay) # yields to the loop while waiting
return name
async def main():
t0 = time.perf_counter()
results = await asyncio.gather(fetch("a", 0.1), fetch("b", 0.2), fetch("c", 0.3))
print(results, round(time.perf_counter() - t0, 1))
asyncio.run(main())Output:
['a', 'b', 'c'] 0.3Three waits of 0.1, 0.2 and 0.3 seconds finish in 0.3 seconds, not 0.6. Each wait releases the loop to run the others. Now replace asyncio.sleep with time.sleep. The total becomes 0.6 seconds, because time.sleep blocks the one thread and nothing else can run. The same is true for a CPU-bound loop or a synchronous database call inside a handler.
Warning
A callback that blocks is the most common cause of latency spikes in Node.js and asyncio services. Health checks time out, and requests queue up behind one slow handler, even though the CPU is mostly idle.
Handling slow work
Three options work well:
- Use an async driver for databases and HTTP, so each call yields while it waits.
- Move CPU work to a worker pool. Node's worker threads and Python's
run_in_executorkeep the loop free for I/O. - Break long loops into chunks, yielding between them, so timers and I/O still get a turn.
Measure the loop's lag. A lag metric, the time between when a timer should fire and when it actually does, shows how long a callback held the loop.
Common mistakes
- Parsing a large JSON payload or running a regex with catastrophic backtracking on the loop.
- Using a synchronous library inside an async handler, such as a file read or a blocking client.
- Trusting average latency while the loop lag, the actual signal, goes unmeasured.
Check yourself
- Why does a 0.3-second
asyncio.sleepin three tasks finish in 0.3 seconds, but a blocking sleep doesn't? Answer:awaityields to the loop, so the others run during the wait. A blocking call holds the only thread. - Why is loop lag a better signal than average latency here? Answer: a single blocked callback delays every request in the queue, and the lag metric measures that delay directly.
Key takeaways
- An event loop runs ready callbacks and waits for the kernel's events.
- Every callback must return quickly, because one slow callback blocks all others.
- Use async drivers, push CPU work to workers, and measure loop lag.
Further learning
Next, see async and await state machines to see what await does to the function.
Related
Async I/O
Why waiting on the network should not hold a thread, how event loops and async functions handle thousands of connections, and when goroutines or thread pools fit better.
Blocking I/O and epoll
How a thread blocks on a socket, why select and poll slow down at scale, and how epoll and kqueue report only the sockets that are ready.
Async and await state machines
What async and await compile down to. Each await is a suspension point, the function becomes a state machine, and function color is why async spreads through a codebase.