Sockets from Scratch

Every network program in C, from curl to nginx to the gRPC stub inside Triton Inference Server, ultimately calls the same handful of BSD socket functions: socket, bind, listen, accept, connect, send, recv, close. The API dates to 4.2BSD (1983) and it has not meaningfully changed. What has changed is the right way to use it — the idioms that are IPv6-safe, protocol-independent, and won’t embarrass you in code review. That is what this file drills.

The end state of this file is that you can, from a blank editor, type out a correct TCP echo server that binds to both IPv4 and IPv6, uses getaddrinfo (not inet_addr), handles partial reads and writes correctly, and shuts down cleanly on SIGTERM. Roughly 80 lines. If you can do that from memory by the end of M9, the socket layer will never be the bottleneck of anything you build.

The seven system calls that matter

TCP server-side flow:

socket()  →  bind()  →  listen()  →  accept()  →  recv()/send()  →  close()

TCP client-side flow:

socket()  →  connect()  →  send()/recv()  →  close()

UDP is simpler: socket() bind() on the server, then recvfrom() / sendto() — no connection state, no accept, no listen.

getaddrinfo — the ONE correct way in 2026

Legacy code you’ll find on the web:

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
inet_pton(AF_INET, "0.0.0.0", &addr.sin_addr);

Don’t write this. It is IPv4-only, it bakes in the address family, and it silently breaks the day your service needs to bind an IPv6 socket. The correct 2026 pattern is getaddrinfo, which returns a linked list of struct addrinfo describing every address family/socket-type/protocol combination that matches your request. You iterate, socket() + bind() (or connect()) each until one succeeds, and let the kernel do the family-agnostic bookkeeping.

struct addrinfo hints = {0}, *res, *rp;
hints.ai_family   = AF_UNSPEC;      // IPv4 or IPv6, whichever
hints.ai_socktype = SOCK_STREAM;    // TCP
hints.ai_flags    = AI_PASSIVE;     // for bind(); wildcard address
int rc = getaddrinfo(NULL, "8080", &hints, &res);
if (rc != 0) errx(1, "getaddrinfo: %s", gai_strerror(rc));

int sfd = -1;
for (rp = res; rp; rp = rp->ai_next) {
    sfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
    if (sfd < 0) continue;
    int yes = 1;
    setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
    if (bind(sfd, rp->ai_addr, rp->ai_addrlen) == 0) break;
    close(sfd); sfd = -1;
}
freeaddrinfo(res);
if (sfd < 0) err(1, "bind");

That block is the same whether you’re binding IPv4, IPv6, a UNIX socket (with AF_UNIX hints), or an IPv6 socket that dual-stack-accepts IPv4 connections. It survives the address family changing. Memorize it.

For clients, drop AI_PASSIVE, pass the hostname as the first arg, and connect() instead of bind(). Same skeleton.

Socket options that actually matter

setsockopt has hundreds of options. In practice, you set a small handful. The rest are for kernel tuning or exotic protocols.

Option

When to set

Why

SO_REUSEADDR

Always, on server sockets

Lets you restart the server without a 60-second TIME_WAIT delay on the bind. Non-negotiable for dev; standard for prod.

SO_REUSEPORT

Multi-process listeners (nginx worker_processes, redis-cluster)

Kernel load-balances incoming SYNs across multiple sockets bound to the same port. This is how nginx, HAProxy, and envoy scale to multiple cores without a master accept-and-dispatch bottleneck. Added Linux 3.9 (2013).

TCP_NODELAY

RPC-style traffic, small requests/responses (Redis pipeline, gRPC)

Disables Nagle’s algorithm. Nagle coalesces small writes into one packet by waiting up to 200 ms for more data. Great for interactive telnet; catastrophic for a 50-byte JSON RPC that now takes 200 ms round-trip.

SO_KEEPALIVE

Long-lived connections behind NAT or load balancers

Sends TCP keepalive probes so dead peers are detected before your app-layer heartbeat notices. Kernel defaults are conservative (2 hours idle); tune via TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT if you care.

SO_LINGER

Rare; graceful vs abortive close control

If you set l_onoff=1, l_linger=0, close() sends RST instead of FIN. Occasionally useful for load-balancer health-check probes; usually leave alone.

TCP_QUICKACK

Rare; disables delayed ACKs briefly

