Chapter 7: Channel Patterns

Chapters 3 through 6 gave you the building blocks: channels for communication, select for multiplexing, buffering for timing control, and directional types for API safety. These primitives are powerful but low-level—like having lumber, nails, and tools without blueprints. This chapter provides the blueprints: battle-tested patterns that solve recurring concurrency problems. These aren’t theoretical constructs—they’re the patterns that appear in production Go systems processing millions of requests.

What you'll learn
  • Pipeline pattern: Composing stages for data transformation
  • Fan-out/Fan-in: Distributing work and aggregating results
  • Worker pools: Fixed concurrency with job queues and graceful shutdown
  • Or-channel: First-response-wins coordination
  • Or-done: Propagating cancellation through complex flows
  • Tee and broadcast: Duplicating streams to multiple consumers
What we’re NOT covering in Chapter 7
  • Context package internals—Chapter 13
  • Error handling strategies across goroutines—Chapter 14
  • Testing concurrent code—Chapter 16
Prerequisites

You should understand channel lifecycle and the sender-closes principle (Chapter 3), select statements (Chapter 4), buffered channels (Chapter 5), and directional types with the output channel pattern (Chapter 6).

PRIMITIVES → PATTERNS

A two-tier stack. The upper tier lists the four primitives from Chapters 3 to 6: channels, select, buffering and directional types, each with its job in parentheses. A single arrow runs down to the lower tier, which lists this chapter's six patterns: pipeline, fan-out/fan-in, worker pool, or-channel, or-done and tee/broadcast. The caption is that patterns combine primitives into reusable production structures.

Each pattern solves a specific problem. Learn when to use each, and you’ll recognize the right tool when you encounter these problems in your own systems.

GENERIC VS CONCRETE TYPES

This chapter uses both: concrete types (chan int) in teaching examples for clarity, and generic types (<-chan T) in reusable utilities (tee, orDone, Hub).

In your code: use concrete types for application logic (clearer, easier to debug); use generics for utilities you’ll reuse across types.


Which Go are we on?

The book targets Go 1.25+, the same version the exercises declare in their go.mod, and two releases show through in this chapter. Every goroutine started under a WaitGroup here uses wg.Go(func(){…}), added in Go 1.25, rather than the Add(1)/defer Done() pair. And the timeout code assumes the Go 1.23 timer rewrite: an unreferenced timer is collected whether or not it fired and whether or not you stopped it, so time.After in this chapter is a throughput question, never a leak. Chapter 4 §4.4 works through that change in full.

7.1 Pipeline Pattern

A pipeline is a series of stages connected by channels, where each stage:

  1. Receives values from an upstream channel
  2. Performs a transformation or computation
  3. Sends results to a downstream channel

Data flows in one direction—from generators through transformers to consumers. Each stage runs concurrently, processing values as they arrive.

PIPELINE STRUCTURE

Four stages left to right: generator, transformer, transformer, consumer, joined by three arrows labeled ch1, ch2 and ch3. Under each stage is its job, and under the first three is who closes which channel: the generator closes ch1 when done, the second closes ch2 when ch1 closes, the third closes ch3 when ch2 closes. Data flows left to right and all stages run concurrently.

Why pipelines?

Sequential vs Pipeline Processing

The power of pipelines becomes clear when comparing execution patterns:

SEQUENTIAL VS PIPELINE PROCESSING

A comparison of two timelines. In the sequential version each item runs read, parse, validate and transform to completion before the next item starts. In the pipeline version each of the four stages runs its own stream concurrently, staggered by one step. The note underneath: first-item latency is the same either way because a value still crosses every stage, but throughput is bounded by the slowest single stage rather than the sum of all of them.

Stage Types

Pipelines consist of three stage types:

Generator (Source)

Creates values from parameters, computation, or I/O. Has no input channel:

generate.go
// Illustrative snippet — not a complete program
func generate(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            select {
            case <-ctx.Done():
                return
            case out <- n:
            }
        }
    }()
    return out
}
WHY GENERATORS ACCEPT CONTEXT

Even though generators have no upstream, they need context because they may perform I/O (reading files, network calls), they may generate millions of values, and consumers need a way to signal “stop generating.”

Without context, a generator producing a million values would run to completion even if the consumer only needed the first 10.

Transformer (Middle Stage)

Receives values, transforms them, sends results downstream:

square.go
// Illustrative snippet — not a complete program
func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case <-ctx.Done():
                return
            case out <- n * n:
            }
        }
    }()
    return out
}

Consumer (Sink)

Receives values and performs side effects. Has no output channel:

consume.go
// Illustrative snippet — not a complete program
func consume(in <-chan int) {
    for n := range in {
        fmt.Println(n)
    }
}

Consumers typically run in the calling goroutine (often main) and don’t need context—they simply range until the input channel closes.

WHEN CONSUMERS NEED CONTEXT

Simple consumers that just iterate don’t need context—channel closure is their termination signal. However, if your consumer performs expensive work per item, check context to exit early:

expensive_consumer.go
// Illustrative snippet — not a complete program
func expensiveConsumer(ctx context.Context, in <-chan Item) {
    for item := range in {
        if ctx.Err() != nil {
            return  // Don't start expensive work if cancelled
        }
        expensiveWork(item)
    }
}

We use ctx.Err() here for a non-blocking check before starting work. Use select on ctx.Done() when you need to wait on cancellation alongside a channel operation (as in the stage template).

STAGE TYPES

Three stage shapes. A source takes nothing and returns a receive-only channel of T, creating values from parameters, files or the network. A transform takes a receive-only channel of T and returns a receive-only channel of U, where T and U may differ. A sink takes a receive-only channel of T and returns nothing, consuming values for their side effects.

The Stage Template

Every pipeline stage follows this structure:

stage_template.go
// Pseudocode — replace T and U with concrete types
func stageName(ctx context.Context, in <-chan T) <-chan U {
    out := make(chan U)
    go func() {
        defer close(out)          // 1. Always close output
        for value := range in {   // 2. Process until input closes
            result := transform(value)
            select {
            case <-ctx.Done():    // 3. Respect cancellation
                return
            case out <- result:   // 4. Send result
            }
        }
    }()
    return out
}

Four essential elements:

  1. defer close(out): Guarantees closure on all exit paths
  2. for range in: Processes until input closes
  3. select with ctx.Done(): Enables cancellation during sends
  4. Returns <-chan U: Caller receives read-only view
KEY INSIGHT: WHY SELECT ON SEND?

Without the select, a stage blocks on send if the next stage has stopped receiving. With select, the ctx.Done() case provides an immediate exit path.

select_on_send.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Blocks forever if receiver stops
for value := range in {
    out <- transform(value)  // Can't cancel mid-send
}

// ✓ SAFE: Can exit immediately when cancelled
for value := range in {
    select {
    case <-ctx.Done():
        return
    case out <- transform(value):
    }
}
WHY FOR RANGE IS SAFE FOR INPUT

You might wonder why we don’t check ctx.Done() on receive. When context cancels, upstream stages exit via their select on send, then defer close(out) runs, closing our input channel—causing for range to exit. Cancellation propagates through channel closures.

Caveat: This assumes upstream stages respect context. If you’re consuming from a channel produced by code that doesn’t check context (e.g., a third-party library), use explicit select:

explicit_select_receive.go
// Illustrative snippet — not a complete program
for {
    select {
    case <-ctx.Done():
        return
    case n, ok := <-in:
        if !ok {
            return
        }
        // process n
    }
}

Basic Pipeline Example

pipeline_basic.go
// Illustrative snippet — not a complete program
func main() {
    ctx := context.Background()

    // Build pipeline: generate → square
    numbers := generate(ctx, 1, 2, 3, 4, 5)
    squares := square(ctx, numbers)

    // Consume results
    for n := range squares {
        fmt.Println(n)  // 1, 4, 9, 16, 25
    }
}

Execution timeline:

EXECUTION TIMELINE

A timeline with three rows. generate emits 1 through 5 then closes. Arrows drop from each value to the square row, which emits 1, 4, 9, 16, 25 then closes. Arrows drop again to main, which prints five times and exits. All three run concurrently and values move as soon as they are available rather than in batches.

Composing Multiple Stages

Pipelines shine when composed from reusable stages:

pipeline_compose.go
// Illustrative snippet — not a complete program
func filter(
    ctx context.Context,
    in <-chan int,
    predicate func(int) bool,
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            if predicate(n) {
                select {
                case <-ctx.Done():
                    return
                case out <- n:
                }
            }
        }
    }()
    return out
}

func main() {
    ctx := context.Background()

    // Four-stage pipeline:
    // generate → square → filter evens → consume
    numbers := generate(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    squared := square(ctx, numbers)
    evens := filter(ctx, squared, func(n int) bool {
        return n%2 == 0
    })

    for n := range evens {
        fmt.Println(n)  // 4, 16, 36, 64, 100
    }
}

Each stage is independent—square doesn’t know it’s between generate and filter. Stages are self-contained functions on channels—composability comes free.

Closure Propagation

When a stage’s input channel closes, its for range loop exits, triggering defer close(out). This propagates through the entire pipeline:

CLOSURE PROPAGATION

A timeline of four rows showing closure cascading downward. generate finishes sending, its loop exits, and its deferred close runs. An arrow drops to square, whose range loop exits and closes its own output. The same happens to filter, and finally main's range over evens exits and the program completes. Each stage's deferred close is what triggers the next stage's exit.

No coordination needed. Each stage independently follows the pattern: receive until input closes, then close output. The cascade is automatic.

Important: Closure propagates downstream only—from source to sink. If the consumer stops early, upstream stages have no way to know through closure alone. That’s why context-based cancellation (next section) is essential for the reverse direction.

CRITICAL: EVERY STAGE MUST CLOSE ITS OUTPUT

If any stage forgets defer close(out), downstream stages block forever on for range:

broken_square.go
// Illustrative snippet — not a complete program
func brokenSquare(
    ctx context.Context,
    in <-chan int,
) <-chan int {
    out := make(chan int)
    go func() {
        // Missing: defer close(out)
        for n := range in {
            select {
            case <-ctx.Done():
                return
            case out <- n * n:
            }
        }
    }()
    return out
}

// Consumer blocks forever after receiving all values
for n := range brokenSquare(ctx, generate(ctx, 1, 2, 3)) {
    // Receives 1, 4, 9, then blocks forever
}

The Problem: Early Termination

Closure propagation works when the pipeline runs to completion. But what if the consumer stops early?

early_exit_leak.go
// Illustrative snippet — not a complete program
func main() {
    ctx := context.Background()

    numbers := generate(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    squares := square(ctx, numbers)

    // Only consume first 3 values
    count := 0
    for n := range squares {
        fmt.Println(n)
        count++
        if count >= 3 {
            break  // Stop consuming
        }
    }

    // Problem: generate and square goroutines
    // still running! They block forever.
}

When main breaks out of the loop, no one receives from squares. The square goroutine blocks on send. Since square isn’t receiving from numbers, the generate goroutine blocks too. Both leak.

GOROUTINE LEAK ON EARLY EXIT

A leak timeline. main receives three values, breaks out of its loop and exits, so no further receives ever happen. square is left blocked on a send, marked LEAK. generate is left blocked on a send, also marked LEAK. With no receiver, every upstream stage blocks forever.

Solution: Context-Based Cancellation

Each stage checks ctx.Done() alongside its operations. When cancelled, stages exit immediately:

pipeline_cancel.go
// Illustrative snippet — not a complete program
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()  // Ensure cleanup on any exit

    numbers := generate(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    squares := square(ctx, numbers)

    count := 0
    for n := range squares {
        fmt.Println(n)
        count++
        if count >= 3 {
            cancel()  // Signal all stages; cancel is idempotent
            break
        }
    }

    // All goroutines exit cleanly via ctx.Done()
}
CONTEXT CANCELLATION

The same scenario with a context. main receives three values, calls cancel, breaks and exits. An arrow shows ctx.Done closing. Both square and generate see the ctx.Done case in their select, return, and run their deferred close. Every goroutine detects the cancellation and exits.

CANCELLATION IS COOPERATIVE

Context cancellation only works because every stage checks ctx.Done(). If any stage ignores context, it will block on send and leak. This is why the stage template is critical—deviating from it breaks the cancellation chain.

Cancellation via select is best-effort—a stage may process one more in-flight value before noticing ctx.Done(), because select picks randomly among ready cases.

CRITICAL: ALWAYS DEFER CANCEL()

Always pair context.WithCancel with defer cancel(). This guarantees cleanup on any exit path: normal completion, early return, or panic. See Chapter 13 for details on context lifecycle.

Two Propagation Mechanisms

Pipelines use two independent mechanisms that work together:

Normal Completion: Closure Propagation

As we saw above, when the generator finishes, closure cascades downstream through each stage’s defer close(out). This is sequential and downstream only—if the consumer stops early, upstream stages won’t know through closure alone.

Cancellation: Context Propagation

When context is cancelled, all stages detect it simultaneously:

CONTEXT BROADCAST

A four-step chain: calling cancel closes ctx.Done as a broadcast, which fires the select case in every stage, which makes every stage return, which runs every deferred close. All stages exit in parallel rather than one after another.

Key insight: Closure propagation handles normal completion (downstream). Context handles abnormal termination (broadcast to all). Both trigger defer close(out), ensuring downstream always sees closure.

Complete Cancellable Pipeline

pipeline_complete.go
package main

import (
    "context"
    "fmt"
    "time"
)

// generate emits integers 1..max (range-based variant)
func generate(ctx context.Context, max int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 1; i <= max; i++ {
            time.Sleep(10 * time.Millisecond) // Simulate I/O
            select {
            case <-ctx.Done():
                return
            case out <- i:
            }
        }
    }()
    return out
}

func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case <-ctx.Done():
                return
            case out <- n * n:
            }
        }
    }()
    return out
}

func filter(
    ctx context.Context,
    in <-chan int,
    pred func(int) bool,
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            if pred(n) {
                select {
                case <-ctx.Done():
                    return
                case out <- n:
                }
            }
        }
    }()
    return out
}

