Till now you have seen programs that do I/O one file descriptor at a time. Your program reads from a socket, waits, gets data, then processes it. This is the blocking I/O model. It is simple and it works well for many programs. But what happens when your server needs to handle 1000 clients at the same time? Waiting for each one will kill your performance.
This chapter introduces three powerful alternatives that let a single process watch many file descriptors at once without getting stuck. These are the building blocks behind every modern high-performance server you use today.
The Linux kernel gives you three modern alternatives to blocking I/O when you need to watch multiple file descriptors:
In the default mode, when you call read() on a socket or pipe, your process sleeps until data arrives. The kernel puts your process in the waiting state and schedules other processes. When data arrives, the kernel wakes your process and your read() returns.
This is called blocking I/O because your process is blocked (stuck doing nothing) while waiting for data.
Between T=1 and T=3, the process is completely stuck and cannot serve other clients
For a simple client-server program with one client, this is fine. The problem starts when you have many clients. If client A sends no data and you’re blocked waiting for A, client B is ignored even if it has data ready.
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
char buf[256];
ssize_t n;
printf("Waiting for input on stdin...\n");
/*
* read() will BLOCK here until user types something.
* The process is completely stuck. It cannot do any
* other work while waiting.
*/
n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Got: %s\n", buf);
}
return 0;
}
This works for one input source. But imagine you have 1000 sockets. You cannot call read() on all of them one by one because the first blocked one will stop you from reading the others.
Disk files behave a little differently from pipes and sockets. The kernel uses a buffer cache for disk I/O:
- write() to a disk file returns quickly โ it just copies data to the kernel buffer cache, not directly to disk. The kernel writes to disk in the background.
- read() from a disk file checks the buffer cache first. If data is there, it returns immediately. If not, the kernel puts your process to sleep until a disk read completes.
- If you use O_SYNC flag, write() waits until data actually hits the disk.
Before modern alternative I/O models, developers used two workarounds:
Create one child process per client. Each child blocks on its own read(). The parent is free.
โข Expensive: fork() is heavy
โข Need IPC to communicate back
โข Does not scale beyond a few hundred clients
Create one thread per client. Threads are lighter than processes but each thread still blocks on its socket.
โข Complex synchronization
โข Thread pools add more complexity
โข Memory overhead per thread
Both approaches can work but they do not scale well. Modern web servers handle tens of thousands of concurrent connections. You cannot create 50,000 processes or threads. This is why alternative I/O models were developed.
Here is how all the I/O models compare side by side:
| Model | How it Works | Best For | Limitation |
|---|---|---|---|
| Blocking I/O | Process sleeps until data ready | Simple single-client apps | Cannot handle multiple FDs |
| Nonblocking I/O | Returns EAGAIN if not ready, must poll | Low latency, few FDs | Wastes CPU in tight loop |
| I/O Multiplexing | select()/poll() watch many FDs | Moderate FD counts, portable | Poor scaling at thousands of FDs |
| Signal-driven I/O | Kernel sends signal when ready | Large FD counts | Complex signal handling |
| epoll | Kernel notifies only ready FDs | Thousands of FDs, best performance | Linux-only |
| POSIX AIO | Queue I/O, notified on completion | Background disk I/O | Linux implementation is thread-based (glibc) |
POSIX Asynchronous I/O (AIO) is a different model where you queue an I/O request and your process continues immediately. The kernel notifies you (via signal or callback) when the I/O is done.
On Linux, POSIX AIO is currently implemented using a thread pool inside glibc. This means it is not a true kernel-level async mechanism yet. Work was ongoing to add proper kernel-level POSIX AIO support for better scaling.
When a process calls read() on a socket and no data is available, the process sleeps. During this sleep it cannot check any other socket. So if you have 100 clients, you are stuck waiting for client 1 even if clients 2 to 100 have data ready. This is the core scaling problem that alternative I/O models solve.
1) I/O Multiplexing using select() and poll() system calls. 2) Signal-driven I/O where the kernel sends a signal when I/O is ready. 3) The epoll API which is Linux-specific and provides the best performance for large numbers of file descriptors.
No. This is a very common confusion. These APIs only tell you that a file descriptor is ready for I/O. You still have to call read() or write() yourself to actually transfer the data. They are I/O readiness notification mechanisms, not I/O mechanisms.
In a tight polling loop, the process keeps calling read() repeatedly even when no data is available. Since the call returns EAGAIN immediately (nonblocking mode), the process spins the CPU at 100% usage doing no real work. This wastes CPU time and hurts overall system performance.
For sockets and pipes, blocking I/O truly blocks until data arrives from a remote source. For disk files, the kernel uses a buffer cache. A write() returns quickly after copying to the cache. A read() returns immediately if data is in cache. Only if the data is not cached does the process sleep for a disk read. So disk I/O blocks much less often than socket I/O.
You use the O_NONBLOCK open file status flag. You can set it at open() time or later using fcntl() with F_SETFL. When set, any I/O system call that would have blocked returns immediately with errno set to EAGAIN (or EWOULDBLOCK).
When you use a third-party library that internally does blocking I/O and you cannot modify it. You put the library call in a separate thread. The main thread stays free and continues handling other work. The library thread blocks internally but that does not affect your main thread.
POSIX AIO lets you queue an I/O operation and get notified when it completes. With epoll you are notified when a file descriptor is ready and you still call read()/write() yourself. POSIX AIO is conceptually more asynchronous (the kernel does the I/O for you), while epoll is a readiness notification model. On Linux, POSIX AIO is implemented using threads inside glibc, not a true kernel mechanism.
