Alternative I/O Models Overview & The Problem with Blocking I/O

 

Chapter 63: Alternative I/O Models
Part 1 of 5 โ€” Overview & The Problem with Blocking I/O
๐Ÿ“– TLPI Chapter 63
โฑ ~15 min read
๐Ÿ’ป Linux Systems

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.

Keywords in this part:

Blocking I/O File Descriptor I/O Multiplexing select() poll() epoll Signal-driven I/O POSIX AIO O_NONBLOCK

What This Chapter Covers

The Linux kernel gives you three modern alternatives to blocking I/O when you need to watch multiple file descriptors:

Three Alternative I/O Models
I/O Multiplexing
Uses select() or poll() system calls. Watches many FDs at once. Portable across UNIX systems.
Signal-driven I/O
Kernel sends a signal when I/O is ready. Your process does other work and handles the signal later. Better for large FD counts.
epoll API
Linux-specific. Very efficient for monitoring thousands of file descriptors. Best performance of the three.
All three just tell you when I/O is ready. You still call read()/write() yourself.

What is Blocking I/O?

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.

Blocking I/O Timeline
Time Process State What Happens
T=0 RUNNING Process calls read() on socket
T=1 BLOCKED No data yet. Kernel puts process to sleep
T=2 BLOCKED Still waiting… cannot do anything else
T=3 WAKING UP Data arrives. Kernel wakes process
T=4 RUNNING read() returns. Process processes 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.

Code Example: Classic Blocking Read

#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.

Special Case: Disk Files

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.
โš  Note: Because of the buffer cache, disk files do not truly block in most cases. The real blocking problem is with sockets, pipes, FIFOs, terminals, and other I/O devices.

What About Using Multiple Processes or Threads?

Before modern alternative I/O models, developers used two workarounds:

โคด Fork a Child Process

Create one child process per client. Each child blocks on its own read(). The parent is free.

Problems:
โ€ข Expensive: fork() is heavy
โ€ข Need IPC to communicate back
โ€ข Does not scale beyond a few hundred clients
โคด Use Threads

Create one thread per client. Threads are lighter than processes but each thread still blocks on its socket.

Problems:
โ€ข 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.

๐Ÿ’ก When threads are still useful: If you need to call a third-party library that does blocking I/O internally and you cannot change it, putting that call in a separate thread is a good solution. The main thread stays free.

All I/O Models at a Glance

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)

What About POSIX AIO?

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.

This chapter does NOT cover POSIX AIO in detail. It focuses on I/O multiplexing (select/poll), signal-driven I/O, and epoll which are the most widely used alternatives in Linux systems programming.

Interview Questions
Q1. What is the main problem with the blocking I/O model when handling multiple clients?

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.

Q2. Name the three alternative I/O models discussed in Chapter 63 of TLPI.

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.

Q3. Do select(), poll(), or epoll actually perform I/O?

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.

Q4. Why is polling with nonblocking I/O in a tight loop a bad idea?

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.

Q5. What is the difference between blocking I/O for sockets vs disk files?

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.

Q6. What flag do you set to put a file descriptor in nonblocking mode?

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).

Q7. When would using a separate thread for blocking I/O make sense even with modern alternatives available?

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.

Q8. What is POSIX AIO and how is it different from epoll?

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.

Next: Nonblocking I/O in Detail
Part 2 covers how O_NONBLOCK works, EAGAIN errors, and how to implement nonblocking I/O correctly with code examples.

Part 2: Nonblocking I/O โ†’

Leave a Reply

Your email address will not be published. Required fields are marked *