func main() {
    ctx, cancel := context.WithTimeout(
        context.Background(),
        100*time.Millisecond,
    )
    defer cancel()

    // Build pipeline:
    // generate → square → filter (evens only)
    numbers := generate(ctx, 100)
    squared := square(ctx, numbers)
    evens := filter(ctx, squared, func(n int) bool {
        return n%2 == 0
    })

    // Consume results
    for n := range evens {
        fmt.Println(n)
    }

    fmt.Println("Done")
}
go run main.go
4
16
36
64
Done

The generator sleeps 10 ms per value and the deadline is 100 ms, so the stream is cut after roughly ten values — 2, 4, 6, 8 squared and kept. Whether the tenth value (100) makes it out is a genuine coin flip against the deadline, which is what a deadline is: the pipeline stops wherever the clock catches it, and every stage shuts down cleanly either way. That is the property to take from this example, not the exact list.

Measured go1.26.1, darwin/amd64: 30 consecutive runs gave 4 16 36 64 Done 27 times and 4 16 36 64 100 Done 3 times. Done printed every time — the consumer’s range loop always ended, because closure propagated through all three stages.

Long-Running Transformations

The stage template checks ctx.Done() on sends, but what if the transformation itself takes significant time?

long_transform_problem.go
// Illustrative snippet — not a complete program
for value := range in {
    // Takes 10s — can't cancel mid-work
    result := expensiveTransform(value)
    select {
    case <-ctx.Done():
        return
    case out <- result:
    }
}

Problem: If expensiveTransform takes 10 seconds and context cancels after 1 second, the goroutine still runs for 9 more seconds before checking ctx.Done().

Solution: Check context before starting expensive work:

long_transform_fix.go
// Illustrative snippet — not a complete program
for value := range in {
    // Check before expensive work
    select {
    case <-ctx.Done():
        return
    default:
    }

    result := expensiveTransform(value)

    select {
    case <-ctx.Done():
        return
    case out <- result:
    }
}

For truly long operations, pass context into the operation itself: expensiveTransform(ctx, value).

Error Handling in Pipelines

Stages can fail. The simplest approach bundles values and errors together:

pipeline_error.go
// Illustrative snippet — not a complete program
type Result struct {
    Value int
    Err   error
}

func process(
    ctx context.Context,
    in <-chan string,
) <-chan Result {
    out := make(chan Result)
    go func() {
        defer close(out)
        for s := range in {
            n, err := strconv.Atoi(s)
            select {
            case <-ctx.Done():
                return
            case out <- Result{Value: n, Err: err}:
            }
        }
    }()
    return out
}

// Consumer: ALWAYS check Err before using Value
// Value is the zero value of its type when Err != nil
for r := range process(ctx, strings) {
    if r.Err != nil {
        log.Printf("error: %v", r.Err)
        continue
    }
    use(r.Value)
}

Use when: You want to process as many values as possible despite errors.

Alternative strategies exist for different error semantics:

Buffering in Pipelines

By default, pipeline channels are unbuffered—providing natural backpressure. Add buffering when stages have variable processing times:

pipeline_buffered.go
// Illustrative snippet — not a complete program
func squareBuffered(
    ctx context.Context,
    in <-chan int,
) <-chan int {
    out := make(chan int, 10)  // Buffer smooths throughput
    go func() {
        defer close(out)
        for n := range in {
            select {
            case <-ctx.Done():
                return
            case out <- n * n:
            }
        }
    }()
    return out
}
Buffer Size Guidelines
Row
Stages have similar speed
Upstream occasionally bursts
Memory-constrained
BUFFERS DON’T FIX THROUGHPUT MISMATCH

If a stage is consistently slower than its input, buffers only delay blocking—they don’t fix the problem. For sustained mismatch, parallelize the slow stage with fan-out (§7.2).

When to Use Pipelines

Good use cases:

Poor use cases:

pipeline_when.go
// Illustrative snippet — not a complete program
// ✗ OVERKILL: Pipeline for 5 numbers
// Creates 2 goroutines, 2 channels
for n := range square(ctx, generate(ctx, 1, 2, 3, 4, 5)) {
    process(n)
}

// ✓ SIMPLER: Direct loop is clearer and faster
for _, n := range []int{1, 2, 3, 4, 5} {
    process(n * n)
}

// Rule of thumb: Pipelines pay off when:
// - Items involve non-trivial computation or I/O, OR
// - Stages involve I/O or blocking, OR
// - Data is streaming (won't fit in memory)

Common Mistakes

No defer close(out)
Problem

Consumer’s for range blocks forever after all values are received

Fix

Always defer close(out) in the goroutine

No cancellation support
Problem

Goroutines leak on early exit from consumer

Fix

Accept context.Context, select on ctx.Done()

Sending without select
Problem

Blocked send ignores cancellation

Fix

Wrap all sends in select with ctx.Done()

Returning chan T instead of <-chan T
Problem

Caller can send or close, breaking ownership

Fix

Always return <-chan T (read-only view)

Not sharing context across stages
Problem

Cancellation doesn’t reach all stages

Fix

Pass the same context to all stages

Expensive work before select
Problem

Can’t cancel mid-computation

Fix

Check ctx.Done() before expensive work

Key Takeaways

  1. Pipelines = stages + channels—data flows through transformation steps
  2. Three stage types—generator (source), transformer (middle), consumer (sink)
  3. Every stage closes its outputdefer close(out) is mandatory
  4. Closing propagates downstream—upstream close triggers downstream exit
  5. All stages accept context—enables cancellation and timeout
  6. Cancellation is cooperative—every stage must check ctx.Done()
  7. Check ctx.Done() in sends—allows immediate exit on cancellation
  8. Prefer pure transforms—complex side effects are easier to manage in dedicated consumer stages
  9. Unbuffered by default—add buffering only with justification
  10. Composition is the power—build complex flows from simple, reusable stages

Next: §7.2 covers fan-out and fan-in patterns—distributing work across multiple workers (fan-out) and aggregating results from multiple sources (fan-in). You’ll learn when parallel processing improves throughput and how to implement it correctly.


7.2 Fan-Out and Fan-In

§7.1 showed pipelines where data flows through sequential stages. But what happens when one stage is significantly slower than others?

pipeline_bottleneck.go
// Illustrative snippet — not a complete program
func main() {
    ctx := context.Background()

    urls := generate(ctx, getURLs()...)  // Fast: generates instantly
    responses := fetch(ctx, urls)       // SLOW: network I/O
    results := parse(ctx, responses)     // Fast: CPU parsing

    for r := range results {
        process(r)
    }
}

The fetch stage dominates execution time. The pipeline processes one URL at a time—each request waits for the previous to complete. With 100 URLs at 100ms each, that’s 10 seconds of sequential waiting.

PIPELINE BOTTLENECK

A bottleneck timeline. generate emits url1 through url100 quickly. The fetch stage below takes 100 milliseconds per URL and processes them one at a time. The parse stage below that sits mostly idle waiting. The arithmetic: 100 URLs at 100 milliseconds each is 10 seconds sequential. One slow stage serializes the whole pipeline.

Fan-out and fan-in solve this:

PARALLEL PROCESSING WITH FAN-OUT/FAN-IN

The same pipeline with the fetch stage fanned out. generate feeds five parallel fetch workers, each running 100-millisecond fetches concurrently, and their outputs merge back into a single parse stage. Total time drops to roughly 2 seconds, a fivefold speed-up.

Fan-Out: Multiple Workers, Shared Input and Output

Fan-out is surprisingly simple in Go. Multiple goroutines can safely receive from the same channel—Go’s runtime ensures each value is delivered to exactly one receiver. Workers then send results to a shared output channel:

fan_out.go
// Illustrative snippet — not a complete program
func fanOut(
    ctx context.Context,
    in <-chan int,
    workers int,
    work func(int) int,
) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Start N workers, all reading from
    // same input, writing to same output
    for i := 0; i < workers; i++ {
        wg.Go(func() {
            for n := range in {
                result := work(n)
                select {
                case <-ctx.Done():
                    return
                case out <- result:
                }
            }
        })
    }

    // Close output when all workers finish
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}
FAN-OUT MECHANICS

One input channel holding values 1, 2, 3 and onward feeds three workers below it. Worker 1 gets 1, worker 2 gets 2, worker 3 gets 3. All three feed a single shared output channel below. Each input value goes to exactly one worker, and a WaitGroup coordinates the close of the shared output.

THIS PATTERN COMBINES FAN-OUT AND FAN-IN

The fanOut function above includes implicit fan-in—workers share a single output channel. This is the pattern you’ll use 90% of the time. The explicit fanIn function (shown later) is for merging independent channels from different sources.

HOW WORK DISTRIBUTION WORKS

When multiple goroutines receive from one channel, they compete for values. Go’s scheduler selects an arbitrary ready receiver:

  • Self-balancing: Fast workers naturally process more items (they’re ready more often)
  • Effective distribution: For non-trivial workloads, all workers stay busy (though Go makes no formal fairness guarantee)
  • No explicit scheduling: The channel itself coordinates distribution

For small workloads (< 100 items), distribution may be uneven. For large workloads, it balances effectively.

CANCELLATION AND IN-FLIGHT WORK

Workers check ctx.Done() between items, not during work execution. If work(n) takes 10 seconds and context cancels after 1 second, the worker finishes that item before exiting. For truly interruptible work, pass context to the work function itself.

inflight_cancel.go
// Illustrative snippet — not a complete program
result := work(n)  // Runs to completion even if ctx cancelled
select {
case <-ctx.Done():
    return
case out <- result:
}

Fan-In: Merging Independent Channels

When you have multiple independent data sources (not workers sharing an input), use explicit fan-in to merge them:

fan_in.go
// Illustrative snippet — not a complete program
func fanIn(
    ctx context.Context,
    channels ...<-chan int,
) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Start a forwarder for each input channel
    for _, ch := range channels {
        wg.Go(func() {
            for n := range ch {
                select {
                case <-ctx.Done():
                    return
                case out <- n:
                }
            }
        })
    }

    // Close when all inputs are exhausted
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}
FAN-IN: MERGE INDEPENDENT SOURCES

Three independent API servers each own a channel, ch[0], ch[1] and ch[2]. All three merge downward into one output channel. The note distinguishes this from fan-out: use fan-in when aggregating genuinely independent sources, and the combined fanOut when parallelizing workers over one input.

When to use explicit fan-in:

WHEN TO USE EXPLICIT FANIN VS COMBINED PATTERN

Combined pattern (fanOut above): Workers share both input AND output channels. Use when parallelizing a single stage—workers are interchangeable and process the same type of work.

Explicit fanIn: Each source has its own channel producing data independently. Use when aggregating truly different data sources—different APIs, files, or subsystems that happen to produce the same type.

RESULTS ARRIVE OUT OF ORDER

Both fan-out and fan-in produce results in completion order, not input order:

Output (one real run — order varies)
Input order: 1, 2, 3, 4, 5, 6, 7, 8, 9
Output order: 1, 4, 5, 2, 3, 6, 9, 7, 8

Notice that real fan-out output clusters: each worker runs ahead on a short streak (1, 4, 5 from one worker, 2, 3, 6 from another) rather than interleaving evenly. The scheduler hands a worker several values in a row while the others are still busy, so neighbouring outputs tend to share a worker.

If order matters, include sequence numbers and reorder afterward (shown later).

Measured go1.26.1, darwin/amd64: the listing above run 300 times with 3 workers produced 103 distinct orderings. The one shown was the most common at 44/300; strict input order 1–9 came up 13 times. Your run will differ.

When Does Fan-Out Help?

Fan-out improves throughput only when the bottleneck stage can benefit from parallelism:

I/O-Bound Work: High Benefit

When workers spend time waiting, more workers means more concurrent waits:

I/O-BOUND: FAN-OUT EFFECTIVE

An I/O-bound comparison. One worker runs three 100-millisecond waits back to back for 300 milliseconds total. Three workers run the same three waits stacked in parallel for 100 milliseconds total. Because the workers are waiting rather than computing, total time approaches the longest single wait rather than the sum.

CPU-Bound Work: Limited by Cores

For CPU-intensive work, parallelization is limited by available cores:

CPU-BOUND: LIMITED BY CORES

A CPU-bound comparison on a four-core machine. Four workers each compute, reaching full CPU utilization with minimal overhead, marked optimal. Eight workers deliver the same throughput slightly worse, because context switching costs something and there is no more CPU to use. More workers than cores adds overhead without benefit.

Decision Table

Fan-Out Benefit by Work Type
Row
I/O-bound (network, disk)
CPU-bound (pure computation)
CPU + occasional I/O
Memory-bound (large allocs)
Shared state with locks

Choosing the Number of Workers

Once you’ve decided to fan out, use these starting points for worker count:

Guidelines

Worker Count Starting Points
Row
CPU-bound
I/O (network)
I/O (disk)
Mixed
WORKER COUNT VS THROUGHPUT (I/O-BOUND)

A throughput curve against worker count. Throughput climbs steeply from 1 worker, bends between 10 and 30, and flattens into a plateau by 40 to 50. An arrow marks the sweet spot at the bend. Too few workers underuse I/O capacity; too many add switching overhead and can overload the backend.

MEASURE, DON’T GUESS

The right worker count depends on your specific workload. Benchmark different values and look for where throughput plateaus—that’s your sweet spot.

benchmark_workers.go
// Illustrative snippet — not a complete program
// drain consumes all values and returns the count
func drain[T any](ch <-chan T) int {
    count := 0
    for range ch {
        count++
    }
    return count
}

// Pseudocode — define generate, process for your workload
for workers := 1; workers <= 64; workers *= 2 {
    tasks := generate(ctx, 10000)  // Fresh channel each iteration
    start := time.Now()
    results := fanOut(ctx, tasks, workers, process)
    count := drain(results)
    throughput := float64(count) / time.Since(start).Seconds()
    fmt.Printf("%2d workers: %.0f items/sec\n",
        workers, throughput)
}

Bounded vs Unbounded Workers

Always use a fixed worker count in production:

bounded_workers.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Unbounded — one goroutine per task
for task := range tasks {
    go func() {
        process(task)  // 1M tasks = 1M goroutines
    }()
}

// ✓ SAFE: Fixed worker count
results := fanOut(ctx, tasks, 10, process)
Fixed vs Unbounded Workers
Row
Goroutines
Memory
Backpressure
Use case

