Alternative I/O Models I/O Multiplexing: select() and poll()

 

Chapter 63: Alternative I/O Models
Part 3 of 5 โ€” I/O Multiplexing: select() and poll()
๐Ÿ“– TLPI Chapter 63
โฑ ~25 min read
๐Ÿ’ป Linux Systems

I/O Multiplexing solves the CPU waste problem of tight polling loops. Instead of spinning and calling read() over and over, your process calls select() or poll() and sleeps until at least one file descriptor becomes ready. The kernel wakes your process only when something interesting happens. No wasted CPU. No missed events.

These are the oldest and most portable alternative I/O APIs in the UNIX world. Every serious network programmer needs to understand them.

Keywords:

select() poll() fd_set FD_SET FD_ISSET FD_ZERO struct pollfd POLLIN POLLOUT POLLERR timeout nfds readfds writefds

The Core Idea of I/O Multiplexing

The word multiplexing means handling many things through a single channel. In I/O multiplexing, a single system call monitors many file descriptors at once and tells you which ones are ready.

How I/O Multiplexing Works

1
Tell kernel which FDs to watch
You give select()/poll() a list of file descriptors and what events you care about (read ready? write ready? error?)

2
Your process sleeps (efficiently)
The kernel monitors all FDs. Your process uses zero CPU. You can set a timeout so you wake up after N seconds even if nothing is ready.

3
Kernel wakes your process
When at least one FD is ready, the kernel wakes your process and tells you which ones are ready.

4
You do I/O on ready FDs only
You call read()/write() only on the FDs that are confirmed ready. No blocking. No EAGAIN. Then go back to step 1.

The select() System Call

select() is the oldest I/O multiplexing API. It has been part of UNIX since BSD 4.2. Every UNIX system supports it.


#include <sys/select.h>
#include <sys/time.h>

int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

/*
 * Returns: number of ready FDs on success
 *          0 if timeout expired (no FD became ready)
 *         -1 on error (check errno)
 */
    
Parameter Type What it Means
nfds int Highest FD number in your sets + 1. e.g. if you watch FD 5 and FD 7, nfds = 8
readfds fd_set * Set of FDs to watch for readability (data available to read)
writefds fd_set * Set of FDs to watch for writability (space in send buffer)
exceptfds fd_set * Set of FDs to watch for exceptional conditions (e.g. out-of-band TCP data)
timeout struct timeval * Max time to wait. NULL = wait forever. {0,0} = return immediately (poll)
fd_set Macros

You manipulate fd_set structures using four macros:


fd_set readfds;

FD_ZERO(&readfds);         /* Clear all bits in the set (always do this first!) */
FD_SET(fd, &readfds);     /* Add fd to the set */
FD_CLR(fd, &readfds);     /* Remove fd from the set */
FD_ISSET(fd, &readfds);   /* Test if fd is set (use after select() returns) */
      
โš  Critical Warning: select() modifies the fd_set you pass in. After select() returns, the sets only contain the FDs that are ready, not your original set. If you loop, you must rebuild the fd_set from scratch before each call to select(). This is a common bug for beginners.
FD_SETSIZE Limitation

fd_set is a fixed-size bitmask. Its size is defined by FD_SETSIZE, which is typically 1024 on Linux. This means select() can only watch file descriptors with numbers 0 to 1023. If your server opens more than 1024 file descriptors (common in high-load servers), select() simply cannot monitor the higher ones. This is one of the main reasons select() does not scale.

select() Full Code Example

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>

/*
 * Monitor stdin (fd=0) and a pipe read end simultaneously.
 * Whichever has data first gets processed.
 * Demonstrates the select() pattern.
 */
int main() {
    int pipefd[2];
    char buf[256];
    ssize_t n;
    fd_set readfds;
    struct timeval timeout;
    int ret, maxfd;

    /* Create a pipe */
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    /* Write something into the pipe (simulating data from a client) */
    write(pipefd[1], "pipe data!", 10);

    maxfd = pipefd[0];  /* stdin=0, pipe read end. pipefd[0] is higher */

    printf("Waiting for data on stdin or pipe...\n");

    /* select() loop */
    while (1) {

        /*
         * IMPORTANT: Rebuild fd_set before EVERY call to select().
         * select() modifies the sets to show only ready FDs.
         */
        FD_ZERO(&readfds);
        FD_SET(STDIN_FILENO, &readfds);  /* watch stdin */
        FD_SET(pipefd[0],    &readfds);  /* watch pipe read end */

        /* Timeout: wait up to 5 seconds */
        timeout.tv_sec  = 5;
        timeout.tv_usec = 0;

        ret = select(maxfd + 1, &readfds, NULL, NULL, &timeout);

        if (ret == -1) {
            perror("select");
            break;
        } else if (ret == 0) {
            /* Timeout expired. No FD became ready in 5 seconds. */
            printf("Timeout! No data arrived in 5 seconds.\n");
            break;
        }

        /* ret > 0: at least one FD is ready */

        /* Check stdin */
        if (FD_ISSET(STDIN_FILENO, &readfds)) {
            n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                printf("stdin: got '%s'\n", buf);
            } else if (n == 0) {
                printf("stdin: EOF\n");
                break;
            }
        }

        /* Check pipe */
        if (FD_ISSET(pipefd[0], &readfds)) {
            n = read(pipefd[0], buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                printf("pipe: got '%s'\n", buf);
                break;  /* pipe data consumed, exit loop */
            }
        }
    }

    close(pipefd[0]);
    close(pipefd[1]);
    return 0;
}
    

