select and poll
/ select -> see-LEKT; poll -> pohl /
Suppose you are watching ten kettles and want to act the moment any one starts to boil, without staring at each one in turn. You need a single command that means 'watch all of these and wake me when at least one is ready.' For file descriptors, the two oldest such commands are select() and poll(). They let one thread wait on many descriptors at once and return as soon as any becomes readable, writable, or errored - the original answer to serving many connections without one thread each.
How select() works: you fill three bitmaps (sets of descriptor numbers) - one for read-readiness, one for write, one for errors - call select(), and it blocks until at least one descriptor is ready, then rewrites your bitmaps to mark exactly which ones. You then loop over your descriptors and handle the ready ones. poll() does the same job with a cleaner interface: you pass an array of struct pollfd entries, each naming a descriptor and the events you care about, and poll() fills in a revents field on each saying what actually happened. Both are non-destructive only if you re-prepare the input each call.
Why they fade at scale: both are O(n) in the number of watched descriptors. Every single call you must hand the kernel the WHOLE list, the kernel must scan the WHOLE list, and on return you must scan it again to find the ready ones. With ten connections that is nothing; with a hundred thousand, you copy and walk a hundred-thousand-entry list on every wakeup even if just one socket got a byte. select() has an extra historic wart: it is typically capped at FD_SETSIZE (often 1024) descriptors. This O(n)-per-call cost is precisely the pain that epoll and kqueue were invented to remove.
struct pollfd fds[2] = { { sock1, POLLIN, 0 }, { sock2, POLLIN, 0 } }; int r = poll(fds, 2, 1000 /* ms timeout */); if (fds[0].revents & POLLIN) { /* sock1 is readable */ }
poll() waits on two sockets at once and reports, in revents, which one became readable.
A common bug: select() rewrites its bitmaps in place, so you must rebuild the fd_set before every call - reusing the modified set silently watches the wrong descriptors. poll() avoids this since it reads fd and events but writes only revents.