Complete Example: Parallel URL Fetcher

parallel_fetcher.go
package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "sync"
    "time"
)

type FetchResult struct {
    URL  string
    Size int
    Err  error
}

// generate emits each URL (string variant of the pipeline generator)
func generate(
    ctx context.Context,
    urls ...string,
) <-chan string {
    out := make(chan string)
    go func() {
        defer close(out)
        for _, url := range urls {
            select {
            case <-ctx.Done():
                return
            case out <- url:
            }
        }
    }()
    return out
}

func fetch(
    ctx context.Context,
    url string,
) FetchResult {
    req, err := http.NewRequestWithContext(
        ctx, "GET", url, nil,
    )
    if err != nil {
        return FetchResult{URL: url, Err: err}
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return FetchResult{URL: url, Err: err}
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return FetchResult{URL: url, Err: err}
    }

    return FetchResult{URL: url, Size: len(body)}
}

func fanOutFetch(
    ctx context.Context,
    urls <-chan string,
    workers int,
) <-chan FetchResult {
    out := make(chan FetchResult)
    var wg sync.WaitGroup

    for i := 0; i < workers; i++ {
        wg.Go(func() {
            for url := range urls {
                result := fetch(ctx, url)
                select {
                case <-ctx.Done():
                    return
                case out <- result:
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

// A local server, so the byte counts and the timing below are the
// same on every machine. Each page takes 200ms to produce.
func newSlowServer() *httptest.Server {
    return httptest.NewServer(http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            time.Sleep(200 * time.Millisecond)
            size := map[string]int{
                "/a": 1024, "/b": 2048,
                "/c": 4096, "/d": 8192,
            }[r.URL.Path]
            w.Write(make([]byte, size))
        },
    ))
}

func main() {
    ctx, cancel := context.WithTimeout(
        context.Background(),
        10*time.Second,
    )
    defer cancel()

    srv := newSlowServer()
    defer srv.Close()

    urls := []string{
        srv.URL + "/a", srv.URL + "/b",
        srv.URL + "/c", srv.URL + "/d",
    }

    start := time.Now()

    // Pipeline: generate → fan-out fetch → consume
    urlChan := generate(ctx, urls...)
    results := fanOutFetch(ctx, urlChan, 3)

    for r := range results {
        if r.Err != nil {
            fmt.Printf("Error fetching %s: %v\n",
                r.URL, r.Err)
        } else {
            fmt.Printf("Fetched %s: %d bytes\n",
                r.URL[len(srv.URL):], r.Size)
        }
    }

    // 4 pages at 200ms each: ~800ms one at a time, ~400ms
    // with 3 workers (three in the first wave, one in the second).
    fmt.Printf("Total time: %v\n",
        time.Since(start).Round(10*time.Millisecond))
}
Output (one real run — order varies)
Fetched /b: 2048 bytes
Fetched /c: 4096 bytes
Fetched /a: 1024 bytes
Fetched /d: 8192 bytes
Total time: 400ms

The sizes and the total are the same on every machine; only the order changes. Four 200 ms pages take ~800 ms one at a time. Three workers run the first three concurrently and the fourth in a second wave, so the wall clock is two waves — 400 ms — not four.

Measured go1.26.1, darwin/amd64: ten consecutive runs each reported Total time: 400ms. The completion order differed between them, which is the point of the example.

Preserving Order (When Required)

If output order must match input order, include sequence numbers:

ordered_fanout.go
// Illustrative snippet — not a complete program
type IndexedResult struct {
    Index int
    Value int
}

// orderedFanOut processes values in parallel
// and returns results in input order.
// On cancellation, unprocessed indices contain zero values.
func orderedFanOut(
    ctx context.Context,
    values []int,
    workers int,
    work func(int) int,
) ([]int, error) {

    type indexedTask struct {
        index int
        value int
    }

    // Send indexed tasks
    tasks := make(chan indexedTask)
    go func() {
        defer close(tasks)
        for i, v := range values {
            select {
            case <-ctx.Done():
                return
            case tasks <- indexedTask{i, v}:
            }
        }
    }()

    // Fan-out processing
    results := make(chan IndexedResult)
    var wg sync.WaitGroup

    for i := 0; i < workers; i++ {
        wg.Go(func() {
            for t := range tasks {
                r := IndexedResult{
                    Index: t.index,
                    Value: work(t.value),
                }
                select {
                case <-ctx.Done():
                    return
                case results <- r:
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect and reorder
    ordered := make([]int, len(values))
    received := 0
    for r := range results {
        ordered[r.Index] = r.Value
        received++
    }

    // Check for partial completion
    if received < len(values) {
        return ordered, fmt.Errorf(
            "cancelled: received %d of %d",
            received, len(values),
        )
    }

    return ordered, nil
}
ORDER PRESERVATION HAS COSTS
  • Memory: Must buffer all results before returning
  • Latency: Can’t stream results—must wait for all to complete
  • Partial results: On cancellation, returns error with partial data (unprocessed indices are zero)

Only preserve order when actually required. Most use cases don’t need it.

Combining Fan-Out with Pipelines

Fan-out integrates naturally into multi-stage pipelines—parallelize only the slow stages:

pipeline_fanout.go
// Illustrative snippet — not a complete program
func processPipeline(
    ctx context.Context,
    urls []string,
) <-chan Analyzed {
    // Stage 1: Generate URLs (fast — single)
    urlsCh := generate(ctx, urls...)

    // Stage 2: Fetch (slow — fan out 10 workers)
    fetched := fanOutFetch(ctx, urlsCh, 10)

    // Stage 3: Parse (fast — single stage)
    parsed := parse(ctx, fetched)

    // Stage 4: Analyze (slow — fan out 5 workers)
    analyzed := fanOutAnalyze(ctx, parsed, 5)

    return analyzed
}
MULTI-STAGE PIPELINE WITH FAN-OUT

A multi-stage pipeline. A generator fans out to three fetch workers. Their outputs merge into a single parse stage, which then fans out again to two analyze workers, which merge into one output. The rule: fan out the slow stages and leave the fast ones single.

Rule: Only fan out stages that are:

  1. Consistently slower than upstream/downstream
  2. Independently processable (no shared state)
  3. Worth the coordination overhead

When to Use Fan-Out/Fan-In

Good use cases:

Poor use cases:

fanout_when.go
// Illustrative snippet — not a complete program
// ✗ OVERKILL: Fan-out for fast, simple operations
results := fanOut(ctx, numbers, 10, func(n int) int {
    return n * 2
})

// ✓ SIMPLER: Sequential for lightweight work
for n := range numbers {
    process(n * 2)
}

For fan-out with error handling and automatic cancellation on first error, errgroup (Chapter 14) eliminates manual WaitGroup management.

Common Mistakes

Forgetting WaitGroup
Problem

Output channel never closes—consumer blocks forever

Fix

Use sync.WaitGroup—wait for all workers, then close output

broken_fanout.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Output never closes — consumer blocks forever
func brokenFanOut(in <-chan int, workers int) <-chan int {
    out := make(chan int)
    for i := 0; i < workers; i++ {
        go func() {
            for n := range in {
                out <- n * 2
            }
            // Who closes out? No coordination!
        }()
    }
    return out
}
Loop variable capture (Go <1.22)
Problem

In Go <1.22, all goroutines may see the same ch value when captured by closure

Fix

Go 1.22+ fixed this by creating a new loop variable per iteration. For older Go versions, pass as parameter: go func(c <-chan int){ ... }(ch)

Too many workers for CPU-bound work
Problem

Context switching exceeds benefit with 1000 workers

Fix

Match available cores: runtime.NumCPU()

worker_count.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Context switching exceeds benefit
results := fanOut(ctx, tasks, 1000, cpuBoundWork)

// ✓ CORRECT: Match available cores
results := fanOut(ctx, tasks, runtime.NumCPU(), cpuBoundWork)
Expecting ordered output
Problem

Results arrive in completion order, not input order

Fix

Use sequence numbers and reorder with orderedFanOut

ordered_output.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Order is NOT preserved!
results := fanOut(ctx, tasks, 5, process)
for r := range results {
    fmt.Println(r)  // Order is NOT preserved!
}

// ✓ CORRECT: Use sequence numbers if order matters
ordered, err := orderedFanOut(ctx, tasks, 5, process)
if err != nil {
    // Handle partial results
}

Key Takeaways

  1. Fan-out distributes work—multiple goroutines reading from one channel
  2. Fan-in aggregates results—merge multiple channels into one
  3. Combined pattern handles most cases—workers share input and output
  4. Results arrive out of order—include sequence numbers if order matters
  5. Bound your workers—unlimited parallelism can exhaust resources
  6. I/O-bound work benefits most—workers wait in parallel
  7. CPU-bound is limited by cores—use runtime.NumCPU() workers
  8. WaitGroup coordinates shutdown—wait for all workers before closing output
  9. Cancellation doesn’t stop in-flight work—workers finish current item first
  10. Measure to find optimal worker count—don’t guess

Next: §7.3 covers Worker Pools—a pattern for long-lived workers processing jobs from a queue with graceful shutdown and proper lifecycle management.


7.3 Worker Pools

§§7.1 and 7.2 showed pipelines and fan-out for processing data streams. Workers in those patterns are tied to a single input channel—they start when the pipeline starts and exit when input closes. But many systems need something different: long-lived workers that continuously process incoming jobs throughout a service’s lifetime.

Consider a web service handling image uploads:

unbounded_handler.go
// Illustrative snippet — not a complete program
func handleUpload(w http.ResponseWriter, r *http.Request) {
    // ✗ PROBLEM: Unbounded concurrency
    go processImage(r.Body)  // One goroutine per request
    w.WriteHeader(http.StatusAccepted)
}

Under load, this spawns thousands of goroutines, potentially exhausting memory. What you need: a fixed pool of workers processing jobs from a queue.

GOROUTINE-PER-REQUEST VS WORKER POOL

Two resource models. Goroutine-per-request: 1000 requests become 1000 goroutines, each carrying a stack, a database connection and an HTTP client, risking memory exhaustion, connection pool saturation, upstream overload and scheduler pressure. Worker pool: 1000 requests go into a job queue served by four workers, so resource usage is bounded no matter how many requests arrive, and a full queue provides backpressure by rejecting or blocking.

Worker pools provide:

Fan-Out vs Worker Pool

Both patterns parallelize work, but have different lifecycles:

Fan-Out vs Worker Pool
Row
Lifetime
Workers
Job source
Shutdown
Use case

Use fan-out when: Processing a bounded dataset through a pipeline stage.

Use worker pool when: Long-running service accepting jobs over time with controlled lifecycle.

Basic Worker Pool

Here’s the minimal pattern:

basic_pool.go
// Illustrative snippet — not a complete program
type Job struct {
    ID   int
    Data string
}

type Pool struct {
    jobs    chan Job
    workers int
    wg      sync.WaitGroup
}

func NewPool(workers, queueSize int) *Pool {
    p := &Pool{
        jobs:    make(chan Job, queueSize),
        workers: workers,
    }

    // Start workers immediately
    for i := 0; i < workers; i++ {
        p.wg.Go(func() {
            p.worker(i)
        })
    }

    return p
}

func (p *Pool) worker(id int) {
    for job := range p.jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job.ID)
        time.Sleep(100 * time.Millisecond)  // Simulate work
    }
}

func (p *Pool) Submit(job Job) {
    p.jobs <- job  // Blocks when queue is full
}

func (p *Pool) Stop() {
    close(p.jobs)  // Signal workers to stop
    p.wg.Wait()    // Wait for workers to finish
}
WORKER POOL LIFECYCLE

A worker pool timeline. NewPool starts three workers, each waiting and then taking jobs in turn: worker 0 takes jobs 0, 3 and 6, worker 1 takes jobs 1, 4 and 7, worker 2 takes jobs 2, 5 and 8. The main goroutine submits jobs and each is picked up by whichever worker is free.

Adding Context for Cancellation

Production pools need cancellation support:

pool_context.go
// Illustrative snippet — not a complete program
var (
    ErrPoolStopped  = errors.New("pool stopped")
    ErrQueueFull    = errors.New("queue full")
    ErrSubmitTimeout = errors.New("submit timed out")
)

type Pool struct {
    jobs    chan Job
    workers int
    wg      sync.WaitGroup
    ctx     context.Context
    cancel  context.CancelFunc
}

func NewPool(ctx context.Context, workers, queueSize int) *Pool {
    ctx, cancel := context.WithCancel(ctx)

    p := &Pool{
        jobs:    make(chan Job, queueSize),
        workers: workers,
        ctx:     ctx,
        cancel:  cancel,
    }

    for i := 0; i < workers; i++ {
        p.wg.Go(func() {
            p.worker(i)
        })
    }

    return p
}

func (p *Pool) worker(id int) {

    for {
        select {
        case <-p.ctx.Done():
            return  // Immediate exit on cancellation
        case job, ok := <-p.jobs:
            if !ok {
                return  // Jobs channel closed
            }
            p.process(job)
        }
    }
}

func (p *Pool) Submit(job Job) error {
    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case p.jobs <- job:
        return nil
    }
}

The process method is a placeholder for your application’s work function—we’ll flesh it out (including panic recovery) in the complete production example later in this section.

WHY TWO EXIT CONDITIONS IN THE WORKER?

Workers check both ctx.Done() and channel closure to support two shutdown modes:

  • Graceful: Closes jobs channel → workers drain queue, then exit when ok == false
  • Immediate: Cancels context → workers exit via ctx.Done(), abandoning queued work

This dual-exit pattern enables both strategies with the same worker implementation.

Queue Sizing and Backpressure

Buffer the job channel to control backpressure:

queue.go
// Illustrative snippet — not a complete program
jobs: make(chan Job, queueSize)  // Buffered queue
BACKPRESSURE MECHANISM

A backpressure diagram. A fast producer submits at 100 per second into a job queue shown half full at 50 of 100. Below, slow workers process at 20 per second in total. When the queue reaches 100 of 100, Submit blocks, which slows the producer to the consumer's rate and keeps the system bounded.

Sizing guidelines:

Queue Size Guidelines
Row
0 (unbuffered)
Workers × 2
Workers × 10
100–1000
LARGE QUEUES HIDE PROBLEMS

A 10,000-job queue can mask that workers are too slow. If jobs arrive at 1000/sec but workers process 100/sec, the queue fills in ~11 seconds. Monitor queue depth—growing queues indicate capacity problems.

Non-Blocking Submit

For servers, reject work when overloaded instead of blocking:

nonblocking_submit.go
// Illustrative snippet — not a complete program
func (p *Pool) TrySubmit(job Job) error {
    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case p.jobs <- job:
        return nil
    default:
        return ErrQueueFull
    }
}

func (p *Pool) SubmitTimeout(job Job, timeout time.Duration) error {
    timer := time.NewTimer(timeout)
    defer timer.Stop()  // Release now, not at next GC

    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case <-timer.C:
        return ErrSubmitTimeout
    case p.jobs <- job:
        return nil
    }
}
Submit Variants
Row
Submit()
TrySubmit()
SubmitTimeout()

Usage in HTTP handler:

http_handler.go
// Illustrative snippet — not a complete program
func handleUpload(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }

    job := Job{ID: generateID(), Data: string(body)}

    if err := pool.TrySubmit(job); err != nil {
        http.Error(w, "Server busy", http.StatusServiceUnavailable)
        return
    }

    w.WriteHeader(http.StatusAccepted)
}

Worker Pool with Results

When you need results from processed jobs:

pool_results.go
// Illustrative snippet — not a complete program
type Result struct {
    JobID int
    Value string
    Err   error
}

type Pool struct {
    jobs    chan Job
    results chan Result
    wg      sync.WaitGroup
    ctx     context.Context
    cancel  context.CancelFunc
}

func NewPool(ctx context.Context, workers, queueSize int) *Pool {
    ctx, cancel := context.WithCancel(ctx)

    p := &Pool{
        jobs:    make(chan Job, queueSize),
        results: make(chan Result, queueSize),  // Buffer for results
        ctx:     ctx,
        cancel:  cancel,
    }

    for i := 0; i < workers; i++ {
        p.wg.Go(func() {
            p.worker(i)
        })
    }

    return p
}

func (p *Pool) worker(id int) {

    for {
        select {
        case <-p.ctx.Done():
            return
        case job, ok := <-p.jobs:
            if !ok {
                return
            }
            result := p.safeProcess(job)
            select {
            case <-p.ctx.Done():
                return
            case p.results <- result:
            }
        }
    }
}

func (p *Pool) Results() <-chan Result {
    return p.results
}
RESULTS CHANNEL DEADLOCK: HOW IT HAPPENS

If the results buffer fills before the consumer starts, a deadlock occurs: workers fill the results buffer, block on send, stop receiving from the jobs channel, and Submit() blocks too.

Always start the consumer before submitting jobs.

consumer_first.go
// Illustrative snippet — not a complete program
// Start consumer FIRST — drains results so workers don't block
go func() {
    for result := range pool.Results() {
        handleResult(result)
    }
}()

// THEN submit jobs — safe because consumer is already draining
for i := 0; i < 100; i++ {
    pool.Submit(Job{ID: i})
}
CRITICAL: START CONSUMER BEFORE SUBMITTING

Two orderings. The correct one creates the pool, starts a consumer goroutine ranging over the results, and only then submits jobs. The wrong one creates a pool with 2 workers and a queue of 3, submits 100 jobs, and never reaches the consumer. The explanation: workers fill the three-slot results buffer, so they block, so they stop receiving from the jobs channel, so Submit blocks, so the consumer line is never reached — deadlock.

Graceful Shutdown

Shutdown is critical for worker pools. Production systems need thread-safe shutdown that handles concurrent Submit() and Shutdown() calls—common when HTTP handlers submit while signal handlers trigger shutdown.

The Problem: Submit/Shutdown Race

A naive implementation has a race condition:

shutdown_race.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Race between Submit and Stop
func (p *Pool) Submit(job Job) error {
    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case p.jobs <- job:  // Can panic if Stop() closes p.jobs
        return nil
    }
}

func (p *Pool) Stop() {
    close(p.jobs)  // Race: Submit might be about to send
    p.wg.Wait()
}

Timeline showing the bug:

SUBMIT / SHUTDOWN RACE

A two-column race. In the left column an HTTP handler calls Submit, passes the ctx.Done check and is about to send to the jobs channel. In the right column a signal handler calls Stop, which closes the jobs channel. The left column then performs its send and panics with send on closed channel.

Production-Safe Implementation

Use sync.Once, atomic.Bool, and recover() in Submit for fully thread-safe shutdown:

safe_shutdown.go
// Illustrative snippet — not a complete program
type Pool struct {
    jobs    chan Job
    results chan Result
    wg      sync.WaitGroup
    ctx     context.Context
    cancel  context.CancelFunc

    // Shutdown coordination
    shutdownOnce sync.Once
    shutdownDone chan struct{} // closed when shutdown finishes
    shutdownErr  error         // written once, before the close
    stopped      atomic.Bool
}

func (p *Pool) Submit(job Job) (err error) {
    if p.stopped.Load() {
        return ErrPoolStopped
    }
    defer func() {
        // Catches send-on-closed-channel panic from the race window
        // between stopped.Load() and the channel send in select.
        if recover() != nil {
            err = ErrPoolStopped
        }
    }()

    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case p.jobs <- job:
        return nil
    }
}

func (p *Pool) TrySubmit(job Job) (err error) {
    if p.stopped.Load() {
        return ErrPoolStopped
    }
    defer func() {
        if recover() != nil {
            err = ErrPoolStopped
        }
    }()

    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case p.jobs <- job:
        return nil
    default:
        return ErrQueueFull
    }
}