Compile: gcc -o select_demo select_demo.c && ./select_demo

The poll() System Call

poll() was designed to fix some of the awkward parts of select(). Instead of three separate fd_sets, poll() takes an array of struct pollfd structures. Each structure describes one file descriptor and what events you want to watch.


#include <poll.h>

int poll(struct pollfd fds[], nfds_t nfds, int timeout);

/*
 * Returns: number of ready FDs on success
 *          0 if timeout expired
 *         -1 on error
 */

struct pollfd {
    int   fd;       /* File descriptor to watch */
    short events;   /* Events we are interested in (input bitmask) */
    short revents;  /* Events that actually occurred (output bitmask) */
};
    

struct pollfd Fields
fd
The file descriptor number to monitor. Set to -1 to temporarily ignore this slot without removing it from the array.
events (input)
Bitmask of events you want to monitor. You set this before calling poll(). e.g. POLLIN | POLLOUT
revents (output)
Bitmask of events that occurred. Kernel fills this in. You read this after poll() returns.

Common poll() Event Flags
Flag In events? In revents? Meaning
POLLIN โœ” Yes โœ” Yes Data available to read
POLLOUT โœ” Yes โœ” Yes Space available to write (without blocking)
POLLPRI โœ” Yes โœ” Yes Urgent/out-of-band data available
POLLERR โœ– No โœ” Yes Error condition on FD
POLLHUP โœ– No โœ” Yes Hangup (e.g. pipe write end closed)
POLLNVAL โœ– No โœ” Yes Invalid fd (not open)
timeout parameter (milliseconds)
Value Behavior
-1 Block indefinitely until an event occurs
0 Return immediately (non-blocking check)
N (positive) Wait at most N milliseconds

poll() Full Code Example

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <poll.h>

/*
 * Simple poll() example: monitor stdin and a pipe simultaneously.
 * poll() is cleaner than select() - no fd_set rebuilding needed,
 * no FD_SETSIZE limit.
 */
int main() {
    int pipefd[2];
    char buf[256];
    ssize_t n;
    int ret;

    /* Create a pipe and put some data in it */
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }
    write(pipefd[1], "Hello from pipe!", 16);

    /*
     * Set up the pollfd array.
     * One entry per FD we want to monitor.
     * Unlike select(), no need to track the highest FD number.
     */
    struct pollfd fds[2];

    fds[0].fd      = STDIN_FILENO;  /* stdin */
    fds[0].events  = POLLIN;        /* watch for readable */
    fds[0].revents = 0;             /* kernel fills this */

    fds[1].fd      = pipefd[0];     /* pipe read end */
    fds[1].events  = POLLIN;
    fds[1].revents = 0;

    printf("Monitoring stdin and pipe with poll()...\n");

    /*
     * poll() loop.
     * Unlike select(), poll() does NOT modify the events field,
     * only the revents field. So you do NOT need to rebuild fds[]
     * before each call (though you should reset revents to 0).
     */
    while (1) {
        /* Reset revents before each call */
        fds[0].revents = 0;
        fds[1].revents = 0;

        /* Wait up to 5000ms (5 seconds) */
        ret = poll(fds, 2, 5000);

        if (ret == -1) {
            perror("poll");
            break;
        }
        if (ret == 0) {
            printf("Timeout: no events in 5 seconds\n");
            break;
        }

        /* Check stdin */
        if (fds[0].revents & POLLIN) {
            n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                printf("stdin ready: got '%s'\n", buf);
            } else if (n == 0) {
                printf("stdin: EOF\n");
            }
        }
        if (fds[0].revents & POLLERR) {
            printf("stdin: error condition\n");
        }

        /* Check pipe */
        if (fds[1].revents & POLLIN) {
            n = read(pipefd[0], buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                printf("pipe ready: got '%s'\n", buf);
                break;
            }
        }
        if (fds[1].revents & POLLHUP) {
            printf("pipe: write end closed (HUP)\n");
            break;
        }
    }

    close(pipefd[0]);
    close(pipefd[1]);
    return 0;
}
    

