Chapter 5: Buffered Channels

Chapters 3 and 4 established unbuffered channels as synchronization points—when two goroutines communicate through an unbuffered channel, they must rendezvous. The sender blocks until a receiver is ready; the receiver blocks until a sender provides a value. This tight coupling is powerful for coordination but limiting when you need timing independence. Buffered channels decouple send from receive timing, enabling patterns impossible with unbuffered channels—but introducing complexity you must understand to use correctly.

Which Go are we on?

The same baseline as the rest of the book: Go 1.25+, matching the go.mod in every exercise. Two things matter here—since Go 1.22 each loop iteration gets its own variable, so passing the loop value as a parameter is no longer needed, and since Go 1.19 the typed atomics (atomic.Int64 and friends) replace the atomic.AddInt64(&x, 1) form.

What you'll learn
  • Buffered channel mechanics: capacity, length, and blocking behavior
  • When buffering solves real problems: timing decoupling, burst absorption, known-size collection
  • When buffering masks bugs: design problems, unbounded queues
  • How to choose buffer sizes with concrete heuristics
  • Buffered channels as counting semaphores
  • Performance characteristics and trade-offs
Building toward

This chapter focuses on buffered channel mechanics and sizing decisions. Master these concepts before exploring directional channel types (Chapter 6), channel-based patterns like pipelines and worker pools (Chapter 7), and context for cancellation (Chapter 13).

Prerequisites

You should understand unbuffered channel semantics from Chapter 3 (creation, send/receive blocking, closing, sender-closes principle) and select fundamentals from Chapter 4 (multiplexing, timeouts, non-blocking operations).

Consider this pattern from earlier chapters—a timeout-protected computation using an unbuffered channel:

timeout_unbuffered.go
// Illustrative snippet — not a complete program
func processWithTimeout(
    data []byte,
    timeout time.Duration,
) (Result, error) {
    resultCh := make(chan Result)  // Unbuffered

    go func() {
        result := expensiveComputation(data)
        resultCh <- result  // BLOCKS FOREVER if timeout wins
    }()

    select {
    case r := <-resultCh:
        return r, nil
    case <-time.After(timeout):
        return Result{}, errors.New("timeout")
        // Worker blocked on send—LEAK
    }
}

The problem: When the timeout fires first, the handler returns. The worker completes its computation, then blocks forever trying to send to an abandoned channel—a goroutine leak.

TIMEOUT WITH UNBUFFERED CHANNEL: LEAK

A timeline of a handler that spawns a worker and gives up at 100ms. The handler returns at the timeout, so nobody is left to receive; the worker finishes at 200ms, blocks forever on its send into an unbuffered channel, and is leaked.

Buffered channels solve this:

timeout_buffered.go
// Illustrative snippet — not a complete program
func processWithTimeout(
    data []byte,
    timeout time.Duration,
) (Result, error) {
    resultCh := make(chan Result, 1)  // Buffer size 1

    go func() {
        result := expensiveComputation(data)
        resultCh <- result  // Never blocks—buffer has space
        // Goroutine exits cleanly
    }()

    select {
    case r := <-resultCh:
        return r, nil
    case <-time.After(timeout):
        return Result{}, errors.New("timeout")
        // Worker sends to buffer and exits—no leak
    }
}

The buffer decouples send from receive timing. The sender doesn't need a receiver waiting—it can deposit the value in the buffer and exit cleanly.

UNBUFFERED vs BUFFERED CHANNELS

Two channels side by side. On an unbuffered channel the sender and receiver meet at a single synchronization point and neither proceeds without the other. On a buffered one the value goes into a queue, so the sender continues while the receiver is still elsewhere.

CRITICAL CAVEAT: BUFFERING ≠ CANCELLATION

The buffered channel prevents the goroutine leak (worker can exit), but it doesn't cancel the computation. The worker still runs to completion even after the timeout fires. The buffer solves cleanup (goroutine exits), not efficiency (work stops). For true cancellation, use context.Context (Chapter 13).

This decoupling enables patterns impossible with unbuffered channels:

But buffering isn't free. It introduces complexity: you must choose a capacity, understand when blocking occurs, and avoid the trap of using buffers to mask design problems.

THE DEFAULT SHOULD BE UNBUFFERED

This chapter teaches buffering, but don't mistake coverage for recommendation. Most channels should be unbuffered. Unbuffered channels provide clear synchronization semantics, immediate backpressure, and simpler reasoning. Buffer when you have a specific reason—timing decoupling, burst handling, or leak prevention. “Just in case” is not a reason. If your primary coordination logic only works with a buffer, you likely have a design bug—buffering handles edge cases, not core flow.


5.1 Capacity, Length, and Blocking Behavior

Section 3.1 introduced channel creation with make(chan T)—unbuffered channels with zero capacity. Buffered channels add a capacity parameter that fundamentally changes when operations block.

This section covers the mechanics: what capacity means, how to inspect buffer state, and precisely when operations block. Master these fundamentals before exploring when and why to use buffering.


Creating Buffered Channels

The second argument to make specifies buffer capacity:

buffered_create.go
// Illustrative snippet — not a complete program
ch := make(chan int, 3)  // Buffered channel, capacity 3

This creates a channel with an internal queue that can hold up to 3 values. Sends succeed without blocking as long as the buffer isn't full; receives succeed without blocking as long as the buffer isn't empty.

Syntax comparison:

make_variants.go
// Illustrative snippet — not a complete program
make(chan int)      // Unbuffered (capacity 0)
make(chan int, 0)   // Unbuffered (explicit 0)
make(chan int, 1)   // Buffered, capacity 1
make(chan int, 100) // Buffered, capacity 100

The capacity is fixed at creation and cannot be changed.

CAPACITY 0 vs CAPACITY 1: FUNDAMENTALLY DIFFERENT

make(chan int) (capacity 0): every send blocks until another goroutine receives. make(chan int, 1) (capacity 1): one value can be sent immediately—only the second send would block. This single-slot difference matters for timeout-safe sends and event notifications where at most one pending signal is needed. When you do need a buffer, start with capacity 1—increase only with specific justification (Section 5.2).


Capacity vs Length

Two built-in functions query channel state:

cap_len.go
// Illustrative snippet — not a complete program
cap(ch)  // Max capacity—set at creation
len(ch)  // Current values in buffer
cap_len_demo.go
package main

import "fmt"

func main() {
    ch := make(chan int, 5)

    fmt.Println("Capacity:", cap(ch))  // 5
    fmt.Println("Length:", len(ch))    // 0 (empty)

    ch <- 1
    ch <- 2

    fmt.Println("Capacity:", cap(ch))  // 5 (unchanged)
    fmt.Println("Length:", len(ch))    // 2

    <-ch  // Receive one

    fmt.Println("Capacity:", cap(ch))  // 5 (still unchanged)
    fmt.Println("Length:", len(ch))    // 1
}
Output
Capacity: 5
Length: 0
Capacity: 5
Length: 2
Capacity: 5
Length: 1
CAPACITY vs LENGTH

A three-slot buffer walked through four states. Capacity stays 3 throughout; only length moves — 0 when empty, rising as values are sent, falling as they are received. Capacity is fixed at make time; length is the current occupancy.

For unbuffered channels:

unbuffered_cap_len.go
// Illustrative snippet — not a complete program
unbuf := make(chan int)

fmt.Println(cap(unbuf))  // 0
fmt.Println(len(unbuf))  // 0 (always)

Unbuffered channels always have length 0—values pass directly from sender to receiver with no intermediate storage.

USING len() FOR FLOW CONTROL IS A RACE CONDITION

len(ch) returns the length at the instant you call it. By the time you act on that information, another goroutine may have changed it. This is a classic TOCTOU (Time-Of-Check-Time-Of-Use) bug. Never use len() for synchronization decisions.

len_race.go
// Illustrative snippet — not a complete program
// ✗ RACE CONDITION: State can change
if len(ch) < cap(ch) {
    // Another goroutine might send here!
    ch <- value  // May still block
}

// ✓ CORRECT: Atomic non-blocking send
select {
case ch <- value:
    // Sent successfully
default:
    // Would block—handle accordingly
}
THE len() RACE CONDITION (TOCTOU)

A time-of-check to time-of-use race. Goroutine A reads len(ch) at T1 and acts on it at T3, but goroutine B sends at T2. The length A observed is already stale by the time it is used, which is why len is safe for reporting and unsafe for flow control.

WHEN len() IS APPROPRIATE

Monitoring/metrics: observing queue depth for dashboards. Debugging: inspecting state in tests. Logging: reporting buffer utilization. Never use len() for synchronization decisions.


The Three Buffer States

A buffered channel exists in one of three states that determine blocking behavior:

BUFFER STATES (CAPACITY 3)

The three states of a capacity-3 buffer. Empty: sends succeed at once, receives block. Partial: both succeed. Full: sends block, receives succeed. Blocking depends only on which of these three the buffer is in.

Blocking Rules
Row
Empty (len=0)
Partial (0<len<cap)
Full (len=cap)

Key insight: Buffering delays blocking, it doesn't prevent blocking. When the buffer is full, sends block just like unbuffered channels.

WHY UNBUFFERED CHANNELS BLOCK BOTH WAYS

Unbuffered channels (capacity 0) exist only in the “empty” state—there's no buffer to be partial or full. With len=0 and cap=0: send blocks (waiting for receiver) and receive blocks (waiting for sender). Both operations block because there's never a “partial” or “full” state to enable one-sided progress. This is why unbuffered channels require rendezvous.


