I/O Multiplexing in C: Select, Poll, and Epoll
I/O multiplexing allows a network server written in C to monitor
multiple socket descriptors concurrently using a single thread,
eliminating the need to allocate dedicated threads or processes per
client connection. Operating systems provide system calls—primarily
select, poll, and Linux-specific
epoll—that notify an application when one or more sockets
are ready for non-blocking read or write operations. While all three
mechanisms solve the fundamental concurrency bottleneck of synchronous
blocking I/O, they differ significantly in their internal kernel
architecture, memory overhead, API ergonomics, and algorithmic
scalability.
The Concept of I/O Multiplexing
In a traditional blocking network model, calling read()
or recv() on a socket stalls thread execution until data
arrives from the client. To serve thousands of concurrent clients, a
server would need thousands of threads, incurring severe
context-switching overhead and memory consumption.
I/O multiplexing resolves this by setting client sockets to
non-blocking mode (O_NONBLOCK via fcntl) and
registering their file descriptors with an OS-level polling mechanism.
The application suspends execution on a single system call until the
kernel signals that network activity has occurred on at least one
registered descriptor.
1. select(): The
Legacy Standard
Introduced in BSD Unix, select() is defined in
<sys/select.h> and is the most portable I/O
multiplexing call.
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);Mechanics
- Bitmasks: Sockets are tracked using fixed-size bit
arrays (
fd_set). Macros likeFD_SET(),FD_CLR(), andFD_ISSET()manipulate these masks. - In-Place Modification: The kernel modifies the
passed
fd_setpointers in place to indicate readiness. Consequently, the application must re-populate the sets before every invocation ofselect().
Limitations
- Descriptor Limit: Sockets cannot exceed
FD_SETSIZE(typically hardcoded to 1024 in glibc). Sockets with descriptor numbers \(\ge 1024\) cause undefined behavior or buffer overflows. - Linear Complexity (\(O(N)\)): The kernel iterates
through all bits up to
nfdsto determine readiness. Upon return, the user space must also iterate through all descriptors up tonfdswithFD_ISSET()to find which sockets fired. - Repeated Copying: The entire bitmask state must be copied from user space to kernel space and back on every call.
2. poll():
Overcoming Fixed Limits
Standardized by POSIX in <poll.h>,
poll() improves upon select() by ditching
bitmasks in favor of a dynamic array of structures.
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events (e.g., POLLIN, POLLOUT) */
short revents; /* returned events */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);Mechanics
- Separation of Input and Output:
eventsdefines what to listen for, while the kernel writes results exclusively torevents. This eliminates the need to rebuild the tracking structure before each loop iteration. - No Hardcoded Limit: The
fdsarray can be dynamically allocated to accommodate any number of file descriptors permitted by system resource limits (RLIMIT_NOFILE).
Limitations
- Linear Complexity (\(O(N)\)): Like
select(), both the kernel and the user application must perform an \(O(N)\) scan across the entire array on each iteration to check for ready sockets. - Memory Overhead: The entire array of
struct pollfdinstances must be copied between user space and kernel space on every call, generating substantial overhead under high connection counts.
3.
epoll(): High-Performance Scalability for Linux
Introduced in Linux 2.6 and defined in
<sys/epoll.h>, epoll solves the \(O(N)\) scaling bottlenecks of
select() and poll() by decoupling event
registration from event waiting.
Core API
epoll_create1(int flags): Allocates anepollinstance in the kernel and returns an epoll file descriptor.epoll_ctl(int epfd, int op, int fd, struct epoll_event *event): Adds (EPOLL_CTL_ADD), modifies (EPOLL_CTL_MOD), or removes (EPOLL_CTL_DEL) specific file descriptors within the kernel's interest list.epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout): Blocks until one or more registered descriptors become ready, writing only the active events into the user-providedeventsbuffer.
Architectural Advantages
- Persistent State in Kernel Space: Monitored descriptors are organized inside the kernel using a red-black tree. Descriptors are registered once; the application does not re-pass lists on every wait call.
- Constant-Time Notification (\(O(1)\)): The kernel uses hardware interrupts and internal device callbacks to place ready descriptors directly into a ready list (a doubly linked list).
- Sparse Event Retrieval:
epoll_wait()returns only the descriptors that actually have events pending (\(O(K)\), where \(K\) is the number of active sockets), completely eliminating the need for linear scans across inactive connections.
Triggering Modes
- Level-Triggered (LT): Default behavior.
epoll_wait()continuously reports a descriptor as ready as long as its buffer contains data. - Edge-Triggered (ET): Enabled via the
EPOLLETflag. The kernel notifies the application only when a state change occurs (e.g., new data arrives). To avoid dropped packets or hung connections, sockets must be non-blocking, and loops must drain the socket usingread()orwrite()until encountering anEAGAINorEWOULDBLOCKerror.
Technical Comparison
| Feature | select() |
poll() |
epoll() |
|---|---|---|---|
| Availability | POSIX / Ubiquitous | POSIX / Ubiquitous | Linux-only |
| Max Connections | Typically 1024
(FD_SETSIZE) |
Limited by system file limits | Limited by system file limits |
| Kernel Data Structure | Bitmask array | Array of pollfd structs |
Red-Black Tree & Ready List |
| Time Complexity | \(O(N)\) | \(O(N)\) | \(O(K)\) (where \(K \le N\) ready events) |
| Memory Transfer | Re-copied on every invocation | Re-copied on every invocation | Transferred only on modification
(epoll_ctl) |
| Triggering Modes | Level-Triggered only | Level-Triggered only | Level-Triggered and Edge-Triggered |
For modern high-load network daemons on Linux (such as Nginx or
Redis), epoll is the industry standard because its
performance remains steady regardless of the number of idle connections.
For cross-platform tools where connection counts remain low or
deterministic, select() and poll() continue to
provide portable alternatives.