Networking

Sockets: The Kernel Endpoint Behind Every Network Connection

Open a browser tab and your machine may hold 6 to 300+ sockets at once — one per TCP connection to a CDN, an ad server, a WebSocket, a DNS resolver. Each is a small kernel object, identified in the connected case by a 5-tuple (protocol, local IP, local port, remote IP, remote port), and each hangs two byte queues — a send buffer and a receive buffer — off a file descriptor your process can read() and write() like a file. A single Linux box routinely juggles a million of them.

The socket API, born in 1983 BSD Unix (Bill Joy, Sam Leffler, et al.), is the reason "the network is a file" is more than a slogan. Understanding it means understanding the exact state machine a connection walks through, why accept() returns a new descriptor, and how one thread can watch 100,000 connections with epoll in O(ready) time instead of O(n).

  • Invented1983, BSD 4.2 Unix
  • Identity5-tuple (proto, srcIP:port, dstIP:port)
  • Buffers2 per socket: send + recv queue
  • epoll waitO(ready), not O(n)
  • select() capFD_SETSIZE = 1024
  • Ports16-bit ⇒ ≤ 65535 per (IP, proto)

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The core idea: a socket is a named endpoint plus two queues

A socket is a kernel data structure representing one end of a communication channel. You create it with socket(domain, type, protocol) — e.g. socket(AF_INET, SOCK_STREAM, 0) for IPv4 TCP — and the kernel hands back a small non-negative integer, the file descriptor, an index into your process's file table. From that moment the socket looks like a file: you read(), write(), and close() it.

The load-bearing invariant is the connection identity. A connected TCP socket is uniquely keyed by the 5-tuple (protocol, local IP, local port, remote IP, remote port). Two different browser tabs to the same server share your IP and the server's IP:port but get different local ephemeral ports, so their tuples differ and the kernel demultiplexes arriving packets to the correct socket. This is why a server on port 443 can hold thousands of simultaneous connections on one port — the tuples are all distinct.

  • Send buffer (write queue): bytes you've written but the peer hasn't yet acknowledged; drained by the TCP stack as ACKs arrive.
  • Receive buffer (read queue): in-order bytes the stack has accepted but your app hasn't yet read(); its free space is advertised as the TCP receive window, giving flow control for free.

TCP is a byte stream, not a message stream: one write() of 4 KB may arrive as three read()s, and vice versa. Framing (length prefixes, delimiters) is your job. UDP sockets (SOCK_DGRAM) instead preserve datagram boundaries but drop the connection abstraction — no buffers-as-window, no retransmission.

How a connection is built, step by step

The server and client walk different sequences of syscalls. On the server:

  • bind(fd, addr) — pin the socket to a local IP:port (e.g. 0.0.0.0:8080).
  • listen(fd, backlog) — mark it passive and allocate two kernel queues: the SYN queue (half-open handshakes) and the accept queue (completed handshakes waiting to be handed to the app). backlog bounds the accept queue.
  • accept(fd) — pop one completed connection off the accept queue and return a brand-new descriptor for it. The listening socket keeps listening; the new socket carries the connected 5-tuple. This split is the whole trick behind concurrent servers.

On the client: connect(fd, serverAddr) triggers the TCP three-way handshake (SYN → SYN-ACK → ACK). For a blocking socket connect() returns when the handshake completes; for a non-blocking one it returns EINPROGRESS and you wait for writability. Minimal server skeleton:

fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(fd, SO_REUSEADDR, 1);
bind(fd, &addr);
listen(fd, 512);
while (1) {
  conn = accept(fd, &peer);   // new fd per client
  handle(conn);               // read()/write() the 5-tuple
}