Blocking Behavior Demonstration

blocking_demo.go
package main

import "fmt"

func main() {
    ch := make(chan int, 2)  // Capacity 2

    ch <- 1  // Buffer: [1][_] — succeeds
    fmt.Println("Sent 1")

    ch <- 2  // Buffer: [1][2] — succeeds
    fmt.Println("Sent 2")

    ch <- 3  // Buffer full — BLOCKS
    fmt.Println("Sent 3")
}

Without a receiver, the third send blocks forever:

Output
Sent 1
Sent 2
fatal error: all goroutines are asleep - deadlock!
DEADLOCK DETECTION ONLY CATCHES COMPLETE DEADLOCKS

The runtime's deadlock detector fires when all goroutines are blocked. If even one goroutine remains runnable (a ticker, a server, a waiting loop), blocked goroutines will leak silently. In production, monitor goroutine count with runtime.NumGoroutine() to detect leaks.

With a concurrent receiver:

blocking_unblock.go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 2)

    go func() {
        time.Sleep(100 * time.Millisecond)
        fmt.Println("Received:", <-ch)
    }()

    ch <- 1
    ch <- 2
    fmt.Println("Buffer full, about to block...")
    ch <- 3  // Blocks until receiver runs
    fmt.Println("Sent 3")

    time.Sleep(200 * time.Millisecond)
}
Output
Buffer full, about to block...
Received: 1
Sent 3

The third send unblocks when the receiver removes a value, creating space. (Output order of the last two lines may vary—both goroutines are runnable simultaneously.)


Buffering Delays Blocking

delay_demo.go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 3)
    start := time.Now()

    // First 3 sends: instant
    for i := 1; i <= 3; i++ {
        ch <- i
        fmt.Printf("Sent %d at %v\n",
            i, time.Since(start))
    }

    // Fourth send: blocks
    go func() {
        fmt.Printf("Send 4 starting at %v\n",
            time.Since(start))
        ch <- 4
        fmt.Printf("Send 4 completed at %v\n",
            time.Since(start))
    }()

    time.Sleep(100 * time.Millisecond)
    <-ch  // Receive one, unblock sender
    time.Sleep(50 * time.Millisecond)
}
Output (exact times will vary)
Sent 1 at 12µs
Sent 2 at 25µs
Sent 3 at 31µs
Send 4 starting at 42µs
Send 4 completed at 100ms

The first three sends complete in microseconds. The fourth send blocks for 100ms until space becomes available. The buffer absorbed the burst, but the fourth value had to wait.


FIFO Ordering Guarantee

Buffered channels maintain strict FIFO (First-In-First-Out) order—values are received in exactly the order they were sent:

fifo_demo.go
package main

import "fmt"

func main() {
    ch := make(chan int, 3)

    ch <- 1
    ch <- 2
    ch <- 3

    fmt.Println(<-ch)  // 1 (first sent)
    fmt.Println(<-ch)  // 2
    fmt.Println(<-ch)  // 3 (last sent)
}
Output
1
2
3

This ordering is guaranteed even with multiple senders—if sender A's send completes (value enters buffer) before sender B's send completes, A's value will be received first. What matters is when the value enters the buffer, not when the send is initiated. With concurrent goroutines, the order of buffer entry is scheduling-dependent—FIFO preserves that entry order but cannot make it predictable across goroutines. The buffer acts as a queue, not a stack.


The N-1 Goroutine Leak: Critical Buffer Sizing

Now that you understand buffer states and blocking rules, let's examine the most common buffered channel bug in production Go code: mismatched buffer size and sender count.

leak_bug.go
// Illustrative snippet — not a complete program
// ✗ BUG: No buffer, but N concurrent senders
func queryMultiple(
    endpoints []string,
    timeout time.Duration,
) (Result, error) {
    results := make(chan Result)  // Unbuffered!

    for _, endpoint := range endpoints {
        go func() {
            result := query(endpoint)
            results <- result  // N-1 block forever!
        }()
    }

    select {
    case result := <-results:
        return result, nil
    case <-time.After(timeout):
        return Result{}, errors.New("timeout")
    }
    // N-1 goroutines blocked on send—LEAK
}
N-1 GOROUTINE LEAK: NO BUFFER FOR LOSERS

A timeline of three goroutines racing to send one result. Main receives the first at 75ms and returns. The other two arrive at an unbuffered channel with no receiver left and block forever — N senders with one receiver leaks N minus 1 goroutines.

The fix—buffer size must match sender count:

leak_fix.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Buffer matches sender count
func queryMultiple(
    endpoints []string,
    timeout time.Duration,
) (Result, error) {
    results := make(chan Result, len(endpoints))

    for _, endpoint := range endpoints {
        go func() {
            result := query(endpoint)
            results <- result  // All can send
        }()
    }

    select {
    case result := <-results:
        return result, nil
    case <-time.After(timeout):
        return Result{}, errors.New("timeout")
    }
    // All goroutines can complete cleanly
}
BUFFER SIZE = NUMBER OF CONCURRENT SENDERS

When multiple goroutines might send to a channel that could be abandoned (timeout, early exit, first-wins), the buffer size must accommodate all of them. N concurrent senders need make(chan T, N). This is a correctness issue, not an optimization.

REAL-WORLD IMPACT

Consider 20 endpoints: 19 leaked goroutines × ~2–4KB each (minimum 2KB stack + overhead) = ~40–80KB per call. At 1,000 calls/day: tens of MBs daily, growing weekly. Until: OOM crash or manual restart. The fix: make(chan Result, len(endpoints))—zero leaks.

Derived Arithmetic from the stated scenario. The ~2–4 KB per goroutine is the runtime’s initial stack plus bookkeeping; it grows if the goroutine’s call depth does.

Demonstrating the Leak

leak_detect.go
package main

import (
    "fmt"
    "runtime"
    "time"
)

func demonstrateLeak() {
    before := runtime.NumGoroutine()

    // Wrong: unbuffered, but 3 senders
    results := make(chan string)
    for i := 0; i < 3; i++ {
        go func() {
            time.Sleep(10 * time.Millisecond)
            results <- fmt.Sprintf("result-%d", i)
        }()
    }
    <-results  // Receive one, abandon others

    time.Sleep(50 * time.Millisecond)
    after := runtime.NumGoroutine()

    fmt.Printf("Leaked goroutines: %d\n", after-before)
}

func main() {
    demonstrateLeak()
}
Output
Leaked goroutines: 2

Closed Buffered Channels: Drain Then Zero

When a buffered channel is closed, buffered values are preserved. Receives drain the buffer before seeing the closure signal:

closed_drain.go
// Illustrative snippet — not a complete program
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)

v1, ok1 := <-ch  // v1=1, ok1=true  (buffer)
v2, ok2 := <-ch  // v2=2, ok2=true  (buffer)
v3, ok3 := <-ch  // v3=3, ok3=true  (buffer)
v4, ok4 := <-ch  // v4=0, ok4=false (closed)
CLOSED BUFFERED CHANNEL BEHAVIOR

A closed channel holding three buffered values. Receives keep returning real values with ok=true until the buffer is empty; only then does a receive return the zero value with ok=false. Closing does not discard what is already queued.

ok IS TRUE WHILE DRAINING

For buffered channels, ok remains true until the buffer is empty. A receive of v=0, ok=true means it's a real zero value from the buffer, not a close signal. Always check ok when zero might be valid data.

Why Drain-Before-Close Matters

This behavior is critical for producer-consumer patterns:

drain_producer.go
// Illustrative snippet — not a complete program
// Producer signals "no more data"
for i := 0; i < 100; i++ {
    workCh <- Task{ID: i}
}
close(workCh)  // "I'm done producing"

// Consumer drains ALL buffered tasks
for task := range workCh {
    process(task)  // Processes all 100
}

If closing discarded buffered values, consumers would miss work. The drain-before-close guarantee ensures clean completion.

The for range loop handles drain automatically—it receives until ok=false, then exits:

drain_range.go
// Illustrative snippet — not a complete program
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)

// ✓ Idiomatic: for range drains
for v := range ch {
    fmt.Println(v)  // 1, 2, 3, then exits
}

// ✗ Manual: more error-prone
for {
    v, ok := <-ch
    if !ok {
        break  // Easy to forget!
    }
    fmt.Println(v)
}

Complete Channel Behavior Reference

This table consolidates unbuffered (Chapter 3), buffered (this section), closed, and nil channel behaviors:

Complete Channel Behavior Reference
Row
ch <- v
<-ch
v,ok:=<-ch
close(ch)
len(ch)
cap(ch)

* Blocks forever. † Drains buffered values (ok=true), then returns zero (ok=false). Sends always panic.


Section Summary

Row
Creation
Send blocks when
Recv blocks when
Synchronization
len(ch)
After close
Ordering

Key Takeaways

  1. Capacity is fixed, length variescap(ch) never changes, len(ch) tracks current buffer occupancy
  2. Three states determine blocking—empty (receive blocks), partial (neither blocks), full (send blocks)
  3. FIFO order guaranteed—values received in exact send order (order of buffer entry)
  4. N senders need buffer size N—prevents goroutine leaks when receiver might abandon
  5. Closed channels drain buffer firstok=true while draining, ok=false after empty
  6. Don't use len() for synchronization—TOCTOU race between check and action
  7. Buffering delays blocking, doesn't prevent it—full buffer still blocks

