JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

The Event Loop, Reactor, and Proactor

Guide 2 handed you a raw kernel mechanism that says which descriptors are ready. This guide turns that mechanism into a real program: the event loop, the reactor pattern that organizes it, and the proactor — its mirror image that the completion model in guide 4 demands.

From a syscall to a loop

Guide 2 left you holding epoll_wait(): one syscall that sleeps until some of your registered descriptors are ready, then hands back exactly those. That is a verb, not a program. To build a server you wrap it in the loop that guide 1 first named — the event loop — and the loop is almost embarrassingly simple. Block on epoll_wait(); when it returns a batch of ready descriptors, walk that batch and do the small non-blocking read or write each one is now ready for; then go back and block again. That outer cycle, repeated forever, is the entire backbone of nginx, Redis, Node.js, and every async runtime you will meet.

Concretely, the body is a single for(;;): call epoll_wait(epfd, events, MAXEV, -1) to park until ready (retrying on the EINTR signal case, bailing on a real error), then a small inner for-loop walks the returned events. For each one you read its descriptor out of events[i].data.fd, and if the EPOLLIN bit is set you call your on_readable() handler, if EPOLLOUT is set you call on_writable() — where on_readable() drains the socket to EAGAIN exactly as guide 2's edge-triggered contract demanded. That is the whole program in a dozen lines, and the multiplexing call from guide 2 is the only place it ever sleeps.

Look hard at one consequence of that shape, because it governs everything else. There is one thread, and it is either parked inside epoll_wait() or running a handler — never both. While on_readable() runs, the loop is not watching anything; epoll_wait() is not called again until every handler in this batch has returned. The whole architecture rests on a single promise: each handler does a tiny, never-blocking slice of work and returns fast. The thread's time is shared cooperatively among thousands of connections, the way one chef works a row of pans — a stir here, a flip there, never standing and staring at one pan until it is done. This is cooperative, not preemptive sharing, and that distinction is about to bite.

The one rule: never block the loop

Recall the cooperative-versus-preemptive distinction from the threads rung. The kernel scheduler is preemptive: if a thread hogs the CPU, a timer interrupt yanks it away and gives someone else a turn, whether it likes it or not. An event loop has no such safety net. Nothing interrupts a handler mid-run; the loop simply waits for it to return. So a single misbehaving handler — one that calls a blocking read(), or grinds through a 50-millisecond computation, or sleeps — does not slow down just its own connection. It freezes the entire loop, and with it every one of the other ten thousand connections, because none of them can be serviced until the thread is back inside epoll_wait().

This is exactly why a handler cannot be a tidy, self-contained function the way blocking code lets you write. In the blocking world you write read request; then read body; then query database; then write response as one straight-line function, and the thread sleeps at each wait. In the loop world you may not sleep, so that single function must be chopped at every point where it would have waited. Each chop becomes a separate handler invocation, and the connection's progress between chops must be remembered somewhere — "this connection has read the headers and is now awaiting the body." That stored progress is per-connection state, and managing it well is the real craft of event-loop programming. The next section gives that craft a name.

The reactor: organizing readiness

Raw, the loop dispatches with a big switch on the descriptor and its event bits — fine for a toy, unmanageable past a handful of connection types. The reactor is the design pattern that tidies this, and it is just a small set of named parts you already half-built. A demultiplexer (epoll_wait itself) waits for readiness on many descriptors at once. A dispatcher (the for-loop) takes the batch of ready descriptors and routes each to the right code. And an event handler — one per descriptor, or per connection — holds both the callback to run and that connection's saved state. You register handlers with the reactor; it calls them back when their descriptor is ready. The name captures the spirit: the program reacts to readiness events as the kernel reports them.

The defining trait of the reactor — and the one to fix in memory — is that you still do every read() and write() yourself. The reactor only ever tells you "descriptor 7 is now readable"; the actual byte-moving is your handler's job, called synchronously, inside the loop thread. This is the readiness model of guide 2, now wearing an architectural name. epoll, kqueue, select, poll — every readiness multiplexer is reactor-shaped, because readiness is precisely "the kernel notified you; you act." libuv, libevent, Java's NIO Selector, Python's asyncio on Linux: all reactors over an underlying epoll or kqueue.

