Chapter 3: Channels

Chapter 2 taught you to create goroutines and coordinate their completion with WaitGroups. But goroutines that can't communicate are severely limited. How does a worker report its result? How does a producer feed data to consumers? How does a supervisor signal shutdown? Channels are Go's answer. They're the primary mechanism for communication between goroutines—typed conduits through which values flow safely, eliminating the need for explicit shared memory or locks.

What you'll learn
  • How to create channels and why the zero value is dangerous
  • Send and receive operations and their blocking semantics
  • Channel lifecycle: closing, detecting closure, and iteration
  • The sender-closes principle and why violating it causes panics
  • Nil channel behavior and its strategic uses
  • The complete channel behavior table—every operation on every state
Building toward

This chapter focuses on unbuffered channels—the foundation. Master these semantics before exploring the select statement (Chapter 4), buffered channels (Chapter 5), directional channel types (Chapter 6), and channel-based patterns like pipelines and worker pools (Chapter 7).

Prerequisites

You should understand goroutine creation and lifecycle from Chapter 2, including WaitGroups and leak prevention.

But channels are more than data pipes. They are synchronization points where goroutines meet and coordinate. When two goroutines communicate through an unbuffered channel, they must both be ready—the sender blocks until a receiver is waiting, and the receiver blocks until a sender provides a value. This synchronization is the channel's essence; data transfer is a consequence.

CHANNELS AS SYNCHRONIZATION

Two goroutines, A and B. A executes a send, ch arrow v; B executes a receive, arrow ch. Both paths converge on a single point labeled synchronization, showing that the send and the receive complete as one event rather than two independent steps.

This mental model—channels as synchronization points—explains behaviors that otherwise seem arbitrary. Keep it in mind throughout this chapter.

About time.Sleep in Examples

Several examples in this chapter use time.Sleep to demonstrate timing and ensure goroutines reach specific execution points. This is for demonstration only—not production code.

Production code uses WaitGroups for goroutine coordination (Chapter 2), channels for completion signals (this chapter), and Context for cancellation and timeouts (Chapter 13). We use time.Sleep to keep examples focused on channel mechanics.


3.1 Creating Channels: make(chan T)

Before goroutines can communicate through channels, the channels must exist. Unlike some Go types with useful zero values, channels require explicit initialization.

The Zero Value: nil

A channel's zero value is nil, not a usable channel:

nil_channel.go
// Illustrative snippet — not a complete program
var ch chan int  // ch is nil

fmt.Println(ch == nil)  // true

This differs from value types like int and string:

ZERO VALUE COMPARISON

Five declarations and their zero values. An int becomes 0 and a string becomes empty, both ready to use. A nil slice is safe to take the length of, range over, and append to, though indexing panics. A nil map is safe to read but panics on write. A nil channel is the odd one out: not ready to use at all, and must be created with make.

Nil Channels Block Forever

Send and receive on a nil channel block the goroutine permanently. In the main goroutine, the runtime detects the deadlock and panics. In a spawned goroutine, it blocks silently—a goroutine leak. Closing a nil channel panics immediately.

nil_operations.go
// Illustrative snippet — not a complete program
var ch chan int  // nil — not initialized

// Each line below is an independent scenario:
ch <- 42      // Blocks forever (deadlock or goroutine leak)
<-ch         // Blocks forever (deadlock or goroutine leak)
close(ch)    // Panic: close of nil channel
Operations on Nil Channel
ch <- v (send)
Blocks forever
<-ch (receive)
Blocks forever
close(ch)
Panic
len(ch), cap(ch)
Returns 0 (safe)

Nil channels have strategic uses in select statements (Section 3.4 and Chapter 4). Outside that pattern, always initialize channels before use.


Creating a Channel: make(chan T)

To create a usable channel, use the built-in make function:

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

This creates an unbuffered channel that carries values of type int.

Here's channel communication in action:

channel_demo.go
package main

import "fmt"

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

    go func() {
        ch <- 42  // Goroutine sends
    }()

    value := <-ch  // Main receives
    fmt.Println(value)  // 42
}

Syntax:

syntax.go
// Illustrative snippet — not a complete program
// T = any type, n = integer capacity
make(chan T)        // Unbuffered channel (capacity 0)
make(chan T, n)     // Buffered channel with capacity n (Chapter 5)

make(chan T, 0) is equivalent to make(chan T)—both create an unbuffered channel. The zero capacity is the default.

For now, focus on make(chan T)—unbuffered channels where every send must synchronize with a receive.

Anatomy of Channel Creation

anatomy.go
// Illustrative snippet — not a complete program
ch := make(chan int)
//         ^^^^ ^^^
//          │    └─ Element type: what flows through
//          └────── The chan keyword

Examples:

channel_types.go
// Illustrative snippet — not a complete program
messages := make(chan string)      // Channel of strings
results := make(chan int)          // Channel of ints
errors := make(chan error)        // Channel of errors
data := make(chan []byte)         // Channel of byte slices
done := make(chan struct{})       // Signaling channel

The element type T can be any Go type—primitives, structs, interfaces, pointers, slices, maps, functions, even other channels.


Type Safety

Channels are strongly typed—a channel of int cannot carry string values:

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

ch <- 42        // ✓ Valid—int
ch <- "hello"   // ✗ Compile error: type mismatch

The compiler enforces this at compile time, preventing runtime type errors.


Unbuffered Channels

make(chan T) creates an unbuffered channel—a channel with zero capacity:

unbuffered.go
// Illustrative snippet — not a complete program
ch := make(chan int)  // Unbuffered (capacity 0)

An unbuffered channel has no internal storage. A send blocks until a receiver is ready, and a receive blocks until a sender provides a value. Both goroutines must rendezvous simultaneously:

UNBUFFERED CHANNEL SYNCHRONIZATION

Three columns: Sender, Channel, Receiver. The sender's arrow reaches the channel and then blocks. The receiver's arrow reaches the channel from the other side and also blocks. Neither side proceeds until both have arrived.

This synchronous behavior makes unbuffered channels pure synchronization primitives. The data transfer is secondary—the synchronization is primary. We cover blocking semantics in depth in Section 3.2.

For unbuffered channels, both len(ch) and cap(ch) return 0—there's no buffer to measure. These functions become meaningful with buffered channels (Chapter 5).

Buffered Channels Preview

Buffered channels can hold values internally, changing blocking behavior—sends block only when full, receives block only when empty. Chapter 5 covers buffered channels comprehensively. Master unbuffered channels first.

buffered_preview.go
// Illustrative snippet — not a complete program
ch := make(chan int, 10)  // Buffered with capacity 10

Channels Are Reference Types

Like maps, channels are reference types: the variable holds a pointer to a structure the runtime allocates, so when you assign a channel to another variable or pass it to a function, both refer to the same underlying channel:

reference_type.go
package main

import "fmt"

func worker(ch chan int) {
    ch <- 42  // Sends to the SAME channel as main
}

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

    go worker(ch)  // Pass channel—copies the reference

    value := <-ch  // Receives from same channel
    fmt.Println(value)  // 42
}
CHANNELS ARE REFERENCE TYPES

The variable ch in main holds a pointer to a channel structure the runtime keeps on the heap. Assigning ch to another variable, or passing it to a function, copies only that pointer, so every copy refers to the same underlying channel.

Slices are not in this club

Maps and channels are handles: the variable is a pointer to a runtime structure. A slice is different—it is a small descriptor holding a pointer, a length, and a capacity, and that descriptor is copied by value. Writing s[0] = 1 inside a function is visible to the caller because both descriptors point at the same array, but append may reassign the callee's copy and the caller will never see it. Group channels with maps, not with slices.

This is why you don't need *chan int:

no_pointer.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Don't pass pointer to channel
func workerBad(ch *chan int) {
    *ch <- 42  // Unnecessarily complex
}

// ✓ CORRECT: Pass channel directly
func worker(ch chan int) {
    ch <- 42  // Already a reference
}

Channels are already references. Adding * creates a pointer-to-reference, which is redundant and confusing.


Comparing Channels

Channels can be compared with == and !=:

compare_channels.go
// Illustrative snippet — not a complete program
ch1 := make(chan int)
ch2 := make(chan int)
ch3 := ch1

fmt.Println(ch1 == ch2)   // false—different channels
fmt.Println(ch1 == ch3)   // true—same channel
fmt.Println(ch1 == nil)   // false—ch1 is initialized

This is useful for checking if a channel is nil before operating and verifying two variables reference the same channel.


Channels Are First-Class Values

Channels can be used anywhere a value is expected:

Passed to Functions

producer_consumer.go
package main

import "fmt"

func producer(out chan int) {
    out <- 1
    out <- 2
    out <- 3
}

func consumer(in chan int) {
    fmt.Println(<-in)  // 1
    fmt.Println(<-in)  // 2
    fmt.Println(<-in)  // 3
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)  // Both sides coordinate exactly 3 values
}

In idiomatic Go, producer would accept chan<- int (send-only) and consumer would accept <-chan int (receive-only) to enforce direction at compile time. We cover directional channel types in Chapter 6. And in Section 3.3, we'll replace this fragile “receive exactly N times” pattern with for range over channels, which automatically handles any number of values.

Returned from Functions

return_channel.go
package main

import "fmt"

func startWorker() chan int {
    ch := make(chan int)
    go func() {
        ch <- 42  // Sends once and exits naturally
    }()
    return ch
}