Next: Section 5.2 explores when buffering solves real problems—timing decoupling, burst absorption, and known-size result collection.

Section 5.1 — in one line

Capacity is fixed at make time and decides only when an operation blocks: a send waits when the buffer is full, a receive when it is empty. cap never moves, len is a snapshot that is stale the moment you read it, and the sizing rule that actually matters is N−1: with N senders and one receiver, anything smaller leaks goroutines.


5.2 When to Buffer

Section 5.1 covered the mechanics—how buffered channels work. This section covers the judgment—when buffering is the right choice.

The default should be unbuffered. Unbuffered channels provide clear synchronization semantics and immediate backpressure when consumers can't keep up. Add a buffer only when it solves a specific problem.

Three scenarios consistently justify buffering:

  1. Preventing goroutine leaks—sender must complete even if receiver abandons
  2. Absorbing bursts—temporary production spikes shouldn't block producers
  3. Known-size result collection—N goroutines produce N results collected together

Each scenario has a clear problem that buffering solves and a principled way to choose buffer size.

PLACEHOLDER FUNCTIONS IN EXAMPLES

Examples in this section use placeholder functions (fetch(), query(), process()) to represent your actual work—HTTP requests, database calls, computations, etc. Focus on the channel mechanics and buffer sizing, not the placeholder implementations.


Pattern 1: Preventing Goroutine Leaks

The chapter opening showed this pattern: a sender blocks forever when the receiver disappears. This is the most common and critical use of buffering.

Single-Sender Pattern

When one goroutine sends a result that might not be received:

fetch_timeout.go
// Illustrative snippet — not a complete program
func fetchWithTimeout(
    url string,
    timeout time.Duration,
) ([]byte, error) {
    result := make(chan []byte, 1)  // Buffer size 1
    errCh := make(chan error, 1)   // Buffer size 1

    go func() {
        data, err := fetch(url)
        if err != nil {
            errCh <- err
            return
        }
        result <- data  // Won't block (cap=1, one sender)
    }()

    select {
    case data := <-result:
        return data, nil
    case err := <-errCh:
        return nil, err
    case <-time.After(timeout):
        // Worker sends to buffer and exits cleanly
        return nil, errors.New("timeout")
    }
}

Why buffer size 1 on each channel? The goroutine sends to exactly one channel (result or error). Each channel needs capacity for that one potential send. The sender can always complete—whether the receiver gets the value, receives an error, or times out first.

SINGLE-SENDER LEAK PREVENTION

Two scenarios for a single sender. If the receiver is still there, the value is taken directly. If it has gone, a buffer of one gives the value somewhere to land so the sender can finish and exit rather than blocking forever.

First-Response-Wins Pattern

When multiple goroutines race to provide a result, only one wins—but all must exit cleanly. This is exactly the queryMultiple fix from Section 5.1: make(chan T, len(endpoints)) ensures all N senders can complete. After the first response is received, the remaining N-1 goroutines still finish their work and send—without sufficient buffer space, they block forever.

FIRST-RESPONSE-WINS: N SENDERS

Three endpoints answering at 50, 80 and 100ms into a channel buffered to 3. The first answer wins and is returned; the two later ones still have room to deposit their values and exit cleanly, which is what the buffer is for.

Why A and C must send despite losing: They've already completed their work. With an unbuffered channel, they'd block forever waiting for a receiver that's gone. Buffer size 3 ensures all can exit cleanly. The abandoned values are garbage collected when the channel becomes unreachable.

Fire-and-Forget Notifications

When you need to notify without blocking, and it's acceptable if notifications are dropped under load:

async_logger.go
// Illustrative snippet — not a complete program
import "sync/atomic"

type AsyncLogger struct {
    logs    chan string
    done    chan struct{}
    dropped atomic.Int64  // Go 1.19+ typed atomic
}

func NewAsyncLogger(bufferSize int) *AsyncLogger {
    l := &AsyncLogger{
        logs: make(chan string, bufferSize),
        done: make(chan struct{}),
    }
    go l.writeLoop()
    return l
}

func (l *AsyncLogger) Log(msg string) {
    select {
    case l.logs <- msg:
        // Queued successfully
    default:
        // Buffer full—drop and count
        l.dropped.Add(1)
    }
}

// Dropped reports how many messages were shed. Without this
// the "always monitor drops" advice below has nothing to read.
func (l *AsyncLogger) Dropped() int64 {
    return l.dropped.Load()
}
async_logger_loop.go
// Illustrative snippet — not a complete program
import (
    "fmt"
    "os"
)

func (l *AsyncLogger) writeLoop() {
    for {
        select {
        case msg := <-l.logs:
            fmt.Fprintln(os.Stderr, msg)
        case <-l.done:
            l.drain()
            return
        }
    }
}

func (l *AsyncLogger) drain() {
    for {
        select {
        case msg := <-l.logs:
            fmt.Fprintln(os.Stderr, msg)
        default:
            return
        }
    }
}

// Close must be called exactly once.
// Caller must stop calling Log first.
func (l *AsyncLogger) Close() {
    close(l.done)
}

Safety contract: The caller must stop calling Log before calling Close. If Log is called concurrently with or after Close, messages can be silently lost without incrementing the dropped counter. In production, enforce this with a shutdown sequence that stops producers before closing the logger.

Fire-and-forget differs from other leak prevention patterns:

FIRE-AND-FORGET vs LEAK PREVENTION
Row
Drops OK?
Sizing basis
Send strategy
Use case

Because drops are acceptable, we can use smaller buffers sized for typical load rather than worst-case sender count.

ALWAYS MONITOR DROPS

When using non-blocking sends with default, track dropped items. Silent drops are invisible bugs. Monitored drops are operational data.

monitor_drops.go
// Illustrative snippet — not a complete program
select {
case ch <- msg:
default:
    droppedCount.Inc()  // Make drops visible
}

When are dropped messages acceptable?

Rule of thumb: If losing the message would require customer support intervention, it's not acceptable to drop.

What About Abandoned Values?

When the receiver times out, buffered values are never retrieved. Two common questions:

Q: Is this a memory leak? No. When the channel becomes unreachable (no references remain), the garbage collector reclaims both the channel and its buffered contents.

Q: Is this wasteful? The goroutines complete their work even though results are discarded. Buffering prevents the goroutine leak, not the work waste. For true cancellation (stopping work mid-execution), use context.Context (Chapter 13).

LEAK PREVENTION BUFFER SIZING

Single sender, receiver might abandon: buffer size 1. N senders, first response wins: buffer size N. Fire-and-forget: expected burst size. The rule: buffer size ≥ number of goroutines that might send after the receiver stops listening.


Pattern 2: Absorbing Bursts

Production rates fluctuate. A web server might receive 10 requests/second normally but spike to 100 during peak moments. Without buffering, producers block during spikes, creating backpressure that can cascade through the system.

The Burst Problem

BURST ARRIVAL PATTERN

A traffic graph that sits near a low steady rate then spikes sharply for a short period before returning to baseline. This is the shape a buffer can absorb: the area under the spike is finite and the system has time to catch up afterwards.

Sizing for Burst Absorption

Formula: Buffer size ≈ (peak_rate − processing_rate) × spike_duration

burst_sizing.go
// Illustrative snippet — not a complete program
// E-commerce checkout during flash sale
//
// Normal:     20 checkouts/sec
// Spike:      200 checkouts/sec for 5s
// Processing: 50 checkouts/sec (DB limit)
//
// What buffer prevents blocking?

// During 5-second spike:
arrivals := 200 * 5           // = 1000 arrive
processed := 50 * 5          // = 250 processed
queued := arrivals - processed // = 750 queued

// Buffer needed: 750
// Add ~33% for load variability: ~1000

checkouts := make(chan Checkout, 1000)

Key insight: Buffer size depends on deficit during spike, not peak rate alone.

Without buffer: 750 checkout requests block, users see timeouts. With buffer: All 1000 requests queued, processed over next 15 seconds (750 ÷ 50/sec = 15 sec to drain queue after spike ends).

Burst Absorption Example

burst_server.go
// Illustrative snippet — not a complete program
import (
    "net/http"
    "runtime"
)

type Server struct {
    requests chan Request
}

func NewServer(burstCapacity int) *Server {
    s := &Server{
        requests: make(chan Request, burstCapacity),
    }

    for i := 0; i < runtime.NumCPU(); i++ {
        go s.worker()
    }

    return s
}

func (s *Server) HandleRequest(
    w http.ResponseWriter,
    r *http.Request,
) {
    req := parseRequest(r)

    select {
    case s.requests <- req:
        w.WriteHeader(http.StatusAccepted)
    default:
        http.Error(w,
            "Server overloaded",
            http.StatusServiceUnavailable)
    }
}

func (s *Server) worker() {
    for req := range s.requests {
        process(req)
    }
}

The buffer absorbs temporary bursts. During a spike, requests queue up. When the spike subsides, workers drain the queue. Handlers don't block as long as the buffer has space.

BUFFERS SMOOTH BURSTS, NOT SUSTAINED OVERLOAD

If your producer is consistently faster than your consumer, buffering only delays the problem. The buffer fills, stays full, and you get the same blocking—just delayed. If production consistently exceeds consumption, you need: more consumers (increase capacity), load shedding (reject excess), or backpressure (slow down producers).

BURST vs SUSTAINED OVERLOAD

Two traffic patterns contrasted. A burst rises above capacity briefly and then falls back, so a buffer absorbs it and the system recovers. Sustained overload stays above capacity indefinitely, so the buffer fills and the failure is only postponed.

