net/go.book
All Parts Marketing

TCP in Depth: Protocol Theory and Go Implementation

"Imagine sending a letter, but you want to make sure it arrives, in order, and without any missing pages. TCP is the postal service that guarantees delivery, order, and reliability—Go gives you the tools to become a master mail carrier!"


What is TCP?

TCP (Transmission Control Protocol) is the backbone of reliable communication on the internet. It's like a polite, organized courier: every message is delivered, in order, and checked for errors. If something goes wrong, TCP tries again—no lost mail!

  • Connection-oriented: Like a phone call—both sides say "hello" before talking.
  • Reliable: Every packet is acknowledged. If lost, it's resent.
  • Ordered: Data arrives in the same order it was sent.
  • Stream-based: Data flows like a river, not in fixed chunks.

Analogy:

  • TCP is a certified mail service: you get a receipt, tracking, and confirmation of delivery.

TCP guarantees order and delivery on a stream of bytes — never on message boundaries.


How TCP Works (Theory)

  1. Three-Way Handshake:
    • SYN: "Can we talk?" (Client asks to start a conversation)
    • SYN-ACK: "Yes, let's talk!" (Server agrees)
    • ACK: "Great, I'm ready!" (Client confirms)
  2. Data Transfer:
    • Data is sent in segments. Each is acknowledged (ACK).
    • Lost segments are retransmitted automatically.
  3. Connection Teardown:
    • Both sides say goodbye (FIN/ACK exchange) to close the connection cleanly.

Diagram:

[Client] --SYN--> [Server]
[Client] <--SYN-ACK-- [Server]
[Client] --ACK--> [Server]
(Data flows)
[Client] --FIN--> [Server]
[Client] <--ACK-- [Server]

Dial can block far longer than you expect
net.Dial has no default timeout. If the remote host is unreachable in a way that produces no immediate RST (a firewall silently dropping SYN packets), your goroutine can block for the OS's default connect timeout, often over a minute. Use net.DialTimeout or a context-aware net.Dialer{}.DialContext for hosts you don't fully control.

Flow Control and Congestion Control

TCP does not just guarantee delivery — it also decides how fast to send, through two independent mechanisms:

  • Flow control protects the receiver: each ACK advertises a receive window, the number of unacknowledged bytes the sender may still have in flight. A slow-reading application shrinks the window, and the sender backs off.
  • Congestion control protects the network: the sender starts cautiously (slow start), doubling its congestion window each round trip until it detects loss, then grows more conservatively.

Go never exposes these knobs directly — they live in the kernel's TCP stack — but they explain behavior you will actually see: why a Write to a healthy connection can briefly block, and why throughput ramps up gradually instead of starting at full speed.

Connection Teardown in Detail

A clean TCP close is a negotiation, not a single event: each side sends a FIN when it has no more data to write, and the other side ACKs it, so either half can close independently (a "half-close"). Either side can also send an RST (reset) instead, which aborts the connection immediately and discards any unread data — this is what you get if you write to a connection after the peer already closed its side.


Go in Action: Simple TCP Client (Step by Step)

This example shows how Go creates a TCP client, connects to a server, sends an HTTP request, and reads the response.

package main

import (
    "bufio" // Buffered reads for efficient streaming
    "fmt"   // Print to the console
    "io"    // io.EOF sentinel error
    "net"   // Networking primitives
    "os"    // Exit the program on error
    "time"  // Deadlines
)

func main() {
    // 1. net.Dial opens a TCP connection to example.com on port 80
    conn, err := net.Dial("tcp", "example.com:80")
    if err != nil {
        fmt.Println("Error connecting:", err)
        os.Exit(1)
    }
    defer conn.Close() // Always close, even on an early return

    // 2. A deadline bounds both the write and the read below, so a
    //    stalled or silent server can't hang this goroutine forever.
    conn.SetDeadline(time.Now().Add(5 * time.Second))

    // 3. Send a simple HTTP request
    if _, err := fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n"); err != nil {
        fmt.Println("Error writing request:", err)
        os.Exit(1)
    }

    // 4. TCP is a byte stream: a single Read is not guaranteed to
    //    return the whole response, so we loop until EOF.
    reader := bufio.NewReader(conn)
    var response []byte
    buf := make([]byte, 4096)
    for {
        n, err := reader.Read(buf)
        response = append(response, buf[:n]...)
        if err == io.EOF {
            break // Server closed the connection: we have it all
        }
        if err != nil {
            fmt.Println("Error reading response:", err)
            os.Exit(1)
        }
    }
    fmt.Println(string(response))
}
  • What does each part do?
    • net.Dial: opens the TCP channel and performs the handshake.
    • SetDeadline: bounds the whole write+read exchange in time.
    • fmt.Fprintf: sends data to the server.
    • bufio.NewReader + loop: accumulates the full response, because HTTP responses routinely arrive across several TCP segments.
    • conn.Close (deferred): closes the connection cleanly.

