Reactor vs Proactor: Event Loops, Async I/O, and How Servers Scale
If you build servers, you’ll eventually hear:
- reactor pattern
- proactor pattern
They describe how your program learns about I/O.
In practice, they answer questions like:
- When does the kernel tell me "you can read/write now"?
- When does the kernel tell me "your async read/write finished"?
- Where do I store per-connection state while I wait?
Both styles still end up as an event loop plus a state machine per connection/request. The difference is whether events represent readiness or completion.
1) Reactor: readiness-based
Reactor says:
- wait for fds to become ready
- then perform I/O yourself
Examples:
select,poll,epoll(readiness)
You do:
epoll_waitreaduntilEAGAIN
Important detail: a reactor API does not perform I/O for you. It says "this fd is probably ready".
That implies:
- You must handle partial reads/writes.
- You must handle EAGAIN/EWOULDBLOCK correctly.
- You must store per-connection state so you can resume work when the fd becomes ready again.
Typical reactor state machine
A common pattern for an HTTP server:
- READ_HEADERS -> READ_BODY -> WRITE_RESPONSE -> DONE
The event loop drives transitions based on readiness events.
2) Proactor: completion-based
Proactor says:
- submit I/O operations
- get notified when they complete
Examples:
- Windows IOCP
- Linux
io_uring(many operations)
You do:
- submit reads
- handle completions
Important detail: in a proactor API you submit an operation, and the kernel (or OS) completes it later.
That implies:
- You manage a queue of in-flight operations.
- You still handle partial I/O, but it is framed as "this completion returned N bytes".
- You must bound outstanding operations (backpressure), otherwise memory usage and latency explode.
A proactor state machine
You still have the same logical states (READ_HEADERS, WRITE_RESPONSE, ...), but transitions are driven by completion results rather than readiness.
3) C: reactor sketch (epoll)
// Conceptual skeleton
// - wait for readiness
// - do read/write
In a real reactor loop, you must:
- set sockets non-blocking
- register interest (EPOLLIN/EPOLLOUT)
- on EPOLLIN: read until EAGAIN, parse what you got, maybe switch interest to EPOLLOUT
If you forget to read until EAGAIN in edge-triggered mode, you can deadlock: you consumed one readiness event, but left data unread, so you may never be notified again.
4) C: proactor sketch (io_uring)
// Conceptual skeleton
// - submit read
// - wait for completion
In a real io_uring loop, you typically:
- submit N receives/reads (or accepts)
- wait for completions
- each completion tells you which connection/operation finished
The big win is often reducing syscalls and avoiding repeated "poll then read" loops.
5) Zig and Rust notes
- Zig often favors explicit control: you can build either style.
- Rust async runtimes (tokio) are reactor-style on many platforms, but io_uring support exists in ecosystem.
In Rust async, your async fn is basically a state machine that gets polled when the reactor says the fd is ready. That maps strongly to the reactor model.
In Zig, you can write the state machine directly, or wrap it with an allocator-backed struct per connection.
6) Practical decision
- Reactor is simpler and portable.
- Proactor can reduce syscalls and overhead for certain workloads.
Rules of thumb:
- If you need portability and simplicity, start with reactor (
epoll,kqueue,selectas fallback). - If your workload is heavy on syscalls and you can target Linux, consider io_uring.
Backpressure matters in both
Regardless of model, you must bound:
- number of connections
- bytes buffered per connection
- number of in-flight requests
- number of in-flight I/O operations
Otherwise, you create a latency amplifier: queues grow, caches thrash, and tail latency explodes.
Common pitfalls
- Assuming readiness means "all" readiness: it does not; partial I/O is normal.
- Forgetting to drain: in edge-triggered reactor mode, you must drain until EAGAIN.
- No timeouts: stuck connections consume state forever.
- No fairness: one busy connection can starve others if you read/write too much per tick.
References
- Reactor pattern: https://en.wikipedia.org/wiki/Reactor_pattern
- Proactor pattern: https://en.wikipedia.org/wiki/Proactor_pattern
epoll(7): https://man7.org/linux/man-pages/man7/epoll.7.htmlio_uring(7): https://man7.org/linux/man-pages/man7/io_uring.7.html- The C10K problem: https://www.kegel.com/c10k.html