select() vs poll(): Head to Head
Feature select() poll()
Portability โญ Excellent (POSIX, every UNIX) โœ… Good (POSIX)
Max FD limit 1024 (FD_SETSIZE) No hard limit
Rebuild sets each call? Yes (modifies input sets) No (only revents changes)
Data structure fd_set bitmask Array of struct pollfd
Timeout precision Microseconds (struct timeval) Milliseconds (int)
Event granularity Read/Write/Except only Many event types (POLLIN, POLLHUP, etc.)
Performance (many FDs) Poor (O(n) scan) Moderate (O(n) scan)
Windows support โœ” Yes (Winsock) โœ– No (not available on Windows)
๐Ÿ’ก Rule of thumb: Prefer poll() over select() for new Linux/POSIX code. It is cleaner, has no FD_SETSIZE limit, and you do not have to rebuild the set every time. Use select() only when you need Windows compatibility or truly ancient UNIX portability.

Why select() and poll() Do Not Scale

Both select() and poll() have an O(n) scalability problem. Even if only one file descriptor out of a thousand is ready, the kernel still has to scan through all n FDs to find which one is ready. Your application then also has to scan through all the returned results to find the ready ones. As n grows to thousands, this becomes very slow.

Kernel Work Per poll()/select() Call
Number of FDs Monitored FDs Actually Ready Kernel Scan Work Efficiency
10 1 Scan 10 FDs Good
1,000 1 Scan 1,000 FDs Moderate
10,000 1 Scan 10,000 FDs Poor

With 10,000 FDs and only 1 ready, 9,999 FDs are checked needlessly on every call

Additionally, each call to poll()/select() requires copying the entire FD list from user space into kernel space. With thousands of FDs this copying overhead becomes significant. This is why epoll was invented. epoll registers interest once and only notifies you about the FDs that actually become ready.

Interview Questions
Q1. What does the nfds parameter in select() mean?

It is the highest file descriptor number in any of your fd_sets, plus one. For example if you are watching FD 3 and FD 7, you pass nfds = 8. The kernel uses this to know how many bits to scan in the bitmask. Passing too small a value means some FDs won’t be monitored. Passing a too-large value is wasteful but not incorrect.

Q2. Why must you rebuild the fd_set before every call to select()?

select() modifies the fd_set in place. When it returns, the fd_set only contains the FDs that are currently ready, not the original set of FDs you wanted to monitor. So on the next iteration of your loop, if you pass the modified set, some FDs will be missing. You must call FD_ZERO and re-add all your FDs each time. poll() does not have this problem because it uses a separate revents field.

Q3. What is FD_SETSIZE and why does it matter?

FD_SETSIZE is a compile-time constant (typically 1024 on Linux) that defines the maximum number of file descriptors that an fd_set can hold. select() can only monitor FDs with numbers 0 to FD_SETSIZE-1. High-performance servers that open thousands of connections cannot use select() because their FD numbers exceed this limit. poll() and epoll have no such restriction.

Q4. What does poll() return when the timeout expires with no events?

poll() returns 0. This is not an error. It means no file descriptor became ready within the specified timeout period. You can use this to perform periodic housekeeping work, like checking connection health or logging statistics.

Q5. What is the difference between events and revents in struct pollfd?

events is an input field that you set before calling poll(). It tells the kernel what events you are interested in for that FD. revents is an output field that the kernel fills in when poll() returns. It tells you which events actually occurred. You should check revents after poll() returns to decide what action to take. Always reset revents to 0 before calling poll() again.

Q6. Why do both select() and poll() perform poorly with thousands of file descriptors?

Both have O(n) complexity. The kernel must scan through every monitored FD to find which are ready, even if only one is. Additionally, the entire list of FDs is copied from user space to kernel space on every call. With 10,000 FDs, this is a lot of work even if only one FD becomes ready. epoll avoids this by using an event-driven model where the kernel only returns the FDs that are actually ready.

Q7. How do you temporarily skip a file descriptor in a poll() array without removing it?

Set its fd field to -1. The kernel ignores entries with fd = -1 and sets revents to 0 for them. This is useful when you want to temporarily disable monitoring of an FD without resizing the array, and re-enable it later by setting fd back to the actual descriptor value.

Q8. What does POLLHUP mean and how is it different from POLLERR?

POLLHUP means the remote end has closed the connection or a pipe’s write end was closed. It is a normal shutdown event. POLLERR means an error condition occurred on the file descriptor, like a socket error. Both can appear in revents simultaneously. After POLLHUP on a socket you should close that FD. After POLLERR you should call getsockopt with SO_ERROR to find out what error occurred.

Next: Signal-driven I/O
Part 4 explains how signal-driven I/O lets the kernel notify your process via a signal when I/O is ready, and how this scales better than select() and poll() for large numbers of file descriptors.

โ† Part 2: Nonblocking I/O Part 4: Signal-driven I/O โ†’

Leave a Reply

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