Demonstrating Burst Absorption

burst_demo.go
package main

import (
    "fmt"
    "time"
)

func demonstrateBurstAbsorption() {
    measure := func(ch chan int) time.Duration {
        // Consumer: processes items (finishes after close)
        go func() {
            for range ch {
                time.Sleep(
                    200 * time.Millisecond)
            }
        }()

        // Let consumer start
        time.Sleep(10 * time.Millisecond)

        // Producer: send 10 items (burst)
        start := time.Now()
        for i := 0; i < 10; i++ {
            ch <- i
        }
        elapsed := time.Since(start)
        close(ch)

        return elapsed
    }

    unbufTime := measure(make(chan int))
    bufTime := measure(make(chan int, 20))

    fmt.Printf("Unbuffered: %v\n",
        unbufTime)
    fmt.Printf("Buffered:   %v\n",
        bufTime)
}

func main() {
    demonstrateBurstAbsorption()
}
Output (exact times will vary)
Unbuffered: 1.805s
Buffered: 2.382µs

The unbuffered producer blocks on 9 of 10 sends (the first succeeds immediately because the consumer is already waiting), so 9 × 200ms ≈ 1.8s. The buffered producer deposits all 10 items into the capacity-20 buffer in microseconds—the buffer absorbed the entire burst.

Burst Absorption Sizing Heuristics
Known spike
(peak - capacity) × spike_duration
Unknown pattern
Start 2-10× normal rate, monitor
Memory-constrained
Smaller buffer + rejection
Latency-sensitive
Smaller buffer (queued = delayed)

Pattern 3: Known-Size Result Collection

When you spawn N goroutines to produce N results, buffering enables independent completion:

collect_results.go
// Illustrative snippet — not a complete program
func processAllItems(items []Item) []Result {
    results := make(chan Result, len(items))

    for _, item := range items {
        go func() {
            results <- process(item)  // Independent
        }()
    }

    output := make([]Result, 0, len(items))
    for range items {
        output = append(output, <-results)
    }

    return output
}

Why this works: Each goroutine sends exactly once. With buffer size N, all N sends succeed without waiting for the collector.

KNOWN-SIZE COLLECTION

Five items processed concurrently into a channel buffered to five. Each goroutine sends its result whenever it finishes, in no particular order, and none can block because the buffer is sized to the known number of results.

VERIFY EACH COLLECTION GOROUTINE

Before spawning goroutines in a collection pattern, check four properties: Exit? Returns after sending exactly once to results. Communicate? Via buffered results channel. Errors? Sent to separate errs channel (same buffer size). Data? Closure captures item by value; no shared mutable state.

Handling Errors

When work can fail, account for both outcomes:

collect_errors.go
// Illustrative snippet — not a complete program
func processAll(
    items []Item,
) ([]Result, error) {
    results := make(chan Result, len(items))
    errs := make(chan error, len(items))

    for _, item := range items {
        go func() {
            result, err := process(item)
            if err != nil {
                errs <- err
                return
            }
            results <- result
        }()
    }

    var collected []Result
    for i := 0; i < len(items); i++ {
        select {
        case r := <-results:
            collected = append(collected, r)
        case err := <-errs:
            // Remaining goroutines still
            // send; buffer accommodates N
            return collected, err
        }
    }

    return collected, nil
}

Why both channels need buffer size N: Each of the N goroutines sends to exactly one channel—either results or errs, never both. If all N succeed, all N send to results. If all N fail, all N send to errs. Buffer size N on each channel handles either extreme.

Why not smaller? If results has capacity 1 but all N succeed, N-1 goroutines block forever on send—a leak.

For production code, golang.org/x/sync/errgroup handles this pattern with cleaner semantics (including cancellation). The manual approach here illustrates the underlying channel mechanics.

Preserving Order

The buffer collects results in completion order. If original order matters:

collect_ordered.go
// Illustrative snippet — not a complete program
type indexedResult struct {
    index  int
    result Result
}

func processAllOrdered(items []Item) []Result {
    results := make(chan indexedResult, len(items))

    for i, item := range items {
        go func() {
            results <- indexedResult{
                index:  i,
                result: process(item),
            }
        }()
    }

    output := make([]Result, len(items))
    for range items {
        ir := <-results
        output[ir.index] = ir.result
    }

    return output
}

Decision Framework

Before adding a buffer, answer these questions:

BUFFERING DECISION TREE

A decision tree for whether to buffer. The first question is whether the receiver could disappear before the sender completes — a timeout, an early return, a first-wins race. If so, size the buffer to the number of senders; otherwise carry on to the next question.

The “Would Unbuffered Work?” Test

A useful mental check: If you had infinitely fast consumers, would you still need the buffer?

Example 1: Timeout pattern

test_timeout.go
// Illustrative snippet — not a complete program
ch := make(chan Result, 1)
go func() { ch <- compute() }()
select {
case r := <-ch: return r
case <-time.After(timeout): return err
}

With infinitely fast consumer: Still need buffer (receiver might not exist after timeout). Real need is leak prevention, not speed.

Example 2: Web server queue

test_queue.go
// Illustrative snippet — not a complete program
queue := make(chan Request, 100)

With infinitely fast workers: Don't need buffer (workers process faster than requests arrive). Real need is burst absorption due to speed mismatch.

Example 3: Parallel collection

test_collection.go
// Illustrative snippet — not a complete program
results := make(chan Result, N)
for i := 0; i < N; i++ {
    go func() { results <- compute() }()
}

With infinitely fast collector: Don't need buffer (collector receives each send instantly, no blocking). Real need is decoupling—without a buffer, goroutines complete their work but block on send until the single collector reaches them. The buffer lets them exit immediately.

This test clarifies your actual requirement and prevents cargo-cult buffering.


Section Summary

Row
Leak prevention (single sender)
Leak prevention (N senders)
Fire-and-forget
Burst absorption
Known-size collection

Key Takeaways

  1. Three legitimate reasons to buffer—leak prevention, burst absorption, known-size collection
  2. Leak prevention—buffer size = number of senders that might outlive the receiver
  3. Burst absorption—buffer size ≈ (peak − capacity) × spike duration
  4. Known-size collection—buffer size = number of results expected
  5. Buffers smooth bursts, not sustained overload—if production continuously exceeds consumption, you need more consumers or backpressure
  6. The “infinitely fast consumer” test—would you still need the buffer? Clarifies your actual requirement

Next: Section 5.3 examines when buffering is the wrong choice—masking design bugs, enabling unbounded growth, and deferring problems instead of solving them.

Section 5.2 — in one line

Three cases justify a buffer, and all three are about a receiver that might not be there: a sender that must not block after a timeout, a burst whose area under the curve is finite, and a known number of results. Size it to the number that could be in flight—never to a round number that feels safe.


5.3 When NOT to Buffer

Sections 5.1 and 5.2 covered buffering mechanics and legitimate use cases. This section covers the opposite: when buffering is the wrong solution.

Buffering has a seductive quality. When code blocks unexpectedly, adding a buffer often makes the immediate symptom disappear. But this “fix” frequently masks deeper design issues that resurface under load, in production, or in subtle ways that are harder to diagnose than the original problem.

The warning signs:

Each of these suggests buffering is hiding a problem rather than solving one.


Anti-Pattern 1: Buffering to “Fix” Deadlocks

The most dangerous misuse of buffering: code deadlocks with an unbuffered channel, so you add a buffer until it stops deadlocking.

The Symptom

deadlock_broken.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Deadlocks
func process() {
    ch := make(chan int)

    ch <- 1  // DEADLOCK: No receiver yet

    go func() {
        fmt.Println(<-ch)
    }()
}

The “Fix” That Isn't

deadlock_masked.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Buffer hides the bug
func process() {
    ch := make(chan int, 1)  // "Fixed" with buffer

    ch <- 1  // Goes to buffer

    go func() {
        fmt.Println(<-ch)
    }()
    // Returns immediately—
    // goroutine may not execute!
}

The code runs without deadlock. Problem solved? No. The buffer masked a fundamental design issue: sending before any receiver exists, and returning before the goroutine completes.

INCOMPLETE EVEN WITH THE BUFFER

This demonstrates the buffering anti-pattern but is incomplete even with the buffer—the function returns before the goroutine prints. In real code, you'd coordinate with sync.WaitGroup or receive a completion signal. The key point: the buffer allowed the send to complete, but proper coordination is still missing.

The buffered version works only because:

Change either condition and it breaks again:

deadlock_fragile.go
// Illustrative snippet — not a complete program
func process() {
    ch := make(chan int, 1)

    ch <- 1
    ch <- 2  // DEADLOCK: Buffer full

    v := <-ch
    fmt.Println(v)
}

The “fix” was fragile—it worked by accident, not by design.

MASKING DEADLOCK WITH BUFFER

The same program with and without a buffer. Unbuffered, the sends block and the runtime reports a deadlock immediately, so the developer sees the coordination bug during development. Buffered, the sends succeed and the bug is hidden until the buffer is exhausted in production.

Real Example: Missing Receives