func main() {
    resultCh := startWorker()
    fmt.Println(<-resultCh)  // 42
}

Stored in Data Structures

struct_field.go
// Illustrative snippet — not a complete program
type Job struct {
    ID     int
    Result chan int  // Channel as struct field
}

Channels can even carry other channels—useful for request-response patterns where each request includes a "reply-to" channel. We'll explore this in Chapter 7.


The Empty Struct Channel: chan struct{}

A common idiom for signaling without data:

signal_channel.go
// Illustrative snippet — not a complete program
done := make(chan struct{})

struct{} is the empty struct—it has zero size.

Why struct{} instead of bool or int?

Signal Channel Type Sizes
chan bool
1 byte
chan int
8 bytes (on 64-bit)
chan struct{}
0 bytes

Beyond the memory savings (which matter most with buffered channels—Chapter 5), struct{} communicates intent: "This channel carries no information, only synchronization." There's no temptation to wonder what true vs false means—the signal itself is the only message.

Common uses:

signal_uses.go
// Illustrative snippet — not a complete program
done := make(chan struct{})    // Completion signal
quit := make(chan struct{})    // Shutdown signal
ready := make(chan struct{})   // Ready notification

Element Type: Value vs Pointer

When you send through a channel, the value is copied:

value_copy.go
package main

import "fmt"

type Data struct {
    Values [1000]int  // Large struct
}

func main() {
    ch := make(chan Data)

    go func() {
        d := Data{Values: [1000]int{1, 2, 3}}
        ch <- d           // Copies entire struct (~8 KB on 64-bit)
        d.Values[0] = 999 // Doesn't affect the sent copy
    }()

    received := <-ch
    fmt.Println(received.Values[0])  // 1, not 999
}

For large structs or when you need shared state, send pointers:

pointer_channel.go
package main

import "fmt"

type Data struct {
    Values [1000]int
}

func main() {
    ch := make(chan *Data)

    go func() {
        d := &Data{Values: [1000]int{1, 2, 3}}
        ch <- d  // Copies pointer (8 bytes), not data
    }()

    received := <-ch
    fmt.Println(received.Values[0])  // 1
}
Pointer Channels and Ownership

Sending a pointer through a channel transfers the reference, not ownership. Both sender and receiver can access the same memory. This creates potential for data races unless you follow the ownership transfer convention: after sending a pointer, don't use it.

ownership.go
// Illustrative snippet — not a complete program
var data *Data = &Data{/* ... */}

ch <- data    // Send pointer
data = nil    // Clear YOUR reference
              // Receiver now "owns" the data

This convention is your responsibility to maintain—the compiler doesn't enforce it. Use go run -race to detect violations at runtime.

Decision Guide: Value vs Pointer Channels

Default to value types unless you have a specific reason for pointers. Value semantics are simpler and eliminate shared-state bugs.

Value vs Pointer Recommendations
Small types (<64 bytes)
chan T — copy is cheap
Large structs/arrays
chan *T — avoid copy overhead
Need shared mutation
chan *T + ownership discipline
Want isolation
chan T — each receiver gets own copy

Declaration vs Initialization

Be clear about the difference:

declaration.go
// Illustrative snippet — not a complete program
// Declaration only—ch is nil
var ch chan int
fmt.Println(ch == nil)  // true

// Declaration with initialization
var ch2 = make(chan int)  // ch2 is ready to use

// Short declaration with initialization
ch3 := make(chan int)     // ch3 is ready to use (idiomatic)
Declaration vs Initialization
Row
var ch chan int
var ch = make(chan int)
ch := make(chan int)

Common Mistakes

Using Uninitialized (nil) Channels
Problem

Declaring var ch chan int without calling make()—the channel remains nil (see “The Zero Value” above).

Fix

Always initialize with ch := make(chan int) before any channel operation.

Conditional Initialization Trap
Problem

Some code paths leave the channel nil: if needResults { results = make(chan int) } — goroutines using results block forever when the condition is false.

Fix

Either guard all channel operations inside the condition, or always initialize the channel regardless of the condition.


Three Channel Operations That Panic

These operations cause immediate panics—they indicate programming errors, not recoverable conditions.

Three Channel Operations That Panic
Row
close(ch)
close(ch)
ch <- value

All other operations block (potentially forever) rather than panic. Keep these three in mind—they're the "instant death" operations.


Summary

Section 3.1 Summary
Creation
make(chan T), make(chan T, n) for buffered
Zero value
nil — blocks send/receive, panics on close
Type safety
Strongly typed — enforced at compile time
Reference type
Passing copies the reference, not the channel
Comparable
Can use == and != to compare channels
Unbuffered
No capacity — send/receive must synchronize
Empty struct
chan struct{} — zero-size, for pure signals
Value copying
Values copied on send; pointers share data
len/cap
Both return 0 for unbuffered channels

Key Takeaways

  1. Always initialize with make()—nil channels block forever on send/receive and panic on close, but have strategic uses in select (Section 3.4)
  2. Channels are references—no need for *chan T
  3. Unbuffered = synchronization—send and receive must rendezvous simultaneously
  4. Use chan struct{} for signals—zero-size, expresses signal-only intent
  5. Sending copies values—use pointers for large data, but transfer ownership discipline
  6. Three operations panic—close nil, close closed, send on closed

Next: Section 3.2 covers send and receive operations—the mechanics of channel communication and their blocking behavior.

Section 3.1 — in one line

A channel is a typed rendezvous point the runtime allocates for you: make(chan T) hands you a reference to it, and the zero value nil hands you nothing usable at all. Channels are copied and compared by that reference, so passing one to a function shares it rather than duplicating it—and chan struct{} is how you say “this carries a signal, not data.”


3.2 Send and Receive Operations

Section 3.1 covered channel creation. Now we explore the fundamental operations: sending values into channels and receiving values from them.

These operations look simple—a single operator, two directions. But their blocking behavior is what makes channels powerful synchronization primitives. Understanding when operations block determines whether your concurrent programs work correctly or deadlock silently.


The Channel Operator: <-

Go uses a single operator for channel communication: <-

channel_operator.go
// Illustrative snippet — not a complete program
ch <- value    // Send: arrow points into channel
value := <-ch  // Receive: arrow points out of channel

The arrow's direction shows data flow—intuitive and consistent.


Send Operation: ch <- value

To send a value through a channel:

send_basic.go
// Illustrative snippet — not a complete program
ch := make(chan string)

go func() {
    ch <- "hello"  // Send value into channel
}()

msg := <-ch
fmt.Println(msg)  // hello

Syntax:

send_syntax.go
// Illustrative snippet — not a complete program
ch <- value
^^    ^^^^^
│     └─ Value to send (must match channel's element type)
└─────── Channel to send through

Expression Evaluation Before Blocking

The right-hand expression is evaluated before the send operation blocks:

eval_before_block.go
// Illustrative snippet — not a complete program
func expensiveComputation() int { /* ~50ms of work */ return 99 }

ch := make(chan int)

go func() {
    time.Sleep(100 * time.Millisecond)
    <-ch  // Receiver ready after 100ms
}()

fmt.Println("Before send")
ch <- expensiveComputation()  // Computation runs first
                              // Then send blocks until receiver ready
fmt.Println("After send")

Timeline:

The computation completes before blocking—the send doesn't start until the value is ready.

Send Is a Statement, Not an Expression

Send operations are statements—they don't return values:

send_statement.go
// Illustrative snippet — not a complete program
ch <- 42           // ✓ Valid statement

result := ch <- 42 // ✗ Compile error: send is not an expression

if ch <- value {   // ✗ Compile error: send has no result
    // ...
}

You cannot check whether a send "succeeded." On unbuffered channels, when send completes, the value has been received—guaranteed.


Receive Operation: <-ch

To receive a value from a channel:

receive_basic.go
// Illustrative snippet — not a complete program
ch := make(chan float64)

go func() {
    ch <- 3.14
}()

result := <-ch  // Receive from channel
fmt.Println(result)  // 3.14

Syntax:

receive_syntax.go
// Illustrative snippet — not a complete program
value := <-ch
         ^^^^
         │ └─ Channel to receive from
         └─── Receive operator

Two Forms of Receive

Form 1: Single-value receive

The basic receive operation returns only the value:

single_value_receive.go
// Illustrative snippet — not a complete program
value := <-ch      // Receive and store in new variable
value = <-ch       // Receive and store in existing variable
fmt.Println(<-ch)  // Receive and use directly
<-ch               // Receive and discard (still synchronizes)

Discarding the value is useful when you need synchronization without the data:

discard_receive.go
// Illustrative snippet — not a complete program
done := make(chan struct{})

go func() {
    doWork()
    done <- struct{}{}  // Signal completion
}()

<-done  // Wait for signal—value doesn't matter
fmt.Println("Work complete")

Form 2: Two-value receive (comma-ok idiom)

The extended form also returns whether the channel is open:

comma_ok.go
// Illustrative snippet — not a complete program
value, ok := <-ch
Why Two Forms Exist

The single-value form is simpler when you don't need to distinguish a real value from the zero value returned after closure. The two-value form is essential when zero is a valid data value or you need to react to closure.

two_forms.go
// Illustrative snippet — not a complete program
value := <-ch        // Get the data
value, ok := <-ch    // Get the data AND channel status

Use the two-value form when:

Section 3.3 covers when and how to use each form in detail, including the idiomatic for range loop that handles closure automatically.

Receive Is an Expression

Unlike send, receive is an expression—it produces a value:

receive_expression.go
// Illustrative snippet — not a complete program
x := <-ch              // Assign received value
y := <-ch + 10         // (<-ch) + 10 — receive, then add
fmt.Println(<-ch * 2)  // (<-ch) * 2 — receive, then multiply

if <-ch > 100 {        // Receive and use in condition
    fmt.Println("Large value")
}

The unary <- operator binds tighter than binary operators, so <-ch * 2 parses as (<-ch) * 2, not <-(ch * 2).


Blocking Semantics: The Core Concept

This is the most important concept in this chapter.

For unbuffered channels, send and receive operations block until the other side is ready:

Blocking Behavior (Unbuffered Channels)

A reference table for unbuffered channels. A send blocks while no receiver is ready and unblocks when some goroutine executes a receive. A receive blocks while no sender is ready and unblocks when some goroutine executes a send.

Buffered channels (Chapter 5) have different blocking behavior—sends block only when full, receives only when empty. Closed channels also behave differently—sends panic, receives return immediately with the zero value (Section 3.3). This table focuses on open, unbuffered channels.

Blocking Is Not a Bug—It's the Feature

Many languages treat blocking as something to avoid. In Go, channel blocking is how goroutines synchronize:

  • Send blocks until receiver ready → rendezvous
  • Receive blocks until sender ready → rendezvous
  • Both unblock → each continues independently

Don't fight blocking—embrace it as Go's coordination mechanism.

What "Blocking" Means

When a goroutine blocks on a channel operation:

  1. The goroutine stops executing at that line
  2. It yields the CPU to other goroutines (no busy-waiting)
  3. It waits until the operation can proceed
  4. The runtime schedules it again when the operation completes
  5. The goroutine's stack remains allocated—blocked goroutines still consume memory (~2KB minimum), which is why goroutine leaks are problematic (Chapter 2)

Blocked goroutines consume no CPU time—they're parked by the runtime scheduler until the operation can proceed.

The Rendezvous Model

An unbuffered channel operation completes only when both sender and receiver are ready. They meet at the channel—a rendezvous point:

THE RENDEZVOUS

A timeline in which the sender arrives first. The sender blocks and waits; the receiver arrives later; the transfer completes at a single shared point; and both goroutines continue from there.

Key insight: It doesn't matter who arrives first. The first to arrive waits for the other. When both are ready, transfer happens and both proceed.

Seeing the Rendezvous in Action

Let's make this concrete with a runnable example:

rendezvous.go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan string)

    go func() {
        fmt.Println("Sender: about to send")
        ch <- "message"
        fmt.Println("Sender: send complete")
    }()

    time.Sleep(10 * time.Millisecond)  // Let sender reach send first

    fmt.Println("Main: about to receive")
    msg := <-ch
    fmt.Println("Main: received:", msg)

    // Demo only: without this, main often returns before the sender is
    // rescheduled, and "Sender: send complete" never prints at all.
    time.Sleep(10 * time.Millisecond)
}
Output (last two lines may swap)
Sender: about to send
Main: about to receive
Main: received: message
Sender: send complete

The sender blocks at ch <- "message" until main reaches <-ch. Both operations complete together—the rendezvous.

Why this order? Both goroutines unblock at the rendezvous. Main's next statement executes immediately on its goroutine. The sender's next statement must wait for the scheduler. The exact interleaving may vary, but both proceed only after the transfer completes.

About time.Sleep in This Example

This example uses time.Sleep for demonstration to control which goroutine reaches the channel first. Production code uses proper synchronization—channel closing (Section 3.3), WaitGroups, or context cancellation—rather than timing-based coordination.


The Happens-Before Guarantee

Channel operations create happens-before relationships from Go's memory model. To understand why this matters, first consider the unsafe pattern:

data_race.go
package main

import (
    "fmt"
    "time"
)

// ✗ DATA RACE: No synchronization between goroutines
var data string

func main() {
    go func() {
        data = "hello"  // Write in goroutine
    }()

    time.Sleep(10 * time.Millisecond)  // Hoping write completes
    fmt.Println(data)  // Read in main—DATA RACE!
}

Why this is a data race:

Why time.Sleep doesn't help: The Go memory model requires synchronization operations (channel operations, mutex locks, atomic operations) to establish happens-before relationships. time.Sleep is not a synchronization operation—it's just a delay. Even if the goroutine finishes writing during the sleep, there's no guarantee the write is visible to main without proper synchronization. Run go run -race data_race.go to see the race detector confirm this violation at runtime.

Now let's fix it with a channel:

happens_before.go
package main

import "fmt"

// ✓ SAFE: Channel creates happens-before relationship
var data string

func main() {
    done := make(chan struct{})

    go func() {
        data = "hello"      // (1) Write to shared variable
        done <- struct{}{}  // (2) Send on channel
    }()

    <-done              // (3) Receive from channel
    fmt.Println(data)   // (4) Read—guaranteed "hello"
}

The happens-before guarantee:

Channel Communication IS Synchronization

Channels don't just transfer data—they synchronize memory. When you receive a value, you're guaranteed to see all writes that happened before the send. Prefer sending the data through the channel rather than relying on side effects.

explicit_data_flow.go
// Illustrative snippet — not a complete program
// ✓ BETTER: Send the data through the channel
ch := make(chan string)

go func() {
    ch <- "hello"  // Data flows through channel
}()

msg := <-ch
fmt.Println(msg)

This makes data flow explicit and is more idiomatic. Synchronizing via a shared variable with a separate signaling channel is occasionally useful for expensive-to-copy data, but sending the data directly through the channel is almost always clearer.


Value Transfer Semantics

As Section 3.1 demonstrated, channel sends copy the value. The receiver gets an independent copy—modifications to the sender's original don't affect the received value. Here's how that plays out for each type:

Value Transfer Semantics
Row
Primitives (int, bool)
Structs (value fields)
Structs (ref fields)
Arrays [N]T
Slices []T
Maps map[K]V
Pointers *T
Channels chan T

For slices, maps, pointers, and channels, the copy shares underlying data. This enables sharing but requires the ownership discipline from Section 3.1: after sending a pointer, don't use it.


Multiple Senders and Receivers

Multiple goroutines can send to or receive from the same channel.

Multiple Senders

multiple_senders.go
package main

import "fmt"

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

    // Three senders
    go func() { ch <- 1 }()
    go func() { ch <- 2 }()
    go func() { ch <- 3 }()

    // Receive three times
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}
Output (order varies)
3
1
2

Each send blocks until a receive is ready. The order depends on scheduling—it's non-deterministic.

Multiple Receivers

multiple_receivers.go
package main

import (
    "fmt"
    "time"
)

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

    // Three receivers waiting
    go func() { fmt.Println("Receiver A:", <-ch) }()
    go func() { fmt.Println("Receiver B:", <-ch) }()
    go func() { fmt.Println("Receiver C:", <-ch) }()

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

    // Send three values
    ch <- 10
    ch <- 20
    ch <- 30

    // Demo only—use a WaitGroup in production
    time.Sleep(10 * time.Millisecond)
}

Each value is received by exactly one goroutine. Once a value is received, it's consumed.

Channels Do Not Broadcast

Each value sent is received by exactly one receiver:

CHANNELS DO NOT BROADCAST

A sender puts the value 42 into a channel. Exactly one receiver gets it. The other receivers stay blocked and will pick up subsequent values instead. To notify every goroutine at once you close the channel, which Section 3.3 covers.


Deadlock: When No One Is Ready

If all goroutines block on channel operations with no way to proceed, the program deadlocks:

Send Without Receiver

deadlock_send.go
package main

import "fmt"

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

    ch <- 42  // Blocks forever—no receiver exists
    fmt.Println("This never prints")
}
Output
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
    /path/to/main.go:8 +0x37

Receive Without Sender

deadlock_receive.go
package main

import "fmt"

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

    value := <-ch  // Blocks forever—no sender exists
    fmt.Println(value)
}

Output: Same deadlock error—[chan receive] instead of [chan send].

Circular Dependency

deadlock_circular.go
package main

import "fmt"

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

    go func() {
        value := <-ch   // Waits for main to send
        ch <- value * 2 // Then sends
    }()

    result := <-ch  // Waits for goroutine—but it waits for us!
    ch <- 21
    fmt.Println(result)
}

Output: Same deadlock error, with both goroutines showing [chan receive].

Both goroutines wait to receive before sending. Neither sends first—circular deadlock.

Fix—ensure someone sends first:

deadlock_fix.go
package main

import "fmt"

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

    go func() {
        value := <-ch   // Waits for send
        ch <- value * 2 // Then sends
    }()

    ch <- 21        // Send first!
    result := <-ch  // Now receive
    fmt.Println(result)  // 42
}
Deadlock Detection Limitations

The runtime only detects complete deadlocks—when ALL goroutines are blocked with no possibility of progress. The error message "all goroutines are asleep" means the runtime has determined no goroutine can ever wake up another.

The runtime does NOT detect:

  • Partial deadlocks: Some goroutines blocked forever while others run
  • Goroutine leaks: Goroutine blocked on channel, but main continues
undetected_leak.go
package main