Exercise: Simple TCP Client

One Read is not the whole response
A one-shot n, _ := conn.Read(buf) followed by printing buf[:n] works against example.com mostly by luck, since the response fits in one segment. TCP makes no promise that a single Read returns a complete message; it only promises the bytes you get arrive in order. Any protocol built on TCP needs its own framing — a length prefix, a delimiter, or a fixed record size — plus a read loop like the one above, so the receiver knows when a full message has arrived.


Go in Action: Simple TCP Server (Explained)

This TCP server listens for connections on port 8080 and responds with a message to each client, shutting down cleanly on Ctrl+C.

package main

import (
    "context"
    "fmt"
    "net"
    "os"
    "os/signal"
)

func main() {
    // 1. net.Listen creates a TCP listener on port 8080
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    defer ln.Close()
    fmt.Println("Server listening on :8080")

    // 2. Cancel this context on Ctrl+C to shut down gracefully
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
    defer stop()
    go func() {
        <-ctx.Done()
        fmt.Println("Shutting down, closing listener...")
        ln.Close() // Unblocks Accept below with an error
    }()

    for {
        // 3. Accept blocks until a new connection arrives
        conn, err := ln.Accept()
        if err != nil {
            select {
            case <-ctx.Done():
                return // We closed the listener on purpose
            default:
                fmt.Println("Error accepting connection:", err)
                continue
            }
        }
        // 4. Handle each connection in its own goroutine
        go func(c net.Conn) {
            defer c.Close() // Runs even if this goroutine panics
            fmt.Fprintln(c, "Hello from the Go TCP server!")
        }(conn)
    }
}
  • What does each part do?
    • net.Listen: opens the port and prepares to accept connections.
    • signal.NotifyContext: turns an OS signal into a cancelable context.
    • ln.Accept: accepts one new incoming connection at a time.
    • go func: handles each client in parallel (you can have thousands!).
    • fmt.Fprintln: sends data to the client.
    • c.Close (deferred): closes the connection.

Exercise: Simple TCP Server

A missing defer conn.Close() is a file descriptor leak
Every accepted connection holds a file descriptor. If a handler returns early — on an error, a panic, or a forgotten line — without closing it, that descriptor stays open until the process exits. Under sustained load this shows up as too many open files errors that take down the whole server. Always pair the connection with defer c.Close() right after the accept (or dial) succeeds.


Real-World Example: Echo Server (Explained)

An echo server returns exactly what it receives. It is ideal for testing connections and understanding how data flows.

package main

import (
    "errors"
    "fmt"
    "io"
    "net"
    "time"
)

func main() {
    ln, _ := net.Listen("tcp", ":9000")
    fmt.Println("Echo server on :9000")
    for {
        conn, _ := ln.Accept()
        go func(c net.Conn) {
            defer c.Close()
            buf := make([]byte, 1024)
            for {
                // Reset the deadline on every loop: an idle client
                // gets disconnected after 30s of silence instead of
                // holding the goroutine open forever.
                c.SetReadDeadline(time.Now().Add(30 * time.Second))
                n, err := c.Read(buf) // Read data from the client
                if n > 0 {
                    // Write can be short: check its error, since
                    // net.Conn.Write only returns n < len(p) together
                    // with a non-nil error.
                    if _, werr := c.Write(buf[:n]); werr != nil {
                        fmt.Println("Write error:", werr)
                        return
                    }
                }
                if err != nil {
                    if !errors.Is(err, io.EOF) {
                        fmt.Println("Read error:", err)
                    }
                    return // Client closed, or the deadline fired
                }
            }
        }(conn)
    }
}
  • What does each part do?
    • c.Read: reads data from the client into a reusable buffer.
    • c.Write: echoes the same bytes back, and its error is checked.
    • SetReadDeadline: bounds how long an idle connection stays open.
    • errors.Is(err, io.EOF): tells a normal close apart from a real network error, instead of logging every disconnect as a failure.
    • The loop allows multiple messages per connection.