Under the hood each connected socket rides the TCP state machine: CLOSED → LISTEN, then per connection SYN_RCVD → ESTABLISHED, and on teardown FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT (the active closer lingers ~2·MSL, typically 60 s, so late duplicate segments can't corrupt a fresh connection reusing the tuple). TIME_WAIT accumulation is a classic ops headache on busy clients.

Complexity: why the readiness model, not the byte count, dominates

The interesting cost is not moving bytes — a read()/write() is O(k) in the bytes copied — it is discovering which of n sockets are ready so one thread can serve many. Three regimes:

  • Thread-per-connection: conceptually trivial, but each thread costs a kernel stack (default ~1–8 MB of address space) and a scheduler slot. At n = 100,000 that's tens of GB of stacks and heavy context-switch churn. Time to serve is fine; space is O(n) with a huge constant, and this is exactly the wall the C10K problem named.
  • select()/poll(): one thread, but every call passes all n descriptors into the kernel and the kernel scans all n. Cost is Θ(n) per wait, so serving n connections that each do a little work is Θ(n²) overall. select() also caps at FD_SETSIZE = 1024.
  • epoll/kqueue: you register interest once — epoll_ctl(ADD) is O(1) (a red-black tree insert, O(log n), but effectively constant) — and epoll_wait() returns only the k ready descriptors in O(k), independent of total n. The kernel maintains a ready-list; readiness is delivered, not polled.

So epoll turns the per-tick cost from Θ(n) to Θ(k), where k = ready sockets. When only a handful of a million idle connections have data — the common case for chat servers, MQTT brokers, push gateways — that's the difference between a spinning CPU and a bored one. The memory footprint drops to O(n) small kernel structs plus one thread, not O(n) thread stacks.

Blocking, non-blocking, and the event loop

By default a socket is blocking: read() sleeps the thread until bytes arrive, write() sleeps until the send buffer has room. Simple, but one slow client stalls the thread. Set O_NONBLOCK (via fcntl) and those calls instead return immediately with EAGAIN/EWOULDBLOCK when they'd block. Non-blocking sockets are the substrate of every high-performance server.

The canonical pattern is the event loop: a single thread parks in epoll_wait(), wakes with a batch of ready fds, and services each without ever blocking. This is Nginx, Redis, Node.js (libuv), HAProxy, Envoy. Two epoll modes matter:

  • Level-triggered (default): epoll_wait reports a socket as long as data remains. Forgiving — you can read a little and come back.
  • Edge-triggered (EPOLLET): reported only on a transition (empty → data). You must drain the socket in a loop until EAGAIN, or you'll miss data. Fewer wakeups, higher throughput, less forgiving — a top interview gotcha.
ep = epoll_create1(0);
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, {EPOLLIN});
while (1) {
  n = epoll_wait(ep, evs, MAX, -1);   // O(ready)
  for (i=0; i<n; i++)
    if (evs[i].fd == listen_fd) accept_all();
    else drain_until_EAGAIN(evs[i].fd);
}

The modern alternative is io_uring (Linux 5.1+): instead of asking "which fds are ready" you submit operations ("read 4 KB into this buffer") to a shared ring and reap completions, amortizing away the syscall per I/O entirely. It's a completion model, not a readiness model — closer to Windows IOCP.

Real systems and the tricks they use

Every networked program you touch is sockets underneath: web servers, databases, SSH, DNS, game netcode, gRPC. The design differences are almost entirely about how they multiplex:

  • Nginx / Envoy / HAProxy: a small pool of worker processes, each an epoll (or kqueue) event loop, typically one per CPU core. No thread-per-connection.
  • Redis: famously (near-)single-threaded event loop — its speed comes from never context-switching for network work.
  • Node.js: libuv wraps epoll/kqueue/IOCP into one cross-platform loop; your JS callbacks run when a socket is ready.
  • Go: hides all of this — a goroutine per connection does a blocking read(), but the runtime's netpoller parks it on epoll and multiplexes millions of goroutines onto a few OS threads. Best of both models.

Two crucial scaling options: SO_REUSEPORT lets multiple processes each bind() the same port; the kernel load-balances incoming connections across them by hashing the 4-tuple, eliminating the accept-lock contention (the "thundering herd") that plagued shared-listener designs. SO_REUSEADDR lets a restarting server rebind a port stuck in TIME_WAIT. And Nagle's algorithm (coalescing small writes) is why interactive protocols set TCP_NODELAY — the classic Nagle-vs-delayed-ACK 40 ms latency stall.

Pitfalls, edge cases, and the failure modes that bite