missing_receive_bug.go
// Illustrative snippet — not a complete program
// ✗ BUG: Sends but never receives
func fetchMultiple(urls []string) {
    // "Should be enough"
    responses := make(chan Response, 100)

    for _, url := range urls {
        go func() {
            responses <- fetch(url)  // Nothing receives!
        }()
    }

    // Missing: receive and use responses
    // Works until someone passes 101 URLs
}
missing_receive_fix.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Fix the actual bug
func fetchMultiple(urls []string) []Response {
    // Size for N senders
    responses := make(chan Response, len(urls))

    for _, url := range urls {
        go func() {
            responses <- fetch(url)
        }()
    }

    // Actually collect the results
    results := make([]Response, 0, len(urls))
    for range urls {
        results = append(results, <-responses)
    }

    return results
}

The Correct Fix: Proper Coordination

deadlock_correct.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Fix ordering, ensure completion
func process() {
    ch := make(chan int)  // Unbuffered is fine

    go func() {
        ch <- 1  // Sender in goroutine
    }()

    v := <-ch  // Blocks until goroutine sends
    fmt.Println(v)
}
THE BUFFER JUSTIFICATION TEST

Before adding a buffer, ask: Which pattern from Section 5.2 does this implement?

Buffer Justification Check
✓ Legitimate
"Buffer N matches N senders for leak prevention"
✓ Legitimate
"Buffer B absorbs measured burst of B items"
✓ Legitimate
"Buffer N collects N known results"
✗ Masking bug
"It deadlocks without buffer and I don't know why"
LEGITIMATE PATTERNS DO REQUIRE BUFFERS

Patterns from Section 5.2 (like first-response-wins) do require buffers and would deadlock without them. The difference: you can articulate why the buffer is needed. “N senders might outlive the receiver” is a reason. “It doesn't work otherwise” is not.


Anti-Pattern 2: Unbounded Queues

Using buffers as general-purpose queues without backpressure leads to memory exhaustion.

The Broken Pattern

unbounded_queue.go
// Illustrative snippet — not a complete program
// ✗ CATASTROPHIC: Unbounded queue → OOM
func (q *UnboundedQueue) Enqueue(
    req Request,
) {
    select {
    case q.incoming <- req:
        // Sent to channel
    default:
        // Channel full—grow overflow
        q.mu.Lock()
        q.items = append(q.items, req)
        q.mu.Unlock()  // Grows forever!
    }
}
THE CHANNEL BUFFER IS FIXED

The channel buffer itself is fixed at creation (make(chan Request, 100)). The anti-pattern is implementing overflow handling (the growing slice) that defeats this natural bound. The key problem: append() with no upper limit.

What makes this appear attractive:

Why it's catastrophic:

UNBOUNDED QUEUE: PATH TO OOM

Scenario: Producer rate > consumer rate. Production: 100 req/sec, Processing: 50 req/sec, Deficit: 50 req/sec accumulate.

Row
0 sec
1 min
5 min
30 min
2 hours
8 hours

Result: Linear memory growth until OOM kill.

Why Large Buffers Make It Worse

When production rate exceeds consumption rate, the buffer fills at the deficit rate (production − consumption). Larger buffers just delay the inevitable:

LARGER BUFFER = LATER FAILURE

At 10/sec deficit

Row
100
10,000
1,000,000

The larger the buffer, the longer until you discover the problem—and the more catastrophic the failure.

UNBOUNDED QUEUES IN PRODUCTION

Day 1: Queue size ~100, launch successful. Week 1: Queue at 10,000—“probably fine.” Month 2: Queue at 2M, memory at 8GB. Month 2, 3 AM: OOM kill—all 2M queued jobs lost (in-memory queue gone with process). Root cause: Job creation at 105/sec, processing at 100/sec. Deficit of just 5 jobs/sec = 432,000 jobs/day. Fix: Add one more worker. Had they used bounded queue with rejection: Rejections visible immediately, added capacity before crisis, zero data loss.

The Correct Approach: Bounded Queue with Backpressure

bounded_queue.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Bounded queue with rejection
func (q *BoundedQueue) Enqueue(req Request) error {
    select {
    case q.items <- req:
        return nil
    default:
        q.dropped.Add(1)  // atomic.Uint64 (Ch 11)
        return errors.New("queue full")
    }
}
Derived Arithmetic from the stated scenario, not a measurement. Memory assumes roughly 3.3 KB per queued Request — substitute your own payload size; the growth rate is what matters, not the absolute figure.

Benefits:

BOUNDED IS A FEATURE, NOT A LIMITATION

A bounded buffer that fills up is telling you something important: production exceeds consumption capacity. This information lets you scale up consumers, implement load shedding, alert on sustained overload, and investigate the rate mismatch. An unbounded queue hides this information until the system fails catastrophically.


Anti-Pattern 3: Arbitrary Buffer Sizing

Choosing buffer sizes based on intuition or imitation rather than actual requirements.

The Symptoms

arbitrary_sizes.go
// Illustrative snippet — not a complete program
// ✗ WRONG: No justification
ch := make(chan Task, 1000)   // "Should be enough"
ch = make(chan Task, 10000)   // "Better safe"
ch = make(chan Task, 100000)  // "Never fills"

Questions that reveal arbitrary sizing:

Why Arbitrary Sizes Are Wrong

1. Memory waste

memory_waste.go
// Illustrative snippet — not a complete program
type Task struct {
    Data [1024]byte  // 1KB per task
}

ch := make(chan Task, 10000)
// 10MB allocated for buffer
// Typical usage: 10 items = 10KB
// Waste: 99.9%

2. False confidence

false_confidence.go
// Illustrative snippet — not a complete program
// "Buffer is huge, so we're safe"
results := make(chan Result, 10000)

for _, ep := range endpoints {  // 15,000!
    go func() {
        results <- query(ep)  // ~5K block
    }()
}

return <-results  // ~5,000 goroutines leaked

The buffer size doesn't match the pattern (15K senders need 15K buffer), so leaks still happen.

3. Delayed feedback

Larger buffers delay problem detection (see the “LARGER BUFFER = LATER FAILURE” table in Anti-Pattern 2). What could be a 5-second alert becomes a 3 AM pager.

Right-Sizing Instead

RIGHT-SIZING APPROACHES
Row
Leak prevention
Known burst
Unknown burst

The Documentation Test

Rule: Every buffer size > 1 should have a comment explaining the reasoning.

documented_buffers.go
// Illustrative snippet — not a complete program
// ✓ GOOD: Justification clear
// Buffer for 3 concurrent senders
// (primary + 2 fallbacks) to prevent
// goroutine leaks on early abandon.
results := make(chan Result, 3)

// ✓ GOOD: Measured and documented
// Buffer for 95th percentile burst of
// 120 requests, plus 25% margin.
queue := make(chan Request, 150)

// ✓ GOOD: Simple pattern acknowledged
// Buffer 1 to prevent leak on timeout.
result := make(chan Result, 1)

// ✗ BAD: No reasoning
queue := make(chan Request, 1000)  // Why?
IF YOU CAN'T JUSTIFY THE NUMBER, IT'S WRONG

When choosing buffer size, complete this sentence: “I need buffer size N because _____.” If you can't complete it, start unbuffered and add buffer when you observe a specific problem.


Anti-Pattern 4: Buffering Sustained Overload

Using larger buffers when you actually need more processing capacity. Section 5.2 emphasized that buffers smooth bursts, not sustained overload.

The Misdiagnosis

overload_wrong.go
// Illustrative snippet — not a complete program
// 200 req/sec arriving, 100 req/sec capacity

// ✗ WRONG: Increase buffer size
requests := make(chan Request, 100)   // Fills up
requests = make(chan Request, 1000)   // Still fills
requests = make(chan Request, 10000)  // Just delayed

Why This Fails

This is the same dynamic as Anti-Pattern 2: the deficit accumulates at (production − consumption) per second, and no buffer size changes that rate. As the “LARGER BUFFER = LATER FAILURE” table showed, bigger buffers just delay the inevitable—turning a 10-second alert into a 3 AM pager.

The Correct Solutions

Solution 1: Add more consumers

fix_consumers.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Scale workers to match load
queue := make(chan Request, 100)  // Small for bursts

for i := 0; i < 10; i++ {  // 10 workers
    go worker(queue)
}

Solution 2: Apply backpressure

fix_backpressure.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Reject when overloaded
select {
case queue <- req:
    return nil
default:
    return errors.New("service unavailable")
}

Solution 3: Shed low-priority load

fix_loadshed.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Drop non-critical under load
// Soft threshold: len() is approximate (see
// 5.1); select/default below is the hard guard
if req.Priority == Low &&
    len(queue) > cap(queue)*8/10 {  // 80%
    return errors.New("load shed")
}

// Hard limit: never block unexpectedly
select {
case queue <- req:
    return nil
default:
    return errors.New("queue full")
}
BUFFERING IS NOT A SUBSTITUTE FOR CAPACITY

Buffers smooth variance, not throughput. If sustained load exceeds capacity: add more consumers, apply backpressure, or shed load. Making the buffer bigger delays failure—it doesn't prevent it.


The Hidden Costs of Buffering

Even when buffering is appropriate, it has costs that should inform sizing decisions.

Latency Cost

Items in a buffer are items waiting. High buffer utilization means high latency:

BUFFER LATENCY IMPACT

A latency calculation. With a 100ms service target, 500 requests already queued and 10ms of processing each, a request joining the back of the queue waits five seconds before work even begins — fifty times the target, entirely because of buffering.

For latency-sensitive systems, smaller buffers that reject quickly may be preferable to large buffers that accept work destined to timeout.

Memory Cost

memory_cost.go
// Illustrative snippet — not a complete program
type LargeMessage struct {
    Payload [64 * 1024]byte  // 64KB per msg
}