// Shutdown gracefully stops the pool with optional timeout.
// Pass 0 for no timeout (wait indefinitely for drain).
// Safe for concurrent calls and idempotent: every caller blocks
// until the shutdown finishes and every caller gets the same error.
func (p *Pool) Shutdown(timeout time.Duration) error {
    p.stopped.Store(true)  // Reject new submissions

    p.shutdownOnce.Do(func() {
        defer close(p.shutdownDone)
        close(p.jobs)  // Signal workers: no more jobs

        drained := make(chan struct{})
        go func() {
            p.wg.Wait()
            close(drained)
        }()

        if timeout == 0 {
            // Graceful: wait indefinitely for drain
            <-drained
            close(p.results)
            return
        }

        // Graceful with timeout: try to drain, then force
        select {
        case <-drained:
            // Workers finished within timeout
            close(p.results)
            return
        case <-time.After(timeout):
        }

        // Timeout: cancel, then give workers a grace period to
        // notice. A worker that ignores ctx must not be able to
        // block Shutdown forever — that is the case the timeout
        // exists for, so it has to be bounded here too.
        p.cancel()
        select {
        case <-drained:
            close(p.results)
            p.shutdownErr = errors.New(
                "shutdown timeout: jobs abandoned",
            )
        case <-time.After(timeout):
            // results stays open: a stuck worker may still send.
            p.shutdownErr = errors.New(
                "shutdown timeout: workers did not stop",
            )
        }
    })

    <-p.shutdownDone  // late callers wait for the real outcome
    return p.shutdownErr
}
UNIFIED SHUTDOWN DESIGN

This pool uses a single Shutdown(timeout) method instead of separate Stop() and Shutdown() methods:

  • pool.Shutdown(0) — Graceful, no timeout—wait for queue to drain
  • pool.Shutdown(5*time.Second) — Graceful with timeout—force after 5s

Why this design?

  • Single method: Impossible to call wrong method or in wrong order
  • sync.Once: Channel closes happen exactly once, even with concurrent callers
  • atomic.Bool: Fast-path rejection in Submit() after shutdown starts
  • Idempotent: Safe to call multiple times from signal handlers

The stopped flag is set before shutdownOnce.Do() so that Submit() rejects immediately, even if another goroutine is inside the Do() block waiting for workers.

Error Handling Strategies

Strategy 1: Return Errors in Results

Most common approach—every result has an error field:

error_in_results.go
// Illustrative snippet — not a complete program
for result := range pool.Results() {
    if result.Err != nil {
        log.Printf("Job %d failed: %v", result.JobID, result.Err)
        continue
    }
    use(result.Value)
}

Strategy 2: Panic Recovery

Prevent one bad job from killing a worker:

safe_process.go
// Illustrative snippet — not a complete program
func (p *Pool) safeProcess(job Job) (result Result) {
    defer func() {
        if r := recover(); r != nil {
            // Log with stack trace for debugging
            log.Printf("Worker panic on job %d: %v\n%s",
                job.ID, r, debug.Stack())

            result = Result{
                JobID: job.ID,
                Err:   fmt.Errorf("panic: %v", r),
            }
        }
    }()
    return p.process(job)
}

Why panic recovery matters:

PANIC RECOVERY

A before-and-after comparison. Without recovery, a panicking job terminates its worker goroutine, leaving the pool with two workers instead of three, and over time every worker can die the same way. With safeProcess recovery, the panic is caught, an error is logged, a result is still sent, and the worker continues with the next job, so the pool keeps full capacity.

Strategy 3: Cancel on First Error

USE WITH CAUTION

This pattern abandons all queued work when one job fails. Use only when all jobs are part of one atomic operation and partial completion is worse than no completion.

cancel_on_error.go
// Illustrative snippet — not a complete program
func (p *Pool) worker(id int) {
    for {
        select {
        case <-p.ctx.Done():
            return
        case job, ok := <-p.jobs:
            if !ok {
                return
            }
            result := p.process(job)
            select {
            case <-p.ctx.Done():
                return
            case p.results <- result:
            }
            if result.Err != nil {
                p.cancel()  // Cancel AFTER sending the error result
            }
        }
    }
}

Context Cancellation and In-Flight Work

When context is cancelled, workers exit at their next select check—but work already in progress runs to completion:

IN-FLIGHT WORK TIMELINE

A three-row timeline of a worker and the context. At T equals 0 the worker starts process and the context is active. At T equals 2 the worker is still inside process when cancel is called. At T equals 5 process returns, the worker's select sees ctx.Done, and the worker exits. Cancellation does not interrupt work already running; it takes effect at the next channel operation.

The worker finishes its current job before responding to cancellation.

If work must be interruptible, pass context to the process function:

interruptible_work.go
// Illustrative snippet — not a complete program
func (p *Pool) process(ctx context.Context, job Job) Result {
    for i := 0; i < 100; i++ {
        select {
        case <-ctx.Done():
            return Result{JobID: job.ID, Err: ctx.Err()}
        default:
        }
        // Do chunk of work...
    }
    return Result{JobID: job.ID, Value: "done"}
}

This is the same cooperative cancellation behavior from § 7.2’s fan-out workers: cancellation stops the goroutine’s control flow at the next select check, not the work already in progress.

Complete Production Example

pool_complete.go
package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "runtime/debug"
    "sync"
    "sync/atomic"
    "time"
)

var (
    ErrPoolStopped   = errors.New("pool stopped")
    ErrQueueFull     = errors.New("queue full")
    ErrSubmitTimeout = errors.New("submit timed out")
)

type Job struct {
    ID      int
    Payload string
}

type Result struct {
    JobID int
    Value string
    Err   error
}

type Pool struct {
    jobs    chan Job
    results chan Result
    wg      sync.WaitGroup
    ctx     context.Context
    cancel  context.CancelFunc

    shutdownOnce sync.Once
    shutdownDone chan struct{} // closed when shutdown finishes
    shutdownErr  error         // written once, before the close
    stopped      atomic.Bool
}

func NewPool(ctx context.Context, workers, queueSize int) *Pool {
    ctx, cancel := context.WithCancel(ctx)

    p := &Pool{
        jobs:         make(chan Job, queueSize),
        results:      make(chan Result, queueSize),
        ctx:          ctx,
        cancel:       cancel,
        shutdownDone: make(chan struct{}),
    }

    for i := 0; i < workers; i++ {
        p.wg.Go(func() {
            p.worker(i)
        })
    }

    return p
}

func (p *Pool) worker(id int) {

    for {
        select {
        case <-p.ctx.Done():
            log.Printf("Worker %d: stopping (cancelled)", id)
            return
        case job, ok := <-p.jobs:
            if !ok {
                log.Printf("Worker %d: stopping (jobs closed)", id)
                return
            }
            result := p.safeProcess(job)
            select {
            case <-p.ctx.Done():
                return
            case p.results <- result:
            }
        }
    }
}

func (p *Pool) safeProcess(job Job) (result Result) {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("PANIC processing job %d: %v\n%s",
                job.ID, r, debug.Stack())
            result = Result{
                JobID: job.ID,
                Err:   fmt.Errorf("panic: %v", r),
            }
        }
    }()
    return p.process(job)
}

func (p *Pool) process(job Job) Result {
    time.Sleep(100 * time.Millisecond) // Simulate work

    return Result{
        JobID: job.ID,
        Value: fmt.Sprintf("processed: %s", job.Payload),
    }
}

func (p *Pool) Submit(job Job) (err error) {
    if p.stopped.Load() {
        return ErrPoolStopped
    }
    defer func() {
        if recover() != nil {
            err = ErrPoolStopped
        }
    }()

    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case p.jobs <- job:
        return nil
    }
}

func (p *Pool) TrySubmit(job Job) (err error) {
    if p.stopped.Load() {
        return ErrPoolStopped
    }
    defer func() {
        if recover() != nil {
            err = ErrPoolStopped
        }
    }()

    select {
    case <-p.ctx.Done():
        return ErrPoolStopped
    case p.jobs <- job:
        return nil
    default:
        return ErrQueueFull
    }
}

func (p *Pool) Results() <-chan Result {
    return p.results
}

// Shutdown is the implementation from "Production-Safe
// Implementation" above: every caller waits for the real outcome and
// every caller gets the same error.
func (p *Pool) Shutdown(timeout time.Duration) error {
    p.stopped.Store(true)

    p.shutdownOnce.Do(func() {
        defer close(p.shutdownDone)
        close(p.jobs)

        drained := make(chan struct{})
        go func() {
            p.wg.Wait()
            close(drained)
        }()

        if timeout == 0 {
            <-drained
            close(p.results)
            return
        }

        select {
        case <-drained:
            close(p.results)
            return
        case <-time.After(timeout):
        }

        // A worker that ignores ctx must not be able to block
        // Shutdown forever — that is the case the timeout exists
        // for, so the wait after cancel is bounded too.
        p.cancel()
        select {
        case <-drained:
            close(p.results)
            p.shutdownErr = errors.New(
                "shutdown timeout: jobs abandoned",
            )
        case <-time.After(timeout):
            p.shutdownErr = errors.New(
                "shutdown timeout: workers did not stop",
            )
        }
    })

    <-p.shutdownDone
    return p.shutdownErr
}