import "fmt"

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

    go func() {
        ch <- 42  // Blocks forever—goroutine leaks
    }()

    // Main isn't blocked, so no deadlock detected
    fmt.Println("Main exiting")
}

This is a goroutine leak (Chapter 2, Section 2.4), not a detected deadlock.


Practical Patterns

Pattern 1: Get Result from Goroutine

async_result.go
package main

import (
    "fmt"
    "time"
)

// Returns chan int; Chapter 6 restricts it to <-chan int
func computeAsync(n int) chan int {
    ch := make(chan int)
    go func() {
        time.Sleep(100 * time.Millisecond)  // Simulate work
        ch <- n * n
    }()
    return ch
}

func main() {
    resultCh := computeAsync(7)

    // Do other work while computation happens...

    result := <-resultCh  // Block until result ready
    fmt.Println(result)   // 49
}

Four Questions check (the goroutine safety checklist from Chapter 2):

Four Questions Check
Exit?
Returns after sending result to channel
Communicate?
Via the returned channel
Errors?
Not handled (simplified — see Ch. 14)
Data?
Captures n (read-only), ch (safe); no shared state
Goroutine Lifecycle Note

If the caller abandons the returned channel without receiving, the goroutine leaks (blocks forever on send). For production code, use context for cancellation (Chapter 13) or buffered channels (Chapter 5) to prevent blocking. This pattern is fine when the caller always receives.

Pattern 2: Signal Completion

signal_completion.go
package main

import (
    "fmt"
    "time"
)

func worker(done chan struct{}) {
    fmt.Println("Working...")
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Done")
    done <- struct{}{}  // Signal completion
}

func main() {
    done := make(chan struct{})
    go worker(done)

    <-done  // Wait for signal
    fmt.Println("Worker finished")
}

Pattern 3: Coordinate Sequence

coordinate_sequence.go
package main

import (
    "fmt"
    "time"
)

func main() {
    step1Done := make(chan struct{})
    step2Done := make(chan struct{})

    // Step 1
    go func() {
        fmt.Println("Step 1: Initializing")
        time.Sleep(50 * time.Millisecond)
        step1Done <- struct{}{}
    }()

    // Step 2 waits for Step 1
    go func() {
        <-step1Done  // Wait for step 1
        fmt.Println("Step 2: Processing")
        time.Sleep(50 * time.Millisecond)
        step2Done <- struct{}{}
    }()

    <-step2Done
    fmt.Println("All steps complete")
}
Output (guaranteed order)
Step 1: Initializing
Step 2: Processing
All steps complete

The channel operations guarantee this execution order regardless of goroutine scheduling. Channels provide happens-before relationships that enforce ordering—not just convenience, but correctness.


Common Mistakes

Send and Receive in Same Goroutine
Problem

On an unbuffered channel, ch <- 42 blocks forever because no other goroutine can execute <-ch. The receive on the next line is never reached—instant deadlock.

Fix

Use separate goroutines: go func() { ch <- 42 }() for the send, then value := <-ch in main.

No Receiver for Send (Goroutine Leak)
Problem

A function launches a goroutine that sends to a channel, then returns without receiving. The goroutine blocks forever on send—a goroutine leak.

Fix

Always receive before returning: return <-result. Every send must have a corresponding receive.

Mismatched Sends and Receives
Problem

A goroutine sends 3 values but only 2 are received. The third send blocks forever—goroutine leaks silently.

Fix

Match sends and receives, or use channel closing with for range (Section 3.3) to drain all values automatically.

Assuming Order with Multiple Senders
Problem

Launching goroutines with go func() { ch <- 1 }() then go func() { ch <- 2 }() does not guarantee receive order matches launch order—scheduling is non-deterministic.

Fix

If order matters, use explicit sequencing (channels between steps) or a single sender.

Goroutine Leak Reminder

Mistakes 2 and 3 are the "blocked sender" leak pattern from Chapter 2, Section 2.4. Channels that never receive (or never send) are the #1 source of leaks. If you're struggling with leak detection, review that section—it covers detection with goleak, runtime monitoring, and prevention strategies.


Summary

Send vs Receive Summary
Row
Syntax
Type
Blocks when
Unblocks when
On closed chan
On nil chan

Key Takeaways

  1. Send (statement) and receive (expression) use <-—the arrow shows data direction
  2. Two receive forms—single-value for data, comma-ok to also detect channel closure
  3. Expression evaluated before blocking—computation happens, then wait
  4. Blocking is the synchronization mechanism—sender and receiver rendezvous before either proceeds
  5. Happens-before guarantee—channel operations synchronize memory, not just data
  6. Values are copied—pointers/slices/maps share underlying data
  7. Each value consumed once—channels do not broadcast (closing does)
  8. Match sends with receives—unmatched operations deadlock or leak

Next: Section 3.3 covers channel lifecycle—closing channels, detecting closure with the comma-ok idiom, and the idiomatic for range loop over channels.

Section 3.2 — in one line

A send and a receive on an unbuffered channel are one event, not two: whichever side arrives first waits for the other, and the handoff establishes happens-before. A value goes to exactly one receiver—channels never broadcast—and when nobody is ever going to arrive, that same blocking is what the runtime reports as a deadlock.


3.3 Channel Lifecycle: Closing and Iteration

Sections 3.1 and 3.2 covered channel creation and send/receive operations. But there's a critical question we haven't answered: how does a receiver know when no more values are coming?

Consider a producer sending values to a consumer:

problem.go
package main

import "fmt"

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

    go func() {
        ch <- 1
        ch <- 2
        ch <- 3
        // How does the receiver know we're done?
    }()

    fmt.Println(<-ch)  // 1
    fmt.Println(<-ch)  // 2
    fmt.Println(<-ch)  // 3
    fmt.Println(<-ch)  // Blocks forever
}

The problem: The receiver doesn't know when to stop. The fourth receive blocks forever, and the runtime exits with a fatal error: "all goroutines are asleep - deadlock!"

Solutions that don't work:

The solution: close the channel.

solution.go
package main

import "fmt"

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

    go func() {
        ch <- 1
        ch <- 2
        ch <- 3
        close(ch)  // Signal: no more values coming
    }()

    for value := range ch {  // Receives until closed
        fmt.Println(value)
    }
}

Closing channels enables clean termination without coordination complexity.


The close() Built-in Function

To close a channel, use the close built-in:

close_example.go
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)  // Mark channel as closed

What closing does:

  1. Marks the channel as closed—a permanent state change
  2. Unblocks all waiting receivers immediately—they detect closure
  3. Makes future receives return zero value—with ok as false
  4. Makes future sends panic—you cannot send to a closed channel

What closing does NOT do:

Closing is purely a signal to receivers: "No more values are coming."

CHANNEL STATES

The three channel states side by side. A nil channel blocks forever on send and on receive, and panics on close. An open channel blocks on send until the value is received and on receive until a value is sent, and closes normally. A closed channel panics on send, returns the zero value immediately on receive, and panics on a second close. The states are permanent: a closed channel cannot be reopened.


Receiving from a Closed Channel

After a channel is closed, receive operations return immediately:

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

go func() {
    ch <- 42  // Send completes before close
    close(ch)
}()

v1 := <-ch  // 42 (sent before close)
v2 := <-ch  // 0 (zero value—channel closed and empty)
v3 := <-ch  // 0 (keeps returning zero)
fmt.Println(v1, v2, v3)

Key behavior: Receiving from a closed channel never blocks. It immediately returns:

  1. Remaining buffered values (for buffered channels—Chapter 5)
  2. The zero value of the element type (once empty)

For unbuffered channels (our focus in this chapter), there are no buffered values, so closes immediately make all receives return zero values.

The Problem: Zero Value Ambiguity

This creates ambiguity—how do you distinguish "received legitimate zero" from "channel closed"?

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

go func() {
    ch <- 0   // Send actual zero
    ch <- 0   // Send another zero
    close(ch)
}()

fmt.Println(<-ch)  // 0—but is it data or closure?
fmt.Println(<-ch)  // 0—data or closure?
fmt.Println(<-ch)  // 0—this one is closure

All three receives return 0, but only the third indicates closure. The comma-ok idiom solves this.


Detecting Closure: The Comma-Ok Idiom

The two-value receive form detects whether a value was actually received:

comma_ok.go
// Illustrative snippet — not a complete program
value, ok := <-ch
comma_ok_demo.go
package main

import "fmt"

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

    go func() {
        ch <- 0   // Send actual zero
        ch <- 42
        close(ch)
    }()

    v1, ok1 := <-ch
    fmt.Printf("v1=%d, ok=%v\n", v1, ok1) // v1=0, ok=true (real data)

    v2, ok2 := <-ch
    fmt.Printf("v2=%d, ok=%v\n", v2, ok2) // v2=42,ok=true (real data)

    v3, ok3 := <-ch
    fmt.Printf("v3=%d, ok=%v\n", v3, ok3) // v3=0, ok=false (closed!)
}
Buffered Channels and Comma-Ok

For buffered channels (Chapter 5), ok remains true while buffered values are being drained after close, then becomes false once the buffer is empty. For unbuffered channels, ok becomes false immediately after close since there's no buffer to drain.

No Way to Check Closure Without Receiving

There's no function to check if a channel is closed without receiving:

no_is_closed.go
// Illustrative snippet — not a complete program
// ✗ WRONG: No such function exists
if isClosed(ch) {
    // ...
}