Sockets are simple until they aren't. The recurring bugs:

  • Short reads/writes: write(fd, buf, 8000) may return 3000. You must loop on the returned count. Assuming a single write() ships everything is the #1 beginner bug, and it hides until buffers fill under load.
  • Partial framing: because TCP is a stream, you need length-prefix or delimiter framing; a recv() can straddle two logical messages or split one.
  • SIGPIPE: writing to a socket the peer already closed raises SIGPIPE, killing your process by default. Ignore it or use MSG_NOSIGNAL.
  • Half-open connections: if a peer's machine yanks its power cord, TCP has no keepalive by default — your socket sits in ESTABLISHED forever. Enable SO_KEEPALIVE or app-level heartbeats.
  • Ephemeral port / TIME_WAIT exhaustion: a client opening many short-lived connections can burn through the ~28,000 ephemeral ports, all stuck in TIME_WAIT. Reuse connections (pools, HTTP keep-alive) or tune the range.
  • fd leaks: forgetting close() exhausts the per-process RLIMIT_NOFILE; accept() then fails with EMFILE and the server silently stops accepting.
  • Edge-triggered under-drain: with EPOLLET, not looping to EAGAIN leaves bytes unread with no future wakeup — a hang that only shows under specific timing.

Edge case worth naming: accept() can itself return errors like ECONNABORTED (client hung up mid-handshake) — a robust loop treats it as "skip and continue," never fatal.

I/O readiness models: how one thread learns which of n sockets are ready
MechanismPer-call costFD limitTrigger / Notes
select()O(n) scan of bitmaskFD_SETSIZE = 1024Level; rebuilds fd_set every call
poll()O(n) scan of arrayNone (array grows)Level; still passes all fds each call
epoll (Linux)O(ready) return, O(1) add/mod~open-files limitLevel or edge (EPOLLET); kernel keeps the set
kqueue (BSD)O(ready) return, O(1) register~open-files limitEdge/level; filters beyond fds too
io_uringO(batch), amortized syscall-free~open-files limitCompletion (async op), not readiness

Frequently asked questions

Why does accept() return a new file descriptor instead of reusing the listening one?

The listening socket's job is to keep receiving new connections; it stays in the LISTEN state bound to the port. Each completed handshake is a distinct connection with its own 5-tuple, send buffer, and receive buffer, so it needs its own descriptor. That separation is exactly what lets one listener spawn thousands of concurrent connections on a single port.

What's the actual complexity difference between select() and epoll?

select()/poll() are Θ(n) per wait: you pass all n descriptors in and the kernel scans all of them, making n connections doing small work Θ(n²) overall. epoll registers interest once (O(1) per add, O(log n) tree insert) and epoll_wait() returns only the k ready descriptors in O(k), independent of n. For a million mostly-idle connections, that's the whole ballgame.

Why do I need message framing if I'm already using TCP?

TCP guarantees an ordered, reliable byte stream — not message boundaries. One write() of a 4 KB message may arrive as several read()s, and two small writes may coalesce into one read(). You must add your own framing: a length prefix (e.g. 4-byte big-endian size) or a delimiter, then buffer partial reads until a full frame is available.

What is edge-triggered vs level-triggered epoll, and why does it matter?

Level-triggered (the default) keeps reporting a socket while data remains, so reading a little and returning is safe. Edge-triggered (EPOLLET) reports only on an empty-to-ready transition, so you must drain the socket in a loop until it returns EAGAIN — otherwise the leftover bytes never trigger another wakeup and the connection hangs. Edge mode reduces wakeups and boosts throughput but is far less forgiving.

How does a single Linux box handle a million connections (the C10K/C10M problem)?

Not with a thread per connection — that's O(n) large thread stacks and crushing context-switch overhead. You use non-blocking sockets on an epoll (or kqueue) event loop, often one loop per CPU core with SO_REUSEPORT to spread accepts, so per-tick cost scales with ready sockets rather than total sockets. Redis, Nginx, and Envoy are built exactly this way; Go's netpoller does it transparently behind blocking-looking goroutines.

Why do restarted servers fail to bind with 'Address already in use', and what fixes it?

The socket the old process closed can linger in TIME_WAIT (~2·MSL, roughly 60 s) so stray late packets can't corrupt a new connection on the same tuple. During that window a fresh bind() to the port fails with EADDRINUSE. Setting SO_REUSEADDR before bind() lets the new process reclaim the port immediately; SO_REUSEPORT additionally lets several processes share it for load balancing.