I/O Models: Blocking, Non-Blocking, Multiplexing, and Async
Five I/O models exist in UNIX: blocking (wait until data ready), non-blocking (return immediately with EAGAIN), I/O multiplexing (select/poll/epoll wait on multiple FDs), signal-driven (SIGIO when data ready), and async (aio_read: kernel copies data, then notifies). Node.js and nginx use epoll-based multiplexing. Go uses runtime-managed goroutine scheduling over non-blocking syscalls.
The five I/O models
All I/O has two phases: waiting for data to be ready (kernel receives data into socket buffer), and copying data from kernel buffer to user space. The models differ in how the application handles each phase.
1. Blocking I/O
// Thread blocks here until data arrives and is copied to buf
n = read(fd, buf, sizeof(buf));
// Execution resumes here
Thread is suspended in the kernel until both phases complete. Simple to program; one thread per connection — doesn't scale to thousands of connections (each blocked thread holds stack memory, ~1-8MB for OS threads).
2. Non-blocking I/O
// Set fd to non-blocking
fcntl(fd, F_SETFL, O_NONBLOCK);
// Polling loop
while (1) {
n = read(fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
// No data yet — do other work or retry
continue;
}
// Data available, process it
break;
}
read() returns immediately with EAGAIN if no data is ready. The application must poll repeatedly — CPU-intensive if polling a tight loop with no data.
3. I/O Multiplexing (select/poll/epoll)
// Monitor multiple file descriptors; block until any one is ready
struct epoll_event events[MAX_EVENTS];
int epfd = epoll_create1(0);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);
for (int i = 0; i < nfds; i++) {
read(events[i].data.fd, buf, sizeof(buf)); // guaranteed ready
}
One thread handles thousands of connections. epoll_wait blocks until any monitored FD has data. The read is still blocking (but guaranteed to return immediately since data is ready). This is the model behind nginx, Node.js, Redis, and Go's network poller.
epoll vs select/poll: select/poll scan all monitored FDs on every call — O(n). epoll maintains a kernel data structure; only ready FDs are returned — O(ready) per call, critical at 10K+ connections.
4. Signal-driven I/O
The kernel sends SIGIO when data is ready. The application installs a signal handler and processes data in the handler. Rarely used in practice — signal handlers have severe restrictions on what they can call safely.
5. Asynchronous I/O (aio_read)
struct aiocb cb = {
.aio_fildes = fd,
.aio_buf = buf,
.aio_nbytes = sizeof(buf),
};
aio_read(&cb); // returns immediately
// ... do other work ...
// Later: check if complete
while (aio_error(&cb) == EINPROGRESS);
n = aio_return(&cb); // get result
Truly async: both phases (wait for data AND copy to user buffer) happen in the kernel. The application is notified only when the buffer is fully populated. Less common in network code; more common for disk I/O on Linux via io_uring.
epoll is edge-triggered or level-triggered — choosing wrong causes missed events or busy loops
GotchaSystems Programmingepoll has two modes: level-triggered (LT, default) and edge-triggered (ET). In LT mode, epoll_wait returns as long as data remains in the buffer — if you read only part of the data, the next epoll_wait will return again for that FD. In ET mode, epoll_wait returns only when new data arrives (the FD transitions from not-ready to ready). ET mode requires reading until EAGAIN to drain all data; stopping early means no future notification until more data arrives. nginx and most production servers use ET mode for efficiency, but it requires careful implementation.
Prerequisites
- File descriptors
- Kernel socket buffers
- Event-driven programming
Key Points
- Level-triggered: fires as long as data is available (safe default — partial reads are fine).
- Edge-triggered: fires once per new-data arrival (requires reading to EAGAIN in a loop).
- ET + non-blocking FDs: standard combination. Blocking read on ET FD will hang if partial read leaves unread data.
- Go's net poller uses level-triggered epoll internally but exposes blocking semantics to goroutines.
How Go abstracts I/O models
Go's runtime wraps non-blocking I/O + epoll in a goroutine-friendly interface:
// From the programmer's perspective: blocking
n, err := conn.Read(buf)
// What actually happens:
// 1. conn.Read calls into Go runtime
// 2. Runtime calls non-blocking read() syscall
// 3. If EAGAIN: park the goroutine (not the OS thread)
// 4. Register FD with netpoll (epoll)
// 5. When epoll signals data ready: unpark the goroutine
// 6. Goroutine resumes, retries read(), returns data
The OS thread is never blocked — it runs other goroutines while one goroutine waits for I/O. This enables millions of concurrent goroutines on a small thread pool.
📝io_uring: Linux's modern async I/O interface
io_uring (Linux 5.1+) provides a ring buffer interface between user space and kernel for submitting and completing I/O operations without syscall overhead per operation:
- Submission Queue (SQ): user space writes I/O requests into a shared ring buffer
- Completion Queue (CQ): kernel writes completion results; user space polls without syscalls
Benefits over epoll for high-throughput I/O:
- Batch multiple operations in one syscall (
io_uring_enter) - Zero-copy file reads for sequential access
- Truly async disk I/O (epoll doesn't work on regular files —
read()on a file always blocks)
Used by: PostgreSQL (as of v16), Tokio (Rust async runtime), newer Linux database engines.
Node.js is single-threaded and handles thousands of concurrent HTTP connections. Why doesn't it block when waiting for a slow database query?
mediumNode.js runs on a single OS thread. A slow database query takes 500ms to return. During that 500ms, Node.js continues handling other requests.
ANode.js spawns a new OS thread for each database query
Incorrect.Node.js's event loop runs on a single thread. libuv (used by Node.js) has a thread pool for file I/O and DNS, but network I/O (including database connections) uses the event loop with non-blocking I/O — no extra threads per connection.BNode.js uses I/O multiplexing (epoll on Linux): the database socket is registered with epoll; the event loop processes other callbacks while waiting; when the response arrives, epoll wakes the loop and the callback runs
Correct!Node.js's event loop is built on libuv, which uses epoll (Linux), kqueue (macOS), or IOCP (Windows). When you make a database query, the socket is set to non-blocking, registered with epoll, and the query's callback is stored. The event loop continues processing other events. When the database response arrives, epoll signals the loop, which pulls the callback off the queue and runs it. The single OS thread is never truly idle (blocked) — it either runs callbacks or is in epoll_wait waiting for any socket to become ready.CNode.js caches all database results so subsequent requests don't block
Incorrect.Caching is an application-level concern. The fundamental non-blocking behavior comes from epoll-based I/O multiplexing, not caching.DJavaScript Promises make synchronous code non-blocking
Incorrect.Promises are a syntactic abstraction for callbacks. They don't change the underlying I/O model. The non-blocking behavior comes from the OS-level epoll integration in libuv.
Hint:What mechanism allows a single thread to monitor hundreds of sockets simultaneously without blocking on any one of them?