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.
- 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
- Context package internals—Chapter 13
- Error handling strategies across goroutines—Chapter 14
- Testing concurrent code—Chapter 16
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).
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.
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.
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:
- Receives values from an upstream channel
- Performs a transformation or computation
- 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.
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?
- Separation of concerns: Each stage has one job
- Composability: Stages can be rearranged or replaced
- Concurrent execution: All stages run in parallel
- Bounded memory: Data streams through; no need to load everything. Unbuffered channels ensure at most one value exists between each pair of stages.
- Backpressure: Unbuffered channels naturally pace producers to consumers
Sequential vs Pipeline Processing
The power of pipelines becomes clear when comparing execution patterns:
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:
// 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
}
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:
// 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:
// 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.
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:
// 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).
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:
// 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:
-
defer close(out): Guarantees closure on all exit paths -
for range in: Processes until input closes -
selectwithctx.Done(): Enables cancellation during sends -
Returns
<-chan U: Caller receives read-only view
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.
// 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):
}
}
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:
// Illustrative snippet — not a complete program
for {
select {
case <-ctx.Done():
return
case n, ok := <-in:
if !ok {
return
}
// process n
}
}
Basic Pipeline Example
// 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:
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:
// 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:
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.
If any stage forgets defer close(out), downstream
stages block forever on for range:
// 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?
// 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.
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:
// 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()
}
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.
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.
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:
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
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")
}
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.
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?
// 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:
// 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:
// 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:
-
Separate error channel: Return
(<-chan T, <-chan error)to stop on first error - errgroup package (Chapter 14): Coordinate multiple goroutines with automatic cancellation on first error
Buffering in Pipelines
By default, pipeline channels are unbuffered—providing natural backpressure. Add buffering when stages have variable processing times:
// 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
}
0 (unbuffered)
(peak - steady) × burst_duration
0 or minimal
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:
- Stream processing: Log parsing, data transformation, ETL
- Unbounded data: Network streams, file processing, event handling
- Composable transformations: When operations naturally chain
- Memory-bounded processing: Large datasets that shouldn’t load entirely
Poor use cases:
- Simple transformations: Just call the function directly
- Small, known data: Use slices and loops—simpler and often faster
- Trivial per-item computation: Channel overhead may exceed benefit; for expensive CPU-bound stages, consider fan-out (§7.2)
// 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
defer close(out)
Consumer’s for range blocks forever after
all values are received
Always defer close(out) in the goroutine
Goroutines leak on early exit from consumer
Accept context.Context, select on
ctx.Done()
selectBlocked send ignores cancellation
Wrap all sends in select with
ctx.Done()
chan T instead of
<-chan T
Caller can send or close, breaking ownership
Always return <-chan T (read-only view)
Cancellation doesn’t reach all stages
Pass the same context to all stages
select
Can’t cancel mid-computation
Check ctx.Done() before expensive work
Key Takeaways
- Pipelines = stages + channels—data flows through transformation steps
- Three stage types—generator (source), transformer (middle), consumer (sink)
-
Every stage closes its output—
defer close(out)is mandatory - Closing propagates downstream—upstream close triggers downstream exit
- All stages accept context—enables cancellation and timeout
-
Cancellation is cooperative—every stage
must check
ctx.Done() -
Check
ctx.Done()in sends—allows immediate exit on cancellation - Prefer pure transforms—complex side effects are easier to manage in dedicated consumer stages
- Unbuffered by default—add buffering only with justification
- 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?
// 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.
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:
- Fan-out: Multiple goroutines read from the same channel, processing items in parallel
- Fan-in: Multiple channels merge into one, aggregating results
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:
// 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
}
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.
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.
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.
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.
// 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:
// 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
}
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:
- Aggregating independent data sources (multiple APIs, files, streams)
- Workers with separate output channels (for per-worker monitoring)
- Merging results from parallel pipelines
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.
Both fan-out and fan-in produce results in completion order, not input order:
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 order1–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:
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:
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
runtime.NumCPU()
NumCPU() × 1.5–2
Choosing the Number of Workers
Once you’ve decided to fan out, use these starting points for worker count:
Guidelines
runtime.NumCPU()
NumCPU() × 2
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.
The right worker count depends on your specific workload. Benchmark different values and look for where throughput plateaus—that’s your sweet spot.
// 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:
// 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)
Complete Example: Parallel URL Fetcher
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))
}
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 reportedTotal 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:
// 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
}
- 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:
// 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
}
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:
- Consistently slower than upstream/downstream
- Independently processable (no shared state)
- Worth the coordination overhead
When to Use Fan-Out/Fan-In
Good use cases:
- I/O-bound bottleneck: HTTP requests, database queries, file processing
- Independent work items: Each task processable in isolation
- Throughput critical: Need more items/sec, not faster individual items
Poor use cases:
- Already fast stages: Overhead exceeds benefit (< 100µs per item)
- Order must be preserved: Unless you can buffer and re-sort
- Shared state requiring locks: Workers serialize anyway
- Small, fast datasets: Sequential is simpler when total processing time is already acceptable
// 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
Output channel never closes—consumer blocks forever
Use sync.WaitGroup—wait for all workers,
then close output
// 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
}
In Go <1.22, all goroutines may see the same
ch value when captured by closure
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)
Context switching exceeds benefit with 1000 workers
Match available cores:
runtime.NumCPU()
// 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)
Results arrive in completion order, not input order
Use sequence numbers and reorder with
orderedFanOut
// 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
- Fan-out distributes work—multiple goroutines reading from one channel
- Fan-in aggregates results—merge multiple channels into one
- Combined pattern handles most cases—workers share input and output
- Results arrive out of order—include sequence numbers if order matters
- Bound your workers—unlimited parallelism can exhaust resources
- I/O-bound work benefits most—workers wait in parallel
-
CPU-bound is limited by cores—use
runtime.NumCPU()workers - WaitGroup coordinates shutdown—wait for all workers before closing output
- Cancellation doesn’t stop in-flight work—workers finish current item first
- 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:
// 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.
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:
- Bounded concurrency: Fixed workers, regardless of load
- Backpressure: Queue fills → system slows or rejects new work
- Graceful shutdown: Complete in-flight work before exit
- Reusability: Workers process many jobs over their lifetime
Fan-Out vs Worker Pool
Both patterns parallelize work, but have different lifecycles:
Submit() calls
Shutdown() with drain
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:
// 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
}
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:
// 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.
Workers check both ctx.Done() and channel closure to
support two shutdown modes:
-
Graceful: Closes
jobschannel → workers drain queue, then exit whenok == 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:
// Illustrative snippet — not a complete program
jobs: make(chan Job, queueSize) // Buffered queue
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:
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:
// 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()
TrySubmit()
SubmitTimeout()
Usage in HTTP handler:
// 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:
// 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
}
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.
// 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})
}
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:
// 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:
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:
// 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
}
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 inSubmit()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:
// 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:
// 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:
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
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.
// 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:
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:
// 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
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")
}
Pool Sizing Guidelines
Number of Workers
runtime.NumCPU()
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
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.
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
Results buffer fills, workers block, Submit
blocks—deadlock
Start consumer before submitting jobs
// 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() { ... }
Blocks entire HTTP request if queue full—server becomes unresponsive
Use TrySubmit() with proper HTTP error response
// 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)
}
Panic kills worker permanently—pool silently loses workers over time
Use safeProcess() to recover from panics
// 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
}
}
Consumer’s for range blocks forever after
all jobs complete
Close results channel in
Shutdown() after workers finish
// 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
- Worker pools bound concurrency—fixed workers, bounded queue
- Long-lived workers—created once, reused for service lifetime
-
Queue provides backpressure—
Submitblocks when queue is full -
Non-blocking submit for servers—
TrySubmitreturns error when full -
Unified shutdown with timeout—
Shutdown(0)for graceful,Shutdown(duration)for bounded wait -
Thread-safe shutdown—
sync.Once+atomic.Bool+recover()in Submit prevents races and panics - Always consume results—or workers block on send
- Start consumer before submitting—prevents buffer-full deadlock
- Recover from panics with stack traces—don’t let one bad job kill workers
- 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.
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:
// 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)
}
}
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.
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.
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:
// 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.
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.
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:
// 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):
// 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
}
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:
- Buffered channel: Size matches goroutine count so losers don’t block
-
Context cancellation:
defer cancel()signals losers to stop - Returns first result: May be success or error
First-Success-Wins
Sometimes you want the first successful result, skipping errors:
// 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
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:
// 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
}
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.
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:
- P50: 20ms (50% of requests complete)
- P75: 50ms (75% of requests complete)
- P99: 500ms (99% of requests complete)
With hedge delay = 50ms (P75):
- 75% of requests complete before hedge fires → no extra load
- 25% of slow requests get a backup → reduces P99
- Much better than doubling all traffic
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:
// 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:
- < 15%: Hedge delay may be too long (not helping tail latency)
- 15-25%: Ideal range—reducing tail latency without excessive load
- > 30%: Hedge delay too short, or backend is degraded. At 30%, you’re sending 130 requests per 100 operations—significant extra backend load.
Alert on sustained high hedge rates—they indicate either misconfigured delays or backend problems.
When to use hedging:
The Or-Done Pattern
A common problem: reading from a channel while respecting cancellation. The naive approach is verbose:
// 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:
// 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:
// Illustrative snippet — not a complete program
// ✓ CLEAN: Range over wrapped channel
for v := range orDone(ctx, in) {
process(v)
}
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.
// Illustrative snippet — not a complete program
// ✗ BROKEN: Only one select
case v, ok := <-in:
if !ok { return }
out <- v // ← BLOCKS if no receiver!
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:
// 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.
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:
// 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
// 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
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
// 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
// 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:
}
}()
}
Complete Example: Resilient API Client
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))
}
Common Mistakes
Using an unbuffered channel when racing goroutines means losing goroutines block forever on send.
Buffer the channel with capacity equal to the number of goroutines.
// 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))
Losing requests continue running to completion, wasting CPU, network, and backend resources.
Use defer cancel() to cancel context when the
winner returns.
// 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
}
Not checking if the input channel is closed causes the or-done wrapper to send zero values forever.
Always use v, ok := <-in and check
ok to detect channel closure.
// 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 writes or mutations can cause duplicate side effects (e.g., charging a customer twice).
Only hedge reads or idempotent operations.
// 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
Consider simpler alternatives:
context.WithTimeout directly
select statement
// 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
- Or-channel closes when any input closes—useful for combining cancellation signals
- First-response returns fastest result—may be success or error
- First-success skips errors—waits until one succeeds or all fail
- Hedging trades resources for latency—only for idempotent operations
- Buffer results channels—size equals goroutine count to prevent leaks
-
Always call
defer cancel()—stops losing operations, frees resources - Or-done needs two selects—check cancellation on both receive and send
- Monitor hedge rate in production—15-25% is ideal, >30% indicates problems
- Check channel closure with ok—avoid zero-value bugs
- 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.
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:
- Logging + processing: Log every event AND process it
- Caching + persistence: Write to cache AND database
- Multiple analyses: Same data to different transformations
- Monitoring: Observe stream without disrupting it
The Tee Pattern
The tee pattern (named after Unix tee command) splits one
input into two identical outputs:
// 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
}
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.
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:
// 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()
}
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?
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:
- All consumers must see all values
- Data loss is unacceptable
- Slow consumer indicates a problem to fix
Pros: Simple, no message loss, natural
backpressure
Cons: Slow consumer affects entire system
Strategy 2: Buffered Channels
Add buffers to absorb temporary speed differences:
// 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:
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.
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:
// 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
}
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:
- Metrics collection (sampling is fine)
- Real-time dashboards (stale data is worthless)
- Monitoring/logging (missing occasional entries okay)
When dropping is NOT acceptable:
- Transactional data
- Audit logs
- Ordered processing where gaps cause errors
Broadcast to N Consumers
For more than two consumers, generalize to broadcast:
// 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
}
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:
// 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:
// 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
}
The closed flag ensures:
-
Publish()afterClose()is a no-op (no panic) -
Subscribe()afterClose()returns a closed channel (caller’s range exits immediately) -
Close()is idempotent (safe to call multiple times)
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:
// 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)
Copy Semantics: When to Copy Before Broadcasting
When broadcasting values, consider whether consumers might mutate shared data:
int, float64, string,
bool
[]T (slice)
map[K]V
Rule: If consumers might mutate AND it would affect others, deep copy before broadcast.
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
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()
}
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
Only consuming one tee output blocks the tee goroutine forever.
Consume all outputs concurrently using goroutines.
// 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()
Without nilling channels after send, select may send to the same output twice.
Set channel to nil after sending to disable that
select case.
// 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
}
}
}
All consumers share the same slice/map reference—one consumer’s mutation affects others.
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.
Concurrent reads and writes to the subscribers map cause a data race.
Protect with sync.RWMutex—read lock for
publish, write lock for subscribe/unsubscribe.
// 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()
// ...
}
Consumers ranging over outputs block forever waiting for closure that never comes.
Always defer close() all output channels in the
tee/broadcast goroutine.
// 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
Use fan-out instead when:
- Work distribution needed (each value processed once)
- Only one consumer needs each item
- Load balancing is the goal
Key Takeaways
- Broadcast duplicates data—every consumer sees every value
- Fan-out distributes data—each value goes to one worker
- Tee is broadcast for two—simple, common case
- Nil channel disables select case—enables fair multi-send
- Slowest consumer limits throughput—synchronous tee provides backpressure
- Buffers absorb spikes—but don’t fix consistently slow consumers
- Lossy broadcast prevents blocking—but consumers may see different data
- Pub/Sub enables dynamic subscribers—hub manages subscriptions with mutex
- Always close all outputs—consumers need termination signal
- 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:
// 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.
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:
// 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:
-
Accept
context.Contextfor cancellation -
Return
<-chan T(receive-only) for output -
Close output when done via
defer close(out) -
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
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.
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.
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.
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.
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.
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).
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.
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:
Choosing the right pattern:
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:
context.Context
<-chan T
defer close(out)
ctx.Done() for sends
Common pitfalls across all patterns:
defer close(out)
Key Takeaways
- Pipelines chain stages—data flows through transformations, each stage owns its output
- Fan-out parallelizes—multiple workers share input, self-balancing distribution
- Worker pools bound concurrency—fixed workers, explicit lifecycle, graceful shutdown
- Or-channel races signals—first closure wins, useful for combining cancellation sources
- Or-done wraps channels—adds cancellation to channels that don’t support context
- Tee/Broadcast duplicates—every consumer sees every value, unlike fan-out which distributes
- Patterns compose—consistent conventions (context, channel ownership, closure) enable combination
- Always consider cancellation—every long-running goroutine needs an exit path
- Buffer strategically—prevent leaks in races, smooth bursts in pipelines
- 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
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.
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
}
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")
}
}
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.
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.
labs/go-concurrency/code/ch07/. A worked answer sits in
solution/fanout.go.txt.
Further reading
-
Go Concurrency Patterns: Pipelines and cancellation
— the source text for §7.1 and §7.2. Written before
contextexisted, so it threads an explicitdonechannel; read it for the reasoning about who closes what, then usectx.Done()as this chapter does. - Go Concurrency Patterns: Context — why cancellation is a parameter rather than a global, which is the assumption every stage template in §7.1 is built on.
- Dean & Barroso, The Tail at Scale — where hedged requests come from. Worth reading for the measurements rather than the number: the paper hedges at the 95th percentile, while §7.4 starts more conservatively at the P75. Both are starting points to be tuned against your own hedge rate in production.
-
golang.org/x/sync/errgroup— the fan-out of §7.2 plus error propagation andSetLimitfor bounded concurrency, already written. Read the patterns here first so you know what it is doing for you. -
WaitGroup.Go— added in Go 1.25 and used throughout this chapter. It removes theAdd(1)/defer Done()pair, and with it the class of bug where one of the two goes missing.
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.