ch := make(chan LargeMessage, 1000)
// 64MB allocated at creation, even when empty

Debugging Cost

Buffers add indirection that complicates debugging:


Throughput Cost: What a Buffer Actually Buys

Everything above is about the costs of buffering. It is fair to ask what you get back, and the answer is smaller than most people expect—and it stops improving much sooner.

One producer, one consumer, 300,000 handovers, timed per send. The benchmarks are in code/ch05/bench_test.go if you want to run them on your own machine:

go test -bench=Send -benchtime=300000x
BenchmarkSendUnbuffered-16 183.0 ns/op
BenchmarkSendBuffered1-16 135.0 ns/op
BenchmarkSendBuffered64-16 43.30 ns/op
BenchmarkSendBuffered1k-16 46.83 ns/op
BenchmarkSendNoHandover-16 34.61 ns/op
Measured Measured on go1.26.1, darwin/amd64, Intel i7-10700K @ 3.80GHz, one producer and one consumer, 300,000 sends per run, three runs. Your absolute numbers will differ; the shape of the curve will not.

Two things to take from this. Going from unbuffered to a buffer of 64 cuts the per-send cost by about four times, because the producer stops paying for a rendezvous on every value. Going from 64 to 1024 buys nothing at all—43.3 against 46.8 ns, which is to say slightly worse. The floor is 34.6 ns, which is what a send costs with no handover to arrange.

That flattening is the whole argument of §5.3 in one line: past the point where the buffer covers your burst, extra capacity is not buying throughput. It is only buying you a longer wait before you find out something is wrong.

Memory Cost, Measured

Capacity is allocated up front, in full, at make time—element size times capacity, plus a small header:

go test -bench=Chan -benchmem
BenchmarkChanIntUnbuffered-16 112 B/op 1 allocs/op
BenchmarkChanInt1000-16 8192 B/op 1 allocs/op
BenchmarkChanStruct1000-16 112 B/op 1 allocs/op
Measured Measured on go1.26.1, darwin/amd64 with -benchmem, which reports one allocation per make. The channel header is 96 bytes in this build, but the allocator rounds to its 112-byte size class — both figures are implementation details and may change between releases.

A buffered chan int of 1000 costs 8 KB whether or not a single value is ever queued. A chan struct{} of the same capacity costs exactly what an unbuffered channel costs—the two numbers are identical because a zero-size element needs no storage at all, so both allocate only the header. That is why signal and semaphore channels can be sized generously without thinking about it, and data channels cannot.

Monitoring: Detecting Anti-Patterns

Even with good intentions, anti-patterns slip through. Monitor these signals:

monitor_buffer.go
// Illustrative snippet — not a complete program
// Pattern only — requires a metrics library
// (Prometheus, expvar, etc.)
import (
    "log/slog"
    "time"
)

func monitorBuffer(
    ch chan Request,
    name string,
    done <-chan struct{},
) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    var prevDepth int

    for {
        select {
        case <-ticker.C:
            depth := len(ch)
            capacity := cap(ch)
            util := float64(depth) / float64(capacity)

            metrics.Gauge(
                name+".depth", depth)
            metrics.Gauge(
                name+".util", util)

            // Detect growing trend
            if depth > prevDepth && util > 0.5 {
                slog.Warn(
                    "Buffer depth increasing",
                    "name", name,
                    "depth", depth)
            }

            if util > 0.9 {
                slog.Error(
                    "Buffer nearly full",
                    "name", name,
                    "capacity", capacity)
            }

            prevDepth = depth

        case <-done:
            return
        }
    }
}
BUFFER MONITORING SIGNALS
Row
Depth increasing
Always at capacity
Depth oscillating
Always near zero

Warning Signs Checklist

BUFFER MISUSE WARNING SIGNS

A checklist of symptoms that a buffer is being misused: raising the size until a deadlock stopped happening, which masks a coordination bug; and buffer depth growing steadily over time, which means sustained overload rather than a burst.


Decision Framework: Buffer or Fix Design?

SHOULD I AVOID BUFFERING?

A decision tree for avoiding buffering. The first question is whether the buffer is being added to make a deadlock go away. If it is, the answer is not to buffer at all but to fix the coordination.


Section Summary

Row
Buffer to fix deadlock
Unbounded queue
Arbitrary sizing
Buffering sustained overload

Key Takeaways

  1. If code deadlocks without a buffer, adding one doesn't fix it—it hides the coordination bug until production
  2. Unbounded queues always fail—linear growth until OOM, just a question of when
  3. “Increase until it works” is a red flag—you're hiding a coordination issue
  4. Bounded buffers are a feature—they provide essential backpressure signals
  5. Buffer size ≠ throughput capacity—buffers absorb variance, not volume
  6. Justify every buffer size—if you can't explain the number, reconsider
  7. Monitor buffer utilization—sustained high utilization signals capacity problems

Next: Section 5.4 explores buffered channels as counting semaphores—using buffer capacity to limit concurrent access to resources.

Section 5.3 — in one line

A buffer added to make a deadlock go away does not fix the coordination bug; it moves the failure from your laptop to production, and a bigger buffer only moves it further out. If the arrival rate exceeds the service rate, nothing you put in front of it is a queue—it is just a countdown.


5.4 Buffered Channels as Counting Semaphores

Sections 5.1–5.3 covered buffering for timing decoupling, burst absorption, and result collection. This section explores a different use: using buffer capacity to limit concurrent access to resources.

A counting semaphore controls how many goroutines can access a resource simultaneously. Buffered channels implement this pattern naturally—the buffer capacity becomes the concurrency limit.


The Semaphore Concept

A counting semaphore maintains a fixed pool of permits:

SEMAPHORE CONCEPT

Five permits drawn as filled circles. Each acquire removes one and each release returns one; when none are left, the next acquirer waits. That counting behavior is all a semaphore is.

Common uses:

CONCURRENCY LIMITING vs RATE LIMITING

Semaphores limit concurrency (N simultaneous operations), not rate (N operations per time unit). If each request takes 100ms and you allow 10 concurrent, you could still make 100 requests/second. For true rate limiting, use golang.org/x/time/rate. The two concepts are distinct.


Buffered Channels as Semaphores

A buffered channel naturally implements a counting semaphore:

semaphore_create.go
// Illustrative snippet — not a complete program
// Semaphore with 5 permits
sem := make(chan struct{}, 5)

The mapping:

Semaphore ↔ Channel Mapping
Acquire permit
sem <- struct{}{} (send)
Release permit
<-sem (receive)
Available permits
cap(sem) - len(sem)
Max permits
cap(sem) (buffer capacity)

Why this works:

BUFFERED CHANNEL AS COUNTING SEMAPHORE

A capacity-3 channel used as a semaphore. Each send takes a slot and each receive returns one, so the number of goroutines past the acquire can never exceed the capacity. The values sent are empty structs and carry no data.

WHY struct{}?

Semaphores don't carry data—only the count matters. struct{} has zero size, so make(chan struct{}, 1000) allocates 0 bytes for element storage vs 8KB for chan int. Use struct{} to signal “this is for coordination, not data.”


The Basic Pattern

semaphore_basic.go
// Illustrative snippet — not a complete program
sem := make(chan struct{}, maxConcurrent)

func worker() {
    sem <- struct{}{}        // Acquire permit
    defer func() { <-sem }() // Release permit

    doWork()  // Protected section
}

Key elements:

  1. Acquire before work: sem <- struct{}{}
  2. Defer release: guarantees release on all exit paths
  3. Work protected: only cap(sem) goroutines execute simultaneously

Two Acquisition Strategies

Where you acquire the semaphore affects what gets limited.

Strategy 1: Acquire Inside Goroutine

Limits concurrent execution:

acquire_inside.go
// Illustrative snippet — not a complete program
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup

for _, task := range tasks {
    wg.Go(func() {

        sem <- struct{}{}        // Acquire INSIDE
        defer func() { <-sem }()
        process(task)
    })
}

wg.Wait()

All goroutines created immediately. They wait on semaphore before executing. Coordination is simple via WaitGroup.

Strategy 2: Acquire Before go

Limits goroutine creation:

acquire_before.go
// Illustrative snippet — not a complete program
sem := make(chan struct{}, maxConcurrent)

for _, task := range tasks {
    sem <- struct{}{}  // Acquire BEFORE spawn

    go func() {
        defer func() { <-sem }()
        process(task)
    }()
}

// Wait by refilling: once we hold all cap(sem) permits,
// every worker must have released. This SPENDS the
// semaphore — it is full afterwards, so a further
// acquire would block forever. One-shot, not reusable.
for i := 0; i < cap(sem); i++ {
    sem <- struct{}{}
}

At most maxConcurrent goroutines exist simultaneously. Lower memory overhead but harder coordination.

Comparison

ACQUIRE INSIDE vs BEFORE GOROUTINE

One hundred tasks against a semaphore of five, under both strategies. Acquiring inside creates all hundred goroutines and five of them work while ninety-five wait. Acquiring before the go statement means only five goroutines exist at a time.

Strategy Comparison
Row
Goroutines created
Memory usage
Coordination
Best for
DEFAULT CHOICE: ACQUIRE INSIDE

When in doubt, acquire inside the goroutine—it’s simpler to coordinate with a WaitGroup, and a parked goroutine costs only its stack (~2KB). Note that acquiring inside still gates the work: process(t) runs after the permit is taken either way. What it does not gate is the goroutines themselves. Acquire before go when the task count is large enough that those stacks add up—a million waiting goroutines is ~2GB before any work starts.