func main() {
    ctx := context.Background()
    pool := NewPool(ctx, 3, 10)

    // Start result consumer FIRST
    var consumerWg sync.WaitGroup
    consumerWg.Go(func() {
        for result := range pool.Results() {
            if result.Err != nil {
                log.Printf("job %d err: %v",
                    result.JobID, result.Err)
            } else {
                fmt.Printf("Job %d: %s\n",
                    result.JobID, result.Value)
            }
        }
    })

    // Submit jobs
    for i := 0; i < 10; i++ {
        job := Job{ID: i, Payload: fmt.Sprintf("task-%d", i)}
        if err := pool.Submit(job); err != nil {
            log.Printf("Submit failed: %v", err)
        }
    }

    // Graceful shutdown with timeout
    if err := pool.Shutdown(5 * time.Second); err != nil {
        log.Printf("Shutdown: %v", err)
    }

    consumerWg.Wait()
    fmt.Println("All jobs complete")
}
Output (one real run — order varies)
Job 2: processed: task-2
Job 1: processed: task-1
Job 0: processed: task-0
Job 5: processed: task-5
Job 4: processed: task-4
Job 3: processed: task-3
Job 6: processed: task-6
Job 7: processed: task-7
Job 8: processed: task-8
2026/08/30 10:05:25 Worker 0: stopping (jobs closed)
2026/08/30 10:05:25 Worker 2: stopping (jobs closed)
2026/08/30 10:05:25 Worker 1: stopping (jobs closed)
Job 9: processed: task-9
All jobs complete

Pool Sizing Guidelines

Number of Workers

Worker Count by Work Type
Row
CPU-bound
I/O-bound
Mixed
External API

These guidelines match the fan-out sizing heuristics from § 7.2—the difference is that worker pools are long-lived, so choosing the right count matters even more.

When to Use Worker Pools

DECISION GUIDE

Five if-then pairs. Process a batch of items in parallel points to fan-out, which is simpler and needs no lifecycle management. Handle jobs as they arrive indefinitely points to a worker pool. Limit concurrent access to a resource points to a worker pool, since fixed workers means fixed concurrency. Reject work when overloaded points to a worker pool with TrySubmit. Complete all work before shutdown points to a worker pool with Shutdown zero for a graceful drain.

Simpler Alternative: errgroup.SetLimit

For batch-style "process N items with bounded concurrency," errgroup.SetLimit() provides worker-pool semantics without manual channel plumbing. We cover errgroup in detail in Chapter 14. Use a full worker pool when you need long-lived workers, submit variants, or graceful shutdown with drain.

Common Mistakes

Deadlock from results channel
Problem

Results buffer fills, workers block, Submit blocks—deadlock

Fix

Start consumer before submitting jobs

deadlock_results.go
// Illustrative snippet — not a complete program
// ✗ DEADLOCK: Results buffer fills, workers block
pool := NewPool(ctx, 5, 10)

for i := 0; i < 100; i++ {
    pool.Submit(Job{ID: i})  // Eventually blocks
}

// Consumer starts too late — already deadlocked
for result := range pool.Results() { ... }
Blocking Submit in request handler
Problem

Blocks entire HTTP request if queue full—server becomes unresponsive

Fix

Use TrySubmit() with proper HTTP error response

blocking_handler.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Blocks entire request if queue full
func handler(w http.ResponseWriter, r *http.Request) {
    pool.Submit(job)  // User waits indefinitely
}

// ✓ CORRECT: Non-blocking with feedback
func handler(w http.ResponseWriter, r *http.Request) {
    if err := pool.TrySubmit(job); err != nil {
        http.Error(w, "Server busy", http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusAccepted)
}
No panic recovery
Problem

Panic kills worker permanently—pool silently loses workers over time

Fix

Use safeProcess() to recover from panics

no_recovery.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Panic kills worker permanently
func (p *Pool) worker(id int) {
    for job := range p.jobs {
        process(job)  // If this panics, worker dies
    }
}
// Result: Pool silently loses workers over time

// ✓ CORRECT: Recover from panics
func (p *Pool) worker(id int) {
    for job := range p.jobs {
        p.safeProcess(job)  // Panic caught, worker continues
    }
}
Not closing results channel
Problem

Consumer’s for range blocks forever after all jobs complete

Fix

Close results channel in Shutdown() after workers finish

close_results.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Consumer blocks forever
func (p *Pool) Shutdown(timeout time.Duration) error {
    close(p.jobs)
    p.wg.Wait()
    // Missing: close(p.results)
    return nil
}

// ✓ CORRECT: Close results after workers finish
func (p *Pool) Shutdown(timeout time.Duration) error {
    // ... shutdown logic ...
    close(p.results)  // Consumer's range exits
    return nil
}

Key Takeaways

  1. Worker pools bound concurrency—fixed workers, bounded queue
  2. Long-lived workers—created once, reused for service lifetime
  3. Queue provides backpressureSubmit blocks when queue is full
  4. Non-blocking submit for serversTrySubmit returns error when full
  5. Unified shutdown with timeoutShutdown(0) for graceful, Shutdown(duration) for bounded wait
  6. Thread-safe shutdownsync.Once + atomic.Bool + recover() in Submit prevents races and panics
  7. Always consume results—or workers block on send
  8. Start consumer before submitting—prevents buffer-full deadlock
  9. Recover from panics with stack traces—don’t let one bad job kill workers
  10. Cancellation doesn’t stop in-flight work—only affects next select check

Next: §7.4 covers the Or-Channel and Or-Done patterns—composing cancellation across channel pipelines and racing multiple operations where the first result wins.


7.4 Or-Channel and Or-Done Patterns

§§7.1–7.3 showed patterns for processing all items from channels. But sometimes you need the first result from multiple sources—whichever completes first wins, and the rest are abandoned.

FIRST-RESULT-WINS SCENARIOS

Four situations where taking the first result is the right shape. Redundant requests: send the same request to three replicas and use the first response, so a slow replica stops mattering. Multiple data sources: check cache, database and remote API at once and use whichever answers first. Timeout racing: race an operation against a deadline. Hedged requests: send a duplicate if the primary is slow.

The or-channel pattern combines multiple channels where the first to produce a value wins. The or-done pattern wraps channels to respect cancellation cleanly.

Basic Pattern: Two Channels

With two channels, select works directly:

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

    go func() {
        data, err := fetch(ctx, url)
        if err != nil {
            errCh <- err
            return
        }
        result <- data
    }()

    timer := time.NewTimer(timeout)
    defer timer.Stop()

    select {
    case data := <-result:
        return data, nil
    case err := <-errCh:
        return nil, err
    case <-timer.C:
        return nil, fmt.Errorf("timeout after %v", timeout)
    }
}
WHY BUFFER THE CHANNELS?

If the timeout fires first, we return immediately. The goroutine still completes and tries to send—without a buffer, it would block forever (goroutine leak). With buffer size 1, the send succeeds even with no receiver.

Why time.NewTimer Instead of time.After?

Not to prevent a leak. On this book’s Go 1.25 baseline an unreferenced timer is reclaimed whether or not it fired and whether or not you called Stop — Chapter 4 §4.4 works through that change, and it is the same one Chapter 6 §6.5 applies to tickers. The old advice, that time.After holds a timer alive until it fires, is history on Go 1.23+.

What remains is one allocation per call. That is a throughput cost, not a leak, and it only matters on a hot path. time.NewTimer with defer timer.Stop() releases the timer at once instead of waiting for the next GC, which is worth doing in a function called thousands of times a second and not worth the extra line anywhere else.

Measured go1.26.1, darwin/amd64: 200,000 time.After(time.Hour) timers created, never fired and never stopped, moved HeapObjects by roughly +20 to +30 across runs and machines and NumGoroutine() by zero. Neither figure scales with the number of timers.

This basic example doesn’t cancel the fetch goroutine when the timeout fires—the fetch continues until the parent context cancels or the operation completes naturally. The patterns below fix this with context.WithCancel and defer cancel(), which is essential for expensive operations.

Or-Channel for N Channels

For multiple “done” channels where N is determined at runtime, you can’t write a select with variable cases. The recursive approach solves this:

or.go
// Illustrative snippet — not a complete program
func or(channels ...<-chan struct{}) <-chan struct{} {
    switch len(channels) {
    case 0:
        return nil  // nil channel blocks forever in select (no-op)
    case 1:
        return channels[0]
    }

    orDone := make(chan struct{})
    go func() {
        defer close(orDone)

        switch len(channels) {
        case 2:
            select {
            case <-channels[0]:
            case <-channels[1]:
            }
        default:
            // Clip the capacity so append allocates instead of
            // writing orDone into the caller's backing array.
            rest := channels[3:len(channels):len(channels)]
            select {
            case <-channels[0]:
            case <-channels[1]:
            case <-channels[2]:
            case <-or(append(rest, orDone)...):
            }
        }
    }()

    return orDone
}

How the recursion works:

Each call handles up to 3 channels directly in a select. Remaining channels are handled recursively. Including orDone in the recursive call (or(append(channels[3:], orDone)...)) is crucial: when any of the first 3 channels closes, orDone closes, which propagates down the tree and causes all child goroutines to exit.

OR-CHANNEL BEHAVIOR

A timeline of three channels. Channel A closes late, channel B closes first, channel C closes last. Below them the combined or of A, B and C closes at the same moment B does. The or-channel fires on the first closure and ignores the rest.

Goroutine Cost

The recursive or spawns approximately one goroutine per 2 input channels (e.g., 6 channels → 3 goroutines; 9 channels → 4 goroutines). This is acceptable for typical use cases (combining a few cancellation signals), but avoid in hot paths with dozens of channels. For more than ~20 channels, consider using reflect.Select instead (more complex but O(1) goroutines).

In practice, context parent-child trees handle most cancellation combining (see “Simpler Alternatives” below). The recursive or() is valuable when combining signals from independent sources that don’t share a context tree—for example, a user cancel button and a system shutdown signal.

Usage with multiple cancellation sources:

or_usage.go
// Illustrative snippet — not a complete program
func doWork(
    ctx context.Context,
    userCancel, systemShutdown <-chan struct{},
) error {
    // Wait for ANY cancellation source
    select {
    case <-or(ctx.Done(), userCancel, systemShutdown):
        return errors.New("cancelled")
    case result := <-doExpensiveWork(ctx):
        return processResult(result)
    }
}

First-Response-Wins

Race multiple sources and return the first response (success or error):

first_response.go
// Illustrative snippet — not a complete program
// fetchFirstResponse returns the first response (success OR error).
// For first successful response, use fetchFirstSuccess.
func fetchFirstResponse(
    ctx context.Context, urls []string,
) ([]byte, error) {
    if len(urls) == 0 {
        return nil, errors.New("no URLs provided")
    }

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()  // Cancel remaining requests when first completes

    type result struct {
        data []byte
        err  error
    }

    results := make(chan result, len(urls)) // Buffered: no leaks

    // One slot per goroutine, so this send can never block and
    // never needs a select on ctx.Done to stay leak-free.
    for _, url := range urls {
        go func() {
            data, err := fetch(ctx, url)
            results <- result{data, err}
        }()
    }

    r := <-results
    return r.data, r.err
}
Why defer cancel() Is Critical

When the first result arrives, N-1 goroutines are still running. Without defer cancel():

  • Losing goroutines continue until their operations complete
  • For HTTP requests, this means waiting for full responses you don’t need
  • Wastes CPU, memory, network, and backend resources

With defer cancel(), context cancellation propagates to all goroutines—operations that respect context exit immediately.

Key points:

First-Success-Wins

Sometimes you want the first successful result, skipping errors:

first_success.go
// Illustrative snippet — not a complete program
func fetchFirstSuccess(
    ctx context.Context,
    urls []string,
) ([]byte, error) {
    if len(urls) == 0 {
        return nil, errors.New("no URLs provided")
    }

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    type result struct {
        data []byte
        err  error
    }

    results := make(chan result, len(urls))

    for _, url := range urls {
        go func() {
            data, err := fetch(ctx, url)
            results <- result{data, err}
        }()
    }

    var firstErr error
    for i := 0; i < len(urls); i++ {
        r := <-results
        if r.err == nil {
            return r.data, nil  // First success wins
        }
        if firstErr == nil {
            firstErr = r.err
        }
    }

    // Returns first error only; use errors.Join to collect all
    return nil, firstErr
}

First-Response vs First-Success: The Trade-off

First-Response vs First-Success
Row
Fast error, slow success
All succeed
All fail
Latency
SCENARIO COMPARISON

A comparison over three backends whose latencies are 50 milliseconds returning an error, 100 milliseconds succeeding, and 150 milliseconds succeeding. First-response returns at 50 milliseconds with the error. First-success skips the error and returns at 100 milliseconds with data.

Hedged Requests

Hedged requests reduce tail latency by sending a duplicate if the primary is slow:

hedged_fetch.go
// Illustrative snippet — not a complete program
func hedgedFetch(
    ctx context.Context,
    url string,
    hedgeDelay time.Duration,
) ([]byte, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    type result struct {
        data []byte
        err  error
    }

    // Two slots, two senders: neither send can ever block.
    results := make(chan result, 2)

    // Primary request
    go func() {
        data, err := fetch(ctx, url)
        results <- result{data, err}
    }()

    // Hedged request after delay. In production, send this to a
    // different replica or endpoint than the primary.
    go func() {
        select {
        case <-ctx.Done():
            results <- result{nil, ctx.Err()}
        case <-time.After(hedgeDelay):
            data, err := fetch(ctx, url)
            results <- result{data, err}
        }
    }()

    // Take the first SUCCESS, not the first response. A primary that
    // fails fast is exactly the case hedging exists to survive, so
    // returning its error would defeat the pattern. See "First-Response
    // vs First-Success" above.
    var firstErr error
    for i := 0; i < 2; i++ {
        r := <-results
        if r.err == nil {
            return r.data, nil
        }
        if firstErr == nil {
            firstErr = r.err
        }
    }
    return nil, firstErr
}
Why time.After Is Acceptable Here

