Sockets for Dummies: The server-side story you never understood
Sockets for Dummies: The server-side story you never understood
You've read socket tutorials. You've copy-pasted server code. But deep down, you're still confused about what listen() and accept() actually do. Why are there two sockets? Who does the handshake? Let's fix that.
Mental Model
Here's the key insight that changes everything:
The server socket never connects to anyone. It just sits there, listening. The kernel does all the handshake work behind your back, and gives you a brand new socket for each client.
YOUR CODE KERNEL
───────────── ──────────
socket() → Creates fd=3 Allocates structure
bind(:6969) → "I want port 6969" Maps port to socket
listen() → "Start accepting" Creates connection queues
[your code sleeps]
Client sends SYN →
← Kernel sends SYN-ACK
Client sends ACK →
Kernel creates fd=5 (new socket!)
Puts fd=5 in accept queue
accept() → "Give me one" Returns fd=5
Now you have fd=5!See that? Your code did nothing during the handshake. The kernel handled the entire TCP dance automatically.
Step by step: What actually happens
Step 1: socket() - Create the structure
int fd = socket(AF_INET, SOCK_STREAM, 0); // Returns fd=3This creates a structure in the kernel. Think of it as reserving a slot in a table:
File descriptor table (your process):
┌─────┬──────────────────────────┐
│ fd │ What it points to │
├─────┼──────────────────────────┤
│ 0 │ stdin │
│ 1 │ stdout │
│ 2 │ stderr │
│ 3 │ Socket (UNCONNECTED) │ ← New!
└─────┴──────────────────────────┘At this point, the socket has no IP, no port, no nothing. It's just an empty structure.
Step 2: bind() - Assign an address
bind(fd, &addr, sizeof(addr)); // addr = 0.0.0.0:6969Now the kernel knows: "When packets arrive at port 6969, they belong to fd=3."
Kernel's port table:
┌───────┬─────────────────┐
│ Port │ Socket │
├───────┼─────────────────┤
│ 22 │ sshd │
│ 80 │ nginx │
│ 6969 │ fd=3 (yours!) │ ← Registered
└───────┴─────────────────┘You can skip bind() for client sockets. The kernel will pick a random ephemeral port (like 54321) automatically when you call connect().
Step 3: listen() - Prepare the queues
listen(fd, 128); // 128 = backlog sizeThis is where people get confused. listen() does not wait for connections. It just:
- Marks the socket as a listener (state: LISTEN)
- Creates two queues for incoming connections
Socket fd=3 (LISTEN mode):
┌─────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ SYN Queue (half-open connections) │ │
│ │ Clients that sent SYN, waiting for ACK │ │
│ │ [empty] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Accept Queue (completed connections) │ │
│ │ Handshake done, waiting for accept() │ │
│ │ [empty] │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘A socket in LISTEN mode is special. It doesn't have buffers for data. It only has queues for connections. It's a completely different beast from connected sockets.
Step 4: The kernel handles the handshake (you do nothing)
Now your code calls accept() and blocks. Meanwhile, a client somewhere does:
// CLIENT CODE
connect(client_fd, &server_addr, sizeof(server_addr));Here's what happens in your server's kernel, without your code doing anything:
TIME CLIENT SERVER KERNEL YOUR CODE
──── ────── ──────────── ─────────
t=0 accept() → sleeping
t=1 ──── SYN ────────────────→ Receives SYN
Creates entry in SYN queue
←─── SYN-ACK ──────────── Sends SYN-ACK
t=2 ──── ACK ────────────────→ Receives ACK
Handshake complete!
Creates NEW socket (fd=5)
- state: ESTABLISHED
- has read/write buffers
- knows client IP:port
Moves fd=5 to Accept Queue
t=3 accept() returns fd=5!The kernel did the entire TCP handshake. Your code just woke up and got a fresh socket.
Step 5: accept() - Get the new socket
int client_fd = accept(fd, &client_addr, &addr_len); // Returns fd=5accept() does this:
- Looks at the accept queue of fd=3
- If empty: sleeps until something arrives
- If not empty: pops the first completed connection
- Returns the new socket (fd=5) for that specific client
Now your file descriptor table looks like:
┌─────┬──────────────────────────────────────────┐
│ fd │ What it points to │
├─────┼──────────────────────────────────────────┤
│ 0 │ stdin │
│ 1 │ stdout │
│ 2 │ stderr │
│ 3 │ Listener socket (LISTEN, no buffers) │
│ 5 │ Client socket (ESTABLISHED, has buffers)│ ← New!
└─────┴──────────────────────────────────────────┘Two types of sockets
This is the key distinction that clears up all confusion:
| Aspect | Listener socket (fd=3) | Connection socket (fd=5) |
|---|---|---|
| State | LISTEN | ESTABLISHED |
| Purpose | Accept new connections | Send/receive data |
| Has data buffers? | No | Yes (read + write) |
| Has connection queues? | Yes (SYN + Accept) | No |
| Created by | Your code (socket()) | The kernel |
| One per server? | Yes | One per client |
| Can you read/write? | No | Yes |
Think of the listener socket as a receptionist and connection sockets as phone lines. The receptionist takes incoming calls and hands you a phone line for each one.
What happens with 10 clients at once?
Say 10 clients connect simultaneously:
Client 1 ──SYN──→ │
Client 2 ──SYN──→ │ KERNEL YOUR CODE
Client 3 ──SYN──→ │ ────── ─────────
Client 4 ──SYN──→ │
Client 5 ──SYN──→ │ Handles 10 handshakes accept() → gets fd=5
Client 6 ──SYN──→ │ in parallel accept() → gets fd=6
Client 7 ──SYN──→ │ accept() → gets fd=7
Client 8 ──SYN──→ │ Creates fd=5..14 accept() → gets fd=8
Client 9 ──SYN──→ │ ...
Client 10 ──SYN──→ │ Puts all in accept queue accept() → gets fd=14The kernel handles all 10 handshakes in parallel, creates 10 new sockets, and queues them all. Your code just calls accept() 10 times to retrieve them.
If your code is slow calling accept(), the accept queue fills up. New connections get dropped. The listen(fd, 128) backlog parameter controls the max queue size.
The complete picture in Go
package main
import (
"fmt"
"net"
)
func main() {
// socket() + bind() + listen() all in one
listener, err := net.Listen("tcp", ":6969")
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("Listening on :6969")
// At this point: fd=3 is in LISTEN state
// Kernel is ready to handle handshakes
for {
// accept() - blocks until a connection is ready
conn, err := listener.Accept()
if err != nil {
fmt.Println("Accept error:", err)
continue
}
// conn is a NEW socket (fd=5, fd=6, ...)
// Each client gets their own socket
go handleClient(conn)
}
}
func handleClient(conn net.Conn) {
defer conn.Close()
// Now you can read/write on this specific connection
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
conn.Write(buf[:n]) // Echo back
}
}Common confusions, answered
"Does the listener socket do the handshake?"
No. The listener socket never sends or receives TCP segments. The kernel handles the handshake and gives you a new socket when it's done.
"Can I read data from the listener socket?"
No. listener.Read() doesn't exist. The listener has no data buffers, only connection queues. You read from the sockets that Accept() returns.
"What if I call accept() before any client connects?"
Your code blocks (sleeps) until the kernel puts a completed connection in the accept queue. Then it wakes up and returns that socket.
"What if clients connect while I'm handling a previous one?"
The kernel handles their handshakes and queues the new sockets. They wait in the accept queue until your code calls accept() again.
"Why not just use one socket for everything?"
Because each TCP connection needs its own state: sequence numbers, window size, buffers, remote address. One socket per connection keeps things isolated.
TL;DR Checklist
-
socket()creates an empty structure, returns file descriptor -
bind()assigns IP:port to your socket -
listen()creates connection queues, marks socket as listener - The kernel does the entire TCP handshake automatically
-
accept()pops a completed connection and returns a NEW socket - Listener socket: has queues, no buffers, never sends data
- Connection socket: has buffers, no queues, send/receive data
- One listener, many connection sockets (one per client)