How does one thread watch thousands of sockets? The OS tells it which are ready via select, poll, epoll, kqueue, or io_uring. Learn I/O multiplexing and the reactor pattern behind every async server.
The mechanism that makes event loops work is I/O multiplexing: you hand the OS a set of sockets and ask "tell me which are ready to read or write," and it blocks until at least one is, then returns that subset. Your single thread then services exactly those sockets and asks again. Instead of one thread per connection, you have one thread reacting to readiness across all of them.
I/O MULTIPLEXING: ask the OS "of these N sockets, which are READY?"
loop:
ready = wait_for_readiness(all_sockets) # OS blocks until >=1 is ready
for sock in ready: # service ONLY the ready ones
data = sock.recv(...) # guaranteed not to block
handle(sock, data)
-> one thread services thousands of connections, only touching the ones with
activity. Idle connections cost (almost) nothing.
The "wait_for_readiness" call is the key. Its implementation (select/poll/epoll/
kqueue/io_uring) determines how well it scales (next).The readiness call has evolved for scale. select and poll are portable but rescan every socket each call — O(n), slow with many connections. epoll (Linux) and kqueue (BSD/macOS) are O(1)-ish: you register sockets once and the kernel returns only the ready ones, scaling to hundreds of thousands. io_uring is the modern Linux interface that batches operations to cut system-call overhead further. You rarely call these directly, but knowing the ladder explains why your framework is fast.
The readiness-notification ladder (older -> newer, worse -> better at scale):
select() portable, but SCANS all fds each call + a hard fd limit (~1024). O(n).
poll() like select, no fd limit, still O(n) per call.
epoll() Linux: register fds ONCE, kernel returns only the READY ones.
O(ready), scales to 100k+ connections. (nginx/Node use it.)
kqueue() BSD/macOS equivalent of epoll.
io_uring modern Linux: submit/complete rings — batches I/O ops, fewer
syscalls, even lower overhead. The current frontier.
You rarely call these directly — your language/runtime picks the best available.
But this is WHY an event-loop server scales where thread-per-connection can't.The design pattern wrapping all this is the reactor: an event loop waits for readiness (via epoll/kqueue), then dispatches each ready event to a registered handler (callback). Your code registers "when this socket is readable, run this function." The loop demultiplexes events and calls the right handlers. This reactor is the architecture of nginx, Redis, Node.js, and most game servers — a single loop reacting to I/O events.
import selectors, socket
sel = selectors.DefaultSelector() # uses epoll/kqueue under the hood
def accept(server):
conn, addr = server.accept()
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, read) # handler for this socket
def read(conn):
data = conn.recv(1024)
if data: conn.sendall(data) # echo
else: sel.unregister(conn); conn.close()
server = socket.socket(); server.bind(("0.0.0.0", 7777)); server.listen()
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, accept)
while True: # THE REACTOR / EVENT LOOP
for key, _ in sel.select(): # wait for ready sockets
key.data(key.fileobj) # dispatch to the registered handler
# one thread, one loop, thousands of connections. The pattern behind nginx,
# Redis, Node.js, and most game servers.Two related patterns differ in what the OS reports. In the reactor (readiness-based, epoll/kqueue) the OS says "this socket is ready" and you then perform the read. In the proactor (completion-based, io_uring, Windows IOCP) you submit a read and the OS notifies you when it has completed, handing you the data. Completion-based models can be more efficient and are what io_uring and Windows servers use. Both give you scalable async I/O; the difference is who does the read.
Two async I/O patterns — who performs the actual read?
REACTOR (readiness-based) epoll, kqueue, select
OS: "socket is READY to read" -> YOU then call recv()
PROACTOR (completion-based) io_uring (Linux), IOCP (Windows)
YOU: "please read into this buffer" -> OS does it -> OS: "DONE, here's
the data"
proactor removes the extra readiness step and can batch/overlap operations
-> often more efficient at very high throughput.
Windows game servers use IOCP; modern Linux uses io_uring. Your async runtime
(asyncio, Tokio, libuv, Go's netpoller) abstracts whichever the platform offers.