// ✓ CORRECT: Must attempt receive
_, ok := <-ch
if !ok {
    // Channel is closed
}

A hypothetical isClosed() function would be useless anyway—you can't check closure without also consuming a value. The comma-ok idiom solves this by atomically receiving and reporting status in one operation. Even if isClosed() existed, by the time you checked and acted, another goroutine might have changed the state.

Manual Loop with Comma-Ok

You can use comma-ok to loop until closure:

manual_loop.go
// Illustrative snippet — not a complete program
func consumer(ch chan int) {
    for {
        value, ok := <-ch
        if !ok {
            fmt.Println("Channel closed, stopping")
            return
        }
        fmt.Println("Received:", value)
    }
}

This pattern works correctly but is verbose and error-prone (easy to forget the ok check). The for range loop provides cleaner syntax.


The Idiomatic Way: for range Over Channels

The for range loop over a channel receives values until the channel is closed:

for_range.go
package main

import "fmt"

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

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
        }
        close(ch)  // Signal completion
    }()

    for value := range ch {
        fmt.Println(value)
    }
    fmt.Println("Done")
}
Output
1
2
3
4
5
Done

How for range works:

range_equivalent.go
// Illustrative snippet — not a complete program
// This loop:
for value := range ch {
    process(value)
}

// Is equivalent to:
for {
    value, ok := <-ch
    if !ok {
        break  // Channel closed and empty
    }
    process(value)
}
for range Is the Idiomatic Way

When receiving all values from a channel until it closes, always use for range. The for range form is clearer, more concise, and immediately signals intent: "process all values until closed."

for range Requires Closing

If the channel is never closed, for range blocks forever:

range_leak.go
// Illustrative snippet — not a complete program
// ✗ BUG: Channel never closes
ch := make(chan int)

go func() {
    ch <- 1
    ch <- 2
    // Forgot to close(ch)!
}()

for value := range ch {  // Blocks forever after receiving 2
    fmt.Println(value)
}
// Never reaches here

The loop prints 1 and 2, then waits forever for more values. In this simple program, the runtime detects the deadlock and exits. In larger programs with other active goroutines, this becomes a silent goroutine leak—the blocked for range goroutine is never collected. Always close channels that receivers iterate with for range.


The Sender-Closes Principle

Critical Rule: Only the Sender Should Close a Channel

A channel should only be closed by the goroutine responsible for sending into it. The sender knows when no more values will come; the receiver does not.

THE SENDER-CLOSES PRINCIPLE

A producer sends three values through a channel to a consumer and then closes it. The close travels in the same direction as the data, which is why closing is the sender's responsibility and never the receiver's.

Why Senders Close

The sender knows when no more values will come:

sender_closes.go
package main

import "fmt"

// ✓ CORRECT: Sender closes
func main() {
    ch := make(chan int)

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
        }
        close(ch)  // Sender knows it's done
    }()

    for value := range ch {
        fmt.Println(value)
    }
}
Output
1
2
3
4
5

Only the sender knows when its work is complete. The receiver just consumes values until told to stop. Note that this version terminates cleanly—compare it with the next one.

Why Receivers Don't Close

Receiver closing causes panics:

receiver_closes.go
package main

import (
    "fmt"
    "time"
)

// ✗ WRONG: Receiver closes
func main() {
    ch := make(chan int)

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i  // Will panic after receiver closes
            time.Sleep(10 * time.Millisecond)
        }
    }()

    fmt.Println(<-ch)
    fmt.Println(<-ch)
    close(ch)  // Receiver closes—sender panics!

    // Demo only: hold main open so the sender reaches its next send.
    // Without this, main returns and the process exits first—you would
    // see no panic at all, which is what makes this bug so dangerous.
    time.Sleep(50 * time.Millisecond)
}
Output
1
2
panic: send on closed channel

The sender panics when it tries to send after the receiver closed the channel.

Multiple Senders: Who Closes?

With multiple senders, no single sender knows when all senders are done. Use a coordinator:

multiple_senders_close.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int)
    var wg sync.WaitGroup

    // Multiple senders
    for i := 1; i <= 3; i++ {
        wg.Go(func() {
            ch <- i * 10
        })
    }

    // Coordinator: closes when all senders done
    go func() {
        wg.Wait()
        close(ch)  // Close after ALL senders finish
    }()

    // Receiver
    for value := range ch {
        fmt.Println(value)
    }
}
Output (order varies)
10
20
30

Pattern: Use a WaitGroup to track senders. A dedicated coordinator goroutine waits for all senders to finish, then closes the channel. No individual sender closes.

Why Not Close in Each Sender?

Multiple goroutines calling close(ch) causes "panic: close of closed channel." With N senders, N-1 will panic. Only one goroutine should close—hence the coordinator pattern.

What If the Receiver Wants to Stop Early?

Sometimes the receiver needs to stop consuming before the sender has finished. Use a separate done channel to signal the sender to stop. The sender checks this channel using select, which picks whichever channel operation is ready first (covered fully in Chapter 4):

early_stop.go
package main

import "fmt"

func main() {
    ch := make(chan int)
    done := make(chan struct{})

    // Sender checks done channel using select (Chapter 4)
    go func() {
        defer close(ch)  // Sender still closes
        for i := 0; ; i++ {
            select {
            case ch <- i:
            case <-done:
                return  // Stop sending
            }
        }
    }()

    // Receiver signals when it's had enough
    for value := range ch {
        fmt.Println(value)
        if value >= 5 {
            close(done)  // Signal sender to stop
            break
        }
    }
}

The key insight: the sender still closes ch. The receiver only closes done—a channel it "owns" as the sender of the stop signal. The select statement allows the sender to monitor both operations; we cover it fully in Chapter 4.


Closing as Broadcast

Unlike sends (which reach exactly one receiver), closing broadcasts to all receivers. This makes closing perfect for signaling shutdown or completion to multiple goroutines simultaneously:

broadcast_close.go
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    shutdown := make(chan struct{})
    var wg sync.WaitGroup

    // Three workers waiting for shutdown
    for i := 1; i <= 3; i++ {
        wg.Go(func() {
            <-shutdown  // All three receive from same close
            fmt.Printf("Worker %d: shutting down\n", i)
        })
    }

    time.Sleep(10 * time.Millisecond)  // Let workers start and block

    fmt.Println("Signaling all workers...")
    close(shutdown)  // Broadcast to all
    wg.Wait()         // Wait for all workers to finish
}
Output (worker order varies)
Signaling all workers...
Worker 1: shutting down
Worker 3: shutting down
Worker 2: shutting down
CLOSING AS BROADCAST

Before the close, three workers are each blocked receiving from the shutdown channel. After close(shutdown), all three unblock immediately. One close releases every waiting receiver, which is what makes close a broadcast.

This is why chan struct{} is common for signal channels—the value doesn't matter, only the close.


Operations That Panic

As noted in Section 3.1, three channel operations cause unrecoverable panics:

1. Send on Closed Channel

panic_send_closed.go
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)
ch <- 42  // panic: send on closed channel

2. Close Already-Closed Channel

panic_double_close.go
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)
close(ch)  // panic: close of closed channel

3. Close Nil Channel

panic_close_nil.go
// Illustrative snippet — not a complete program
var ch chan int  // nil
close(ch)  // panic: close of nil channel

Why these panic: They indicate programming errors—confused ownership or lifecycle bugs—not recoverable conditions.


When to Close Channels (and When Not To)

Closing is optional. Close only when receivers need to know that no more values are coming.

DO close when:

DON'T close when:

when_to_close.go
// Illustrative snippet — not a complete program
// No need to close—single receive, channel becomes unreachable
func compute(n int) int {
    ch := make(chan int)
    go func() {
        ch <- n * 2
        // No close needed—we receive exactly once
    }()
    return <-ch
}

// Must close—for range needs termination signal
func processAll(items []int) {
    ch := make(chan int)
    go func() {
        for _, item := range items {
            ch <- item
        }
        close(ch)  // for range needs this
    }()

    for value := range ch {
        fmt.Println(value)
    }
}
Closing Is for Signaling, Not Cleanup

Channels don't need to be closed to be garbage collected—they just need to be unreachable. However, goroutines blocked on unclosed channels will leak. Closing is necessary to unblock waiting goroutines (via for range or receive operations), not to free the channel itself. Think of closing as "signaling completion" rather than "resource cleanup."

Best Practice: defer close()

Use defer close(out) at the start of sender goroutines. This ensures the channel closes even if the goroutine returns early due to an error or guard clause, preventing downstream goroutine leaks. In production, pair this with an error channel so consumers can distinguish error-induced closure from normal completion (Chapter 14).


Practical Pattern: Pipeline with Proper Closing

Each stage closes its output channel when done, propagating termination through the pipeline:

pipeline.go
package main

import "fmt"

func generateNumbers(max int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)  // Producer closes its output
        for i := 1; i <= max; i++ {
            out <- i
        }
    }()
    return out
}

func squareNumbers(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)  // Stage closes its output
        for n := range in {  // Receives until in closes
            out <- n * n
        }
    }()
    return out
}

func main() {
    numbers := generateNumbers(5)
    squares := squareNumbers(numbers)

    for result := range squares {
        fmt.Println(result)
    }
    fmt.Println("Pipeline complete")
}
Output
1
4
9
16
25
Pipeline complete