Chapter 4 §4.4 retired the “time.After leaks in loops” advice for Go 1.23+. Nothing here depends on that, and this usage would be fine either way:

  • It’s a one-time creation (not in a loop)
  • The goroutine exits via ctx.Done() if the primary wins, and the timer is garbage collected when unreachable
  • If the timer fires, it’s consumed normally

For hot paths called thousands of times per second, use time.NewTimer with Stop(). For this pattern (one hedge per request), the simpler time.After is fine.

HEDGED REQUEST PATTERN

Two hedging scenarios. In the first the primary is fast: it responds and wins, the hedge is cancelled while still waiting out its 50-millisecond delay, and its timer never fires. In the second the primary is slow and gets cancelled, while the hedge waits 50 milliseconds, runs fast and wins. Hedging cuts tail latency without doubling load on every request.

Choosing Hedge Delay

Typical hedge delay is P50–P75 latency of your operation.

Example: If your service latency is:

With hedge delay = 50ms (P75):

Start conservative (P75), measure impact, tune based on capacity and latency goals.

Monitor Hedge Rate in Production

Track what percentage of requests trigger the hedge to validate your delay setting:

hedge_metrics.go
// Illustrative snippet — not a complete program
var (
    totalRequests = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "requests_total",
    })
    hedgesSent = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "hedged_requests_total",
    })
)

// In hedgedFetch, before sending hedge request:
hedgesSent.Inc()

Hedge rate guidelines:

Alert on sustained high hedge rates—they indicate either misconfigured delays or backend problems.

When to use hedging:

When to Use Hedging
Row
Request type
Idempotency
Latency impact
Backend capacity

The Or-Done Pattern

A common problem: reading from a channel while respecting cancellation. The naive approach is verbose:

verbose_receive.go
// Illustrative snippet — not a complete program
// ✗ VERBOSE: Check done in every receive
for {
    select {
    case <-ctx.Done():
        return
    case v, ok := <-in:
        if !ok {
            return
        }
        process(v)
    }
}

The or-done pattern wraps a channel to make it cancellable:

or_done.go
// Illustrative snippet — not a complete program
// orDone wraps a channel to respect context cancellation.
func orDone[T any](ctx context.Context, in <-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for {
            select {
            case <-ctx.Done():
                return
            case v, ok := <-in:
                if !ok {
                    return
                }
                select {
                case <-ctx.Done():
                    return
                case out <- v:
                }
            }
        }
    }()
    return out
}

Usage:

or_done_usage.go
// Illustrative snippet — not a complete program
// ✓ CLEAN: Range over wrapped channel
for v := range orDone(ctx, in) {
    process(v)
}
WHY TWO SELECTS?

Without a second select around the send, out <- v blocks forever if the consumer has already exited (e.g., due to cancellation). The second select checks ctx.Done() on the send path, preventing a goroutine leak.

or_done_single_select_bug.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Only one select
case v, ok := <-in:
    if !ok { return }
    out <- v  // ← BLOCKS if no receiver!
SINGLE SELECT BUG

A four-step leak timeline for a single-select or-done. At T equals 1 the consumer starts ranging. At T equals 2 the context is cancelled and the consumer exits its loop. At T equals 3 upstream sends a value. At T equals 4 the or-done goroutine receives it and tries to send onward, blocking forever because the consumer is gone — a goroutine leak.

With second select:

or_done_fix.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Second select checks cancellation on send
select {
case <-ctx.Done():
    return  // Exit instead of blocking on send
case out <- v:
}

Now at T=4, the second select sees ctx.Done() and exits cleanly.

OR-DONE CHANNEL

A before-and-after pair. Without or-done, ctx.Done fires but the external channel keeps delivering v1 through v6 and the consumer cannot stop. With or-done, ctx.Done fires, or-done forwards v1 and then closes, and the consumer exits as soon as the wrapped channel closes.

When to Use Or-Done

Or-done is needed when wrapping channels that don’t respect context:

or_done_wrap.go
// Illustrative snippet — not a complete program
// Third-party library returns channel, doesn't accept context
dataChan := legacyLib.Subscribe(topic)

// Wrap it to make it cancellable
for event := range orDone(ctx, dataChan) {
    handleEvent(event)
}

When you DON’T need or-done: If you control the code producing the channel, make it accept context directly instead of wrapping.

Preventing Goroutine Leaks

When racing operations, losing goroutines may still be running. Here’s how leaks happen and how to prevent them:

The Leak Mechanism

goroutine_leak.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Unbuffered channel
results := make(chan []byte)  // Unbuffered!

for _, url := range urls {
    go func() {
        results <- fetch(url)  // Losers block here
    }()
}

return <-results  // Returns first, others leak
GOROUTINE LEAK TIMELINE

A leak timeline. At T equals 0 three goroutines start fetching. At T equals 1 the second finishes first, sends its result, and main returns. At T equals 2 and T equals 3 the other two finish and block on a send with no receiver. Both are leaked, at roughly two kilobytes of stack each.

Prevention Strategies

Strategy 1: Buffered Channels

buffered_prevention.go
// Illustrative snippet — not a complete program
results := make(chan Result, len(urls)) // Buffered

for _, url := range urls {
    go func() {
        results <- fetch(ctx, url) // Buffer: no block
    }()
}

return <-results  // Losers send to buffer and exit

Strategy 2: Context Cancellation

context_prevention.go
// Illustrative snippet — not a complete program
ctx, cancel := context.WithCancel(ctx)
defer cancel()  // Cancels losing goroutines

for _, url := range urls {
    go func() {
        result := fetch(ctx, url)  // Respects context
        select {
        case <-ctx.Done():
            return  // Exit without sending
        case results <- result:
        }
    }()
}
Leak Prevention Strategies
Row
Buffered channels
Context cancellation
Both

Complete Example: Resilient API Client

resilient_client.go
package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "time"
)

type Response struct {
    Data   []byte
    Source string
}

func fetch(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    // Production: use http.Client{Timeout: 10*time.Second}
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    // A 500 from one replica has to count as a failure, or the
    // failover never happens: the caller would accept the error
    // page as data from the first source that answered.
    if resp.StatusCode >= 400 {
        return nil, fmt.Errorf("%s: %s", url, resp.Status)
    }

    return io.ReadAll(resp.Body)
}

func fetchWithRedundancy(
    ctx context.Context,
    urls []string,
    timeout time.Duration,
) (Response, error) {
    if len(urls) == 0 {
        return Response{}, errors.New("no URLs provided")
    }

    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    type result struct {
        data   []byte
        source string
        err    error
    }

    results := make(chan result, len(urls))

    // One slot per source, so no send can block and none leaks.
    for _, url := range urls {
        go func() {
            data, err := fetch(ctx, url)
            results <- result{data, url, err}
        }()
    }

    var lastErr error
    for i := 0; i < len(urls); i++ {
        select {
        case <-ctx.Done():
            if lastErr != nil {
                return Response{}, lastErr
            }
            return Response{}, ctx.Err()
        case r := <-results:
            if r.err == nil {
                return Response{Data: r.data, Source: r.source}, nil
            }
            lastErr = r.err
        }
    }

    return Response{}, fmt.Errorf("all sources failed: %w", lastErr)
}

// Three stand-in replicas so this file runs as written. The primary
// fails fast, replica 1 is slow, replica 2 answers first.
func replicas() (*httptest.Server, []string) {
    mux := http.NewServeMux()
    mux.HandleFunc("/primary", func(
        w http.ResponseWriter, r *http.Request,
    ) {
        http.Error(w, "boom", http.StatusInternalServerError)
    })
    mux.HandleFunc("/replica1", func(
        w http.ResponseWriter, r *http.Request,
    ) {
        time.Sleep(300 * time.Millisecond)
        w.Write([]byte("payload from replica 1"))
    })
    mux.HandleFunc("/replica2", func(
        w http.ResponseWriter, r *http.Request,
    ) {
        time.Sleep(50 * time.Millisecond)
        w.Write([]byte("payload from replica 2"))
    })

    srv := httptest.NewServer(mux)
    return srv, []string{
        srv.URL + "/primary",
        srv.URL + "/replica1",
        srv.URL + "/replica2",
    }
}

func main() {
    ctx := context.Background()

    srv, urls := replicas()
    defer srv.Close()

    start := time.Now()
    resp, err := fetchWithRedundancy(ctx, urls, 5*time.Second)

    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Printf("Success from %s in %v (%d bytes)\n",
        resp.Source[len(srv.URL):],
        time.Since(start).Round(10*time.Millisecond),
        len(resp.Data))
}
go run main.go
Success from /replica2 in 50ms (22 bytes)

Common Mistakes

Unbuffered channel causes leak
Problem

Using an unbuffered channel when racing goroutines means losing goroutines block forever on send.

Fix

Buffer the channel with capacity equal to the number of goroutines.

unbuffered_leak.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Losing goroutines block forever
results := make(chan Response)  // Unbuffered

for _, url := range urls {
    go func() {
        results <- fetch(url)  // Losers block here permanently
    }()
}

return <-results  // 3 URLs → 2 goroutines leaked (~4KB+ memory)

// ✓ CORRECT: Buffer for all goroutines
results := make(chan Response, len(urls))
Not canceling losers
Problem

Losing requests continue running to completion, wasting CPU, network, and backend resources.

Fix

Use defer cancel() to cancel context when the winner returns.

cancel_losers.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Losing requests continue running
func queryFirst(urls []string) Response {
    results := make(chan Response, len(urls))
    for _, url := range urls {
        go func() {
            results <- fetch(url)  // No context—runs to completion
        }()
    }
    return <-results  // Losers waste CPU/network/backend resources
}

// ✓ CORRECT: Cancel context when winner returns
func queryFirst(ctx context.Context, urls []string) Response {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()  // Cancels losing operations
    // ... pass ctx to all fetch calls
}
Missing ok check in or-done
Problem

Not checking if the input channel is closed causes the or-done wrapper to send zero values forever.

Fix

Always use v, ok := <-in and check ok to detect channel closure.

ok_check.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Doesn't detect input channel closing
select {
case <-ctx.Done():
    return
case v := <-in:  // Returns zero value if closed!
    out <- v     // Sends zero value forever
}

// ✓ CORRECT: Check ok to detect closure
select {
case <-ctx.Done():
    return
case v, ok := <-in:
    if !ok {
        return  // Input closed
    }
    // ...
}
Hedging non-idempotent operations
Problem

Hedging writes or mutations can cause duplicate side effects (e.g., charging a customer twice).

Fix

Only hedge reads or idempotent operations.

hedge_safety.go
// Illustrative snippet — not a complete program
// hedgedRequest[T] runs op, hedges after delay, returns first success.
// Both examples below use the same signature.

// ✗ EXTREMELY DANGEROUS: May charge customer twice!
func hedgedPayment(ctx context.Context, amount int) error {
    _, err := hedgedRequest(ctx, 50*time.Millisecond,
        func(ctx context.Context) (struct{}, error) {
            return struct{}{}, paymentGateway.Charge(amount)
        },
    ) // NEVER HEDGE THIS!
    return err
}

// ✓ SAFE: Only hedge reads/idempotent ops
func hedgedRead(
    ctx context.Context, key string,
) ([]byte, error) {
    return hedgedRequest(ctx, 50*time.Millisecond,
        func(ctx context.Context) ([]byte, error) {
            return database.Get(ctx, key)
        },
    )
}

See §7.2 Common Mistakes for loop variable capture in goroutine launches.

When to Use These Patterns

Pattern Selection Guide
Row
Or-channel
First-response
First-success
Hedged requests
Or-done

Consider simpler alternatives:

Simpler Alternatives
Row
Simple timeout
Two channels
Hierarchical cancellation
simpler_alternative.go
// Illustrative snippet — not a complete program
// ✗ OVERKILL: Or-channel for simple timeout
done := or(operation(), timeoutCh)

// ✓ SIMPLER: Context handles timeout + cancellation
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := operation(ctx)

Key Takeaways

  1. Or-channel closes when any input closes—useful for combining cancellation signals
  2. First-response returns fastest result—may be success or error
  3. First-success skips errors—waits until one succeeds or all fail
  4. Hedging trades resources for latency—only for idempotent operations
  5. Buffer results channels—size equals goroutine count to prevent leaks
  6. Always call defer cancel()—stops losing operations, frees resources
  7. Or-done needs two selects—check cancellation on both receive and send
  8. Monitor hedge rate in production—15-25% is ideal, >30% indicates problems
  9. Check channel closure with ok—avoid zero-value bugs
  10. Prefer context for simple cases—or-channel for combining independent signals

Next: §7.5 covers the Tee and Broadcast patterns—duplicating a stream to multiple consumers, enabling parallel processing of the same data.


7.5 Tee and Broadcast Patterns

§§7.1–7.4 showed patterns where each data item goes to one consumer. Fan-out distributes work—item 1 to worker A, item 2 to worker B. But sometimes you need to send the same data to multiple consumers—duplicating the stream rather than dividing it.

FAN-OUT VS TEE/BROADCAST

Two contrasting flows. Fan-out divides work: six input items are taken one each by workers 1, 2 and 3 in rotation, so every item goes to exactly one worker. Tee and broadcast duplicate the stream: the same six items go to consumer A and consumer B in full, so every consumer sees everything.

Common use cases:

The Tee Pattern

The tee pattern (named after Unix tee command) splits one input into two identical outputs:

tee.go
// Illustrative snippet — not a complete program
func tee[T any](
    ctx context.Context,
    in <-chan T,
) (<-chan T, <-chan T) {
    out1 := make(chan T)
    out2 := make(chan T)

    go func() {
        defer close(out1)
        defer close(out2)

        for val := range in {
            // Local copies for select cases
            o1, o2 := out1, out2

            // Send to both outputs
            for i := 0; i < 2; i++ {
                select {
                case <-ctx.Done():
                    return
                case o1 <- val:
                    o1 = nil  // Disable this case after sending
                case o2 <- val:
                    o2 = nil
                }
            }
        }
    }()

    return out1, out2
}
The Nil Channel Trick: How It Works

