Blocking, Non-blocking, Multiplexed, Asynchronous
Four I/O models differ in two independent questions — does the call return before the data is ready, and who moves the data — and high-concurrency servers exist in the corner where the kernel tells you readiness (epoll/kqueue) or completion (io_uring/IOCP) so one thread can wait on thousands of descriptors.
The problem
read on a socket parks the whole thread until a byte arrives. Ten thousand idle connections then means ten thousand parked threads, each with a stack. Either the kernel must tell you *which* socket is ready, or it must do the read for you and tell you when it is *done*. Those are two different designs.Blocking: the default, and correct for most code
A blocking call does not return until it has something to return. read on a socket with an empty receive buffer puts the thread to sleep on the socket’s wait queue (Process States); the NIC delivers data, the kernel copies it into the buffer and wakes the thread; read copies to your buffer and returns. The thread used no CPU while waiting. The cost is the thread itself: a kernel structure, a stack (8 MB reserved, typically 64–256 KiB touched), and a context switch (~1–5 µs) every time it sleeps and wakes.
For a program with a handful of connections, or a batch job reading files, blocking I/O is the right model: simplest code, no state machine, the kernel does the waiting. It breaks down when the number of things to wait on grows past the number of threads you can afford — a chat server with 50,000 idle WebSockets cannot spend 50,000 threads on them (C10K: Ten Thousand Connections, Then a Million) — or when a single thread must serve several sources at once, which a blocking read on one of them makes impossible.
Non-blocking: return immediately, try again later
Set O_NONBLOCK on the descriptor (fcntl, or SOCK_NONBLOCK at creation) and read on an empty socket returns -1 with errno == EAGAIN (EWOULDBLOCK is the same value on Linux) instead of sleeping. write on a full send buffer likewise returns EAGAIN or a short write — fewer bytes than asked. connect returns EINPROGRESS immediately and completes in the background. Your thread is never parked, so it can move on to the next descriptor.
On its own this is useless: a loop that retries read on 10,000 sockets burns 100% of a core doing nothing but failing syscalls. Non-blocking mode is the *ingredient* that makes the next model possible — you need it so that, once the kernel says a socket is readable, you can drain it without accidentally sleeping when it runs dry. Every event loop sets it on every socket it manages.
Regular files are the exception that breaks the model: on Linux O_NONBLOCK has no effect on reads of regular files, because the page cache miss happens inside the call and the file system has no notion of "not ready". A cache miss blocks regardless. This single fact is why Node’s fs module runs on a thread pool while its sockets run on epoll (Async I/O: What `await readFile()` Actually Does), and why "async file I/O" needed io_uring to become real.
1for (;;) {2 ssize_t n = read(fd, buf, sizeof buf);3 if (n > 0) { consume(buf, n); continue; }4 if (n == 0) { peer_closed(fd); break; } // EOF: orderly shutdown5 if (errno == EAGAIN || errno == EWOULDBLOCK) break; // buffer empty; wait for readiness6 if (errno == EINTR) continue; // interrupted by a signal; retry7 fatal(errno);8}Multiplexed: ask the kernel which ones are ready
Readiness-based multiplexing hands the kernel a set of descriptors and blocks once — in select, poll, epoll_wait or kevent — until at least one of them is readable or writable. The thread sleeps in a single place; when it wakes it is told exactly which sockets have data, drains each with non-blocking reads, and goes back to waiting. One thread, ten thousand sockets, CPU spent only on sockets that actually have work. This is the model behind nginx, Redis, HAProxy, Node’s libuv, Go’s netpoller and Python’s asyncio (I/O Multiplexing: select, poll, epoll, kqueue, IOCP covers the primitives in detail).
It is still synchronous I/O: the kernel tells you a read *would* succeed, and then your thread performs the read and the copy. The design works beautifully for sockets and pipes, where readiness is well-defined, and not at all for regular files, where it is meaningless — epoll_ctl on a regular file returns EPERM. It also shifts complexity onto you: every handler is a state machine that must resume where it left off, because it cannot block.
Asynchronous: the kernel does the I/O and tells you when it is done
Completion-based I/O inverts the contract. You submit an operation — "read 4 KiB from this fd at this offset into this buffer" — and it returns at once; the kernel performs the whole operation, including the copy into your buffer, and later posts a completion you collect. Windows has worked this way since NT: overlapped ReadFile/WSARecv plus an I/O completion port that a small pool of threads pulls completions from, and the kernel throttles active threads to the core count (I/O Multiplexing: select, poll, epoll, kqueue, IOCP describes IOCP). POSIX aio_read exists but glibc implements it with user-space threads, and Linux’s older libaio only works properly with O_DIRECT.
Linux’s real answer, since 5.1, is io_uring: two ring buffers shared between user space and the kernel, a submission queue you write entries into and a completion queue the kernel writes results into. Any syscall-shaped operation — read, write, accept, send, recv, fsync, openat, even timeouts — can be queued; many can be submitted with one syscall or, with SQPOLL, none. Because the kernel owns the operation end-to-end, it works for regular files as well as sockets, which readiness models never could. It is the basis of the fastest storage engines and is spreading into runtimes (Tokio, libuv experiments) as the default backend.
The vocabulary that separates the four models: blocking vs non-blocking asks whether the call can sleep; synchronous vs asynchronous asks who performs the transfer. Readiness multiplexing is non-blocking *and* synchronous — you still do the read. Completion I/O is asynchronous — the read happens without you. "Async" in a language runtime (async/await) is a programming model that can sit on either; Node and Python asyncio sit on readiness for sockets and a thread pool for files.
| Model | Call returns… | Who copies the data | CPU while waiting | Scales to | Code complexity | Primitives |
|---|---|---|---|---|---|---|
| Blocking | when data is ready | your thread, inside the call | none (thread sleeps) | threads you can afford (hundreds–thousands) | lowest | plain read/write |
| Non-blocking (alone) | immediately, EAGAIN if not ready | your thread, on a later call | 100% if you spin | nothing — a building block | low | O_NONBLOCK |
| Multiplexed (readiness) | when any fd is ready | your thread, after readiness | none (one thread sleeps in epoll_wait) | tens of thousands of sockets per thread | high (state machines / event loop) | select, poll, epoll, kqueue |
| Asynchronous (completion) | immediately; completion later | the kernel | none | tens of thousands, files included | high (buffers owned by in-flight ops) | io_uring, IOCP, overlapped I/O |
Choosing, and what the runtimes chose
Threads with blocking I/O are still right for CPU-heavy work, for code that must call blocking libraries, and for modest connection counts — a thread pool of 200 handling a few thousand requests per second is unremarkable (The Thread Pool Server). Readiness multiplexing wins when the population of *idle* connections is large: the thread count stops scaling with clients. Completion I/O wins when you need files and sockets under one model or the last microseconds of syscall overhead.
Runtimes hide the choice but not its consequences. Node: epoll/kqueue for sockets, a four-thread pool for file system calls, so heavy fs use starves DNS lookups that share the pool. Go: netpoller on epoll/kqueue/IOCP with goroutines parked on readiness, and *blocking* syscalls for files, for which the runtime spawns extra OS threads. Python asyncio: a selector loop, files via run_in_executor. C++: whatever you build — Boost.Asio maps to epoll/kqueue/IOCP/io_uring depending on the platform. How C++, JavaScript and Python Map onto the OS and Async I/O: What `await readFile()` Actually Does trace these in detail.
Key points
- Two independent axes: can the call sleep (blocking vs non-blocking), and who moves the bytes (synchronous vs asynchronous).
- Blocking I/O costs a thread per waiting operation, not CPU. It is the right default until the number of things to wait on outgrows the threads you can afford.
O_NONBLOCKmakesread/write/connectreturnEAGAIN/EINPROGRESSinstead of sleeping. Alone it is a spin loop; with readiness notification it is an event loop.- Readiness multiplexing (
select/poll/epoll/kqueue) blocks one thread on many descriptors and tells you which are ready; you still perform the read. It does not work on regular files. - Completion I/O (
io_uring, IOCP) performs the operation in the kernel and reports when done; it covers files as well as sockets. - Language
async/awaitis a programming model layered on top of one of these; Node, Go and Python all combine readiness for sockets with threads for files.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does non-blocking mode exist if it just returns errors?
So that a thread that has been told a socket is ready can drain it without risk of sleeping when it runs dry, and so a single thread can service many descriptors in turn. It is the primitive under every event loop, not a model by itself.
▸Why are there both readiness and completion designs?
Readiness is simple and maps naturally to sockets, whose buffers make "ready" well-defined; it emerged in BSD with select. Completion is the only model that makes sense for regular files (there is no "ready", only "done") and it lets the kernel batch and overlap work; Windows chose it from the start and Linux arrived at it with io_uring.
▸Why do event-loop runtimes still have thread pools?
Because readiness notification does nothing for regular-file reads (a page-cache miss blocks regardless), for DNS resolution through libc, or for CPU-bound work. The pool is where the runtime hides the blocking it cannot avoid.
I/O models
| Model | Total time | CPU wasted | Thread free | Syscalls (approx.) | Verdict |
|---|---|---|---|---|---|
| Blocking | 15 ticks | 0 | 0 | 3 | Each read() parks the thread until its data arrives; the three operations happen one after another. |
| Non-blocking + polling | 6 ticks | 3 | 0 | 12 | All three in flight, but the thread burns CPU asking "ready yet?" thousands of times per second. |
| Multiplexed | 6 ticks | 0 | 0 | 6 | One call sleeps on all three; the kernel wakes the thread when any is ready. Same total time as polling, no waste. |
| Asynchronous | 6 ticks | 0 | 3 | 4 | Submit and walk away; the kernel does the copy and posts a completion. The thread is free for other work while waiting. |
How it fails
What the failure looks like from inside real software.
- A thread-per-connection server hits the thread limit or exhausts memory with stacks long before it exhausts CPU; connections queue in the accept backlog and clients time out.
- An event loop calls a blocking file read or a synchronous DNS lookup on the loop thread; every other connection stalls for the duration (
event-loop-blocked). - A "non-blocking" client library spins on
EAGAINwithout waiting for readiness; one core at 100% doing zero work. - Handler reads once after readiness in edge-triggered mode and leaves data in the socket buffer; the event never fires again and the connection hangs.
- A
writeon a non-blocking socket returns a short count; the code ignores the count and the protocol stream is corrupted. - Node’s four-thread
fspool is saturated by large file reads and DNS lookups queue behind them; HTTP requests slow down for no visible reason.