The idea: who does the read?
A server with many connections and one thread needs a way to learn "something happened on connection c1" without blocking on any single socket. Two design patterns answer this. In a Reactor the kernel tells you a socket is ready, and your handler does the read. In a Proactor you start the read first, the kernel does it, and it tells you it is done; your handler gets the data.
The canvas serves the same three connections c1, c2 and c3 twice, Reactor on the left and Proactor on the right (on a phone, one under the other). Each side is split into user space (the app's one dispatcher thread, its handlers h1–h3 and buffers buf1–buf3), kernel space and the network. Each box has its connection's colour. The status line counts the clock and the read() calls the app made.
Reactor: demultiplexer, dispatcher, handlers
The Reactor has three parts. A synchronous event demultiplexer (on Linux, epoll_wait) blocks until one or more handles are ready. A dispatcher, the event loop, maps each ready handle to its event handler and calls it. The handler then does the non-blocking I/O itself: read(c1) copies the bytes from the socket's receive queue into its buffer. The news "c1 is ready" carries no data (Demo 1). See epoll for the interest list and ready list inside the kernel.
Proactor: start first, get the result
The Proactor turns the order around. The app (the initiator) starts an asynchronous operation before any data exists: "receive from c1 into buf1". On Linux that is an io_uring SQE, on Windows a WSARecv on an I/O completion port (IOCP). While the operation is in flight, buf1 belongs to the kernel: the app must not touch or free it. When bytes arrive, the kernel copies them straight into buf1 and posts a completion (a CQE, or an IOCP completion packet) saying which request finished and how many bytes it got. The completion dispatcher reaps it and calls the completion handler, which finds the data already in buf1. The handler then starts the next receive (Demo 1, right side).
| Reactor | Proactor | |
|---|---|---|
| the kernel says | "c1 is readable" | "your recv on c1 finished: 6 bytes" |
| who copies the bytes | the handler, with read() | the kernel, into the buffer you gave it |
| buffer needed | only while reading | for every operation in flight |
| Linux / Windows | epoll, kqueue, select | io_uring / IOCP |
| libraries | nginx, Redis, Netty, libuv sockets, Java NIO | Windows IOCP servers, Boost.Asio, Java NIO.2 (AIO), io_uring runtimes |
Many clients, one thread
When c1, c2 and c3 send at once, the Reactor gets one epoll_wait result with three ready sockets, and the Proactor gets three completions. Either way the dispatcher calls the handlers one at a time on its single thread, so the replies leave at 1, 2 and 3 ms on both sides. The Reactor's handlers made three read() calls; the Proactor's made none (Demo 2). For the cost of those calls in system calls, see io_uring vs epoll.
Slow handlers and the worker pool
A handler runs on the dispatcher thread, so while it works nothing else runs. If c1 asks for GET /report and its handler computes for 50 ms, c2 and c3 wait 50 ms although their data has arrived (Demo 3). The Proactor does not escape this: the I/O was asynchronous, but the handler's CPU work is not. The fix is the same for both: the handler puts the slow job on a worker pool and returns at once. The worker posts the result back to the dispatcher (through an eventfd, or as one more completion), which sends the reply. c2 and c3 are then served at 2 and 3 ms; c1 still waits for its report (Demo 4). Node.js does the same with its thread pool, see the Node.js event loop; nginx never blocks its worker, see nginx.
| slow handler on c1 | c1 | c2 | c3 |
|---|---|---|---|
| pool off (both sides) | 50 ms | 51 ms | 52 ms |
| pool on (both sides) | 51 ms | 2 ms | 3 ms |
Partial reads
TCP is a byte stream: a request can arrive in pieces. In Demo 5 GET /index.html arrives as GET /in (7 bytes) and, 5 ms later, dex.html (8 bytes). The Reactor handler reads 7 bytes, finds no end of request, keeps them and returns to the loop; it must remember where it stopped. The next "ready" brings the other 8. The Proactor's receive completes with res=7: a completion means "some bytes arrived", not "the whole message arrived". The handler submits a new receive into buf1 at offset 7, and the second completion brings res=8. Both patterns need per-connection parsing state; they only differ in who calls the next read.
Choosing, and mixing
A Reactor is simple and needs no buffer until data is there, which matters with 100 000 idle connections. A Proactor saves the read calls and works for regular files, which are always "ready" and so useless with epoll, but it ties up a buffer per pending operation (io_uring's provided buffers and IOCP's zero-byte reads exist to fix that). Libraries mix them: Boost.Asio offers a Proactor API and builds it on a Reactor (epoll) on Linux, and libuv is a Reactor for sockets with a thread pool for files.
The I/O models underneath both patterns are compared in Blocking vs Non-blocking vs Async I/O.
What the page leaves out
Writes in detail (the reply is one box; a real server handles partial writes and "writable" events too), accepting new connections, timers, edge- vs level-triggered epoll, multishot receives and provided buffer rings in io_uring, IOCP's thread pool that several threads can wait on, and errors and cancellation. The timings are a rough scale: I/O and system calls cost 0 ms here.