Pipeline flow:

  1. generateNumbers sends 1-5, then closes its output
  2. squareNumbers receives until input closes, squares each, sends to output
  3. When input closes, for range exits, stage closes its output via defer
  4. main receives until squares closes

Key insight: Each stage closes its own output channel when its input closes. This creates a chain reaction—closing propagates downstream automatically through defer close(out) statements.

Directional Channel Syntax

The return type <-chan int means "receive-only channel"—callers can only receive from it, not send or close. This enforces the sender-closes principle at compile time. Chapter 6 covers directional channel types comprehensively.

Directional Channel Types
Row
chan int
chan<- int
<-chan int

This pipeline pattern—each stage closing its output when its input closes—is fundamental to Go concurrent design. Chapter 7 explores pipelines in depth, including cancellation propagation, fan-out/fan-in, and error handling.


Common Mistakes

Receiver Closes Channel
Problem

Receiver calls close(ch) while the sender is still sending. The sender panics with "send on closed channel."

Fix

Only the sender closes. Use for range on the receiver side: for value := range ch.

Forgetting to Close (Goroutine Leak)
Problem

Sender finishes but never calls close(ch). The for range loop in the receiver blocks forever—goroutine leak.

Fix

Use defer close(ch) at the start of sender goroutines. Guarantees closure even on panic or early return.

Double Close
Problem

Multiple goroutines call close(ch) on the same channel. Second close causes "panic: close of closed channel."

Fix

Single owner closes with defer close(ch). For multiple senders, use a coordinator with sync.WaitGroup.

Not Checking ok When Zero Is Valid
Problem

Using if value == 0 { break } to detect closure. Breaks on legitimately sent zero values, not just closure.

Fix

Use for value := range ch which handles closure detection internally, or use the comma-ok idiom.


Channel Operations Quick Reference

Channel Operations Quick Reference
Row
ch <- v (send)
<-ch (receive)
v, ok := <-ch
close(ch)
len(ch)
cap(ch)

Complete table including nil channels: see end of Section 3.4


Closing and Iteration Summary
close() syntax
close(ch) (statement, no return value)
Who closes
Sender (or coordinator), never receiver
When to close
When receivers need to know no more values
Receive after close
Returns zero value immediately; ok=false
Send after close
Panic
Close closed
Panic
Close nil
Panic
Comma-ok idiom
value, ok := <-chok detects close
for range
Idiomatic loop until close
Close broadcasts
All blocked receivers unblock simultaneously

Key Takeaways

  1. Closing signals "no more values"—essential for clean termination
  2. Sender closes, receiver never closes—prevents send-on-closed panics
  3. Use for range for consumption—idiomatic, auto-handles closure
  4. Comma-ok detects closure—after close, receives return the zero value with ok == false
  5. Closing broadcasts to all—unlike sending, which delivers to one receiver
  6. Three operations panic—send on closed, double close, close nil
  7. Use defer close() for signaling completion—channels don't require closing for GC, but failing to close when receivers are waiting causes goroutine leaks
  8. Multiple senders need coordination—WaitGroup with dedicated closer

Next: Section 3.4 covers nil channels—what happens when you use a channel's zero value, and the surprising ways nil channels become useful in advanced patterns.

Section 3.3 — in one line

Closing is how a sender says no more values, which is why the sender closes and the receiver never does. v, ok := <-ch is the only way to tell a real zero from a closed channel, for range does that for you and stops when the channel closes, and with several senders the close belongs to a coordinator that waits for all of them first.


3.4 Nil Channels

Sections 3.1–3.3 covered creating channels, sending/receiving, and closing. There's one more channel state to understand: nil—a channel variable that was never initialized. Nil channels have surprising behavior that causes bugs when accidental, but enables elegant patterns when intentional.

This Section Previews Chapter 4

The strategic uses of nil channels rely on the select statement, covered fully in Chapter 4. You have two options:

  1. Read now for completeness—the examples are self-contained and illustrate why nil channels exist
  2. Skip to Chapter 4 first, then return here with full select understanding

What Is a Nil Channel?

A nil channel is the zero value of channel types—a channel variable that hasn't been initialized:

nil_channel.go
// Illustrative snippet — not a complete program
var ch chan int  // nil—declared but not initialized
fmt.Println(ch == nil)  // true

Ways to create nil channels:

nil_ways.go
// Illustrative snippet — not a complete program
// Method 1: Declaration without initialization
var ch1 chan int  // nil

// Method 2: Explicit nil assignment
ch2 := make(chan int)
ch2 = nil  // Now nil

// Method 3: Conditional initialization
var ch3 chan int
if someCondition {  // Your application logic
    ch3 = make(chan int)
}
// ch3 remains nil if condition was false

This is different from an initialized channel:

initialized_channel.go
// Illustrative snippet — not a complete program
ch := make(chan int)  // Not nil—initialized, ready to use
fmt.Println(ch == nil)  // false
NIL VS INITIALIZED CHANNELS

Two variables side by side. A channel declared but not initialized holds nil, no channel structure exists, and every operation blocks forever. A channel created with make holds an address pointing at a real channel structure in memory, and operations work normally.


Nil vs. Open vs. Closed Channels

These are three distinct states with different behaviors:

Nil vs. Open vs. Closed Channels
Row
Nil
Open
Closed

Key distinction: Nil means “never created.” Closed means “finished.”


Operations on Nil Channels

A nil channel has no underlying data structure—no send queue, no receive queue, no buffer. When you call make(chan T), Go allocates the channel structure. A nil channel never had this allocation.

Every operation on a nil channel has specific behavior:

Nil Channel Behavior
ch <- v (send)
Blocks forever
<-ch (receive)
Blocks forever
close(ch)
Panic
len(ch)
0
cap(ch)
0

Send on Nil Channel: Blocks Forever

nil_send.go
package main

func main() {
    var ch chan int  // nil

    ch <- 42  // Blocks forever—never proceeds
}
Output
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send (nil chan)]:
main.main()
 /path/to/main.go:6 +0x37

The error message explicitly identifies it as a nil chan. The send operation itself simply blocks forever—no panic, no error from the channel. The runtime separately detects that all goroutines are blocked and reports the deadlock.

Receive from Nil Channel: Blocks Forever

nil_receive.go
package main

import "fmt"

func main() {
    var ch chan int  // nil

    value := <-ch  // Blocks forever—never proceeds
    fmt.Println(value)
}
Output
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]:
main.main()
 /path/to/main.go:8 +0x37

Same blocking behavior as send—the operation never completes.

Close Nil Channel: Panics

nil_close.go
package main

func main() {
    var ch chan int  // nil

    close(ch)  // panic: close of nil channel
}
Output
panic: close of nil channel
goroutine 1 [running]:
main.main()
 /path/to/main.go:6 +0x27

Unlike send/receive (which block), closing a nil channel panics immediately. There's no underlying channel structure to mark as closed.


Why Nil Channels Block Forever

This behavior seems counterintuitive. Why block forever instead of panicking immediately like close(nil) does?

WHY NIL BLOCKS INSTEAD OF PANICKING

Two reasons nil channels block rather than panic. Technically, a nil channel has no underlying structure at all, no send queue, no receive queue and no state, so the goroutine simply cannot proceed. By design, blocking is what allows a nil channel to disable a select case; if nil panicked, that pattern would be impossible.

When you use select to wait on multiple channels (Chapter 4), a nil channel disables that case—it's never selected because it can never be ready. If nil channels panicked instead of blocking, you couldn't use them to dynamically enable/disable channels in select. By blocking forever, they integrate naturally—they simply become inactive.


The Danger: Silent Hangs

Outside of select statements, nil channels almost always indicate bugs. The danger is that operations block forever rather than failing loudly—there is no panic and no error from the channel itself.

The three programs below each end in fatal error: all goroutines are asleep, because they are small enough that every goroutine ends up blocked—the one situation the runtime can detect. A real server has other goroutines still running, so the check never fires and the same bug becomes a completely silent leak. Read each fatal error below as the diagnosis you get in a toy program and will not get in production.

Bug 1: Forgotten Initialization

bug_forgotten_init.go
package main

import "fmt"

// ✗ BUG: Channel never initialized
func main() {
    var ch chan int  // Forgot make()!

    go func() {
        ch <- 42  // Blocks forever on nil channel
    }()

    value := <-ch  // Also blocks forever—runtime detects deadlock
    fmt.Println(value)
}
Severity: Moderate

Runtime detects complete deadlock when all goroutines block. But in programs with other active goroutines, this becomes a silent goroutine leak—no error, no panic, just frozen goroutines consuming memory indefinitely. These bugs are notoriously difficult to detect without runtime monitoring tools (covered in later chapters).

Fix—always initialize with make():

fix_forgotten_init.go
package main

import "fmt"