Use Case: Limiting Concurrent HTTP Requests

The most common use: preventing your application from overwhelming an external service.

fetch_all.go
// Illustrative snippet — not a complete program
import (
    "net/http"
    "sync"
)

type FetchResult struct {
    URL      string
    Response Response  // Your application type
    Error    error
}

func fetchAll(
    urls []string,
    maxConcurrent int,
) []FetchResult {
    sem := make(chan struct{}, maxConcurrent)
    results := make(chan FetchResult, len(urls))

    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Go(func() {

            sem <- struct{}{}        // Acquire
            defer func() { <-sem }() // Release

            resp, err := http.Get(url)
            if err != nil {
                results <- FetchResult{
                    URL: url, Error: err,
                }
                return
            }
            defer resp.Body.Close()

            results <- FetchResult{
                URL:      url,
                Response: processResponse(resp),
            }
        })
    }

    wg.Wait()
    close(results)

    allResults := make([]FetchResult, 0, len(urls))
    for result := range results {
        allResults = append(allResults, result)
    }

    return allResults
}

What happens with 100 URLs and maxConcurrent=10:

  1. All 100 goroutines start immediately
  2. Only 10 can hold permits at once
  3. Others block on sem <- struct{}{}
  4. As each completes and releases, a waiting goroutine proceeds
  5. At most 10 concurrent HTTP requests at any moment
CONCURRENT REQUEST LIMITING

One hundred URLs fetched with a concurrency limit of ten. The first ten acquire immediately and fetch; the next ten wait for a release before starting. Throughput is bounded by the limit, not by the number of URLs.

Choosing Semaphore Capacity

Semaphore Capacity Guidelines
Row
External API calls
Database connections
CPU-intensive work
File I/O
Memory-heavy ops

Use Case: Database Connection Limiter

db_semaphore.go
// Illustrative snippet — not a complete program
import (
    "context"
    "database/sql"
)

type DB struct {
    pool *sql.DB
    sem  chan struct{}
}

func NewDB(dsn string, maxConns int) (*DB, error) {
    pool, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, err
    }
    pool.SetMaxOpenConns(maxConns)

    return &DB{
        pool: pool,
        sem:  make(chan struct{}, maxConns),
    }, nil
}

func (db *DB) Query(
    ctx context.Context,
    query string,
    args ...any,
) (*sql.Rows, func(), error) {
    select {
    case db.sem <- struct{}{}:
    case <-ctx.Done():
        return nil, nil, ctx.Err()
    }

    rows, err := db.pool.QueryContext(
        ctx, query, args...)
    if err != nil {
        <-db.sem
        return nil, nil, err
    }

    // Hold the permit until the caller is done: *sql.Rows
    // keeps the connection checked out until Close().
    release := func() {
        rows.Close()
        <-db.sem
    }
    return rows, release, nil
}
Why the release func, and not just defer

The obvious version puts defer func() { <-db.sem }() right after the acquire. It is wrong here, and subtly so: QueryContext returns as soon as the rows are ready, but *sql.Rows keeps the underlying connection checked out until you Close it. A deferred release would therefore fire while the connection is still in use, and the semaphore would bound only the time taken to issue a query—not the time a connection is held, which is the whole point.

Returning the permit alongside the rows makes the lifetime explicit: rows, release, err := db.Query(...) then defer release(). Methods that do not hand back a live connection—ExecContext, QueryRowContext—can use the plain deferred release safely.

WHY SEMAPHORE + CONNECTION POOL?

sql.DB already has SetMaxOpenConns, so why add a semaphore? Early backpressure. Without the semaphore, goroutines queue inside sql.DB invisibly. With a semaphore, you control where waiting happens and can apply timeouts, monitor wait time, and reject requests before they enter the pool queue.


Critical: Always Release with defer

The most common semaphore bug: forgetting to release on error paths.

release_bug.go
// Illustrative snippet — not a complete program
// ✗ BUG: No release on early return
func process(item Item) error {
    sem <- struct{}{}

    if !item.Valid() {
        // Permit NOT released!
        return errors.New("invalid")
    }

    doWork(item)
    <-sem
    return nil
}
release_correct.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: defer ensures all paths
func process(item Item) error {
    sem <- struct{}{}
    defer func() { <-sem }()

    if !item.Valid() {
        // defer still runs on this return
        return errors.New("invalid")
    }

    return doWork(item)
}
PERMIT LEAK TIMELINE

Without defer, forgotten releases accumulate: T=0: 10/10 available. T=10: 7/10 (3 errors leaked permits). T=100: 3/10 (system degrading). T=500: 0/10—FROZEN, all permits leaked. Always use defer func() { <-sem }()—runs on return or panic.


Timeout and Non-Blocking Acquisition

Try-Acquire (Non-Blocking)

Proceed only if a permit is immediately available:

try_acquire.go
// Illustrative snippet — not a complete program
func tryProcess(
    sem chan struct{},
    item Item,
) (bool, error) {
    select {
    case sem <- struct{}{}:
        defer func() { <-sem }()
        return true, doWork(item)
    default:
        return false, nil  // Don't wait
    }
}

Use cases: Return “server busy” immediately, load shedding, checking availability without committing.

Acquire with Timeout

acquire_timeout.go
// Illustrative snippet — not a complete program
func processWithTimeout(
    sem chan struct{},
    item Item,
    timeout time.Duration,
) error {
    timer := time.NewTimer(timeout)
    defer timer.Stop()

    select {
    case sem <- struct{}{}:
        defer func() { <-sem }()
        return doWork(item)
    case <-timer.C:
        return errors.New("timeout")
    }
}

Acquire with Context

acquire_context.go
// Illustrative snippet — not a complete program
func processWithContext(
    ctx context.Context,
    sem chan struct{},
    item Item,
) error {
    select {
    case sem <- struct{}{}:
        defer func() { <-sem }()
        return doWorkWithContext(ctx, item)
    case <-ctx.Done():
        return ctx.Err()
    }
}

When NOT to Use Semaphores

Semaphores limit concurrent access. Don't use them when other primitives are clearer:

Wrong Tool vs Right Tool
Row
Mutual exclusion (N=1)
Wait for completion
Continuous work stream
One-time signal
mutex_vs_semaphore.go
// Illustrative snippet — not a complete program
// ✗ UNCLEAR: Semaphore capacity 1
sem := make(chan struct{}, 1)
sem <- struct{}{}
defer func() { <-sem }()

// ✓ CLEARER: Mutex signals intent
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()

Semaphore vs Worker Pool

Both limit concurrency. When to use which?

Semaphore:

semaphore_pattern.go
// Illustrative snippet — not a complete program
sem := make(chan struct{}, 10)
var wg sync.WaitGroup

for _, item := range items {
    wg.Go(func() {
        sem <- struct{}{}
        defer func() { <-sem }()
        process(item)
    })
}
wg.Wait()

Worker Pool:

worker_pool_pattern.go
// Illustrative snippet — not a complete program
work := make(chan Item, 100)

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Go(func() {
        for item := range work {
            process(item)
        }
    })
}

for _, item := range items {
    work <- item
}
close(work)
wg.Wait()
THE KEY DIFFERENCE

Semaphore: “I have N goroutines already; only M should access the resource at once.” Worker pool: “I create M dedicated workers to process items from a queue.” Semaphore limits existing concurrency. Worker pool creates controlled concurrency. Worker pools are covered in Chapter 7.


Weighted Semaphores

Sometimes different operations need different amounts of capacity—a large file might need more permits than a small one. A simple channel-based implementation:

weighted_semaphore.go
// Illustrative snippet — not a complete program
type WeightedSemaphore struct {
    sem chan struct{}
}

func NewWeightedSemaphore(capacity int) *WeightedSemaphore {
    return &WeightedSemaphore{
        sem: make(chan struct{}, capacity),
    }
}

func (s *WeightedSemaphore) Acquire(n int) {
    for i := 0; i < n; i++ {
        s.sem <- struct{}{}
    }
}

func (s *WeightedSemaphore) Release(n int) {
    for i := 0; i < n; i++ {
        <-s.sem
    }
}
WEIGHTED SEMAPHORE LIMITATIONS

This simple implementation has critical issues: Non-atomic acquisition—acquiring N permits isn't atomic, partial acquisition can deadlock. No fairness—large acquisitions can starve. For production weighted semaphores, use golang.org/x/sync/semaphore which handles atomicity and fairness correctly.

weighted_stdlib.go
// Illustrative snippet — not a complete program
import "golang.org/x/sync/semaphore"

sem := semaphore.NewWeighted(100)

if err := sem.Acquire(ctx, 10); err != nil {
    return err
}
defer sem.Release(10)

Monitoring Semaphore Utilization

Track semaphore usage to tune capacity:

monitor_semaphore.go
// Illustrative snippet — not a complete program
// Pattern only — requires a metrics library
// (Prometheus, expvar, etc.)
import (
    "log/slog"
    "time"
)

func monitorSemaphore(
    sem chan struct{},
    name string,
    done <-chan struct{},
) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            capacity := cap(sem)
            // len() safe here: observation only, not flow control
            inUse := len(sem)
            util := float64(inUse) / float64(capacity)

            metrics.Gauge(name+".in_use", inUse)
            metrics.Gauge(name+".available", capacity-inUse)

            if util > 0.9 {
                slog.Warn(
                    "Semaphore exhausted",
                    "name", name,
                    "in_use", inUse,
                    "capacity", capacity)
            }
        case <-done:
            return
        }
    }
}
Semaphore Utilization Signals
Row
Always low (<20%)
20-80%
Often high (>80%)
Always 100%

