Networking in C · advanced · ~12 min
Serve many sockets from a single thread, without spawning a separate process or thread per connection. By the end you should be able to explain what I/O multiplexing is, rebuild an `fd_set` correctly for `select`, drive a `poll` loop that accepts new clients and services existing ones, and know when to reach for `epoll`/`kqueue` instead.
A network server usually has to keep an eye on several sockets at once: a listening socket that produces new connections, plus one socket per already-connected client. You cannot predict which will have data next, and you must not block on one socket while the others sit idle with unread bytes.
I/O multiplexing is the answer. It means asking the kernel a single question — "of all these file descriptors, which ones are ready right now?" — and being woken only when at least one is. select and poll are the two classic POSIX system calls that answer that question. Both let one thread watch a whole set of descriptors and react to whichever becomes ready, so you can serve dozens or hundreds of clients from a simple single-threaded loop.
The naive alternatives scale badly. Blocking read on one socket stalls every other client. fork-per-connection or thread-per-connection works but costs memory and context-switching, and shared state gets harder to reason about. Multiplexing keeps everything in one thread with one clear event loop, which is easier to debug and free of data races.
This is also the foundation every higher-level async runtime is built on. libevent, libuv (the engine under Node.js), nginx, Redis, and Python's asyncio all sit on top of select/poll/epoll/kqueue. Understanding the raw system calls demystifies what those frameworks do for you, and it is exactly the layer where subtle bugs — a descriptor you forgot to re-arm, a leaked fd, a partial write — actually bite.
Multiplexing is built on a simple idea: instead of doing I/O and blocking, you first ask which descriptors could be operated on without blocking, then act only on those.
+-------------------+
listen fd | |--> new connection ready --> accept()
client 3 | select/poll |--> data to read --> read()
client 5 | (ask the kernel) |--> nothing --> skip
client 8 | |--> peer closed --> read()==0, close
+-------------------+
^
| blocks until >=1 fd is ready (or timeout)
The call blocks until at least one watched descriptor is ready, then hands you back which ones. You loop over the ready set, service each, and call again.
| Aspect | select |
poll |
|---|---|---|
| What you pass | Three fd_set bitmaps (read/write/error) |
One array of struct pollfd |
| Max fd value | Capped by FD_SETSIZE (often 1024) |
No fixed cap; limited by array size / RLIMIT_NOFILE |
| Modifies your input | Yes — rewrites the sets in place | No — you read results from revents, events is untouched |
| Must rebuild each call | Yes, every iteration | No, only add/remove entries |
| Reports what happened | Membership in the returned sets | A bitmask per fd (POLLIN, POLLHUP, POLLERR, …) |
| Portability | Everywhere, very old | POSIX, everywhere modern |
Knowledge check 1: You have a client connected on file descriptor 2000 and you are using
selecton a system whereFD_SETSIZEis 1024. What happens when youFD_SET(2000, &readfds)? Answer: undefined behavior — you write past the end of thefd_setbitmap.selectcannot watch descriptors at or aboveFD_SETSIZE; this is a core reason to preferpoll.
select treats its fd_set arguments as both input and output. On return, a set contains only the fds that are ready — every fd that was not ready has been cleared. So a working select loop keeps a master copy and copies it into a working set before every call:
fd_set master, work;
FD_ZERO(&master);
FD_SET(listen_fd, &master);
for (;;) {
work = master; /* fresh copy every iteration */
select(maxfd + 1, &work, NULL, NULL, NULL);
/* inspect work with FD_ISSET; master is still intact */
}
Knowledge check 2: A student's
selectserver works for the very first event on each socket, then the socket seems to "go silent." What did they most likely forget? Answer: they reused the samefd_setacross iterations instead of restoring it from a master set, so after the first return every not-ready fd was cleared and never watched again.
"Ready to read" only promises that one read will not block — it does not promise how many bytes arrive, and a ready-for-write socket may still accept fewer bytes than you offer. TCP is a byte stream, so you must be prepared for short reads and short writes and loop accordingly. A read returning 0 means the peer performed an orderly close.
Knowledge check 3:
pollsays a client fd hasPOLLINset, but your singleread(fd, buf, 4096)returns only 12 bytes even though the client sent 5000. Is this a bug? Answer: no. Readiness guarantees a non-blocking read, not a complete message. Treat the connection as a stream and keep reading (across futurePOLLINevents) until you have a full application-level message.
select and poll both cost O(n) per call: the kernel scans every descriptor you passed, every time. That is fine for tens or low hundreds of connections. For thousands, use epoll (Linux) or kqueue (BSD/macOS), which register interest once and report only the fds that changed, giving roughly O(ready) behavior.
| Call | Platform | Cost per wait | Good for |
|---|---|---|---|
select |
POSIX (universal) | O(n), FD_SETSIZE cap |
small fd counts, max portability |
poll |
POSIX | O(n), no fixed cap | small–medium fd counts |
epoll |
Linux | ~O(ready) | thousands+ of connections |
kqueue |
BSD, macOS | ~O(ready) | thousands+ of connections |
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds: the highest fd number in any set, plus one — not a count. A frequent off-by-one source.readfds/writefds/exceptfds: sets to watch for readability, writability, and exceptional conditions. Any may be NULL.timeout: NULL waits forever; a zeroed struct timeval polls and returns immediately; otherwise it is the maximum wait.0 on timeout, -1 on error (check errno).FD_ZERO, FD_SET, FD_CLR, FD_ISSET.#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* descriptor to watch; negative to ignore this slot */
short events; /* requested events, e.g. POLLIN | POLLOUT */
short revents; /* filled in by the kernel on return */
};
nfds: the number of entries in the array (a real count, unlike select).timeout: milliseconds; -1 waits forever, 0 returns immediately.revents, 0 on timeout, -1 on error.events): POLLIN (readable), POLLOUT (writable), POLLPRI (urgent).revents even if you did not request them: POLLHUP (peer hung up), POLLERR (error), POLLNVAL (fd not open). Always mask revents against what you handle.A server often needs to watch several sockets at once. You do not know which one will have data first. You also do not want to block on a single socket while others sit idle.
Multiplexing means watching many file descriptors (fds) at the same time and reacting to whichever one becomes ready.
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *to)
select waits until any of several fds becomes ready, then returns.
r, w, e: sets of fds to watch for readability, writability, and errors.to: an optional timeout. Pass NULL to wait forever.poll is the modern, cleaner variant. Instead of fd sets, you pass an array of struct pollfd, one entry per fd you want to watch.
Both select and poll let a single thread serve many connections. You do not need to fork a new process for each client.
For thousands of fds, the next step up is epoll (Linux) or kqueue (BSD and macOS). These scale much better than select or poll when the number of connections is large.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAX_CLIENTS 64
int main(void) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
return 1;
}
int yes = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(listen_fd, 16) < 0) {
perror("listen");
return 1;
}
/* fds[0] is always the listening socket; the rest are clients. */
struct pollfd fds[MAX_CLIENTS + 1];
memset(fds, 0, sizeof(fds));
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
int nfds = 1;
for (;;) {
int ready = poll(fds, nfds, -1); /* -1 = wait forever */
if (ready < 0) {
if (errno == EINTR)
continue;
perror("poll");
break;
}
/* New connection on the listening socket? */
if (fds[0].revents & POLLIN) {
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd >= 0) {
if (nfds <= MAX_CLIENTS) {
fds[nfds].fd = client_fd;
fds[nfds].events = POLLIN;
nfds++;
} else {
close(client_fd); /* table full, refuse */
}
}
}
/* Walk the client entries; index 0 is the listener. */
for (int i = 1; i < nfds; i++) {
if (fds[i].revents & (POLLIN | POLLHUP | POLLERR)) {
char buf[512];
ssize_t n = read(fds[i].fd, buf, sizeof(buf));
if (n <= 0) {
close(fds[i].fd);
fds[i] = fds[nfds - 1]; /* swap-remove */
nfds--;
i--; /* re-check swapped entry */
} else {
write(fds[i].fd, buf, (size_t)n); /* echo back */
}
}
}
}
close(listen_fd);
return 0;
}
A single-threaded poll-based echo server. It listens on TCP port 8080, accepts many clients at once, and echoes back whatever each client sends — all in one thread, no fork, no threads. Compile with cc -std=c11 -o echo echo.c, run it, and connect from another terminal with nc localhost 8080; anything you type is echoed straight back.
socket(AF_INET, SOCK_STREAM, 0) creates the TCP listening socket; a negative return means failure, so we check it.setsockopt(..., SO_REUSEADDR, ...) lets the server rebind to port 8080 immediately after a restart instead of waiting out the kernel's TIME_WAIT period.memset(&addr, 0, ...) then filling sin_family/sin_addr/sin_port builds the bind address. htonl/htons convert host byte order to network byte order. INADDR_ANY binds all interfaces.bind then listen attach the socket to the port and mark it passive with a backlog of 16 pending connections.struct pollfd fds[MAX_CLIENTS + 1] is our watch table. Slot 0 is reserved for the listening socket; slots 1..nfds-1 are connected clients. memset zeroes it so unused slots are inert.fds[0].fd = listen_fd; fds[0].events = POLLIN; registers the listener; readability on a listening socket means a new connection is waiting to be accepted.poll(fds, nfds, -1) is the heart of the loop: it blocks until at least one watched fd is ready, then fills each entry's revents. -1 means wait indefinitely.if (ready < 0) handles errors. EINTR (interrupted by a signal) is not a real failure, so we simply retry; any other error we report and exit.if (fds[0].revents & POLLIN) checks the listener first. We accept the new client, and if the table has room we add it as a fresh POLLIN slot; if it is full we close the new fd rather than leak it.for (int i = 1; i < nfds; i++) loop services clients. We start at 1 because slot 0 is the listener.fds[i].revents & (POLLIN | POLLHUP | POLLERR) catches both data to read and a disconnect/error; in all three cases the right move is to attempt a read.read returning <= 0 means EOF (0, orderly close) or error (-1); we close the fd and remove its slot with a swap-remove (copy the last entry into this slot, shrink nfds), then i-- so the swapped-in entry is examined this pass.read returning > 0 echoes the bytes back with write. (A production server would loop on write to handle short writes; here the payloads are tiny.)close(listen_fd) runs only if the loop breaks on a fatal poll error, tidying up before exit.fd_set on every select iteration. select overwrites the sets in place, leaving only the ready fds. Keep a master set and copy it into a working set before each call, or your sockets go silent after the first event.maxfd + 1 as select's first argument. nfds is the highest descriptor number plus one, not the number of descriptors. Get it wrong and some fds are never watched.FD_SETSIZE. FD_SET on an fd at or above FD_SETSIZE (often 1024) writes out of bounds — undefined behavior. High fd numbers happen sooner than you think once other fds are open.POLLHUP/POLLERR in poll. These appear in revents even if you never asked for them. If you only test POLLIN, a hung-up peer can spin your loop; always handle the error/hangup bits.read, nothing more. Handle short reads (and short writes) by looping.EINTR. A signal can interrupt select/poll with errno == EINTR; treat that as "try again," not as a fatal error.strace -e trace=network,poll,select ./server on Linux (or dtruss/ktrace on macOS) shows exactly which fds you pass in and what comes back — the fastest way to catch a stale set or a wrong nfds.poll's return value and, for each entry, fd and revents in hex. A client that never appears ready points at a registration bug; a client that is always ready usually means an unhandled POLLHUP on a closed peer.ls /proc/<pid>/fd | wc -l (Linux) or lsof -p <pid> reveals a descriptor leak as a number that only ever grows.nc piping a slow stream) to confirm your loop reassembles messages instead of assuming one read per message.errno immediately. select/poll returning -1 is meaningless without errno; distinguish EINTR (retry) from EBADF/EINVAL (a real bug, often a closed fd still left in the set).nc) and confirm the server removes the slot and keeps serving others.pollfd array and check before adding. In the example, nfds <= MAX_CLIENTS gates every insertion into a fixed-size array; without that guard a flood of connections writes past the end. Never index a watch table with an unchecked counter.read(fd, buf, sizeof(buf)) — never a hard-coded length larger than the buffer. A ready socket can deliver up to whatever you ask for.read returns a count; only buf[0 .. n-1] are valid. Echoing sizeof(buf) instead of n would leak uninitialized stack memory back to the client.close(fds[i].fd) and the swap-remove, the old slot no longer refers to that fd, so it cannot be closed again — double-close can hit an unrelated fd reused by the kernel.select's FD_SETSIZE limit is a memory-safety issue, not just a scaling one. FD_SET beyond the bitmap size corrupts adjacent memory; prefer poll when fd values may be large.read's return. It is a signed ssize_t; check <= 0 before casting to an unsigned size for write, or a -1 error turns into a huge length.epoll/kqueue, the same idea at scale).epoll/kqueue; select/poll are the portable fallbacks.asyncio selector loop all wrap these calls so higher-level code can await sockets without threads.poll a listening socket, a Unix control socket, a signalfd/self-pipe, and a timer fd in one loop, reacting to whichever fires.select/poll to avoid blocking on one while the other is active.select/poll take a timeout, they double as a portable way to bound how long you wait for I/O — the basis for connection idle timeouts.accept and each disconnect it prints the current number of connected clients (nfds - 1). Verify the count rises and falls as you open and close nc sessions.poll call from an infinite wait (-1) to a 5000 ms timeout, and print "idle" whenever poll returns 0. Confirm the message appears only when no client sends anything for five seconds.select. Rewrite the same echo server using select instead of poll: maintain a master fd_set, copy it into a working set each iteration, track maxfd, and use FD_ISSET to find ready sockets. Note where the select version is more error-prone.write(fds[i].fd, buf, n) with a helper that loops until all n bytes are sent, retrying on partial writes and on EINTR. Describe why a single write is not guaranteed to send everything.POLLOUT only when that buffer is non-empty. When a client is a slow reader, queue bytes instead of blocking, and drain the queue when the socket reports writable. Explain how this prevents one slow client from stalling the whole single-threaded server. (Do not block anywhere in the loop.)select uses three fd_set bitmaps, overwrites them in place (rebuild from a master every call), takes maxfd + 1 as its first arg, and is capped by FD_SETSIZE.poll uses an array of struct pollfd, leaves your events intact, reports per-fd revents, and has no fixed descriptor cap — generally the better default.POLLHUP/POLLERR, and EINTR.epoll (Linux) or kqueue (BSD/macOS).