Redis 01: The Socket That Answers
The board that opens the Redis build, from Scratch.
Before you start. Build Your Own Redis - From Scratch is the project lesson: a toy key-value server, grown one stage at a time, with a real fix at each stage for something that breaks. You need nothing more than knowing that two computers talk over a network by shipping bytes back and forth. If the hello-and-reply that opens a connection is unfamiliar, TCP vs UDP, and the 3-way handshake in Networking shows it in one read.
1983: one door for every program to build
In the early 1980s the US military’s research agency was paying the University of California, Berkeley, for something specific. Unix was the operating system most research labs ran, and the agency wanted one version of it that every lab’s machines could share, so they could talk to each other over the young network that became the internet. Before that, a programmer who wanted two computers to exchange data wrote something close to a private language for the wire, different for every program.
Bill Joy, running Berkeley’s Unix group, was handed the job of giving programmers one door to the network instead of a hundred private ones. His answer shipped in 1983, in Berkeley’s Unix release of that year (called 4.2BSD), and it is the reason the word “socket” means what it means today.
Joy’s trick was to make a network connection look like something Unix programmers already had: a file. Unix already let you open a file, then read and write it, with calls that did not care what was on the other end - disk, terminal, printer. Joy gave a connection the same shape: ask for a socket, and you read and write it exactly like a file.
That one choice is the reason this board exists. A file has no idea where one “message” stops and the next starts; it is just bytes, handed to you however many happen to be sitting there when you ask. Model a connection on a file and you inherit that cost for free. Forty years later, every program that talks over a socket hits the wall Joy’s design quietly built in: the connection hands you bytes, never sentences.
Building the door: four requests, each doing one job
To let a program answer a connection, the operating system’s core - the kernel, the part no application sees - needs four separate requests, compressed into “open a socket” as if it were one step. It is closer to building a front desk.
First, you request a socket: an endpoint. This gets you a small number the kernel will recognize from now on, a file descriptor - the same kind a plain file gets when you open one. The door has no address yet; nobody can find it.
Second, you give the door a street address. Now it exists at a place.
Third, you unlock it. Before this, anyone dialing that address is turned away outright. After it, the kernel (the core of the operating system) does the brief hello-and-reply every new connection needs, with every arrival, on its own, whether your program is paying attention or not, and seats each arrival on a bench in the lobby.
Fourth - only now - your program calls the next name off that bench. This negotiates nothing; the kernel already let the visitor in. It hands your program a fresh file descriptor for that one guest, a different number for every guest, so the door itself is never what your program talks to - only the guests it has called forward.
Each request is a separate ask to the kernel - a syscall - and TCP, the delivery service underneath all four, promises every guest’s words arrive in order and none go missing. Skip the address and the kernel has nowhere to send complaints; skip unlocking it and a genuine visitor never gets in at all.
flowchart LR A["1. socket(): a door, no address yet"] --> B["2. bind(): give it an address"] B --> C["3. listen(): unlock it, add a bench"] C --> D["4. accept(): call the next name off the bench"]
The bench fills up, and nobody at the door says so
The bench - guests the kernel already let in, waiting for your program to call them forward - only holds so many people. Real Redis asks for 511 seats; the operating system is free to shrink whatever a program asks for down to its own ceiling.
Here is what catches people off guard. When the bench is full and one more guest arrives, the kernel by default does not turn them away and does not say the building is full. It stays silent. The guest’s own phone assumes the call did not go through and tries again, then again, slower each time. From outside, the building looks quiet and slow, not like it said no - and nothing appears in your program’s logs, because your program was never told a guest had shown up.
flowchart LR Q["bench: 511 seats, all taken"] -->|"guest 512 arrives"| F["kernel stays silent - no seat, no refusal"] F --> R["guest's phone assumes it dropped, tries again"]
That “goes quiet, not busy” behavior is worth keeping: a server that seems to slow down for no reason, with no slow work actually happening, is often a full bench and a silent kernel, not a struggling program.
One guest at a time
Now the bug this board exists to show. Say your program calls the next name off the bench, then spends however long it takes to fully finish with that guest before calling the next name again. That is the simplest front desk, and it is badly broken: guest two can walk straight into the building - the kernel already seated them on the bench - and just sit there. Not turned away, not told anything, because your program is still busy with guest one.
The moment guest one leaves, your program calls the next name, and guest two - who has looked perfectly connected the whole time - is finally served. Nothing about their connection failed. It just never got looked at.
Every real fix is a different answer to the same question: how do you get back to calling the next name sooner. A helper who greets a second guest at once. A receptionist who checks the whole bench at a glance instead of calling one name and waiting. That question is the entire next stage of this toy.
The wall: a machine does not say “period”
Run the actual toy from this stage and it does something real: send it PING over a terminal, by hand, and it answers +PONG. Point Redis’s own command-line client at the same door, and it fails outright.
The difference is not the program. It is who is on the other end. A person typing into a terminal supplies a boundary for free: pressing Enter. That keystroke is the only reason “where does this message end” was ever answered. A person naturally pauses, or says “period,” speaking a sentence over the phone too - take the person away, and the listener has only a stream of sound with no gaps in it.
Redis’s real client is not a person. It sends its request as one unbroken run of bytes, no keystroke marking where it stops, and our toy - which was only counting on someone pressing Enter - reads the start of it, gets confused, and answers with an error instead of a PONG.
That is the one thing this board was built to show: a socket hands you a stream of bytes and promises they arrive in order, exactly once - and promises nothing about where one message ends and the next begins. Those edges were never on the wire to begin with. Deciding where a message stops is a job for whoever is listening, never the connection. The next board, Redis 02: RESP, The Wire Protocol, is where every message finally declares its own length up front, so the reader never has to guess.
You now know
- A socket looks and behaves like a file because Berkeley designed it that way in 1983 - read it, write it, and it hands you a stream of bytes, never a message.
- Opening a door to the network is four steps: get an endpoint, give it an address, unlock it, call each guest forward one at a time.
- The kernel keeps a bench of already-admitted guests waiting for your program; when it fills up it stays silent instead of turning anyone away.
- A program that finishes fully with one guest before calling the next leaves later guests sitting, connected and ignored, until it comes back for them.
- Framing - deciding where one message ends and the next begins - is never done for you. A person supplies it for free, by pressing Enter or pausing when they speak; a real client sends one unbroken stream.
Stop here if you want. What follows is the same door in system calls and numbers, for when you want to see it work.
Second read: the four syscalls, the queues, and the code
The four calls, in C:
int fd = socket(AF_INET, SOCK_STREAM, 0); // 1. an endpoint. no address yet.
int yes = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(6379),
.sin_addr = { .s_addr = htonl(INADDR_LOOPBACK) },
};
bind(fd, (struct sockaddr *)&addr, sizeof addr); // 2. claim 127.0.0.1:6379
listen(fd, 511); // 3. become passive; make the queues
for (;;) {
struct sockaddr_in peer;
socklen_t plen = sizeof peer;
int cfd = accept(fd, (struct sockaddr *)&peer, &plen); // 4. a NEW fd
serve(cfd);
close(cfd); // closes the CLIENT. fd is still listening.
}
bind() fails with EADDRINUSE because the kernel holds the old connection’s four-tuple in TIME_WAIT for up to 2×MSL after your server exits; SO_REUSEADDR is the fix, and every “address already in use” while restarting a dev server is this. Go’s net.Listen collapses steps 1–3 into one call, why they blur for anyone who learned this through a runtime instead of libc.
Two queues, not one number:
client SYN -> [ SYN queue ] handshake in flight (SYN-RECEIVED)
| sized by net.ipv4.tcp_max_syn_backlog
v handshake completes (kernel does this alone)
[ accept queue ] ESTABLISHED, waiting for you
| sized by min(backlog, net.core.somaxconn)
v your code finally calls accept()
your fd
listen()’s backlog argument is capped by net.core.somaxconn - 128 on older kernels, 4096 since Linux 5.4. At 128, 1,000 clients arriving in one 10 ms burst - a fleet redeploying at once - parks 128 and drops the rest, who retransmit after 1 s, then 3 s. Real Redis ships tcp-backlog 511 and warns at boot if somaxconn is lower; the evidence is ss -lnt and ListenOverflows in nstat.
A new descriptor per guest, from the four-tuple. The listening socket is a two-tuple, a factory you never read or write. Each accepted connection is a four-tuple, so two clients differing only in remote port still route to different descriptors:
listening socket ( *:6379 ) <- keeps listening, forever
|
+-- accept() -> fd 5 (127.0.0.1:6379 <-> 127.0.0.1:54012)
+-- accept() -> fd 6 (127.0.0.1:6379 <-> 127.0.0.1:54013)
+-- accept() -> fd 7 (127.0.0.1:6379 <-> 127.0.0.1:54014)
Two ceilings follow. Linux hands ephemeral ports from 32768–60999: 60,999 − 32,768 + 1 = 28,232 connections before a load tester itself stalls. On the server, ulimit -n at its default 1,024 leaves 1,024 − 32 ≈ 992 for clients - why Redis, asked for maxclients 10000 under that limit, lowers it and says so.
Blocking, by default: accept() waits for the bench to be non-empty, read() waits for at least one byte, not a full line. Non-blocking (O_NONBLOCK, accept4(..., SOCK_NONBLOCK)) returns EAGAIN instead - “nothing yet, ask again” - useful only paired with a readiness notifier, epoll or kqueue, the stage after this one.
The loop that serves exactly one client, in full:
package main
import (
"fmt"
"net"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:6379") // socket + bind + listen
if err != nil {
panic(err)
}
defer ln.Close()
fmt.Println("listening on 127.0.0.1:6379 (Ctrl-C to stop)")
for {
conn, err := ln.Accept() // pops one completed connection; a NEW fd
if err != nil {
continue
}
handle(conn) // v01: ONE client at a time, on purpose
}
}
func handle(conn net.Conn) {
defer conn.Close()
fmt.Println("client:", conn.RemoteAddr())
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf) // may return 1 byte. may return 4096. never "a line".
if n > 0 {
if _, werr := conn.Write(buf[:n]); werr != nil {
return
}
}
if err != nil { // io.EOF when the client hangs up
return
}
}
}
Two lines are load-bearing: write buf[:n], never the whole buf, and check n > 0 before err != nil - Go allows both a byte count and an error in the same call, so checking the error first drops real bytes at end-of-stream.
Run it, then in two other terminals:
go run ./main.go
# terminal 2
nc localhost 6379 # type anything, see it echoed
# terminal 3 - while terminal 2 is still open
nc localhost 6379 # connects... and then silence
Terminal 3 connects - no refusal - because the kernel finished the handshake and parked it on the bench while your program was still inside handle() for terminal 2. Ctrl-C terminal 2 and terminal 3 wakes mid-sentence.
Swap the echo for the actual first stage - teaching/code/from-scratch/redis/01-socket/main.go - which answers +PONG to PING:
$ nc localhost 6379
PING
+PONG
$ redis-cli -p 6379 ping
(error) ERR unknown command
redis-cli sent *1\r\n$4\r\nPING\r\n, not PING\n, and the line reader read *1 as the command name. Once a read() returning “PIN” looks normal, every wire protocol you meet - RESP, HTTP’s Content-Length, Kafka’s length-prefixed records, gRPC’s five-byte prefix - is the same idea in different clothes.
Try it
cd teaching/code/from-scratch/redis
go run ./01-socket
# does it exist, and how deep is its queue?
ss -lnt 'sport = :6379' # Send-Q on a LISTEN row = the backlog limit
# Recv-Q = connections waiting for accept()
# the one-client-at-a-time proof
nc localhost 6379 # terminal 2: PING -> +PONG
nc localhost 6379 # terminal 3: connects, then silence
# Ctrl-C terminal 2 -> terminal 3 wakes up
# the wall
redis-cli -p 6379 ping # (error) ERR ... -> the "RESP The Wire Protocol" board
Run it yourself: full source file
- Install Go from
go.dev/dl. Check it withgo version. - Make the folder:
mkdir -p teaching/code/from-scratch/redis. Save the file below at the path shown above it, including any subfolders. - Inside that folder, run
go mod init lessononce. Go needs a module name to build. - Run the commands in the block above from the folder where you ran mkdir.
teaching/code/from-scratch/redis/01-socket/main.gopackage main
// v01 — the socket that answers.
//
// Goal: prove a client can connect and get an answer. No protocol yet.
//
// Run: go run ./01-socket
// Poke: nc localhost 6379 -> type PING, enter -> +PONG
// Wall: redis-cli -p 6379 ping -> (error) ERR, because redis-cli sends
// *1\r\n$4\r\nPING\r\n (RESP), not the raw bytes "PING". -> v02.
//
// Go notes: net.Listen opens the socket; err is a value, not an exception.
// `defer conn.Close()` runs on return — Go's cleanup idiom.
import (
"bufio"
"fmt"
"net"
"strings"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:6379")
if err != nil {
panic(err)
}
fmt.Println("listening on :6379 (Ctrl-C to stop)")
for {
conn, err := ln.Accept() // blocks until a client knocks
if err != nil {
continue
}
handle(conn) // v01: ONE client at a time, on purpose (see v04)
}
}
func handle(conn net.Conn) {
defer conn.Close()
r := bufio.NewReader(conn)
for {
line, err := r.ReadString('\n') // we PRETEND one line == one command
if err != nil {
return
}
if strings.ToUpper(strings.TrimSpace(line)) == "PING" {
conn.Write([]byte("+PONG\r\n"))
} else {
conn.Write([]byte("-ERR unknown command\r\n"))
}
}
}
Then break it: comment out defer conn.Close() and hammer it with a loop of nc sessions until you hit ulimit -n. accept: too many open files is the file-descriptor ceiling felt rather than read.
Go deeper
- Beej’s Guide to Network Programming (sections 5 and 6 are exactly these four calls)
man 2 listenandman 2 accept(thesomaxconncap andaccept4)- Redis source -
anetTcpServer()insrc/anet.candlistenToPort()/acceptCommonHandler()insrc/server.c(the real thing, withtcp-backlog) - Redis client-handling docs (
maxclients) - Stevens, UNIX Network Programming, Vol. 1 ch. 4 (the two queues, in full).
Related
- Redis 02: RESP, The Wire Protocol is the next board: every message declares its own length, and the reader stops guessing.
- TCP vs UDP, and the 3-way handshake is the background this board leans on: what TCP promises, and what it does not.
- Build Your Own Redis - From Scratch is the project lesson this stage sits under.
Retrieval practice
7 questions. Answer each one before you open it.
Name the four requests a program makes to answer a connection, and say what each one alone accomplishes.
socket() creates an endpoint and a file descriptor - no address, nobody can reach it. bind() claims a local address and port, the call that fails with EADDRINUSE. listen() flips the socket from active to passive and creates the SYN and accept queues; before it, an incoming connection gets refused, after it the kernel completes handshakes for you regardless. accept() removes one already-completed connection and returns a new file descriptor for it. Go’s net.Listen collapses the first three into one call, which is why the distinction blurs for people who learned this through a runtime.
Why does accept() return a NEW file descriptor, and what identifies each connection?
The listening socket is a two-tuple - local address, local port - a factory you never read or write. Each accepted connection is a four-tuple: local IP, local port, remote IP, remote port. Two clients hitting 127.0.0.1:6379 differ only in remote port, enough for the kernel to route arriving segments to the right descriptor. Consequence: your connection ceiling is a file-descriptor ceiling (ulimit -n, and Redis’s maxclients, which it lowers at boot if the fd limit is too small).
What is the listen backlog, and what does a client experience when it overflows?
The size of the accept queue - connections the kernel already handshaked, waiting for you to call accept() - separate from the SYN queue (handshakes still in flight). It is silently capped by net.core.somaxconn (128 on older kernels, 4096 since Linux 5.4), why Redis ships tcp-backlog 511 and warns at boot when somaxconn is lower. On overflow, Linux drops the packet rather than resetting, so the client retransmits and the connection appears to hang. Nothing appears in your log; the evidence is ss -lnt and the ListenOverflows counter.
What changes when you make a socket non-blocking, and what does EAGAIN mean?
Blocking accept() waits for the accept queue to be non-empty; blocking read() waits for at least one byte, not a full buffer or line. Non-blocking (O_NONBLOCK, accept4(..., SOCK_NONBLOCK)) returns EAGAIN/EWOULDBLOCK instead - not an error, “nothing yet, ask again.” It only helps paired with a readiness notifier (epoll, kqueue) that says which descriptor is ready - the event loop, the next stage after this one.
Why does a naive accept-then-handle loop serve one client at a time, and why does the second client still see a successful connection?
handle(conn) runs to completion before the loop reaches Accept() again - blocked reading client one, it never pops client two off the queue. But client two is not refused: listen() already told the kernel to complete handshakes autonomously, so its connection is ESTABLISHED and sitting in the accept queue - a successful connect() followed by silence. Kill client one and client two comes alive instantly. Every concurrency design - thread per client, goroutine per client, one thread plus epoll - answers the same question: how do you get back to accept() sooner.
Two lines in a Read/Write echo loop are easy to get wrong. Which, and what breaks?
Write buf[:n], never the whole buf - Read fills only a prefix, so writing the full buffer echoes stale bytes from a longer previous read, silent until a short read follows a long one. And act on n > 0 before checking the error: Go allows both in the same call, so checking the error first discards real bytes at end-of-stream. Both are the same mistake - treating a read as a whole, self-contained thing.
The nc version answers PING correctly and redis-cli gets an error. What is the actual difference?
nc works because a human presses Enter - a person supplying one boundary per command, by hand. Our v01 reads a line and pretends a line is a command, which holds only as long as that human is there. redis-cli sends *1\r\n$4\r\nPING\r\n, so the line reader sees *1 as the command name and errors. The boundaries were never in the byte stream; the typist was injecting them. Remove the typist and framing is unsolved - which is precisely what RESP solves in Redis 02: RESP, The Wire Protocol.