Common Mistakes

Semaphore capacity 0
Problem

All acquires block forever—an unbuffered channel has no space for permits.

Fix

Capacity must be ≥ 1. The capacity is the concurrency limit.

Semaphore in wrong scope (per-request)
Problem

Creating a semaphore per request limits nothing—each request gets its own full pool.

Fix

Share the semaphore across goroutines. Create once, use everywhere.

Releasing without acquiring
Problem

Calling <-sem from a path that never acquired corrupts the limit—the semaphore now allows more than cap(sem) concurrent operations.

Fix

Always pair acquire and release. Use defer func() { <-sem }() immediately after a successful acquire to guarantee exactly one release per acquire.


Section Summary

Section 5.4 Summary
Creation
make(chan struct{}, N)
Acquire
sem <- struct{}{}
Release
<-sem
Capacity
Buffer size = max concurrent
Try-acquire
select with default
Timeout
select with time.NewTimer or ctx
Best for
Simple concurrency limits

Key Takeaways

  1. Buffer capacity = concurrency limit—the channel naturally enforces the bound
  2. Send = acquire, Receive = release—filling buffer = taking permits
  3. Always release with defer—prevents permit leaks on panic or early return
  4. struct{} is the canonical token—zero size, signals coordination intent
  5. Two acquisition strategies—inside goroutine (simpler) vs before go (lower memory)
  6. Use select for timeout/cancellation—don't block forever on overloaded systems
  7. Semaphore vs worker pool—semaphore limits existing concurrency, worker pool creates controlled concurrency
  8. For weighted permits, use x/sync/semaphore—channel-based weighted acquire isn't atomic
Section 5.4 — in one line

A buffered chan struct{} is a counting semaphore: send to acquire, receive to release, capacity is the limit. Always release with defer, and choose where you acquire by task count—inside the goroutine is simpler, before go is what keeps a million waiting stacks from existing.


Chapter 5 Self-Check

Test your understanding of the concepts covered in this chapter. Click each question to reveal the answer.

Buffered Channel Mechanics

1. You have 5 goroutines that might all send results after the receiver times out. What buffer size prevents goroutine leaks?

Answer: 5—buffer size must equal the number of senders that might outlive the receiver.

2. Your system spikes to 400 req/sec for 10 seconds. Processing capacity is 150 req/sec. What buffer size absorbs the spike?

Answer: (400 − 150) × 10 = 2,500—deficit per second × spike duration. With safety margin: ~3,000.

3. Why is if len(ch) < cap(ch) { ch <- value } unsafe?

Answer: TOCTOU race—another goroutine can send between your check and your send, filling the buffer. Use select with default for atomic non-blocking send.

4. A buffered channel with 3 values is closed. How many receives return ok=true?

Answer: 3—buffered values drain first with ok=true, then ok=false after the buffer is empty.

When to Buffer

5. Your buffer utilization is steadily increasing over hours and now sits at 100%. Should you increase the buffer size?

Answer: No—sustained 100% utilization means production exceeds consumption. Buffer size doesn't fix capacity problems. Add more consumers or implement backpressure.

6. You added a buffer and your deadlock “went away.” Is the bug fixed?

Answer: No—you masked a coordination bug. The code works by accident (buffer size happens to match current input). Change conditions and it breaks again.

Semaphores

7. You're implementing a semaphore with make(chan struct{}, 10). Which operation acquires a permit—send or receive?

Answer: Sendsem <- struct{}{} acquires (fills buffer); <-sem releases (empties buffer).

8. A semaphore acquire succeeds, but the function returns early due to an error. What happens if you forgot defer?

Answer: Permit leak—the permit is never returned. Over time, all permits leak and new acquires block forever. Always use defer func() { <-sem }().

9. You have 1 million tasks, each allocating 50MB of memory. Should you acquire the semaphore inside the goroutine or before go?

Answer: Before go—but not for the reason that first suggests itself. Acquiring inside still gates the 50MB allocation behind the semaphore, because process(t) runs only after the permit is taken. Peak allocation is cap(sem) × 50MB under either strategy. What acquiring inside does not gate is the goroutines themselves: all one million are created at once, and at roughly 2KB of stack each that is ~2GB of stacks doing nothing but queueing. Acquiring before go caps live goroutines at cap(sem) too, so the deciding factor here is task count, not task size.

10. What’s wrong with using select/default to grow an overflow slice when a buffered channel is full?

Answer: Unbounded queue anti-pattern—the overflow slice grows without limit when production exceeds consumption. Memory increases linearly until OOM. Use a bounded buffer with rejection (backpressure) instead.


Exercise 5.1 — Room for the Losers

Your move

Size a buffer so the slow answers have somewhere to land

§5.1 gave you the N−1 rule and leak_detect.go measured it: three senders, one receive, two goroutines left standing. Here is the same rule in the shape you will actually meet it — a first-answer-wins fan-in across several backends.

ch05/fanin.go
package ch05

import "time"

type Result struct {
	Source string
	Value  int
}

// Provided for you: one backend answering after a delay.
func query(source string, delay time.Duration) Result {
	time.Sleep(delay)
	return Result{Source: source, Value: len(source)}
}

// TODO(reader): First asks every source at once and returns whichever
// answers first, discarding the rest. It is sized wrong in exactly the
// way §5.1 describes.
//
// `results` is unbuffered, so the one goroutine whose answer is
// received finishes — and the other N-1 block forever on a send that
// nobody will ever take. What makes this easy to miss is that First
// still returns the right answer, quickly: from the caller's side
// nothing looks wrong.
//
// Fix it with §5.1's rule. Two constraints, so you reach for the
// right tool:
//   - do not add a WaitGroup and do not wait for the slow sources,
//   - do not change the signature,
//   - First must still return as soon as the first answer lands.
func First(sources []string, delays []time.Duration) Result {
	results := make(chan Result) // <- your move

	for i, src := range sources {
		go func() {
			results <- query(src, delays[i])
		}()
	}

	return <-results
}
ch05/fanin_test.go
package ch05

import (
	"runtime"
	"testing"
	"time"
)

func TestFirstReturnsTheFastestAnswer(t *testing.T) {
	sources := []string{"alpha", "be", "gamma-source"}
	delays := []time.Duration{
		80 * time.Millisecond,
		10 * time.Millisecond,
		120 * time.Millisecond,
	}

	got := First(sources, delays)
	if got.Source != "be" {
		t.Fatalf("First returned %q, want %q (the fastest)",
			got.Source, "be")
	}
}

// The test the naive version cannot pass. First returns the right
// answer either way; what changes is whether the losers can finish.
func TestFirstLeavesNoGoroutinesBehind(t *testing.T) {
	sources := []string{"a", "bb", "ccc", "dddd", "eeeee"}
	delays := []time.Duration{
		10 * time.Millisecond,
		20 * time.Millisecond,
		30 * time.Millisecond,
		40 * time.Millisecond,
		50 * time.Millisecond,
	}

	settle()
	before := runtime.NumGoroutine()

	First(sources, delays)

	// Well past the slowest source: any goroutine still alive here is
	// parked on a send, not merely slow.
	time.Sleep(200 * time.Millisecond)
	settle()
	leaked := runtime.NumGoroutine() - before

	if leaked > 0 {
		t.Fatalf("%d goroutines leaked (want 0).\nWith %d senders and "+
			"one receiver, the %d that lose the race are still parked "+
			"on a send. See §5.1's N-1 rule.",
			leaked, len(sources), len(sources)-1)
	}
}

func settle() {
	for i := 0; i < 5; i++ {
		runtime.Gosched()
		time.Sleep(5 * time.Millisecond)
	}
}

The first test passes already. That is the point: First returns the right answer, in the right time, whether or not you fix it. The leak is invisible from the caller’s side, which is why the second test has to go looking for it:

go test ./...
--- FAIL: TestFirstLeavesNoGoroutinesBehind (0.27s)
  4 goroutines leaked (want 0).
  With 5 senders and one receiver, the 4 that lose the race
  are still parked on a send. See §5.1’s N-1 rule.
FAIL    corebackend.dev/go-concurrency/ch05

Five senders, four leaked — N−1 exactly, the same arithmetic leak_detect.go printed earlier in the chapter. Change the sources and the number moves with them.

Done when: go test -race ./... in code/ch05/ reports ok for both tests, and First still returns as soon as the fastest source answers — if your fix made it wait for the stragglers, you solved a different problem.
Hint, if you want one: one character changes on one line. The question §5.1 asks is how many values could be in flight with nobody to receive them, and the answer is written in the signature.
Where the files are: labs/go-concurrency/code/ch05/. A worked answer sits in solution/fanin.go.txt — worth noticing first that the passing test tells you nothing, because that is the part which transfers to real code.

Further reading

Next

You can now say what a buffer changes and what it does not, size one against the number of senders rather than a round number, name the four situations where adding one makes things worse, and use a buffered chan struct{} to bound concurrency. Every channel so far has been bidirectional—any holder could send or receive. Chapter 6 takes that away: directional channel types, what chan<- T and <-chan T let the compiler enforce, and how narrowing a channel at a function boundary turns the sender-closes principle from a convention you remember into a rule the compiler keeps.