The Socket: A Descriptor With Two Kernel Buffers Behind It
When you call socket() the kernel hands you a descriptor and allocates a send buffer and a receive buffer behind it; bind/listen/accept and connect attach that descriptor to the network stack, and from then on the application only ever copies bytes into and out of those buffers.
The problem
What `socket()` returns
int fd = socket(AF_INET, SOCK_STREAM, 0) returns an integer — an index into the process’s File Descriptors table — pointing at a kernel object that is not yet connected to anything. That object owns two byte buffers: a send buffer that write()/send() copies into, and a receive buffer that the network stack fills and read()/recv() copies out of. On Linux each starts around 208 kB (net.core.rmem_default/wmem_default) and TCP autotunes them up to net.ipv4.tcp_rmem’s maximum, 6 MB by default. Everything the application does to a socket is a copy into or out of these buffers; the kernel does the rest asynchronously (Follow send() Through the OS to recv()).
Because it is a descriptor, a socket is subject to everything descriptors are subject to: it is inherited across fork(), closed by close(), counted against ulimit -n, and waited on by select/poll/epoll (I/O Multiplexing: select, poll, epoll, kqueue, IOCP). read() and write() work on it unchanged — Everything Is I/O in its purest form. send()/recv() add flags; sendmsg()/recvmsg() add scatter-gather and ancillary data (this is how a Unix socket passes descriptors).
The socket is the OS’s edge of the The Layer Model: TCP/IP First, OSI as a Map: above it, an application with a byte stream or datagrams; below it, the transport, IP, link and NIC. The rest of the Networking domain lives beneath this one call.
- Application: send(fd, buf, n)a copy into the socket’s send buffer; returns as soon as it fits↓
- Socket object: send buffer / receive bufferkernel memory; sizes autotuned↓
- Transport (TCP / UDP)segments, sequence numbers, retransmission, windows↓
- IP + routingaddress the packet, pick the interface↓
- Link + NIC driverframe, DMA, interrupt↓
- Networkthe peer’s stack fills its receive buffer; recv() copies out
Server: socket → bind → listen → accept
A server names its socket with bind(fd, {addr, port}) — “this socket answers at 0.0.0.0:8080” — and listen(fd, backlog) turns it into a listening socket: a socket that never carries data, only completed connections. From now on the kernel performs the The Three-Way Handshake on the server’s behalf: a SYN arrives, the kernel replies SYN-ACK and parks the half-open connection in the SYN queue; the final ACK arrives and the kernel moves it to the accept queue. The application has not run a single instruction.
accept(fd) takes the oldest completed connection from the accept queue and returns a new descriptor for it. The listening socket stays where it is; the new socket is the one you read and write. A busy server thus has one listening descriptor and thousands of connected ones, which is why C10K: Ten Thousand Connections, Then a Million is about descriptors and buffers, not about listening. The backlog argument caps the accept queue (Linux clamps it to net.core.somaxconn, 4096 since 5.4, 128 before). When the queue is full, Linux by default silently drops the final ACK and the client sees a connection that completed its handshake and then hangs, or retries — Why Can’t I Connect? covers what that looks like from outside.
Restarting a server immediately after stopping it fails with EADDRINUSE because the old connections are in TIME_WAIT (The Connection Lifecycle: Close, Reset, TIME_WAIT, CLOSE_WAIT) and still own the port. setsockopt(SO_REUSEADDR) before bind tells the kernel that binding over TIME_WAIT entries is fine; every production server sets it. SO_REUSEPORT (Linux 3.9+) goes further and lets several processes bind and listen on the same port, with the kernel load-balancing new connections across them — the trick behind multi-process Node cluster alternatives and nginx reuseport.
1int lfd = socket(AF_INET6, SOCK_STREAM, 0);2int one = 1;3setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); // bind over TIME_WAIT4sockaddr_in6 addr{}; addr.sin6_family = AF_INET6; addr.sin6_port = htons(8080);5addr.sin6_addr = in6addr_any; // :: — also accepts IPv4-mapped6bind(lfd, (sockaddr*)&addr, sizeof addr);7listen(lfd, 4096); // accept-queue depth (clamped to somaxconn)8for (;;) {9 int cfd = accept(lfd, nullptr, nullptr); // NEW descriptor per connection10 handle(cfd); // read()/write() on cfd, then close(cfd)11}Client: socket → connect, and the four-tuple
A client calls socket() and then connect(fd, {server addr, port}). It usually skips bind: the kernel picks an ephemeral port from net.ipv4.ip_local_port_range (32768–60999 on Linux, so about 28,000 per source address) and assigns it. connect blocks until the handshake completes — one round-trip, ~1 ms on a LAN, ~150 ms across an ocean — or, on a non-blocking socket, returns EINPROGRESS and the loop waits for writability.
Once connected, the kernel identifies the connection by its four-tuple (source IP, source port, destination IP, destination port) — plus the protocol, strictly. Every incoming segment is matched against that tuple to find the right socket and the right receive buffer. This is why a single server port can serve millions of connections: 10.0.0.5:8080 is one endpoint, but (client A:41000 → 10.0.0.5:8080) and (client A:41001 → 10.0.0.5:8080) are different tuples and different sockets. It is also why a *client* that opens many connections to one server runs out of ephemeral ports at ~28k, and a NAT (NAT: Many Private Hosts Behind One Public Address) has the same ceiling per public address (the ephemeral-port-exhaustion challenge).
Ports: Addressing a Process, Not a Machine are therefore not “channels” on the machine; they are one field of the key the kernel uses to demultiplex segments to sockets. The listening socket matches on (*, *, local IP, local port); connected sockets match on the full tuple, and the full-tuple match wins.
- The four-tuple is the key of a hash table in the kernel (Hash Table): segment in → hash the tuple → socket → receive buffer.
connectto a port nobody listens on gets aRSTandECONNREFUSEDimmediately;connectto a firewalled port gets nothing and times out after ~2 minutes — the two challengesconnection-refused-nothing-listeningandconnection-timeout-firewallrehearse both.- A closed peer shows up as
read()returning 0 (EOF) — the same convention as a pipe — orECONNRESETif the peer aborted.
Stream vs datagram, and Unix domain sockets
SOCK_STREAM (TCP) is a connected, ordered, reliable byte stream: send()s and recv()s do not correspond one-to-one, and framing is the application’s job — exactly like a Pipes: A Kernel Buffer Between Two Processes. SOCK_DGRAM (UDP, UDP: Datagrams and the Contract You Choose) is connectionless and message-oriented: each sendto() is one datagram that arrives whole or not at all, possibly reordered, with no listen/accept and no handshake. The receive buffer semantics differ accordingly: a full TCP receive buffer makes the peer stop sending (Flow Control: The Receive Window); a full UDP receive buffer makes the kernel *drop* the next datagram and increment a counter you will never look at (netstat -su, RcvbufErrors).
AF_UNIX sockets keep the whole API — bind to a *path* instead of an address, listen, accept, connect, stream or datagram — but never touch the network stack: the kernel copies directly between the two sockets’ buffers. They are as fast as a pipe, bidirectional, connectable by unrelated processes, and can carry descriptors and the peer’s credentials (SO_PEERCRED). That is why PostgreSQL, MySQL, Docker, systemd, D-Bus and every X11/Wayland display use them for local clients, and why a database connection string with host=/var/run/postgresql is faster than host=127.0.0.1.
| TCP stream | UDP datagram | Unix domain (stream) | |
|---|---|---|---|
| Addressing | IP + port | IP + port | file-system path |
| Connection setup | 3-way handshake | none | connect(), no network |
| Boundaries | byte stream | preserved per datagram | byte stream (SOCK_SEQPACKET preserves) |
| Reliability / order | guaranteed | none | guaranteed |
| When receive buffer is full | peer is told to stop | datagrams dropped | sender blocks |
| Remote peer | yes | yes | no — same kernel only |
| Extras | keep-alive, Nagle, windows | multicast, broadcast | passes fds and credentials |
Seeing sockets on a Linux host
ss (the modern netstat) lists sockets straight from the kernel. ss -tlnp shows TCP listeners with their process; ss -tan shows every TCP socket with its state and how many bytes are waiting in the receive (Recv-Q) and send (Send-Q) buffers. Those two columns are the most useful numbers in TCP Debugging: Reading the Handshake on the Wire: a growing Recv-Q means the *application* is not reading; a growing Send-Q means the *peer* is not acknowledging. On a listening socket the columns mean something else — Recv-Q is the current accept-queue length and Send-Q its capacity — so Recv-Q at 4096/4096 on the listener means connections are being dropped before accept().
The other view is from the descriptor side. ls -l /proc/<pid>/fd shows each descriptor as socket:[inode], and that inode is the join key into ss -e or /proc/net/tcp, so you can go from “fd 19 in process 4121” to “the connection to 203.0.113.9:51844 with 1.8 MB unread” and back. That round trip — descriptor → socket → peer → buffer occupancy — is the whole of socket debugging, and it is why this lesson sits between File Descriptors and the Networking domain.
ss -sgives totals per state; thousands inCLOSE-WAITmeans your code never calledclose()after the peer hung up (fd-leak-under-load).lsof -i :8080and/proc/<pid>/fdshow the same sockets from the descriptor side.- On Windows the equivalent is
netstat -anoandGet-NetTCPConnection; Winsock is a separate API with the same four-tuple model.
$ ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("node",pid=4121,fd=19))
LISTEN 0 244 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=911,fd=6))
LISTEN 0 511 *:443 *:* users:(("nginx",pid=1330,fd=8))
$ ss -tan state established '( sport = :8080 )' | head -3
Recv-Q Send-Q Local Address:Port Peer Address:Port
1843200 0 10.0.0.5:8080 203.0.113.9:51844 # 1.8 MB unread: the app is not calling recv()
0 0 10.0.0.5:8080 203.0.113.9:51850Key points
- A socket is a descriptor pointing at a kernel object with a send buffer and a receive buffer;
send()/recv()are copies into and out of them. - Server:
socket → bind → listen → accept; the kernel completes handshakes into the accept queue andaccept()returns a new descriptor per connection. - Client:
socket → connect; the kernel picks an ephemeral port (32768–60999 on Linux). - A TCP connection is identified by the four-tuple (src IP, src port, dst IP, dst port); one server port serves millions of tuples.
SO_REUSEADDRbinds overTIME_WAIT;backlogcaps the accept queue; a full accept queue silently drops connections.- Streams need framing; datagrams preserve boundaries but drop when the buffer is full; Unix domain sockets skip the network stack and can pass descriptors.
ss -tlnp/ss -tan:Recv-Qgrowing = app not reading;Send-Qgrowing = peer not acking.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does the kernel keep buffers instead of handing packets straight to the application?
The network delivers bytes when it wants to, not when the application calls recv(). Buffers decouple the two, let send() return before the bytes reach the wire, and give TCP something to retransmit from.
▸Why does `accept()` return a new descriptor?
The listening socket represents “willing to accept at this address”; each connection has its own four-tuple, its own buffers and its own state machine. One object cannot be both.
▸Why is a socket a file descriptor at all?
So the same read/write/close/epoll machinery works on files, pipes, terminals and connections, and so one event loop can wait on all of them at once.
▸Why do Unix domain sockets exist when TCP on loopback works?
Loopback still runs the whole transport stack — checksums, segmentation, congestion control — for a peer that is in the same kernel. A Unix socket copies buffer to buffer, and it can pass descriptors and credentials, which TCP cannot.
Socket lifecycle
$ ss -tlnp (nothing listening) $ ss -tn (no connections)
How it fails
What the failure looks like from inside real software.
EADDRINUSEon restart because the old connections are inTIME_WAITandSO_REUSEADDRwas not set.- Accept queue overflow under a traffic spike: clients complete the handshake, then hang;
ss -tlnshowsRecv-Qequal toSend-Qon the listener andnstatshowsTcpExtListenOverflowsclimbing. - A client-side connection pool or a load-test client exhausts ephemeral ports at ~28k connections per source address:
connect()fails withEADDRNOTAVAIL. - Thousands of sockets in
CLOSE_WAIT: the peer closed, the application never did, and descriptors leak untilaccept()fails withEMFILE. - Treating a TCP stream as messages: two JSON documents arrive in one
recv()and the parser sees trailing garbage. - A UDP service under load silently drops datagrams because the receive buffer is 208 kB and the reader is slow; nothing errors, requests just vanish.
Follow it through every layer
This lesson is one node of a longer journey. Zoom out, then zoom back in.