One-shot flag (Linux resets it), sometimes used with TCP_NODELAY in latency-critical paths. Test before deploying.

SO_REUSEADDR vs SO_REUSEPORT is not the same thing. SO_REUSEADDR lets you bind to a port whose previous holder is in TIME_WAIT. SO_REUSEPORT lets multiple live sockets bind to the exact same address+port simultaneously, and the kernel spreads accepted connections among them via a hash. nginx workers, redis multi-listener mode, and DPDK apps all rely on SO_REUSEPORT. Set both on production servers.

An 80-line TCP echo server

Blocking, single-connection-at-a-time, IPv4+IPv6 via getaddrinfo. This is the reference program — you’ll fork off from this for every socket exercise in the next few files.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <err.h>
#include <netdb.h>
#include <sys/socket.h>
#include <signal.h>

static volatile sig_atomic_t stop = 0;
static void on_sigterm(int _s) { (void)_s; stop = 1; }

int main(int argc, char **argv) {
    if (argc != 2) errx(1, "usage: %s <port>", argv[0]);
    signal(SIGPIPE, SIG_IGN);   // writing to a closed peer -> EPIPE, not signal
    struct sigaction sa = { .sa_handler = on_sigterm };
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGINT,  &sa, NULL);

    struct addrinfo hints = {0}, *res, *rp;
    hints.ai_family   = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags    = AI_PASSIVE;
    int rc = getaddrinfo(NULL, argv[1], &hints, &res);
    if (rc) errx(1, "getaddrinfo: %s", gai_strerror(rc));

    int lfd = -1;
    for (rp = res; rp; rp = rp->ai_next) {
        lfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (lfd < 0) continue;
        int yes = 1;
        setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
        setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &yes, sizeof yes);
        if (bind(lfd, rp->ai_addr, rp->ai_addrlen) == 0) break;
        close(lfd); lfd = -1;
    }
    freeaddrinfo(res);
    if (lfd < 0) err(1, "bind");
    if (listen(lfd, 128) < 0) err(1, "listen");
    fprintf(stderr, "listening on port %s\n", argv[1]);

    while (!stop) {
        struct sockaddr_storage peer;
        socklen_t plen = sizeof peer;
        int cfd = accept(lfd, (struct sockaddr *)&peer, &plen);
        if (cfd < 0) {
            if (errno == EINTR) continue;
            warn("accept"); continue;
        }
        char host[NI_MAXHOST], serv[NI_MAXSERV];
        getnameinfo((struct sockaddr*)&peer, plen,
                    host, sizeof host, serv, sizeof serv,
                    NI_NUMERICHOST | NI_NUMERICSERV);
        fprintf(stderr, "conn from [%s]:%s\n", host, serv);

        char buf[4096];
        ssize_t n;
        while ((n = recv(cfd, buf, sizeof buf, 0)) > 0) {
            char *p = buf; ssize_t left = n;
            while (left > 0) {                          // handle partial send
                ssize_t w = send(cfd, p, left, 0);
                if (w < 0) { if (errno == EINTR) continue; break; }
                p += w; left -= w;
            }
        }
        if (n < 0 && errno != ECONNRESET) warn("recv");
        close(cfd);
    }
    close(lfd);
    return 0;
}

Compile and test:

$ cc -Wall -Wextra -Werror -O2 -o echo echo.c
$ ./echo 8080 &
$ nc localhost 8080     # then type; each line echoes back
$ nc -6 ::1 8080        # IPv6 works too

Points to internalize:

  • SIGPIPE is ignored at process start. If you write to a peer that closed, you get EPIPE from send() — a return value you can handle — not a signal that kills your process. Every server ignores SIGPIPE.

  • accept() gets struct sockaddr_storage, not struct sockaddr_in. sockaddr_storage is guaranteed large enough for any address family. Then getnameinfo renders it printable, family-agnostic.

  • The inner send loop handles partial writes. send can return fewer bytes than you asked for, especially with SO_SNDBUF pressure. Treat every send and every write as “may return less than requested” — it’s not paranoia, it’s the spec.

  • Recv until zero. recv returning 0 means the peer sent FIN. That’s your signal to close, not an error.

UDP in three lines

For completeness — UDP servers are socket bind recvfrom in a loop. No listen, no accept.