Key insight: The o1, o2 := ... creates NEW local variables each iteration. Setting them to nil doesn’t affect the original channels—it just disables those select cases for the current value. See the step-by-step trace below.

NIL CHANNEL TRICK — STEP-BY-STEP (val = 42)

A step-by-step trace of one outer loop iteration with the value 42. Fresh locals o1 and o2 are set to out1 and out2. In the first inner iteration the select offers both; consumer 2 wins, so o2 is set to nil. In the second inner iteration only o1 can fire, because a send on a nil channel blocks forever, so the value necessarily goes to out1 and o1 becomes nil. Both being nil ends the inner loop, and the next outer iteration rebuilds fresh non-nil locals.

Usage:

tee_usage.go
// Illustrative snippet — not a complete program
func main() {
    ctx := context.Background()

    events := generateEvents(ctx)
    forLogging, forProcessing := tee(ctx, events)

    var wg sync.WaitGroup
    // Consumer 1: Log everything
    wg.Go(func() {
        for e := range forLogging {
            log.Printf("Event: %v", e)
        }
    })

    // Consumer 2: Process everything
    wg.Go(func() {
        for e := range forProcessing {
            process(e)
        }
    })

    wg.Wait()
}
Both Consumers Must Read

Tee blocks until both outputs accept each value. If one consumer is slow or stops reading, the entire pipeline stalls. This is intentional—it provides backpressure.

The Slow Consumer Problem

Critical challenge: What happens when consumers have different speeds?

SLOW CONSUMER BLOCKS ALL

A timeline showing a slow consumer stalling everything. The producer emits item 1, the tee sends it to the fast consumer 1 immediately and then waits on the slow consumer 2. Item 2 cannot start until that wait completes. The whole pipeline therefore runs at the speed of the slowest consumer.

The tee goroutine must send to each consumer before proceeding. A slow consumer blocks the tee, which blocks fast consumers from receiving the next value.

Three strategies to handle this:

Strategy 1: Accept Blocking (Backpressure)

Keep the synchronous tee—slowest consumer controls pace. This is correct when:

Pros: Simple, no message loss, natural backpressure
Cons: Slow consumer affects entire system

Strategy 2: Buffered Channels

Add buffers to absorb temporary speed differences:

buffered_tee.go
// Illustrative snippet — not a complete program
func bufferedTee[T any](
    ctx context.Context,
    in <-chan T,
    bufSize int,
) (<-chan T, <-chan T) {
    out1 := make(chan T, bufSize)  // Buffered
    out2 := make(chan T, bufSize)

    go func() {
        defer close(out1)
        defer close(out2)

        for val := range in {
            o1, o2 := out1, out2
            for i := 0; i < 2; i++ {
                select {
                case <-ctx.Done():
                    return
                case o1 <- val:
                    o1 = nil
                case o2 <- val:
                    o2 = nil
                }
            }
        }
    }()

    return out1, out2
}

Buffer sizing:

Buffer Size Guidelines
Row
0 (unbuffered)
Small (10-50)
Large (100+)
Buffers Don’t Fix Consistently Slow Consumers

If producer rate exceeds consumer rate, buffers eventually fill. A 1000-item buffer just delays blocking by 1000 items. Monitor buffer depth to detect capacity problems.

Buffering Smooths but Doesn’t Decouple

Even with buffers, the tee still waits for both sends per value within each iteration. Buffering helps with temporary speed differences but doesn’t eliminate coupling—if one consumer’s buffer is full, the tee blocks until space is available. For fully independent consumers, use the lossy pattern or separate goroutines per consumer.

Strategy 3: Drop on Slow Consumer

When some data loss is acceptable, drop values instead of blocking:

lossy_tee.go
// Illustrative snippet — not a complete program
func lossyTee[T any](
    ctx context.Context,
    in <-chan T,
    bufSize int,
) (<-chan T, <-chan T) {
    out1 := make(chan T, bufSize)
    out2 := make(chan T, bufSize)

    go func() {
        defer close(out1)
        defer close(out2)

        for val := range in {
            // Exit early if context cancelled
            select {
            case <-ctx.Done():
                return
            default:
            }

            // Non-blocking sends: each consumer
            // receives independently. If buffer
            // full, val dropped for that consumer.
            select {
            case out1 <- val:
            default:
                // Buffer full—drop for consumer 1
            }

            select {
            case out2 <- val:
            default:
                // Buffer full—drop for consumer 2
            }
        }
    }()

    return out1, out2
}
Lossy Tee Delivers Different Data to Each Consumer

Unlike synchronous tee (where both consumers see identical streams), lossy tee may deliver different values to each consumer:

  • Consumer A might receive: [1, 3, 5] (dropped 2, 4)
  • Consumer B might receive: [1, 2, 4, 5] (dropped 3)

Use only when consumers don’t require identical streams (e.g., independent metrics collectors, sampling).

When dropping is acceptable:

When dropping is NOT acceptable:

Slow Consumer Strategies
Row
Synchronous
Buffered
Lossy

Broadcast to N Consumers

For more than two consumers, generalize to broadcast:

broadcast.go
// Illustrative snippet — not a complete program
func broadcast[T any](
    ctx context.Context,
    in <-chan T,
    n int,
) []<-chan T {
    outputs := make([]chan T, n)
    for i := range outputs {
        outputs[i] = make(chan T)
    }

    go func() {
        defer func() {
            for _, ch := range outputs {
                close(ch)
            }
        }()

        for val := range in {
            for _, ch := range outputs {
                select {
                case <-ctx.Done():
                    return
                case ch <- val:
                }
            }
        }
    }()

    // Convert to receive-only
    result := make([]<-chan T, n)
    for i, ch := range outputs {
        result[i] = ch
    }
    return result
}
Broadcast Sends Sequentially

Unlike tee’s select-based approach, broadcast sends to consumers in order. Consumer 0 always receives before consumer 1. This means:

  • Tee (2 consumers): Either consumer can receive first—they race in select
  • Broadcast (N consumers): Consumer 0’s speed directly affects when consumer 1 receives

For most use cases this doesn’t matter. If you need fully independent delivery to many consumers, consider the lossy pattern with buffers, or a pub/sub hub where each subscriber has its own goroutine.

Usage:

broadcast_usage.go
// Illustrative snippet — not a complete program
events := generateEvents(ctx)
consumers := broadcast(ctx, events, 3)

// In production, use a WaitGroup to wait for all consumers
go processEvents(consumers[0])
go logEvents(consumers[1])
go updateMetrics(consumers[2])

The same slow consumer strategies apply—add buffers or use non-blocking sends as needed.

Dynamic Subscribers: Pub/Sub Hub

For consumers that join and leave at runtime, use a pub/sub hub:

hub.go
// Illustrative snippet — not a complete program
type Hub[T any] struct {
    mu          sync.RWMutex
    subscribers map[int]chan T
    nextID      int
    bufSize     int
    closed      bool
}

func NewHub[T any](bufSize int) *Hub[T] {
    return &Hub[T]{
        subscribers: make(map[int]chan T),
        bufSize:     bufSize,
    }
}

// Subscribe returns a subscription ID and channel.
// If the hub is closed, returns (-1, closed channel) so the caller's
// range loop exits immediately.
func (h *Hub[T]) Subscribe() (int, <-chan T) {
    h.mu.Lock()
    defer h.mu.Unlock()

    if h.closed {
        ch := make(chan T)
        close(ch)
        return -1, ch
    }

    id := h.nextID
    h.nextID++

    ch := make(chan T, h.bufSize)
    h.subscribers[id] = ch

    return id, ch
}

func (h *Hub[T]) Unsubscribe(id int) {
    h.mu.Lock()
    defer h.mu.Unlock()

    if ch, ok := h.subscribers[id]; ok {
        close(ch)
        delete(h.subscribers, id)
    }
}

func (h *Hub[T]) Publish(val T) {
    h.mu.RLock()
    defer h.mu.RUnlock()

    if h.closed {
        return  // Silently ignore publishes after close
    }

    for _, ch := range h.subscribers {
        select {
        case ch <- val:
        default:
            // Subscriber too slow, drop
        }
    }
}

// Close shuts down the hub. Safe to call multiple times.
func (h *Hub[T]) Close() {
    h.mu.Lock()
    defer h.mu.Unlock()

    if h.closed {
        return  // Idempotent
    }
    h.closed = true

    for _, ch := range h.subscribers {
        close(ch)
    }
    h.subscribers = nil
}
Hub Lifecycle Safety

The closed flag ensures:

  • Publish() after Close() is a no-op (no panic)
  • Subscribe() after Close() returns a closed channel (caller’s range exits immediately)
  • Close() is idempotent (safe to call multiple times)
PUB/SUB HUB

A hub with a subscribers map from id to channel. Publish enters from the left; three subscriber channels leave from the bottom. Subscribers join and leave dynamically by id, each gets its own buffered channel, and a subscriber too slow to keep up has values dropped rather than blocking the publisher.

Usage:

hub_usage.go
// Illustrative snippet — not a complete program
hub := NewHub[Event](100)
defer hub.Close()

// Subscribers can join anytime
id1, events1 := hub.Subscribe()
id2, events2 := hub.Subscribe()

go func() {
    for event := range events1 {
        processEvent(event)
    }
}()

go func() {
    for event := range events2 {
        logEvent(event)
    }
}()

// Publisher sends to all current subscribers
for event := range eventSource {
    hub.Publish(event)
}

// Subscribers can leave
hub.Unsubscribe(id1)
Tee vs Broadcast vs Pub/Sub
Row
Tee
Broadcast
Pub/Sub Hub

Copy Semantics: When to Copy Before Broadcasting

When broadcasting values, consider whether consumers might mutate shared data:

Copy Semantics by Type
Row
int, float64, string, bool
Small struct (no references)
Struct with slices/maps/pointers
[]T (slice)
map[K]V

Rule: If consumers might mutate AND it would affect others, deep copy before broadcast.

Deep Copy in Production

For complex structs with nested pointers, slices, and maps, manual deep copying is error-prone. Consider:

  • Protocol Buffers with .Clone() methods
  • Third-party libraries like github.com/mohae/deepcopy
  • Code generation tools for type-safe copying

For simple cases like the slice example, manual copying is fine and explicit.

Complete Example: Event Processing with Logging

event_processing.go
package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "time"
)

type Event struct {
    ID   int
    Type string
    Data string
}

func generateEvents(ctx context.Context) <-chan Event {
    out := make(chan Event)
    go func() {
        defer close(out)
        events := []Event{
            {1, "click", "button-submit"},
            {2, "view", "page-home"},
            {3, "purchase", "item-123"},
            {4, "click", "button-cancel"},
            {5, "view", "page-checkout"},
        }
        for _, e := range events {
            select {
            case <-ctx.Done():
                return
            case out <- e:
                time.Sleep(100 * time.Millisecond)
            }
        }
    }()
    return out
}

func tee[T any](
    ctx context.Context,
    in <-chan T,
) (<-chan T, <-chan T) {
    out1, out2 := make(chan T), make(chan T)
    go func() {
        defer close(out1)
        defer close(out2)
        for val := range in {
            o1, o2 := out1, out2
            for i := 0; i < 2; i++ {
                select {
                case <-ctx.Done():
                    return
                case o1 <- val:
                    o1 = nil
                case o2 <- val:
                    o2 = nil
                }
            }
        }
    }()
    return out1, out2
}

func main() {
    ctx, cancel := context.WithTimeout(
        context.Background(), 2*time.Second,
    )
    defer cancel()

    events := generateEvents(ctx)
    forLogging, forProcessing := tee(ctx, events)

    var wg sync.WaitGroup
    // Consumer 1: Log all events
    wg.Go(func() {
        for e := range forLogging {
            log.Printf(
                "[AUDIT] Event %d: %s - %s",
                e.ID, e.Type, e.Data,
            )
        }
    })

    // Consumer 2: Count by type
    wg.Go(func() {
        counts := make(map[string]int)
        for e := range forProcessing {
            counts[e.Type]++
        }
        fmt.Printf("Event counts: %v\n", counts)
    })

    wg.Wait()
}
go run main.go
2026/08/30 10:05:25 [AUDIT] Event 1: click - button-submit
2026/08/30 10:05:25 [AUDIT] Event 2: view - page-home
2026/08/30 10:05:26 [AUDIT] Event 3: purchase - item-123
2026/08/30 10:05:26 [AUDIT] Event 4: click - button-cancel
2026/08/30 10:05:26 [AUDIT] Event 5: view - page-checkout
Event counts: map[click:2 purchase:1 view:2]

The timestamps come from log.Printf and will be your own; the counts will not change. fmt prints a map with its keys sorted, so map[click:2 purchase:1 view:2] is the same on every run and every machine.

Common Mistakes

Not consuming all outputs
Problem

Only consuming one tee output blocks the tee goroutine forever.

Fix

Consume all outputs concurrently using goroutines.

consume_all.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Only consuming one output blocks the tee
out1, out2 := tee(ctx, input)

for v := range out1 {
    process(v)
}
// out2 never consumed—tee goroutine blocks forever

// ✓ CORRECT: Consume all outputs concurrently
out1, out2 := tee(ctx, input)

var wg sync.WaitGroup
wg.Go(func() {
    for v := range out1 { process(v) }
})

wg.Go(func() {
    for v := range out2 { logValue(v) }
})

wg.Wait()
Sequential send without nil trick
Problem

Without nilling channels after send, select may send to the same output twice.

Fix

Set channel to nil after sending to disable that select case.

nil_trick.go
// Illustrative snippet — not a complete program
// ✗ WRONG: May send to same output twice
for val := range in {
    for i := 0; i < 2; i++ {
        select {
        case out1 <- val:  // May fire twice if out1 ready!
        case out2 <- val:
        }
    }
}

// ✓ CORRECT: Nil the channel after sending
for val := range in {
    o1, o2 := out1, out2
    for i := 0; i < 2; i++ {
        select {
        case o1 <- val:
            o1 = nil  // Disable this case
        case o2 <- val:
            o2 = nil
        }
    }
}
Broadcasting mutable values
Problem

All consumers share the same slice/map reference—one consumer’s mutation affects others.