func main() {
    ch := make(chan int)  // Initialize!

    go func() {
        ch <- 42
    }()

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

Bug 2: Conditional Initialization Gone Wrong

bug_conditional_init.go
// Illustrative snippet — not a complete program
// ✗ BUG: Channel might remain nil
func process(needResults bool) {
    var results chan int  // nil by default

    if needResults {
        results = make(chan int)
    }

    go func() {
        results <- 42  // Blocks forever if needResults was false!
    }()
}
Severity: High

Silent goroutine leak—no deadlock detection because other goroutines may be running.

Fix—ensure initialization or guard operations:

fix_conditional_init.go
// Illustrative snippet — not a complete program
func process(needResults bool) {
    if !needResults {
        return  // Don't start goroutine at all
    }

    results := make(chan int)

    go func() {
        results <- 42
    }()

    result := <-results  // Receive the computed value
    fmt.Println(result)
}

Bug 3: Struct Field Not Initialized

bug_struct_field.go
package main

import "fmt"

// ✗ BUG: Field never initialized
type Worker struct {
    tasks chan int
}

func (w *Worker) Run() {
    for task := range w.tasks {  // Blocks forever if nil!
        fmt.Println("Processing:", task)
    }
}

func main() {
    w := &Worker{}  // tasks is nil!
    go w.Run()

    w.tasks <- 1  // Blocks forever
}

Fix—use constructor:

fix_struct_field.go
package main

import "fmt"

type Worker struct {
    tasks chan int
}

func (w *Worker) Run() {
    for task := range w.tasks {
        fmt.Println("Processing:", task)
    }
}

func NewWorker() *Worker {
    return &Worker{
        tasks: make(chan int),  // Always initialize channel fields
    }
}

func main() {
    w := NewWorker()
    go w.Run()

    w.tasks <- 1  // Works
    close(w.tasks)
}

Bug 4: Returning Nil Channel

bug_return_nil.go
package main

func compute() int { return 42 }

// ✗ DANGEROUS: Returns nil channel on error
func getResultChannel(ready bool) chan int {
    if !ready {
        return nil  // Caller will block forever!
    }

    ch := make(chan int)
    go func() {
        ch <- compute()
        close(ch)
    }()
    return ch
}

func main() {
    ch := getResultChannel(false)
    <-ch  // Blocks forever—nil was returned!
}

Fix—return error explicitly:

fix_return_nil.go
package main

import (
    "errors"
    "fmt"
    "log"
)

func compute() int { return 42 }

func getResultChannel(ready bool) (chan int, error) {
    if !ready {
        return nil, errors.New("not ready")
    }

    ch := make(chan int)
    go func() {
        ch <- compute()
        close(ch)
    }()
    return ch, nil
}

func main() {
    ch, err := getResultChannel(false)
    if err != nil {
        log.Fatal(err)  // Handle error explicitly
    }
    value := <-ch
    fmt.Println(value)
}

Strategic Use: Disabling Select Cases

Key Insight: Nil Channels in Select

A select statement ignores cases with nil channels. This enables dynamic control—you can enable or disable cases by setting channels to nil or non-nil values.

We cover select comprehensively in Chapter 4, but here's the essential concept for understanding nil channels:

nil_select_concept.go
// Illustrative snippet — not a complete program
ch1 := make(chan int)
var ch2 chan int  // nil

select {
case v := <-ch1:
    fmt.Println("From ch1:", v)
case v := <-ch2:  // IGNORED (ch2 is nil)
    fmt.Println("From ch2:", v)
}

The select skips the nil channel case entirely—it's as if that case doesn't exist.

Simple Example: Nil Disables a Case

nil_disables_case.go
package main

import "fmt"

func main() {
    ch1 := make(chan int)
    var ch2 chan int  // nil—truly disabled

    go func() { ch1 <- 42 }()

    select {
    case v := <-ch1:
        fmt.Println("ch1:", v)  // This executes
    case v := <-ch2:
        fmt.Println("ch2:", v)  // Never selected
    }
}
Output
ch1: 42

The nil channel case is truly skipped—select only considers the ch1 case.

Pattern: Receive from Multiple Channels Until All Close

The canonical use case for nil channels—merging multiple channels into one, handling closures gracefully:

merge_complete.go
package main

import "fmt"

func merge(ch1, ch2 <-chan int) <-chan int {
    out := make(chan int)

    go func() {
        defer close(out)

        for ch1 != nil || ch2 != nil {
            select {
            case v, ok := <-ch1:
                if !ok {
                    ch1 = nil
                    continue
                }
                out <- v

            case v, ok := <-ch2:
                if !ok {
                    ch2 = nil
                    continue
                }
                out <- v
            }
        }
    }()

    return out
}

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        ch1 <- 1
        ch1 <- 2
        close(ch1)
    }()

    go func() {
        ch2 <- 10
        ch2 <- 20
        close(ch2)
    }()

    for v := range merge(ch1, ch2) {
        fmt.Println(v)
    }
    fmt.Println("Done")
}
Output (order may vary)
1
10
2
20
Done
Key Details in the Merge Pattern

Parameter reassignment is safe: Reassigning ch1 and ch2 to nil inside the function modifies local copies—the caller's original channels are unchanged.

continue targets the for loop: After setting a channel to nil, continue skips sending to out and immediately re-evaluates the loop condition with the updated channel state.

How the merge pattern works:

  1. Initial state: Both ch1 and ch2 are non-nil—loop condition (ch1 != nil || ch2 != nil) is true
  2. Value received: Whichever channel has a value ready, that case executes and forwards the value to out
  3. Closure detected: When ok == false, set that channel to nil to disable its select case
  4. Continue: Loop continues with only non-nil channel(s) active
  5. Exit: When both are nil, loop condition becomes false and defer close(out) fires
SELECT WITH NIL CHANNELS

A select loop over two channels. Initially both ch1 and ch2 are non-nil, so both cases are active. When ch1 drains, the code sets ch1 to nil and that case is ignored, leaving the select waiting only on ch2. When ch2 also drains and is set to nil, both are nil, the loop condition becomes false, and the loop exits.

Without nil channels, you'd need complex bookkeeping with boolean flags.


Nil Channels vs. Closed Channels in Select

It's critical to understand when each is appropriate:

Nil Channels vs. Closed Channels in Select
Row
Disable select case
Signal completion
Remove from select

Why closed channels don't work for disabling select cases:

closed_not_disable.go
package main

import "fmt"

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    close(ch1)  // Try to "disable" ch1

    go func() {
        ch2 <- 1
        ch2 <- 2
        ch2 <- 3
        close(ch2)
    }()

    for i := 0; i < 3; i++ {
        select {
        case v := <-ch1:
            fmt.Println("ch1:", v)
        case v := <-ch2:
            fmt.Println("ch2:", v)
        }
    }
}
Output (one run—the mix varies)
ch1: 0
ch2: 1
ch1: 0
Measured Distribution measured over 200 runs (600 selections) on go1.26.1, darwin/amd64, Intel i7-10700K @ 3.80GHz. The split moves with scheduling; the starvation does not.

The closed ch1 is always immediately ready for receive (returning zero values). When more than one case is ready, select picks one uniformly at random —but that is only a coin flip on the passes where ch2 is also ready, which means its sender has to be parked at the send at that exact moment. On every other pass ch1 is the only ready case and wins by default. Over 200 runs of this program —600 selections—ch1 took 71 % of them.

Run it a few times and you will see the mix change from run to run: sometimes three ch1 lines, more often a blend. That is the problem. Closing ch1 did not remove it from consideration; it turned ch1 into a case that is permanently ready and now out-competes real work indefinitely. This isn't disabling—it's starving ch2.

Nil channel correctly disables:

nil_correctly_disables.go
package main

import "fmt"

func main() {
    var ch1 chan int  // nil—truly disabled
    ch2 := make(chan int)

    go func() {
        ch2 <- 1
        ch2 <- 2
        ch2 <- 3
        close(ch2)
    }()

    for i := 0; i < 3; i++ {
        select {
        case v := <-ch1:
            fmt.Println("ch1:", v)  // Never executes
        case v := <-ch2:
            fmt.Println("ch2:", v)
        }
    }
}
Output
ch2: 1
ch2: 2
ch2: 3

The nil channel case is truly skipped—all values from ch2 are received.


Pattern: Optional Channel Parameters

Nil channels enable optional functionality in APIs:

optional_channel.go
package main

import "fmt"

func process(v int) int { return v * 2 }

func worker(in <-chan int, done <-chan struct{}, progress chan<- int) {
    for {
        select {
        case v, ok := <-in:
            if !ok {
                return
            }
            result := process(v)

            // Progress reporting is optional
            if progress != nil {
                select {
                case progress <- result:
                    // Sent progress update
                default:
                    // Channel full—don't block
                }
            }

        case <-done:
            return
        }
    }
}

func main() {
    in := make(chan int)
    done := make(chan struct{})
    workerDone := make(chan struct{})

    // Without progress reporting: pass nil
    go func() {
        defer close(workerDone)
        worker(in, done, nil)
    }()

    in <- 1
    in <- 2
    close(in)         // Signal no more work
    <-workerDone     // Wait for the worker to drain and return
    fmt.Println("Done")
}
Non-Blocking Send with Default

The nested select with default makes this a non-blocking send—if the progress channel is full or has no receiver, we skip it and continue working. We cover non-blocking operations in Chapter 4.

When progress is nil, the nil check prevents any attempt to send. This allows flexible APIs without requiring callers to create channels they don't need.


Complete Channel Behavior Reference

With nil channels fully covered, here's the complete reference:

Complete Channel Behavior Reference
Unbuffered channels. Buffering changes only the two “Open” blocking cells; Chapter 5 adds that column.
Row
ch <- v (send)
<-ch (receive)
v, ok := <-ch
close(ch)
len(ch)
cap(ch)
for v := range ch
In select

The last row shows why nil channels are useful—they're automatically ignored in select statements.


Common Mistakes

Forgetting to Initialize with make()
Problem

Using var results chan int without calling make(). Send and receive block forever—goroutine leak.