int s = socket(AF_INET6, SOCK_DGRAM, 0);
bind(s, ...);
char buf[65536]; struct sockaddr_storage peer; socklen_t plen = sizeof peer;
ssize_t n = recvfrom(s, buf, sizeof buf, 0, (struct sockaddr*)&peer, &plen);
sendto(s, buf, n, 0, (struct sockaddr*)&peer, plen);   // echo

UDP is the transport for QUIC/HTTP3, DNS, WireGuard, and every game server. It has no built-in retransmit — that’s your problem.

Blocking vs non-blocking sockets

The echo server above is blocking: every recv sleeps the thread until data arrives. That’s fine for one connection at a time, useless above a handful. To scale you’ll switch to non-blocking sockets — fcntl(fd, F_SETFL, O_NONBLOCK) — which return EAGAIN/EWOULDBLOCK immediately when there’s nothing to read. Then you need something to tell you when a fd becomes readable. That something is epoll (file 07). We’ll get there.

For now, know the two flags:

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// or, cleaner, on Linux ≥ 2.6.27 (all modern systems):
int cfd = accept4(lfd, ..., SOCK_NONBLOCK | SOCK_CLOEXEC);

accept4 sets non-blocking + close-on-exec atomically at accept time — no race window where the fd is inherited by a fork+exec before you can set the flags. Use it.

Common errno values you’ll see

errno

Means

What to do

EAGAIN / EWOULDBLOCK

Non-blocking op would have blocked (no data / can’t write more)

Return to event loop; try again when readable/writable

EINTR

Syscall interrupted by signal

Retry the syscall (or use SA_RESTART on your signal handler)

ECONNRESET

Peer sent RST (crashed / firewall / reboot)

Close the fd; log if unexpected

EPIPE

Wrote to a peer that already closed

Close the fd; happens all the time on the web

ETIMEDOUT

Connection timed out (TCP keepalive or connect timeout)

Retry or fail

EADDRINUSE

bind() failed because port is held

Set SO_REUSEADDR; if still fails, another process owns the port

Every one of these is routine, not a bug. A production server logs the count of each per hour, not the individual events.

What most people get wrong about this

They use inet_addr() or hard-code struct sockaddr_in, and their code silently breaks the day an IPv6 client shows up — or the day they need to bind to an IPv6-only cloud VPC. inet_addr() also returns INADDR_NONE (which is -1, cast to in_addr_t) on error, which is ambiguously also the valid address 255.255.255.255. Never call it. Use getaddrinfo for names → addresses, getnameinfo for addresses → names, and struct sockaddr_storage for anything the kernel might hand you back. Every one of those functions is family-agnostic by design. Stevens (UNP 3rd ed.) beats this drum for ~200 pages; it’s earned.

Practice this week

  1. Type the 80-line echo server from memory. Compile with -Wall -Wextra -Werror. Fix every warning.

  2. Verify with nc localhost 8080 (IPv4) and nc -6 ::1 8080 (IPv6). Both must work with a single binary.

  3. Break it: kill the client mid-echo, kill the server mid-echo, Ctrl-C the server while a client is connected. Every case should end cleanly with no zombie fds. Check ls /proc/self/fd from another shell.

  4. Write the UDP echo counterpart. ~30 lines. No accept, no partial-write loop.

  5. Add a client: getaddrinfo("localhost", "8080", ...) + connect. Send a fixed message, read the echo, print, exit.

  6. Set TCP_NODELAY and re-measure a tight request/response cycle with time. On a loopback interface with Nagle’s, small requests can add ~40 ms per RTT.

References

  • Beej’s Guide to Network Programmingbeej.us/guide/bgnet — the friendly companion. v3.3.2, April 2025, still actively maintained. Read chapters 5-6 this week.

  • W. Richard Stevens, UNIX Network Programming, Vol. 1, 3rd ed. — the sockets reference. Chapters 3-7 cover this file’s material with rigor no other book approaches.

  • Linux man pages: socket(2), bind(2), listen(2), accept(2), accept4(2), getaddrinfo(3). man these until they’re familiar.

  • RFC 3493, Basic Socket Interface Extensions for IPv6 — defines getaddrinfo and the sockaddr_storage design. Short and readable.

  • Julia Evans, “How do sockets work in Linux?”jvns.ca. Excellent conceptual grounding.


Return to README.md · Next: 06_the_c10k_problem_and_beyond.md