Fix

Deep copy values containing slices, maps, or pointers before broadcasting.

See the Copy Semantics example above (copy_semantics.go) for the full wrong/correct pattern.

Pub/Sub without mutex
Problem

Concurrent reads and writes to the subscribers map cause a data race.

Fix

Protect with sync.RWMutex—read lock for publish, write lock for subscribe/unsubscribe.

hub_mutex.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Race condition on subscribers map
func (h *Hub[T]) Publish(val T) {
    for _, ch := range h.subscribers {  // Reading without lock
        ch <- val
    }
}

func (h *Hub[T]) Subscribe() (int, <-chan T) {
    ch := make(chan T)
    h.subscribers[id] = ch  // Writing without lock—RACE!
    return id, ch
}

// ✓ CORRECT: Protect with mutex
func (h *Hub[T]) Publish(val T) {
    h.mu.RLock()
    defer h.mu.RUnlock()
    // ...
}

func (h *Hub[T]) Subscribe() (int, <-chan T) {
    h.mu.Lock()
    defer h.mu.Unlock()
    // ...
}
Not closing all outputs
Problem

Consumers ranging over outputs block forever waiting for closure that never comes.

Fix

Always defer close() all output channels in the tee/broadcast goroutine.

close_outputs.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Consumers block forever on range
func tee[T any](in <-chan T) (<-chan T, <-chan T) {
    out1, out2 := make(chan T), make(chan T)
    go func() {
        for val := range in {
            // ... send logic
        }
        // Missing: close(out1), close(out2)
    }()
    return out1, out2
}

// ✓ CORRECT: Always close outputs when input closes
go func() {
    defer close(out1)
    defer close(out2)
    // ... rest of logic
}()

When to Use Tee/Broadcast

Pattern Selection
Row
Tee
Broadcast
Buffered
Lossy
Pub/Sub Hub

Use fan-out instead when:

Key Takeaways

  1. Broadcast duplicates data—every consumer sees every value
  2. Fan-out distributes data—each value goes to one worker
  3. Tee is broadcast for two—simple, common case
  4. Nil channel disables select case—enables fair multi-send
  5. Slowest consumer limits throughput—synchronous tee provides backpressure
  6. Buffers absorb spikes—but don’t fix consistently slow consumers
  7. Lossy broadcast prevents blocking—but consumers may see different data
  8. Pub/Sub enables dynamic subscribers—hub manages subscriptions with mutex
  9. Always close all outputs—consumers need termination signal
  10. Copy mutable values before broadcasting—prevent shared-state bugs

Next: We’ll wrap up Chapter 7 with pattern composition, self-check questions, and a comprehensive summary of all the patterns covered.


Composing Patterns

These patterns combine naturally. A production system might use several together:

composed_pipeline.go
// Illustrative snippet — not a complete program
// Composed: Pipeline with fan-out, tee for monitoring
func processWithMonitoring(
    ctx context.Context,
    items <-chan Item,
) <-chan Result {
    // Stage 1: Fan-out the slow processing (10 workers)
    processed := fanOutProcess(ctx, items, 10)

    // Stage 2: Buffered tee for monitoring + storage
    forStorage, forMetrics := bufferedTee(ctx, processed, 64)

    // Observer: metrics collection (buffered, won't block pipeline)
    go func() {
        for p := range forMetrics {
            recordMetric(p)
        }
    }()

    // Primary path continues
    return forStorage
}

This function combines fan-out (parallelize slow work) with buffered tee (duplicate for monitoring without blocking the pipeline)—the patterns compose because they all follow the same input/output channel conventions.

PATTERN COMPOSITION EXAMPLE

A composed pipeline. Generate fans out to three workers; their outputs merge into a tee, which sends one copy to a buffered metrics consumer and the other to storage. The three regions are labeled pipeline, fan-out and tee. The patterns compose because each produces a receive-only channel of T, each closes its output when done, and each respects context cancellation.

More composition examples:

composition_examples.go
// Illustrative snippet — not a complete program
// Worker pool feeding into pipeline
func serviceWithPool(ctx context.Context) {
    pool := NewPool(ctx, 10, 100)

    // Results flow into pipeline for post-processing
    validated := validate(ctx, pool.Results())
    stored := store(ctx, validated)

    // Consume final results
    for result := range stored {
        respond(result)
    }
}

// Or-done wrapping third-party channel in pipeline
func processExternalEvents(ctx context.Context) {
    // Third-party library doesn't respect context
    externalEvents := thirdPartyLib.Subscribe()

    // Wrap with or-done, then pipeline
    events := orDone(ctx, externalEvents)
    enriched := enrich(ctx, events)

    for e := range enriched {
        handle(e)
    }
}

// Hedged requests with fan-in for redundancy
func resilientFetch(
    ctx context.Context, urls []string,
) <-chan Response {
    channels := make([]<-chan Response, len(urls))

    for i, url := range urls {
        channels[i] = hedgedFetch(ctx, url, 50*time.Millisecond)
    }

    // Fan-in all hedged results
    return fanIn(ctx, channels...)
}

Why composition works:

All patterns follow consistent conventions:

  1. Accept context.Context for cancellation
  2. Return <-chan T (receive-only) for output
  3. Close output when done via defer close(out)
  4. Check ctx.Done() in selects for responsive cancellation

Follow these conventions in your own code, and your stages will compose seamlessly with these patterns.


Self-Check: Channel Patterns

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

Self-Check Questions

1. A pipeline stage receives values but forgets defer close(out). What happens to the downstream consumer?

The downstream consumer blocks forever. Its for range loop waits for the channel to close, but without defer close(out), closure never propagates. This is why defer close(out) is mandatory in every stage.

2. You run a service that continuously receives URLs to fetch. Each fetch takes 50–500ms. Should you use fan-out or a worker pool? What worker count would you start with?

Worker pool, because this is a long-running service accepting jobs over time (not a one-time batch). Start with 10–50 workers for I/O-bound work—enough concurrent requests to saturate network capacity, but not so many you overwhelm backends. Measure and adjust based on throughput.

3. In the tee pattern, why do we use o1, o2 := out1, out2 and then set them to nil after sending, instead of just sending to out1 and out2 directly?

To ensure each value goes to both outputs exactly once. Without the nil trick, the select could send to the same output twice if it happens to be ready both times. Setting o1 = nil after sending disables that case, forcing the second iteration to use o2. The fresh o1, o2 := out1, out2 at the start of each outer loop iteration resets for the next value.

4. Your service queries three backend replicas. You want the first successful response, not just the first response. Which pattern do you use, and why does it matter?

First-success pattern (not first-response). First-response returns immediately on any result, including errors. First-success skips errors and waits until one request succeeds or all fail. If replica 1 returns a 500 error in 10ms while replica 2 returns success in 100ms, first-response gives you the error; first-success gives you the success.

5. When is hedging appropriate, and when is it dangerous? Give one example of each.

Appropriate: Read-only, idempotent operations where tail latency matters—e.g., hedging a database read after P75 latency. Dangerous: Non-idempotent operations like payments or inventory updates—hedging could charge a customer twice or decrement stock twice.

6. What’s the difference between fan-out (§7.2) and broadcast (§7.5)?

Fan-out distributes work—each value goes to exactly one worker. Broadcast duplicates the stream—every consumer sees every value. Use fan-out when you want to parallelize processing; use broadcast when multiple consumers need the same data (e.g., logging + processing).

7. In the or-done pattern, why are two select statements required instead of one?

To prevent goroutine leaks when context cancels after receive but before send. Timeline: (1) Consumer’s context cancels, consumer exits range loop. (2) orDone goroutine receives a value from input. (3) orDone tries to send to output—but no receiver exists. Without the second select checking ctx.Done(), the goroutine blocks forever on send. The second select provides an exit path.

8. A worker pool’s results channel has capacity 10. You submit 100 jobs before starting the consumer. What happens?

Deadlock. Workers fill the results buffer (10 results). The next worker to finish blocks trying to send its result. All workers eventually block on results send. Since workers are blocked, they stop receiving from the jobs channel. Your Submit() blocks waiting for a worker to receive. No consumer is draining results. Fix: Always start the consumer before submitting jobs.


Chapter 7 Summary

This chapter covered Go’s essential channel-based concurrency patterns:

Chapter 7 Pattern Overview
Row
Pipeline
Fan-Out/Fan-In
Worker Pool
Or-Channel
Or-Done
Tee/Broadcast

Choosing the right pattern:

PATTERN SELECTION GUIDE

A selection guide of question-and-answer pairs mapping a need to a pattern and its section: processing items through stages points to the pipeline in section 7.1, parallelizing expensive work to fan-out/fan-in in 7.2, bounded concurrency with a job queue to the worker pool in 7.3, and taking the first result from several sources to the or-channel patterns in 7.4.

Pattern conventions that enable composition:

Row
Accept context.Context
Return <-chan T
defer close(out)
Select on ctx.Done() for sends
WaitGroup for multiple workers
Buffered channels for racing

Common pitfalls across all patterns:

Row
Missing defer close(out)
No context support
Unbuffered channel in races
Submit before consumer starts
Hedging non-idempotent ops
Broadcasting mutable values

Key Takeaways

  1. Pipelines chain stages—data flows through transformations, each stage owns its output
  2. Fan-out parallelizes—multiple workers share input, self-balancing distribution
  3. Worker pools bound concurrency—fixed workers, explicit lifecycle, graceful shutdown
  4. Or-channel races signals—first closure wins, useful for combining cancellation sources
  5. Or-done wraps channels—adds cancellation to channels that don’t support context
  6. Tee/Broadcast duplicates—every consumer sees every value, unlike fan-out which distributes
  7. Patterns compose—consistent conventions (context, channel ownership, closure) enable combination
  8. Always consider cancellation—every long-running goroutine needs an exit path
  9. Buffer strategically—prevent leaks in races, smooth bursts in pipelines
  10. Measure, don’t guess—optimal worker counts depend on your specific workload

What’s Next:

Part 3 shifts from channels to shared memory. Chapter 8: Data Races and the Memory Model explores what makes concurrent code safe, how Go guarantees ordering, and how to use the race detector to catch bugs before they reach production.

The patterns in this chapter used channels to coordinate goroutines safely. Chapter 8 explains what happens when goroutines share memory directly—and why that’s usually a mistake without careful synchronization.


Exercise 7.1 — Give the Close an Owner

Your move

Make the fan-out finish

This is §7.2’s brokenFanOut, the mistake the section names and then moves past. The work is already right — every value is processed exactly once. What is missing is the end: nobody closes the output, so the caller’s for range waits for a close that never comes.

Every previous chapter’s exercise failed with a panic. This one hangs, which is the harder failure to diagnose and the reason the tests carry their own deadline.

ch07/fanout.go
package ch07

// TODO(reader): FanOut spreads `in` across `workers` goroutines and
// merges their results onto one output channel. The work is already
// correct — every value is processed exactly once. What is missing is
// the end.
//
// Nobody closes `out`. Each worker just returns when `in` dries up,
// and the caller's `for range` waits forever for a close that never
// comes. §7.2 names the fix in one line: no single worker can know it
// is the last, so the close belongs to a coordinator that waits for
// all of them.
//
// This one does not panic. It hangs — which is why the test below
// carries its own deadline instead of trusting the runtime to notice.
//
// Do not change the signature, and do not close `out` from inside a
// worker: with more than one worker that is a double close.
func FanOut(
	in <-chan int,
	workers int,
	work func(int) int,
) <-chan int {
	out := make(chan int)

	for i := 0; i < workers; i++ {
		go func() {
			for n := range in {
				out <- work(n)
			}
		}() // <- your move goes after this loop
	}

	return out
}
ch07/fanout_test.go
package ch07

import (
	"slices"
	"testing"
	"time"
)

func feed(values ...int) <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for _, v := range values {
			ch <- v
		}
	}()
	return ch
}

// A fan-out that never closes its output does not crash. It hangs, so
// the deadline is the test: without it this would sit here until the
// package timeout, with no useful failure message.
func TestFanOutClosesItsOutput(t *testing.T) {
	in := feed(1, 2, 3, 4, 5, 6, 7, 8)

	done := make(chan []int, 1)
	go func() {
		var got []int
		for v := range FanOut(in, 3, func(n int) int { return n * n }) {
			got = append(got, v)
		}
		done <- got
	}()

	select {
	case got := <-done:
		slices.Sort(got)
		want := []int{1, 4, 9, 16, 25, 36, 49, 64}
		if !slices.Equal(got, want) {
			t.Fatalf("FanOut gave %v, want %v", got, want)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("FanOut never closed its output channel: the " +
			"consumer's range loop is still waiting. See §7.2.")
	}
}

// Fan-out divides work; it does not duplicate it. Every input must
// come out exactly once no matter how the workers interleave.
func TestFanOutDeliversEachValueOnce(t *testing.T) {
	const n = 200
	values := make([]int, n)
	for i := range values {
		values[i] = i
	}

	seen := make(map[int]int, n)
	done := make(chan map[int]int, 1)
	identity := func(n int) int { return n }
	go func() {
		for v := range FanOut(feed(values...), 8, identity) {
			seen[v]++
		}
		done <- seen
	}()

	select {
	case got := <-done:
		for _, v := range values {
			if got[v] != 1 {
				t.Fatalf("value %d came out %d times; want exactly 1",
					v, got[v])
			}
		}
	case <-time.After(2 * time.Second):
		t.Fatal("FanOut never closed its output channel")
	}
}
Done when: go test -race ./... in code/ch07/ reports ok for both tests. Do not change the signature, and do not close from inside a worker — with more than one worker that is a double close, which is §7.2’s other mistake.
Hint, if you want one: §7.2’s fanOut already contains the answer in three lines. The question it answers is “who can know they are all finished?” — and the answer is never a worker.
Where the files are: labs/go-concurrency/code/ch07/. A worked answer sits in solution/fanout.go.txt.

Further reading

Next

Every pattern in this chapter coordinates goroutines by passing values, never by sharing them — which is why none of it needed a mutex. That is a choice, and it has a cost you have not paid yet. Chapter 8 turns to shared memory: what the Go memory model actually guarantees about ordering, what a data race is as distinct from a race condition, and how the race detector finds the bugs that channels were letting you avoid.