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

  • select has FD set size limits and requires copying bitsets.
  • poll removes FD set size limits but still scans O(N) fds.

3) epoll(): Linux scalable readiness

  • epoll avoids 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 EPOLLONESHOT style patterns
  • backpressure: bounded queues between network and worker threads avoid unbounded memory growth

Related reading:

References