I/O Multiplexing
Intermediate
1 of 3
What is I/O Multiplexing?
Imagine you have 10 open file descriptors — sockets, pipes, files — and you need to know which one has data ready to read without blocking forever on any one of them. That is exactly the problem I/O multiplexing solves.
The select() system call is one of the oldest and most widely supported ways to monitor multiple file descriptors at the same time. You tell the kernel: “watch these descriptors and wake me up when at least one of them is ready for reading, writing, or has an exceptional condition.” The kernel does the waiting; your program stays in control.
This is used everywhere: web servers, chat programs, terminal emulators, network daemons — any place where one thread needs to handle multiple I/O channels without blocking.
The select() call lives in <sys/select.h>. Here is its signature:
#include <sys/select.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
/* Returns: number of ready file descriptors,
0 on timeout, -1 on error */
Let’s understand each argument one by one.
| Parameter | Type | What it means |
|---|---|---|
nfds |
int |
Highest file descriptor number in any set, plus one. Tells kernel how many descriptors to scan. |
readfds |
fd_set * |
Set of descriptors to watch for incoming data (read ready). Can be NULL. |
writefds |
fd_set * |
Set of descriptors to watch for write readiness (space in send buffer). Can be NULL. |
exceptfds |
fd_set * |
Set of descriptors to watch for exceptional conditions (e.g. TCP out-of-band data). Can be NULL. |
timeout |
struct timeval * |
How long to wait. NULL means wait forever. Zero fields mean poll and return immediately. |
An fd_set is just a fixed-size bitset. Each bit position corresponds to one file descriptor. The kernel defines the type; you never access bits directly. Instead, you use four macros:
The maximum number of file descriptors you can put in a set is FD_SETSIZE, which is typically 1024 on Linux. Trying to add a descriptor with a number ≥ FD_SETSIZE is undefined behavior — it can corrupt memory or silently do nothing.
#include <sys/select.h>
#include <stdio.h>
int main(void)
{
fd_set readfds;
/* Step 1: always zero out the set first */
FD_ZERO(&readfds);
/* Step 2: add the descriptors you want to watch */
FD_SET(0, &readfds); /* stdin */
FD_SET(5, &readfds); /* some open socket */
/* nfds must be highest fd + 1 */
int nfds = 5 + 1; /* = 6 */
/* Step 3: call select() ... (see next section) */
/* Step 4: after select() returns, check who is ready */
if (FD_ISSET(0, &readfds))
printf("stdin is ready to read\n");
if (FD_ISSET(5, &readfds))
printf("fd 5 is ready to read\n");
return 0;
}
Why nfds = highest_fd + 1? The kernel loops from 0 to nfds-1 checking each bit. Passing the exact highest descriptor plus one tells it exactly where to stop — not more, not less.
The timeout argument controls how long select() will wait. It uses the struct timeval type:
struct timeval {
time_t tv_sec; /* seconds (whole part) */
suseconds_t tv_usec; /* microseconds (fractional part) */
};
struct timeval timeout;
/* Example: wait up to 5 seconds 500000 microseconds (5.5 s) */
timeout.tv_sec = 5;
timeout.tv_usec = 500000;
int ready = select(nfds, &readfds, NULL, NULL, &timeout);
if (ready > 0)
printf("ready: %d descriptor(s)\n", ready);
else if (ready == 0)
printf("timed out – no activity\n");
else
perror("select");
Important Linux behavior: On Linux, after select() returns, the timeout structure is updated to show how much time was left when the call returned. This is not portable — most other UNIX systems leave it unchanged. If you use select() in a loop, always re-initialize the timeval structure before each call.
/* Portable loop pattern – always reinitialize timeout */
while (1) {
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(sockfd, &rfds);
int n = select(sockfd + 1, &rfds, NULL, NULL, &tv);
if (n < 0) { perror("select"); break; }
if (n == 0) { printf("timeout, retrying\n"); continue; }
if (FD_ISSET(sockfd, &rfds)) handle_incoming();
}
FD_SETSIZE is typically 1024 on Linux. This means select() can only watch file descriptors numbered 0 through 1023. In practice, a server that opens more than 1024 simultaneous connections cannot use select() safely for all of them.
SET
0
0
SET
0
SET
0
/* Safe guard: always check before adding to set */
if (new_fd >= FD_SETSIZE) {
fprintf(stderr, "fd %d exceeds FD_SETSIZE (%d)\n",
new_fd, FD_SETSIZE);
close(new_fd);
return -1;
}
FD_SET(new_fd, &readfds);
For servers needing thousands of connections, use poll() or epoll() instead. They have no such fixed limit.
Before nanosleep() existed on older UNIX systems, programmers used select() to sleep for less than one second. You pass nfds=0, all three sets as NULL, and put the desired sleep time in timeout. select() with no descriptors to watch just sleeps for the timeout duration.
/* Sleep for 250 milliseconds – old-school portable trick */
struct timeval snooze;
snooze.tv_sec = 0;
snooze.tv_usec = 250000; /* 250 ms */
select(0, NULL, NULL, NULL, &snooze);
/* Modern equivalent – use nanosleep() instead */
struct timespec ts = { .tv_sec = 0, .tv_nsec = 250000000 };
nanosleep(&ts, NULL);
This trick still works today, though nanosleep() is the correct modern approach.
nfds tells the kernel how many file descriptors to scan, starting from 0. You calculate it as the highest file descriptor number you added to any of the three sets, plus one. For example, if the highest fd you added is 7, nfds must be 8. Passing a value that is too low means select() will miss some descriptors silently.
An fd_set is a local variable and its contents are undefined until initialized. Uninitialized bits could look like set bits, causing select() to monitor random file descriptors. FD_ZERO() clears all bits to zero so you start with a clean, empty set.
select() modifies the fd_set arguments in place. After it returns, only the bits for descriptors that are actually ready remain set. Any descriptor that was not ready has its bit cleared. This means if you call select() in a loop, you must rebuild the sets from scratch before every call — you cannot reuse the returned sets as input.
NULL means block indefinitely — select() will not return until a descriptor is ready or a signal arrives. A timeval with both fields set to zero is a poll — select() checks all descriptors immediately and returns at once, even if none are ready. These are opposite behaviors despite looking similar.
FD_SETSIZE is the maximum number of file descriptors an fd_set can hold, typically 1024 on Linux. A server managing more than 1024 simultaneous connections cannot use select() because descriptors with numbers at or above 1024 cannot be safely added. For such workloads, poll() or epoll() are used instead since they have no fixed descriptor limit.
Yes, on Linux, select() updates the timeval to reflect how much time remained when the call returned. This is not portable — most other UNIX implementations leave timeval unchanged. Portable code must reinitialize the timeval structure before every select() call inside a loop.
Yes. You can add the same fd to readfds, writefds, and exceptfds simultaneously. After select() returns, if that fd is ready for more than one type of event, it will be marked in each set independently. The total count returned by select() will count it once for each set in which it appears ready.
Next: understand what select() returns and how blocking and signals interact with it.
