I/O Multiplexing: select(), poll(), and epoll() Explained
This article covers I/O Multiplexing: select(), poll(), and epoll() Explained. How do servers handle thousands of connections? Learn readiness-based I/O with select/poll/epoll, common edge cases, and build a tiny event loop in C, Zig, an...
To handle many file descriptors concurrently, you need a way to wait for “something is ready” without dedicating one thread per descriptor.
This is what I/O multiplexing provides.
1) Readiness vs completion
- Readiness APIs tell you: “you can read/write without blocking right now.”
- They do not perform the I/O for you.
select, poll, and epoll are readiness-based.
2) select() and poll(): portable but scaling limits
selecthas FD set size limits and requires copying bitsets.pollremoves FD set size limits but still scans O(N) fds.
3) epoll(): Linux scalable readiness
epollavoids O(N) scanning on each wake by keeping state in the kernel.
Modes:
- Level-triggered: keeps reporting readiness while fd remains ready.
- Edge-triggered: reports only on transitions; requires draining reads until
EAGAIN.
Why edge-triggered exists:
- it reduces repeated wakeups when an fd remains readable
- it shifts responsibility to user space: you must drain the socket/file
If you’re not confident yet, start with level-triggered; correctness first.
4) C: minimal epoll loop (readable events)
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include <unistd.h>
int main(void) {
int ep = epoll_create1(0);
if (ep < 0) {
fprintf(stderr, "epoll_create1: %s\n", strerror(errno));
return 1;
}
// Example monitors stdin (fd=0)
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = 0;
if (epoll_ctl(ep, EPOLL_CTL_ADD, 0, &ev) != 0) {
fprintf(stderr, "epoll_ctl: %s\n", strerror(errno));
return 1;
}
for (;;) {
struct epoll_event out[8];
int n = epoll_wait(ep, out, 8, -1);
if (n < 0) {
if (errno == EINTR) continue;
fprintf(stderr, "epoll_wait: %s\n", strerror(errno));
return 1;
}
for (int i = 0; i < n; i++) {
if (out[i].data.fd == 0 && (out[i].events & EPOLLIN)) {
char buf[4096];
ssize_t r = read(0, buf, sizeof(buf));
if (r > 0) {
write(1, buf, (size_t)r);
}
}
}
}
}
This echoes stdin to stdout when stdin becomes readable.
5) Zig: polling stdin (portable approach)
Zig can call POSIX directly through std.os.
const std = @import("std");
const os = std.os;
pub fn main() !void {
var fds: [1]os.pollfd = .{ .{ .fd = 0, .events = os.POLL.IN, .revents = 0 } };
while (true) {
const rc = try os.poll(&fds, -1);
if (rc == 0) continue;
if ((fds[0].revents & os.POLL.IN) != 0) {
var buf: [4096]u8 = undefined;
const n = try os.read(0, &buf);
if (n == 0) break;
_ = try os.write(1, buf[0..n]);
}
}
}
6) Rust: readiness with libc poll
use std::io;
fn main() -> io::Result<()> {
unsafe {
let mut fds = [libc::pollfd { fd: 0, events: libc::POLLIN, revents: 0 }];
loop {
let rc = libc::poll(fds.as_mut_ptr(), fds.len() as _, -1);
if rc < 0 {
let e = io::Error::last_os_error();
if e.kind() == io::ErrorKind::Interrupted { continue; }
return Err(e);
}
if (fds[0].revents & libc::POLLIN) != 0 {
let mut buf = [0u8; 4096];
let n = libc::read(0, buf.as_mut_ptr() as *mut _, buf.len());
if n == 0 { break; }
if n < 0 { return Err(io::Error::last_os_error()); }
let _ = libc::write(1, buf.as_ptr() as *const _, n as usize);
}
}
}
Ok(())
}
7) Common edge cases
- Always handle
EINTR. - With non-blocking fds, drain reads/writes until
EAGAIN. - Watch for
EPOLLHUP/EPOLLERR.
More real-world edge cases:
- write readiness (
EPOLLOUT) can be almost always true; only subscribe when you have buffered output - fairness: do not drain an fd forever in one tick; cap work per connection
- thundering herd: waking many workers for the same fd causes contention; prefer one accept loop or
EPOLLONESHOTstyle patterns - backpressure: bounded queues between network and worker threads avoid unbounded memory growth
Related reading:
- Reactor vs Proactor: Event Loops, Async I/O, and How Servers Scale
- Async I/O on Linux with io_uring: A Practical Overview
References
select(2): https://man7.org/linux/man-pages/man2/select.2.htmlpoll(2): https://man7.org/linux/man-pages/man2/poll.2.htmlepoll(7): https://man7.org/linux/man-pages/man7/epoll.7.html- “The C10K problem” background: http://www.kegel.com/c10k.html