# I/O & Networking Optimization Network and I/O bottlenecks show up as goroutines blocked on syscalls or waiting for responses. The key levers are connection reuse, proper timeouts, and streaming instead of buffering. ## HTTP Transport Configuration **Diagnose:** 1- `go tool pprof` (goroutine + block profile) — look for goroutines blocked on `net/http.(*Transport).dialConn` or `net/http.(*persistConn).readLoop`; many goroutines waiting here means connection pool exhaustion 2- `fgprof` — captures both on-CPU and off-CPU wait time; look for HTTP calls dominating wall-clock time even when CPU profile shows them as cheap 3- `go tool trace` — visualize goroutine lifecycles; look for long gaps where goroutines wait for network I/O instead of processing 4- Prometheus `go_goroutines` — monitor goroutine count in production; steadily rising under stable load suggests connection or goroutine leaks from misconfigured HTTP clients ### Connection pooling The default `http.Transport` has conservative pool settings — `MaxIdleConnsPerHost` defaults to 2. Under high concurrency, requests queue waiting for connections instead of running in parallel: ```go // Bad — default transport, only 2 idle connections per host client := &http.Client{} // Good — tuned for high-concurrency service-to-service calls var apiClient = &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ MaxIdleConns: 100, // total idle connections across all hosts MaxIdleConnsPerHost: 20, // per-host idle connections (default is 2!) MaxConnsPerHost: 50, // cap total connections per host (0 = unlimited) IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, }, } ``` For web crawlers hitting many different hosts, disable keep-alive to avoid accumulating idle connections: ```go crawlerClient := &http.Client{ Transport: &http.Transport{DisableKeepAlives: true}, } ``` ### Timeouts The zero-value `http.Client` and `http.Server` have NO timeouts. A slow or malicious peer holds connections open indefinitely, exhausting file descriptors and memory: ```go // Server — always set timeouts to prevent Slowloris attacks server := &http.Server{ Addr: ":8080", Handler: handler, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } ``` ### Drain response body for connection reuse Connections are only returned to the pool when the body is fully read. Even if you don't need the body, drain it: ```go resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) // drain to enable connection reuse ``` ## Streaming vs Buffering **Diagnose:** 1- `go tool pprof -inuse_space` — look for large single allocations (MB-sized) from `io.ReadAll`, `bytes.Buffer.Grow`, or `json.Unmarshal`; these indicate buffering entire payloads instead of streaming ### Avoid io.ReadAll for large payloads `io.ReadAll` loads the entire stream into memory. For large files or HTTP responses, this causes massive memory spikes: ```go // Bad — 2GB file = 2GB allocation data, _ := io.ReadAll(f) // Good — process line by line, O(1) memory scanner := bufio.NewScanner(f) for scanner.Scan() { processLine(scanner.Bytes()) } // Good — stream between reader and writer (32KB internal buffer) io.Copy(w, resp.Body) ``` `io.ReadAll` is fine for small, bounded payloads (< 1MB) where the size is known. ### Streaming JSON Use `json.NewDecoder` for large JSON payloads instead of `json.Unmarshal` (which buffers the entire body): ```go dec := json.NewDecoder(r) for dec.More() { var item Item if err := dec.Decode(&item); err != nil { return err } process(item) // one item at a time } ``` ## JSON Performance **Diagnose:** 1- `go tool pprof` (CPU profile) — look for `encoding/json.(*Decoder).Decode`, `reflect.Value.*`, or `encoding/json.Marshal` consuming significant CPU; these indicate reflection-based JSON is the bottleneck 2- `go test -bench -benchmem` — measure ns/op and allocs/op for marshal/unmarshal; expect high alloc counts from reflection; code-gen alternatives should show 2-5x fewer allocs The standard `encoding/json` package uses reflection to inspect struct fields at runtime. For high-throughput services, this creates significant CPU and allocation overhead. **Options for faster JSON:** - **Custom `MarshalJSON`/`UnmarshalJSON`** — hand-written methods for hot-path types eliminate reflection - **Code-generation libraries** — `easyjson`, `ffjson` generate marshal/unmarshal methods at build time, no reflection at runtime - **Drop-in replacements** — `github.com/goccy/go-json`, `github.com/json-iterator/go`, `github.com/bytedance/sonic` offer 2-5x better performance - **`encoding/json/v2`** (experimental, behind `GOEXPERIMENT=jsonv2`) — evaluate deliberately; most production code should keep `encoding/json` unless the project explicitly opts into the experiment When using third-party JSON libraries, refer to the library's official documentation for up-to-date API signatures. ## Cgo Overhead **Diagnose:** 1- `go tool pprof` (CPU profile + threadcreate profile) — look for `runtime.cgocall` or `runtime.asmcgocall` consuming CPU; high threadcreate count means cgo calls are pinning goroutines to OS threads 2- `go test -bench` — benchmark the cgo call loop vs a pure Go equivalent; expect ~50-100ns overhead per cgo crossing Each Go-to-C call via cgo costs ~50-100ns due to stack switching, signal mask manipulation, and scheduler coordination: ```go // Bad — cgo overhead per element dominates for tight loops for i, v := range values { values[i] = float64(C.sqrt(C.double(v))) // ~100ns overhead PER CALL } // Good — use pure Go stdlib (math.Sqrt is as fast as C and inlineable) for i, v := range values { values[i] = math.Sqrt(v) } // Good — batch when C code is unavoidable C.batch_sqrt((*C.double)(&values[0]), C.int(len(values))) // amortize overhead ``` Additional cgo costs: goroutine is pinned to an OS thread, C code cannot be preempted (may delay GC), and function inlining is blocked at the boundary. ## Buffered I/O **Diagnose:** 1- `go test -bench` — benchmark buffered vs unbuffered I/O; expect 3-10x improvement from reducing syscall count 2- `go tool trace` — look for frequent short syscalls (`pread`, `pwrite`) in rapid succession; many tiny I/O operations indicate unbuffered access Unbuffered file reads/writes issue a syscall per operation. `bufio.Reader` and `bufio.Writer` batch small operations, reducing syscalls by 10x or more: ```go // Bad — syscall per line for _, line := range lines { f.WriteString(line + "\n") } // Good — buffered, batches writes into larger chunks w := bufio.NewWriter(f) for _, line := range lines { w.WriteString(line + "\n") } w.Flush() ``` ## Concurrent Multi-Stage Pipelines **Diagnose:** 1- `go tool trace` — visualize resource utilization across stages; look for sequential idle gaps where CPU, disk, or network sit unused while another resource is busy 2- `go tool pprof` (CPU + goroutine profile) — confirm each stage saturates a _different_ resource; if multiple stages compete for the same resource (e.g., both CPU-bound), concurrency won't help In rare scenarios where each pipeline stage saturates a _different_ resource (CPU, disk I/O, network), running stages concurrently instead of sequentially can improve throughput — even with batching between stages. ### The unusual scenario Imagine processing records: Stage A compresses (CPU-bound), Stage B writes to disk (I/O-bound), Stage C uploads to network (network-bound). Sequential execution wastes resources: ``` Time: 0 10 20 30 40 50 CPU: AAAAAAAAAA|..........|..........|..........| Disk: ..........|BBBBBBBBBB|..........|..........| Network: ..........|..........|CCCCCCCCCC|..........| ``` Concurrent stages let resources work in parallel: ``` Time: 0 10 20 30 40 50 CPU: AAAAAAAAAA|AA........| Disk: ..........|BBBBBBBBBB|BB........| Network: ..........|..........|CCCCCCCCCC|CC........| ``` **Code pattern:** ```go // Each stage runs in its own goroutine, bounded by channel buffers compressedCh := make(chan []byte, 100) // A → B buffer uploadedCh := make(chan bool, 100) // B → C buffer // Stage A: CPU-bound compression go func() { for record := range inputCh { compressed := compress(record) // saturates CPU compressedCh <- compressed } close(compressedCh) }() // Stage B: I/O-bound disk writes go func() { for compressed := range compressedCh { diskFile.Write(compressed) // saturates disk I/O uploadedCh <- true } close(uploadedCh) }() // Stage C: network-bound uploads go func() { for <-uploadedCh { client.Post(uploadURL, ...) // saturates network } }() ``` With batching per stage, total throughput = min(A_throughput, B_throughput, C_throughput). Without concurrency, throughput = sequential sum of stages. **Concurrent stages only help when bottlenecks don't overlap.** ### When to use this (and when NOT to) **Use concurrent pipelines only when ALL of these are true:** 1. **Resource saturation is predictable and non-overlapping** — You measured that A saturates one resource (e.g., CPU = 95%), B saturates another (disk I/O = 90%), C saturates a third (network = 85%). Overlapping saturation means concurrency adds no benefit. 2. **Bottleneck shifts don't hurt latency** — Processing order doesn't matter, or records can flow out-of-order through stages. 3. **Buffering overhead is acceptable** — Inter-stage channels consume memory. For large records, channel buffers can overflow system limits. 4. **You've benchmarked the alternative** — Profile both sequential and concurrent versions. Sequential + batching often wins because it is simpler and avoids context-switching overhead. **Avoid concurrent pipelines if:** - **Records must be ordered** — Concurrent processing may reorder records; if downstream expects order, you need synchronization that kills the speedup. - **Resources overlap** — If A and B both compete for CPU (e.g., both compress), concurrency causes context-switching overhead with no resource utilization gain. - **Latency matters more than throughput** — A single record now travels through 3 stages in parallel, increasing per-record latency. - **Memory is tight** — Each stage's channel buffer is a memory budget; deeply buffered channels can exhaust available RAM. → See `samber/cc-skills-golang@golang-concurrency` skill for detailed channel patterns and when to use worker pools instead. ## Batch Operations **Diagnose:** 1- `go test -bench` — benchmark single-item vs batched operations; expect N-fold improvement in throughput when amortizing per-operation overhead (syscalls, round-trips) 2- `go tool trace` — look for repeated short network/disk operations with idle gaps between them; these gaps represent wasted round-trip time that batching eliminates Batching amortizes per-operation overhead (syscalls, network round-trips, transaction costs) across many items. The pattern applies everywhere: I/O, database, network, and even in-memory processing. ### Database: batch inserts over row-by-row Inserting 1,000 rows one at a time means 1,000 round-trips, 1,000 query parses, and 1,000 transaction commits. A single batch insert does it in one round-trip: ```go // Bad — 1,000 round-trips, ~500ms for _, user := range users { db.Exec("INSERT INTO users (name, email) VALUES ($1, $2)", user.Name, user.Email) } // Good — 1 round-trip with multi-row VALUES, ~5ms const batchSize = 1000 for i := 0; i < len(users); i += batchSize { end := min(i+batchSize, len(users)) batch := users[i:end] // Build multi-row INSERT or use COPY protocol tx, _ := db.Begin() stmt, _ := tx.Prepare(pq.CopyIn("users", "name", "email")) for _, u := range batch { stmt.Exec(u.Name, u.Email) } stmt.Exec() tx.Commit() } ``` → See `samber/cc-skills-golang@golang-database` skill for detailed batch patterns and connection pool configuration. ### HTTP: batch API calls Instead of N individual HTTP requests, send one request with N items when the API supports it: ```go // Bad — 100 HTTP round-trips for _, id := range ids { resp, _ := client.Get(fmt.Sprintf("/api/users/%s", id)) // ... } // Good — 1 HTTP request with all IDs resp, _ := client.Post("/api/users/batch", "application/json", bytes.NewReader(marshalIDs(ids))) ``` ### Channel: batch processing from a stream Accumulate items from a channel and process in bulk to reduce per-item overhead: ```go func batchProcessor(in <-chan Item, batchSize int) { batch := make([]Item, 0, batchSize) ticker := time.NewTicker(100 * time.Millisecond) // flush on timeout too defer ticker.Stop() for { select { case item, ok := <-in: if !ok { flush(batch); return } batch = append(batch, item) if len(batch) >= batchSize { flush(batch); batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { flush(batch); batch = batch[:0] } } } } ```