The Core Problem
select() and poll() work fine for small numbers of file descriptors. But when you are building a high-performance server that handles thousands or tens of thousands of simultaneous connections, these APIs start to become a serious bottleneck. This tutorial explains exactly why โ and what the kernel-level limitation is.
Every time you call select() or poll(), three expensive things happen:
Even if 9999 out of 10000 fds have no activity, the kernel must check every single one. This is pure wasted CPU for large idle sets.
Your program passes the entire fd list to the kernel. The kernel modifies it and returns it. This copying happens EVERY call โ even when the monitored set never changes.
After select()/poll() returns, your code must loop through every entry to find which ones are ready. Again O(n) work even if only 1 fd is ready.
The combined result: the CPU time consumed grows linearly with the number of file descriptors being monitored, not with the number of active (ready) fds. This is called O(n) scaling โ very bad for high-connection servers.
Let’s look at the data structures involved and understand why copying them is expensive.
/*
* Rough illustration of copy cost with poll() at scale
*
* Each struct pollfd = 8 bytes
* 10,000 connections = 10,000 * 8 = 80,000 bytes per call
* If poll() is called 1000 times/second:
* = 80,000 * 1000 = 80 MB/sec just for copies between user and kernel
*
* This is pure overhead unrelated to actual I/O work.
*/
#include <poll.h>
#include <stdlib.h>
#include <stdio.h>
#define MAX_CLIENTS 10000
int main(void)
{
struct pollfd *fds = malloc(MAX_CLIENTS * sizeof(struct pollfd));
if (!fds) {
perror("malloc");
return 1;
}
/* Initialize all entries */
for (int i = 0; i < MAX_CLIENTS; i++) {
fds[i].fd = i + 3; /* pretend fds start at 3 */
fds[i].events = POLLIN;
fds[i].revents = 0;
}
/* Every call to poll() copies 80,000 bytes to kernel and back */
while (1) {
int ret = poll(fds, MAX_CLIENTS, -1);
/* After returning, must loop through ALL 10,000 entries */
for (int i = 0; i < MAX_CLIENTS; i++) {
if (fds[i].revents & POLLIN) {
/* handle fd fds[i].fd */
printf("fd %d is ready\n", fds[i].fd);
fds[i].revents = 0;
}
}
}
free(fds);
return 0;
}
The deepest reason select() and poll() scale poorly is fundamental to their design:
The kernel does not remember the list of file descriptors between successive calls to select() or poll(). Each call is completely stateless from the kernel’s perspective. So every time you call these functions, you must hand the entire list to the kernel again, the kernel must build up its internal watch state from scratch, scan all entries, and then destroy that state when it returns.
- App sends full fd list to kernel
- Kernel builds watch state
- Kernel waits for activity
- Kernel reports ready fds
- Kernel forgets everything
- Repeat from step 1 next call
- App registers fd list once
- Kernel keeps watch state permanently
- Kernel only notifies when something happens
- No re-copying, no re-scanning
- Scales with events, not with fd count
- โ This is what epoll provides!
Both signal-driven I/O and epoll solve the root cause problem by letting the kernel maintain a persistent interest list. You register your interest once, and the kernel remembers it across multiple wait operations.
| Mechanism | Scales with | Kernel memory? | Best for |
|---|---|---|---|
| select() | O(highest fd number) | No โ stateless | Small fd counts (<100) |
| poll() | O(number of monitored fds) | No โ stateless | Medium fd counts, sparse sets |
| Signal-driven I/O | O(events occurred) | Yes โ persistent | Single fd, simple cases |
| epoll | O(events occurred) | Yes โ persistent | High-scale servers (10K+ fds) |
The key insight: select() and poll() performance degrades even when no I/O is happening and the fd set is unchanged. epoll and signal-driven I/O only do work proportional to how much actual I/O activity is occurring.
/*
* demo_poll_scale.c
*
* This shows the O(n) nature of poll():
* - Create N pipe pairs
* - Monitor read ends with poll()
* - Only write to the last one
* - Notice poll() still scans ALL N entries
*/
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#define N_PIPES 1000 /* try 10, 100, 1000, 10000 */
int main(void)
{
int pipes[N_PIPES][2];
struct pollfd fds[N_PIPES];
/* Create N pipes */
for (int i = 0; i < N_PIPES; i++) {
if (pipe(pipes[i]) == -1) {
perror("pipe");
exit(1);
}
fds[i].fd = pipes[i][0]; /* read end */
fds[i].events = POLLIN;
fds[i].revents = 0;
}
/* Write one byte to the LAST pipe only */
write(pipes[N_PIPES - 1][1], "x", 1);
/* Time the poll() call */
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
/* poll() must scan ALL N_PIPES entries, even though
* only the last one is ready */
int ret = poll(fds, N_PIPES, 0);
clock_gettime(CLOCK_MONOTONIC, &end);
long ns = (end.tv_sec - start.tv_sec) * 1000000000L
+ (end.tv_nsec - start.tv_nsec);
printf("N_PIPES=%d, ret=%d, time=%ld ns\n", N_PIPES, ret, ns);
/* Check which fd is ready */
for (int i = 0; i < N_PIPES; i++) {
if (fds[i].revents & POLLIN)
printf("Ready: index %d, fd %d\n", i, fds[i].fd);
}
/* Cleanup */
for (int i = 0; i < N_PIPES; i++) {
close(pipes[i][0]);
close(pipes[i][1]);
}
return 0;
}
/*
* Compile: gcc -o demo_poll_scale demo_poll_scale.c
* Run with different N_PIPES values and compare the time output.
* You will see time grows linearly with N_PIPES.
* With epoll, time would remain near-constant regardless of N_PIPES.
*/
Now that you understand the limitations, learn how Signal-Driven I/O addresses them.