Fix

Always initialize: results := make(chan int). Use constructors for struct channel fields.

Closing a Nil Channel
Problem

Calling close(ch) on a nil channel causes panic: close of nil channel.

Fix

Check before closing: if ch != nil { close(ch) }.

Using Closed Instead of Nil to Disable select Cases
Problem

Closing a channel to “disable” it in select. The closed channel returns zero values repeatedly—it's always ready, not disabled.

Fix

Set the channel to nil to truly disable the select case: ch = nil.


Key Takeaways

  1. Nil is the zero value—uninitialized channel variables have no underlying data structure
  2. Send and receive block forever—by design, enabling select case disabling
  3. Close nil panics—you can't close something that doesn't exist
  4. Select ignores nil cases—set a channel to nil to dynamically disable it
  5. Closed channels don't disable—they return zero repeatedly, dominating select
  6. Accidental nil is dangerous—always initialize with make() unless intentional; use constructors for struct channel fields
  7. Nil enables the merge pattern—receive from multiple channels, nil-ing each as it closes

Next: With channels fully understood—creation, send/receive, closing, and nil behavior—Chapter 4 introduces select, which lets goroutines wait on multiple channel operations simultaneously. The nil channel patterns we covered here become essential tools for dynamic channel management in select statements.

Section 3.4 — in one line

A nil channel blocks forever on send and receive, and panics on close. Outside select that is almost always a bug. Inside select it is the feature: assigning nil takes a case out of consideration, which is exactly how you drain several channels until every one of them is done.


Chapter 3 Self-Check

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

1. What happens if you send to a closed channel? What about receiving from a closed channel?

Send panics immediately ("panic: send on closed channel"). Receive returns the zero value of the element type immediately, with ok as false in the two-value form.

2. Why must the sender close a channel rather than the receiver?

The sender knows when no more values will come—the receiver doesn't. If the receiver closes, subsequent sends panic. With multiple senders, no single sender knows when others are done.

3. You have three goroutines sending to one channel. Who should close it, and how?

A coordinator goroutine closes, not individual senders. Use a WaitGroup to track all senders. A separate goroutine waits for all senders to finish, then closes:

coordinator_close.go
// Illustrative snippet — not a complete program
go func() {
    wg.Wait()
    close(ch)
}()
4. A for range loop over a channel runs forever. What's missing?

The sender never closes the channel. for range blocks on the next receive indefinitely because the channel is never closed. Fix: add defer close(ch) at the start of the sender goroutine to guarantee closure.

5. You want to disable one case in a select statement. Should you close the channel or set it to nil? Why?

Set it to nil. A closed channel in select is "always ready"—it returns zero values immediately and tends to dominate other cases. A nil channel case is truly ignored—select never considers it, effectively removing that case from contention.

6. What's the difference between value := <-ch and value, ok := <-ch?

Single-value (value := <-ch) returns only the data. Two-value (value, ok := <-ch) also returns whether the channel is open—ok is false when the channel is closed and empty. Essential when zero is a valid data value.

7. A channel is created with var ch chan int. What happens when you send to it?

Blocks forever. The channel is nil (not initialized). Sending to nil blocks indefinitely, causing a goroutine leak or deadlock.

8. Two goroutines execute ch <- 1 and v := <-ch on the same unbuffered channel. Who blocks, and when do they unblock?

Whoever arrives first blocks. When the second arrives, the value transfers in a single coordinated handoff—this is the rendezvous. Neither goroutine can proceed past its channel operation until both are ready. This is the core of unbuffered channel synchronization: channels are synchronization points, not queues.

9. Why should sender goroutines use defer close(ch) instead of calling close(ch) at the end of the function?

defer guarantees closure even if the goroutine panics or returns early from multiple exit paths. Without defer, an early return or panic skips the close() call, leaving receivers blocked forever—a goroutine leak.

10. In the merge pattern, why do we set a channel to nil after it closes instead of removing the select case entirely?

You can't remove cases from a select at runtime—the cases are fixed at compile time. Setting a channel to nil achieves the same effect: a nil channel case is completely ignored by select, effectively removing it from contention without changing the code structure.

11. What happens if you call len() and cap() on a nil channel?

Both return 0 without blocking or panicking. They are the only value-returning operations that are safe on a nil channel: send, receive (including the comma-ok form v, ok := <-ch), and close all either block forever or panic. Two other things remain safe, though— comparing with ch == nil, and using a nil channel as a select case, which simply disables that case. That second one is the entire subject of §3.4.

12. What is the output of fmt.Println(<-ch + 10) if ch is a chan int and the value received is 5?

The output is 15. The receive operator <-ch binds tighter than the addition operator, so the expression is evaluated as (<-ch) + 10, not <-(ch + 10) (which wouldn't compile). The receive completes first, yielding 5, then 5 + 10 produces 15.


Exercise 3.1 — Who Closes?

Your move

Close it once, and only when everyone is done

§3.3 gave you the sender-closes principle, then the awkward question it raises: who closes when there is more than one sender? Here is that question as running code. The function below looks reasonable and is wrong in the most common way — every producer closes the channel on its way out.

ch03/merge.go
package ch03

import "sync"

// Provided for you: a producer that emits n values and then returns.
// It never closes the channel — who does that is the exercise.
func produce(id, n int, out chan<- int) {
	for i := 0; i < n; i++ {
		out <- id*100 + i
	}
}

// TODO(reader): Merge fans three producers into one channel and
// collects everything they send. It is broken in exactly the way
// §3.3 warns about: every producer closes the channel when it
// finishes, so whichever one finishes second panics — either
// "close of closed channel" or, if it is still mid-send, "send on
// closed channel".
//
// Fix it so that:
//   - the channel is closed exactly once,
//   - it is closed only after EVERY producer has finished, and
//   - the `for range` below still terminates.
//
// Two constraints, so you reach for the right tool:
//   - do not change the signature, and
//   - do not drain with a fixed count. The caller does not know
//     how many values are coming, which is the whole reason
//     close() exists.
func Merge(counts []int) []int {
	ch := make(chan int)
	var wg sync.WaitGroup

	for id, n := range counts {
		wg.Go(func() {
			produce(id, n, ch)
			close(ch) // <- your move
		})
	}

	var got []int
	for v := range ch {
		got = append(got, v)
	}
	return got
}
ch03/merge_test.go
package ch03

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

func TestMergeCollectsEveryValue(t *testing.T) {
	got := Merge([]int{3, 4, 5})

	want := []int{0, 1, 2, 100, 101, 102, 103, 200, 201, 202, 203, 204}
	slices.Sort(got)
	if !slices.Equal(got, want) {
		t.Fatalf("Merge returned %v, want %v", got, want)
	}
}

// The edge case that separates a real fix from a lucky one. With no
// producers, a close that lives inside a producer never runs at all,
// so the `for range` waits forever. A coordinator that waits on the
// WaitGroup closes immediately and the range ends at once.
//
// Merge runs on its own goroutine so a wrong answer fails in two
// seconds with a message instead of hanging until the test timeout.
func TestMergeTerminatesWithNoProducers(t *testing.T) {
	done := make(chan []int, 1)
	go func() { done <- Merge(nil) }()

	select {
	case got := <-done:
		if len(got) != 0 {
			t.Fatalf("Merge(nil) gave %d values, want 0", len(got))
		}
	case <-time.After(2 * time.Second):
		t.Fatal("Merge(nil) never returned: with no producers " +
			"nothing closed the channel, so for range still waits")
	}
}

Run it and the failure is immediate, not subtle:

go test ./...
panic: send on closed channel [recovered, repanicked]
goroutine 21 [running]:
corebackend.dev/go-concurrency/ch03.produce(...)
 /path/to/ch03/merge.go:9
FAIL    corebackend.dev/go-concurrency/ch03

Which panic you get is itself a race: whichever producer finishes second either closes an already-closed channel or is caught mid-send on one. Both are the same underlying mistake, and §3.3’s “Operations That Panic” names them both.

That is the plain go test output. Run it the way “Done when” below asks — with -race — and you get a WARNING: DATA RACE report before the panic, pointing at chansend and closechan. Same bug, caught one step earlier: the race detector sees the conflicting access before the runtime gets as far as panicking.

The second test is what separates a real fix from a lucky one. With Merge(nil) there are no producers at all, so a close that lives inside a producer never runs and the for range waits forever. It runs Merge on its own goroutine behind a two-second deadline, so a wrong answer fails with a message rather than hanging until the test timeout. The right answer closes even when there was nothing to send.

Done when: go test -race ./... in code/ch03/ reports ok for both tests, and Merge still returns every value the producers sent — no fixed count, no dropped values, no changed signature.
Hint, if you want one: no producer can know whether it is the last one. Something that outlives all of them has to make the call, and §3.3 already showed you what.
Where the files are: labs/go-concurrency/code/ch03/. A worked answer sits in solution/merge.go.txt — worth sitting with the panic for a few minutes first, because the fix is three lines and the reason it has to be shaped that way is the actual lesson.

Further reading

Next

You can now create a channel, reason about a send and a receive as a single event, close one from the right side, iterate it with for range, and say what each of the three states does to every operation. All of that has been about one channel at a time. Chapter 4 introduces select: waiting on several channels at once, why it picks at random among the ready ones—the behavior you already met in §3.4—and how a nil case quietly drops out of the running.