Make this concrete with one connection walked through a reactor. A client connects; you accept() it, set the socket non-blocking, register it with the reactor for EPOLLIN, and attach a handler whose state says "awaiting request line." Later epoll_wait reports it readable; the reactor calls your handler; the handler drains the socket to EAGAIN (the guide-2 edge-triggered contract), parses what arrived, advances the state to "awaiting body," and returns immediately — it does not wait for the body, it returns to the loop and lets nine thousand other connections get their turn. Every wakeup nudges one connection forward by one non-blocking step. That is the reactor, and it is the whole of classic high-performance I/O on Linux.

The proactor: organizing completion

Now flip the mirror. Guide 1 planted a second family, the completion model, and it needs its own architecture. The proactor is the reactor's mirror image — same loop shape, opposite division of labor. Instead of registering interest in readiness and then doing the read yourself, you hand the kernel a whole operation up front: "read 4 KiB from descriptor 7 into this buffer." The kernel performs the read in the background. When the bytes are already sitting in your buffer, it posts a completion event, and your loop's job is to react to operations that are already finished. The defining difference, stated as a single contrast: a reactor tells you you may now read; a proactor tells you I have already read for you.

  reactor  (readiness):            proactor (completion):
  ---------------------            ----------------------
  register: "watch fd 7"           submit:   "read 4 KiB from fd 7"
  wait     -> "fd 7 readable"      wait     -> "that read is DONE"
  you call read() yourself         kernel already filled the buffer
  you handle the bytes            you handle the bytes

  kernel says: you may act         kernel says: I have acted
Same event-loop skeleton, mirrored labor. The reactor reports readiness and you do the I/O; the proactor does the I/O and reports completion.

Why bother with the mirror image at all? Three honest reasons, and one honest cost. First, completion folds the readiness-wait and the read into a single kernel round trip, halving the syscall overhead that guide 1 warned matters at C10M scale. Second, it generalizes cleanly to operations that have no readiness, above all real disk files — a regular file is essentially always "readable," so epoll cannot usefully watch it, but a completion interface schedules the disk read like any other op. Third, it is Windows' native model: its IOCP, the platform's high-performance path, is a proactor and always has been. The cost is that you must keep the buffer alive and untouched for the entire time the kernel owns it — a real ownership burden the reactor never imposes.

Where the threads went, and where async-await comes from

Step back and notice the trade you made. The blocking, thread-per-connection model put each connection's progress in a thread's call stack — its place in the code, its local variables, its position in read-then-parse-then-respond — and let the kernel scheduler interleave thousands of stacks preemptively. The reactor throws the threads away and so loses the stack as a place to keep progress; that is exactly why it forces per-connection state on you, hand-rolled as a struct or a state machine. You did not eliminate the bookkeeping that threads were doing; you moved it out of the kernel's stacks and into your own data structures, in exchange for not paying for ten thousand stacks and ten thousand context switches.

Writing those state machines by hand is miserable, and that misery is the whole reason async-await exists. When you write async fn handle(conn) and await each read inside it, the compiler reads your straight-line function and mechanically chops it into exactly the reactor handlers you would have hand-written — turning the function into a state machine whose variants are its await points, and stashing the local variables that must survive each await into a generated struct. The await keyword marks a chop. An async executor (Tokio, asyncio, Node's libuv core) is the reactor underneath, driving those generated state machines on its event loop. You get the readable shape of blocking code and the one-thread efficiency of the reactor at once.

And now the warning from earlier returns with full force, because async-await hides the loop so well it is easy to forget it is there. An await point yields the one loop thread back so it can serve other connections — but only at an await. If your async function does a blocking call or a long computation between awaits, nothing yields, and you stall the executor exactly as a raw handler would; the prettier syntax did not buy you preemption. This is the single sharpest lesson of the rung so far: never block the executor, whether you write the handler by hand or let async-await write it for you. Carry that, plus the reactor-versus-proactor mirror, straight into guide 4, where io_uring makes the proactor real on Linux.