Guide

When your program calls recv on a socket with no data yet, the thread blocks. The kernel parks it and wakes it when data arrives. That is simple, and it's fine for a handful of connections. It doesn't scale: 10,000 connections would need 10,000 parked threads, each with its own stack.

Non-blocking I/O fixes the thread count. The socket returns immediately, with an error saying "no data yet". The program then needs a way to learn which sockets are ready, without checking each one in a loop. The kernel provides that through readiness notification.

Walking through it: readiness

The kernel keeps a list of file descriptors your process cares about. You ask it: "which of these are readable or writable now?" It sleeps your thread until at least one is ready, then returns only those.

Python
import selectors, socket

a, b = socket.socketpair()
sel = selectors.DefaultSelector()          # epoll on Linux, kqueue on macOS
sel.register(b, selectors.EVENT_READ, data="conn-b")

print("ready before write:", sel.select(timeout=0))
a.send(b"ping")
for key, _ in sel.select(timeout=1):
    print("ready:", key.data, key.fileobj.recv(4))

Output:

ready before write: []
ready: conn-b b'ping'

Nothing is ready at first, so the call returns an empty list at once. After the write, the kernel reports conn-b as readable, and only that socket appears in the result. The program never touches sockets that have nothing to say.

select, poll and epoll

  • select takes a bitmask of file descriptors. It's limited to 1024 descriptors on many systems, and the kernel scans the whole set on every call.
  • poll removes the fixed limit, but it still scans every descriptor on each call.
  • epoll (Linux) and kqueue (BSD, macOS) keep the interest list inside the kernel. You register a socket once. Each wait returns only the ready ones, so the cost tracks the number of active connections, not the total.

That difference is why select and poll fall over at tens of thousands of connections, while epoll handles far more.

Two trigger modes matter. Level-triggered reports a socket as ready as long as data remains, so you can read part of it and come back later. Edge-triggered reports it once per change, so you must drain the socket until it returns EAGAIN, or you miss data.

Warning

With edge-triggered epoll, a partial read that stops early can leave data unread forever, since no new event arrives. Read until the call would block.

Where this sits

Event loops such as libuv, Node.js and Python's asyncio are built on these calls. You rarely call epoll directly, but knowing it explains the behavior you see. A server that handles many idle connections is cheap, while a server that does heavy work per request is limited by CPU and threads, not by the number of sockets.

Common mistakes

  • Using select for a server that needs thousands of connections.
  • Reading one chunk from an edge-triggered socket and waiting for more events.
  • Keeping blocking reads on a socket that is supposed to be non-blocking.

Check yourself

  • Why does epoll scale better than select for many idle connections? Answer: epoll keeps the interest list in the kernel and returns only the ready descriptors, so the cost tracks active connections.
  • Why must an edge-triggered reader drain the socket? Answer: the kernel signals once per change, so unread data won't trigger another event.

Key takeaways

  • A blocking read parks a thread. Non-blocking I/O plus readiness notification lets one thread serve many sockets.
  • select and poll scan every descriptor on each call. epoll and kqueue return only the ready ones.
  • Edge-triggered mode requires draining each socket until it would block.

Further learning

Next, see event loops for how the readiness events turn into work the program runs.