Exercise: TCP Echo Server

Read's contract: check n before err
io.Reader can return n > 0 and a non-nil error (including io.EOF) in the same call — buf[:n] is still valid and must be processed. Code that checks err first and returns on any non-nil error can silently drop the last chunk of a message.


What Go Does Behind the Scenes

  • net.Dial and net.Listen make operating system calls to create TCP sockets (socket, connect, bind, listen, accept).
  • Go manages the handshake, retransmission, and connection teardown through the kernel's TCP stack — none of it is reimplemented in userspace.
  • Goroutines let you handle thousands of concurrent connections with very little code, backed by the Go scheduler multiplexing them onto a small number of OS threads.
  • The net package is cross-platform: the same code runs unchanged on Windows, Linux, and macOS.


Visual Summary

[Client] <---TCP---> [Server]
   |                   |
[Send] <---ACK---> [Receive]
   |                   |
[Data] <---Order---> [Data]

Try It Yourself: Frame Your Own Protocol

The echo server above forwards raw bytes with no concept of a "message" — it is purely a stream. Extend it into a line-based protocol:

  1. Replace the raw c.Read/c.Write loop with a bufio.Scanner wrapping the connection, using bufio.ScanLines (the default split function) so each call to Scan() returns exactly one line.
  2. Add a bufio.Writer for output, and reply with the received line uppercased instead of echoed verbatim — this proves you are parsing framed messages, not just bouncing bytes.
  3. Set scanner.Buffer with a larger max token size, and test what happens when a client sends a single line longer than that limit (hint: bufio.Scanner returns bufio.ErrTooLong — decide how your server should react instead of letting it crash the goroutine).
  4. Connect with nc localhost 9000, type multiple lines, and confirm the server treats each one as an independent unit even though TCP delivered them as one continuous stream of bytes.

This lab is the practical proof of the framing Warning above: TCP gives you bytes, and your protocol has to supply the boundaries.

A socket without a deadline is a goroutine that can block forever.


Frequently Asked Questions

If TCP guarantees ordered, reliable delivery, why did my single conn.Read call only return part of the response I expected? Because TCP guarantees an ordered stream of bytes, never message boundaries. A single Read can return whatever happened to arrive in the kernel's buffer at that moment — part of a response, all of it, or more than one logical message stuck together. That's exactly why the chapter's TCP client loops on Read until io.EOF instead of trusting one call, and why any real protocol needs its own framing, like a length prefix or a delimiter.

Why does my server sometimes fail to restart with "address already in use" right after I stop it? That's TIME_WAIT doing its job: after a connection closes, the side that initiated the close lingers in that state for roughly twice the maximum segment lifetime (often around 60 seconds) so stray duplicate packets from the old connection don't get confused with a new one on the same port. Go's net.Listen already sets SO_REUSEADDR for you on Unix-like systems, which is why most Go servers can restart on the same port almost immediately — if you still see the error, something else is likely holding the socket open.

Should I turn on TCP_NODELAY for every connection to make my server more responsive? Not universally—it depends on what you're sending. Nagle's algorithm batches small writes to cut overhead, which is great for bulk transfers but adds latency to small, time-sensitive messages like a keystroke or a game update. Type-assert the net.Conn to *net.TCPConn and call SetNoDelay(true) for those latency-sensitive cases, and leave Nagle's algorithm on for anything pushing large amounts of data.

My echo server's Read returned an error, but the chapter says I should still look at buf[:n] first. Why? Because io.Reader's contract explicitly allows n > 0 together with a non-nil error, including io.EOF, in the very same call. Code that checks err first and bails out on anything non-nil can silently drop the last chunk of a message the peer already sent. Always process buf[:n] if n > 0, then decide what the error means.

Why does every server example in this chapter spawn a goroutine per connection instead of handling clients one at a time? Because Accept only hands back one connection at a time, and handling it inline would make every other waiting client sit behind whichever one is currently being served. Goroutines are cheap — a few kilobytes of stack that grows on demand — so go func(c net.Conn) { ... }(conn) lets the accept loop go straight back to waiting for the next client. It isn't free at unlimited scale, though: a flood of slow or malicious clients each holding one goroutine, buffer, and file descriptor is exactly why production servers pair this pattern with connection limits and deadlines like SetReadDeadline above.