The idea: three ways to wait for a socket
A server spends most of its time waiting for bytes that have not arrived yet. What its threads do while they wait is the whole difference between the I/O models. With blocking I/O the thread sleeps inside read(). With non-blocking I/O read() never sleeps: on an empty socket it returns EAGAIN at once, and the thread has to try again. With async I/O one thread asks the kernel to tell it when something has happened: epoll says "this socket is ready, read it now", io_uring says "your read is done, here is the data".
The canvas runs the same requests through three lanes at once. Each lane has a user space band with its threads as timelines (one cell per millisecond), a kernel band with the sockets' receive buffers, and a network band where requests arrive. Under each lane are its counters: threads and their stacks (8 MB each, the Linux default), system calls, context switches (one when a thread goes to sleep, one when it is woken), CPU time spent on real work and CPU time wasted spinning, and the latency of each request. One Step is one millisecond in every lane.
Blocking I/O: one thread per connection
A blocking read(fd) on an empty socket puts the thread on the socket's wait queue and takes it off the CPU. When data arrives the kernel wakes it, and the same read() returns the bytes. The code is the simplest there is, a plain loop of read, handle, write, and a sleeping thread uses no CPU. The catch is that a thread can only wait on one socket, so a server needs one thread per connection (Demo 1: 3 threads, 24 MB of stacks, 9 context switches).
Non-blocking I/O: EAGAIN and busy polling
After fcntl(fd, F_SETFL, O_NONBLOCK), read() on an empty socket fails at once with EAGAIN (also called EWOULDBLOCK). Now one thread can serve every socket by trying each in turn. But if it does nothing else, it never sleeps: it loops over read() calls that return EAGAIN, about a thousand per millisecond, and burns a whole core. In Demo 1 the polling thread is on the CPU for all 12 ms, of which 9 are wasted, and makes 9007 system calls for 3 requests. On its own, non-blocking I/O is a building block: it is meant to be paired with a readiness API that says which socket to read.
Readiness: epoll
With epoll the thread registers its sockets once (epoll_ctl) and then sleeps in epoll_wait. When data arrives the kernel puts the socket on the ready list and wakes the thread, which calls read() only on the sockets that are ready. One thread, no wasted CPU, and the cost depends on the number of active sockets, not on how many are open. This is the event loop inside nginx, Redis, Node.js and Netty; see Linux epoll for the interest list and level vs edge triggering, and Reactor vs Proactor for the pattern built on it.
Completion: io_uring
With io_uring the thread does not ask "is it ready?", it hands over the read itself: a read request (SQE) goes into a submission ring shared with the kernel, and one io_uring_enter submits it and waits. When data arrives the kernel copies it into the app's buffer and posts a completion (CQE). The thread wakes with the data already there and never calls read() (Demo 5: 4 system calls against epoll's 7). While the thread is busy, the kernel keeps completing armed reads in the background. io_uring vs epoll shows the rings, batching and SQPOLL in detail.
Many connections: threads vs one loop
Real servers hold thousands of connections that are mostly idle: keep-alive clients, chat users, IoT devices. Demo 2 keeps the same three requests but adds 997 connections that send nothing. The blocking server now needs 1000 threads and reserves 8000 MB of stack address space, and the scheduler has 1000 sleeping threads to manage. The polling thread's loop is now 1000 sockets long: it still spins, now over 1000 file descriptors. The epoll lane does exactly what it did with three connections, 7 system calls, because idle sockets never reach the ready list. That is the C10K problem and why event loops won it.
When each one wins
No model wins every workload. When data is always waiting (Demo 3, a request every millisecond), the polling thread never spins: every read() finds data, so it makes 12 system calls against epoll's 24 (an epoll_wait before every read()) and no context switches. That is why high-speed networking (DPDK, kernel busy-poll sockets) polls on purpose. When one request is slow (Demo 4, 4 ms of CPU), the single event-loop thread is stuck with it and the other requests wait in their buffers: average latency 4 ms, against 2 ms for blocking threads that run in parallel on other cores.
| model | threads | CPU while waiting | wins when | loses when |
|---|---|---|---|---|
| Blocking | one per connection | none (asleep) | few connections, slow or CPU-heavy handlers, simple code | thousands of mostly idle connections (memory, scheduler) |
| Non-blocking polling | one (or one per core) | 100%, spinning on EAGAIN | data almost always waiting, lowest latency matters | sockets are often idle: the CPU burns for nothing |
| epoll | one per core | none (asleep in epoll_wait) | many connections, few active at a time | a slow handler stalls every other connection on its loop |
| io_uring | one per core | none (asleep in io_uring_enter) | high request rates: fewer system calls, real async file I/O | older kernels, locked-down systems, harder API |
Most real servers combine them: an event loop per core for the sockets, plus a thread pool for blocking work such as file reads, DNS lookups and CPU-heavy handlers. See Node.js under the hood for libuv's split between epoll and its 4-thread pool, and thread pools.
What the page leaves out
Writes and partial reads (a read() may return fewer bytes than the request); select and poll, which scan every fd on each call; edge-triggered epoll, EPOLLONESHOT and several threads sharing one epoll instance; POSIX AIO (aio_read), which on Linux is mostly done with threads in glibc; green threads and coroutines (Go, Java virtual threads), which write blocking-style code on top of an event loop; the cost of a context switch itself; and real timings: the 1 ms tick, 1 µs per read() and 8 MB stacks are a rough scale, not measurements.