Sockets for Dummies: The server-side story you never understood

6 min read

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=3

This 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:6969

Now 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
└───────┴─────────────────┘
📝Note

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 size

This is where people get confused. listen() does not wait for connections. It just:

  1. Marks the socket as a listener (state: LISTEN)
  2. 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]                                 │   │
│  └─────────────────────────────────────────┘   │
│                                                 │
└─────────────────────────────────────────────────┘
🚨The listener socket has NO read/write buffers

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=5

accept() does this:

  1. Looks at the accept queue of fd=3
  2. If empty: sleeps until something arrives
  3. If not empty: pops the first completed connection
  4. 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:

AspectListener socket (fd=3)Connection socket (fd=5)
StateLISTENESTABLISHED
PurposeAccept new connectionsSend/receive data
Has data buffers?NoYes (read + write)
Has connection queues?Yes (SYN + Accept)No
Created byYour code (socket())The kernel
One per server?YesOne per client
Can you read/write?NoYes

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=14

The 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.

⚠️Accept queue can overflow

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

TL;DR
  • 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)