Linux I/O Models
How applications wait for data — the foundation of every high-performance server.
Each section below ends with a quick check — try to answer before revealing:
The Five I/O Models
graph TD
classDef blocking fill:#e74c3c,stroke:#c0392b,color:#fff
classDef nonblock fill:#e67e22,stroke:#d35400,color:#fff
classDef mux fill:#3498db,stroke:#2980b9,color:#fff
classDef async fill:#2ecc71,stroke:#27ae60,color:#fff
classDef signal fill:#9b59b6,stroke:#8e44ad,color:#fff
BLK["Blocking I/O read() blocks until data arrives Process sleeps in S state Simple but wastes a thread per connection"]:::blocking
NB["Non-blocking I/O read() returns EAGAIN if no data Process must poll in a loop (busy-wait) Wastes CPU"]:::nonblock
SEL["I/O Multiplexing select/poll/epoll Monitor many fds at once Block until any fd is ready One thread handles N connections"]:::mux
SIG["Signal-driven I/O SIGIO signal when data ready Rarely used in practice"]:::signal
AIO["Async I/O io_uring / aio Kernel does I/O in background App gets completion notification True async: no blocking ever"]:::async
Non-blocking I/O and I/O multiplexing (select/poll/epoll) both let a single thread avoid being stuck on one connection. What's the key difference between them?
read() and burns CPU checking for data that isn't there yet. I/O multiplexing blocks efficiently instead: the thread sleeps until the kernel says at least one fd is ready, so it costs zero CPU while idle — same as blocking I/O, but watching many fds at once instead of one.Blocking I/O — What Actually Happens
sequenceDiagram
participant APP as Application Thread
participant KERN as Kernel
participant NIC as NIC / Disk
APP->>KERN: read(fd, buf, 1024)
Note over APP: Thread blocked (S state) Scheduled out by kernel No CPU consumed while waiting
NIC->>KERN: Data arrives (DMA into kernel buffer)
KERN->>KERN: Copy data from kernel buffer to user buf
KERN-->>APP: read() returns (thread woken up)
Note over APP: Thread running again
Step through the same wait, one moment at a time:
read(fd, buf, 1024) and is still running at this point — no data has arrived yet.
read().
read() returns with the data. The thread is scheduled back onto a CPU and resumes running — the wait is over.
The problem: One thread blocked = one thread wasted. For 10,000 concurrent connections, you need 10,000 threads. Each thread costs ~8MB stack → 80GB RAM just for stacks. That's the C10K problem.
During a blocking read(), is the waiting thread consuming CPU? So what's the actual cost of blocking I/O at scale?
select and poll — The Old Way
graph LR
classDef app fill:#3498db,stroke:#2980b9,color:#fff
classDef kern fill:#e74c3c,stroke:#c0392b,color:#fff
classDef limit fill:#e67e22,stroke:#d35400,color:#fff
APP["App passes fd_set (bitmap of fds)"]:::app
KERN["Kernel scans ALL fds in set O(N) scan every call"]:::kern
COPY["Kernel copies entire fd_set back to userspace"]:::kern
APP2["App must scan full set to find which fds are ready"]:::app
LIMIT["select: max 1024 fds (FD_SETSIZE) poll: no limit but still O(N)"]:::limit
APP --> KERN --> COPY --> APP2
APP2 -.- LIMIT
select/poll problems:
- O(N) scan of all fds on every call — scales poorly past 1000 fds
selecthard limit: 1024 fds- Full fd set copied kernel↔userspace on every call
- App must re-scan entire set to find ready fds
poll() removes select's 1024-fd limit. Does that mean poll scales better to thousands of connections?
poll fixes the hard fd-count ceiling, but the kernel still does an O(N) scan of every fd on every call, and the whole set is still copied kernel↔userspace each time. More fds just means more per-call work — the scaling problem select had, poll still has.epoll — The Modern Way
epoll is O(1) for event notification regardless of how many fds you're watching. Used by nginx, Node.js, Redis, Go's netpoller.
sequenceDiagram
participant APP as Application
participant KERN as Kernel epoll instance
APP->>KERN: epoll_create() — create epoll fd
APP->>KERN: epoll_ctl(EPOLL_CTL_ADD, fd1, EPOLLIN)
APP->>KERN: epoll_ctl(EPOLL_CTL_ADD, fd2, EPOLLIN)
APP->>KERN: epoll_ctl(EPOLL_CTL_ADD, fd3, EPOLLIN)
Note over KERN: Kernel registers interest list internally No repeated fd set copies
APP->>KERN: epoll_wait(epfd, events, maxevents, timeout)
Note over KERN: Thread blocks, kernel monitors all fds
Note over KERN: fd2 becomes readable (data arrives)
KERN-->>APP: returns 1 event: {fd2, EPOLLIN}
Note over APP: Only process fd2, not all fds
One iteration of the event loop, step by step:
epoll_create() and registered fd1, fd2, fd3 with epoll_ctl(EPOLL_CTL_ADD, ...). The kernel now holds all three in its interest list.
{fd2, EPOLLIN}. fd1 and fd3 are never mentioned because they're not ready.
epoll_wait() again for the next event — this is the event loop.
Why epoll is O(1):
- Interest list stored in a red-black tree inside kernel —
epoll_ctlis O(log N) - Ready list is a separate linked list — when an fd becomes ready the kernel adds it directly
epoll_waitreturns only ready fds — app never scans unready ones
Edge-triggered (EPOLLET) vs level-triggered (default):
- Level-triggered (default):
epoll_waitreturns as long as data is available. Safe but can cause many wakeups. - Edge-triggered:
epoll_waitreturns only when state changes (new data arrives). More efficient but you MUST read untilEAGAINor you'll miss data.
// Adding fd with edge-triggered mode
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // edge-triggered
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
An fd is in edge-triggered (EPOLLET) mode. epoll_wait reports it readable, you call read() once and get less data than's actually available. What happens if you don't call read() again right away?
read() until it returns EAGAIN. Level-triggered (default) doesn't have this trap — it keeps reporting the fd as ready for as long as any data remains.Go's Runtime Netpoller (Built on epoll)
Go doesn't expose epoll directly. Its runtime wraps it transparently — Go code looks blocking but is actually non-blocking underneath.
graph TD
classDef go fill:#00add8,stroke:#007d9c,color:#fff
classDef run fill:#e67e22,stroke:#d35400,color:#fff
classDef kern fill:#2c3e50,stroke:#1a252f,color:#fff
GOCODE["Go code: conn.Read(buf) looks like blocking I/O"]:::go
RUNTIME["Go runtime: 1. Sets fd to non-blocking 2. Calls read() — gets EAGAIN 3. Registers fd with epoll 4. Parks goroutine (not OS thread)"]:::run
EPOLL["Linux epoll waits for fd to be readable"]:::kern
NETPOLL["Go netpoller goroutine calls epoll_wait when fd ready: unparks goroutine"]:::run
RESUME["Goroutine resumes read() succeeds returns to Go code"]:::go
GOCODE --> RUNTIME --> EPOLL
EPOLL --> NETPOLL --> RESUME
The key insight: One OS thread runs many goroutines. When a goroutine would block on I/O, the runtime parks it and switches to another goroutine on the same OS thread. The OS thread never actually blocks — it's always running some goroutine. This is how Go handles 100,000 concurrent connections with far fewer OS threads than connections.
conn.Read(buf) in Go looks like an ordinary blocking call. Does the underlying OS thread actually block while it waits for data?
read(), gets EAGAIN, registers the fd with epoll, and parks the goroutine — not the OS thread. The OS thread is immediately freed to run a different goroutine. The OS thread never blocks; it's always doing something. That's how Go serves far more connections than it has OS threads.sendfile(2) and splice(2) — The Classic Zero-Copy Syscalls
The problem with naive I/O forwarding. Serving a static file over a socket the naive way looks like this:
ssize_t n = read(file_fd, buf, sizeof(buf)); // page cache -> userspace buffer
write(socket_fd, buf, n); // userspace buffer -> socket buffer
That's two data copies for bytes the application never actually needed to look at:
read() copies the file's data from the kernel's page cache into a userspace buffer,
then write() copies that same data straight back into a kernel-side socket buffer for
transmission. Two copies, plus two syscall round trips into and out of the kernel — all
to move data the app just passes through unchanged.
graph TD
classDef app fill:#3498db,stroke:#2980b9,color:#fff
classDef kern fill:#2ecc71,stroke:#27ae60,color:#fff
classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff
subgraph Naive["Naive read plus write: 2 copies"]
direction TD
PC1["Page cache file data"]:::kern
UB["Userspace buffer read lands here"]:::bad
SB1["Socket buffer write lands here"]:::bad
NIC1["NIC"]:::kern
PC1 -->|"copy 1: read()"| UB
UB -->|"copy 2: write()"| SB1
SB1 --> NIC1
end
subgraph ZeroCopy["sendfile / splice: 0 userspace copies"]
direction TD
PC2["Page cache file data"]:::kern
SB2["Socket buffer kernel-internal transfer"]:::kern
NIC2["NIC"]:::kern
PC2 -->|"sendfile or splice, kernel only"| SB2
SB2 --> NIC2
end
sendfile(2) — fd to fd, entirely in kernel space
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
sendfile() copies data directly from one file descriptor to another entirely within
kernel space — no userspace buffer ever gets involved. The classic call is
sendfile(socket_fd, file_fd, &offset, count): the kernel reads from the file (page
cache) and writes into the socket buffer itself, in one syscall, with zero copies into
userspace. This is exactly what nginx does when you leave sendfile on; set (the
default) to serve static assets — the file's bytes go straight from disk cache to
network card without ever passing through the worker process's address space.
A naive read()+write() pair to forward file data to a socket costs two data copies. Why does sendfile() cost zero?
read() copies data from the kernel's page cache into a userspace buffer, and write() copies it right back from userspace into the kernel's socket buffer — two copies for data the app never touched. sendfile() skips both: it transfers the data from the source fd to the destination fd entirely within kernel space, so userspace never receives a copy at all.splice(2) — the general case, via a kernel pipe buffer
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
sendfile() only covers one specific shape: read from this fd, write to that fd.
splice() generalizes it — it moves data between two file descriptors via a
kernel-only pipe buffer, without the restriction that one side has to be a regular file
the way sendfile() effectively requires. That's what lets you splice
socket → pipe → socket: proxying data between two TCP connections with the same
zero-copy property, something sendfile() alone can't do since neither end there is a
file. HAProxy uses splice() for exactly this — forwarding bytes between the client and
backend connections it's proxying without ever copying the payload into its own
userspace buffers.
How this fits next to io_uring
This file already covers io_uring's own zero-copy send/receive (IORING_OP_SEND_ZC,
registered buffers, and so on) — so what's the difference? sendfile/splice are
older, narrower, synchronous syscalls purpose-built to solve one problem: avoid the
userspace round-trip when moving data between two kernel-visible endpoints. io_uring
generalizes the same zero-copy idea across arbitrary async I/O operations — reads,
writes, sends, receives, accepts — all through the same submission/completion ring,
rather than one fd-to-fd forwarding call. In practice, which one a modern
high-performance server reaches for depends on what it's already built on: a server
already running an io_uring event loop stays in that ring and uses its zero-copy
send/receive opcodes; a simpler blocking or thread-per-connection server (nginx's
traditional worker model, HAProxy) reaches for sendfile/splice directly, since that
gets the zero-copy win without restructuring the whole I/O loop around io_uring.
What's the key difference in scope between sendfile() and splice()?
sendfile() is narrower — it covers one specific fd-to-fd shape (classically file to socket). splice() is more general: it moves data between any two file descriptors via a kernel-only pipe buffer, which is what makes patterns like socket-to-socket proxying (via an intermediate pipe) possible — something sendfile() alone can't do because neither end there is a regular file.io_uring — True Async I/O (Linux 5.1+)
graph LR
classDef sq fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef cq fill:#2ecc71,stroke:#27ae60,color:#fff
classDef kern fill:#e74c3c,stroke:#c0392b,color:#fff
APP["Application"]
SQ["Submission Queue (SQ) App writes I/O requests ring buffer shared with kernel no syscall needed"]:::sq
KERN["Kernel processes SQ entries does I/O asynchronously"]:::kern
CQ["Completion Queue (CQ) Kernel writes results App polls for completions no syscall needed"]:::cq
APP -->|"writes request"| SQ
SQ --> KERN
KERN -->|"writes result"| CQ
CQ -->|"app reads result"| APP
One submission/completion cycle, step by step:
SQPOLL mode, zero syscalls at all, because a kernel thread is already polling the SQ continuously.
Why io_uring is faster than epoll:
- Zero-copy between app and kernel (shared ring buffers)
- Batched submissions — submit 100 I/O operations with one syscall (or zero with
SQPOLL) - Works for files, not just sockets (epoll doesn't work on regular files —
O_NONBLOCKon files is a lie) - No context switches in
SQPOLLmode — kernel thread polls SQ continuously
In Go: Go's runtime netpoller is still epoll-based (as of Go 1.23) — the standard library does not use io_uring. io_uring is available only through third-party libraries (e.g. iceber/iouring-go). Tokio (Rust) can use it heavily. For most workloads Go's epoll-based netpoller is excellent and io_uring is unnecessary.
epoll can't usefully wait on a regular file becoming "ready" — O_NONBLOCK on files doesn't really work. Does io_uring have the same limitation?
Comparison
The one-line version of each model, side by side — the table below has the full breakdown:
read() blocks until data arrives. The thread sleeps (S state) — zero CPU while waiting, but one thread per connection, which is the C10K problem at scale.
read() returns EAGAIN immediately if no data is ready. The app must poll in a loop to check again — it busy-waits, burning CPU for no work done.
select caps out at 1024 fds; poll removes that cap but keeps the O(N) scan.
epoll_wait returns only the fds that are actually ready — O(1) notification no matter how many fds are being watched.
| Model | Syscall | Scalability | CPU when idle | Works for files? | Used by |
|---|---|---|---|---|---|
| Blocking | read/write |
O(1) per conn, O(N) threads | Low | Yes | Simple servers |
| Non-blocking poll | read + busy loop |
Poor (CPU waste) | High | Yes | Rarely used directly |
| select | select |
O(N) fds | Low | Yes | Legacy code |
| poll | poll |
O(N) fds | Low | Yes | Legacy code |
| epoll | epoll_wait |
O(1) events | Low | No (sockets only) | nginx, Node.js, Go, Redis |
| io_uring | ring buffer | O(1), zero-copy | Very low | Yes | Tokio, newer Linux services |