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.
- 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
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).
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.
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.
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:
// 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:
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.
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.
// 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
ch <- v (send)<-ch (receive)close(ch)len(ch), cap(ch)
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:
// 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:
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:
// 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
// Illustrative snippet — not a complete program
ch := make(chan int)
// ^^^^ ^^^
// │ └─ Element type: what flows through
// └────── The chan keyword
Examples:
// 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:
// 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:
// 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:
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 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.
// 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:
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
}
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.
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:
// 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 !=:
// 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
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
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
// 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:
// 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?
chan boolchan intchan struct{}
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:
// 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:
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:
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
}
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.
// 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.
Default to value types unless you have a specific reason for pointers. Value semantics are simpler and eliminate shared-state bugs.
chan T — copy is cheap
chan *T — avoid copy overhead
chan *T + ownership discipline
chan T — each receiver gets own copy
Declaration vs Initialization
Be clear about the difference:
// 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)
var ch chan int
var ch = make(chan int)
ch := make(chan int)
Common Mistakes
Declaring var ch chan int without calling
make()—the channel remains nil (see
“The Zero Value” above).
Always initialize with
ch := make(chan int) before any channel
operation.
Some code paths leave the channel nil:
if needResults { results = make(chan int) }
— goroutines using results block forever
when the condition is false.
Either guard all channel operations inside the condition, or always initialize the channel regardless of the condition.
These operations cause immediate panics—they indicate programming errors, not recoverable conditions.
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
make(chan T), make(chan T, n) for
buffered
nil — blocks send/receive, panics on close
== and != to compare channels
chan struct{} — zero-size, for pure signals
Key Takeaways
-
Always initialize with
make()—nil channels block forever on send/receive and panic on close, but have strategic uses inselect(Section 3.4) -
Channels are references—no need for
*chan T - Unbuffered = synchronization—send and receive must rendezvous simultaneously
-
Use
chan struct{}for signals—zero-size, expresses signal-only intent - Sending copies values—use pointers for large data, but transfer ownership discipline
- 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.
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:
<-
// 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:
// 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:
// 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:
// 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:
- 0ms: "Before send" prints
- 0ms:
expensiveComputation()runs (say 50ms) - 50ms: Send operation blocks waiting for receiver
- 100ms: Receiver ready, transfer happens
- 100ms: "After send" prints
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// Illustrative snippet — not a complete program
value, ok := <-ch
-
If
okistrue: channel is open (or closed with buffered values remaining),valueis a received value -
If
okisfalse: channel is closed and drained,valueis the zero value
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.
// 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:
- Zero is a valid data value—can't distinguish "received zero" from "channel closed"
- You're draining a channel that might close—need to know when to stop
- You need to react differently to closure vs. receiving data—different code paths
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:
// 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:
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.
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:
- The goroutine stops executing at that line
- It yields the CPU to other goroutines (no busy-waiting)
- It waits until the operation can proceed
- The runtime schedules it again when the operation completes
- 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:
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:
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)
}
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.
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:
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:
-
The write and read are concurrent accesses to
data - No happens-before relationship exists between them
- The sleep doesn't create synchronization—it's a guess
-
The Go memory model provides no guarantees about what
mainsees
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:
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:
- (1) happens before (2)—sequential within the goroutine
- (2) happens before (3) completes—channel guarantee from Go's memory model
- Therefore (1) happens before (4)—the write is visible to the read
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.
// 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:
int, bool)
[N]T
[]T
map[K]V
*T
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
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)
}
Each send blocks until a receive is ready. The order depends on scheduling—it's non-deterministic.
Multiple Receivers
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:
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
package main
import "fmt"
func main() {
ch := make(chan int)
ch <- 42 // Blocks forever—no receiver exists
fmt.Println("This never prints")
}
Receive Without Sender
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
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:
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
}
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
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
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):
n (read-only), ch (safe); no
shared state
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
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
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")
}
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
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.
Use separate goroutines:
go func() { ch <- 42 }() for the send, then
value := <-ch in main.
A function launches a goroutine that sends to a channel, then returns without receiving. The goroutine blocks forever on send—a goroutine leak.
Always receive before returning:
return <-result. Every send must have a
corresponding receive.
A goroutine sends 3 values but only 2 are received. The third send blocks forever—goroutine leaks silently.
Match sends and receives, or use channel closing with
for range (Section 3.3) to drain all values
automatically.
Launching goroutines with
go func() { ch <- 1 }() then
go func() { ch <- 2 }() does not guarantee
receive order matches launch order—scheduling is
non-deterministic.
If order matters, use explicit sequencing (channels between steps) or a single sender.
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
ch <- v)
<-ch)
ch <- value
value := <-ch
<-ch
ch <- v
Key Takeaways
-
Send (statement) and receive (expression) use
<-—the arrow shows data direction - Two receive forms—single-value for data, comma-ok to also detect channel closure
- Expression evaluated before blocking—computation happens, then wait
- Blocking is the synchronization mechanism—sender and receiver rendezvous before either proceeds
- Happens-before guarantee—channel operations synchronize memory, not just data
- Values are copied—pointers/slices/maps share underlying data
- Each value consumed once—channels do not broadcast (closing does)
- 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.
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:
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:
- ✗ Fixed count: Receiver must know exactly how many values (brittle coupling)
- ✗ Sentinel value: Requires reserving a value to mean "end of stream"—not possible for all types, ambiguous when the sentinel is legitimate data
- ✗ Timeout: Race-prone (may stop too early if the producer is slow, or too late if it's already done) and wasteful
The solution: close the channel.
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:
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch) // Mark channel as closed
What closing does:
- Marks the channel as closed—a permanent state change
- Unblocks all waiting receivers immediately—they detect closure
-
Makes future receives return zero value—with
okasfalse - Makes future sends panic—you cannot send to a closed channel
What closing does NOT do:
- ✗ Does not delete or deallocate the channel
- ✗ Does not block or wait for anything
- ✗ Does not discard buffered values—they remain readable after close (for buffered channels—Chapter 5)
- ✗ Does not notify senders (they discover closure when they try to send—via panic)
Closing is purely a signal to receivers: "No more values are coming."
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:
// 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:
- Remaining buffered values (for buffered channels—Chapter 5)
- 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"?
// 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:
// Illustrative snippet — not a complete program
value, ok := <-ch
ok == true: A value was successfully received-
ok == false: Channel is closed and empty,valueis the zero value
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!)
}
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:
// 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:
// 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:
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")
}
How for range works:
// 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:
// 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
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.
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:
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)
}
}
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:
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)
}
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:
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)
}
}
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.
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):
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:
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
}
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
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)
ch <- 42 // panic: send on closed channel
2. Close Already-Closed Channel
// Illustrative snippet — not a complete program
ch := make(chan int)
close(ch)
close(ch) // panic: close of closed channel
3. Close Nil Channel
// 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:
-
Receivers use
for range—they need closure to exit the loop - Multiple receivers need completion signal—broadcast with close
- Signaling "no more work"—explicit termination signal
DON'T close when:
-
No goroutines will ever block on this channel—if
the channel is used for a fixed number of send/receive pairs with no
for rangeor indefinitely-waiting receivers, closing is unnecessary (though harmless). The channel will be GC'd when unreachable. - Channel used indefinitely—long-lived workers run until program exits
// 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)
}
}
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."
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:
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")
}
Pipeline flow:
-
generateNumberssends 1-5, then closes its output -
squareNumbersreceives until input closes, squares each, sends to output -
When input closes,
for rangeexits, stage closes its output viadefer mainreceives 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.
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.
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 calls close(ch) while the sender is
still sending. The sender panics with "send on closed
channel."
Only the sender closes. Use for range on the
receiver side: for value := range ch.
Sender finishes but never calls close(ch). The
for range loop in the receiver blocks
forever—goroutine leak.
Use defer close(ch) at the start of sender
goroutines. Guarantees closure even on panic or early return.
Multiple goroutines call close(ch) on the same
channel. Second close causes "panic: close of closed channel."
Single owner closes with defer close(ch). For
multiple senders, use a coordinator with
sync.WaitGroup.
ok When Zero Is Valid
Using if value == 0 { break } to detect closure.
Breaks on legitimately sent zero values, not just closure.
Use for value := range ch which handles closure
detection internally, or use the comma-ok idiom.
Channel Operations Quick Reference
ch <- v (send)
<-ch (receive)
v, ok := <-ch
ok=true
ok=false
close(ch)
len(ch)
cap(ch)
Complete table including nil channels: see end of Section 3.4
close(ch) (statement, no return value)
ok=false
value, ok := <-ch — ok detects
close
Key Takeaways
- Closing signals "no more values"—essential for clean termination
- Sender closes, receiver never closes—prevents send-on-closed panics
-
Use
for rangefor consumption—idiomatic, auto-handles closure -
Comma-ok detects closure—after close,
receives return the zero value with
ok == false - Closing broadcasts to all—unlike sending, which delivers to one receiver
- Three operations panic—send on closed, double close, close nil
-
Use
defer close()for signaling completion—channels don't require closing for GC, but failing to close when receivers are waiting causes goroutine leaks - 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.
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.
The strategic uses of nil channels rely on the
select statement, covered fully in Chapter 4. You
have two options:
- Read now for completeness—the examples are self-contained and illustrate why nil channels exist
-
Skip to Chapter 4 first, then return here with
full
selectunderstanding
What Is a Nil Channel?
A nil channel is the zero value of channel types—a channel variable that hasn't been initialized:
// 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:
// 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:
// Illustrative snippet — not a complete program
ch := make(chan int) // Not nil—initialized, ready to use
fmt.Println(ch == nil) // false
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:
var ch chan T
make(chan T)
close(ch)
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:
ch <- v (send)<-ch (receive)close(ch)len(ch)cap(ch)Send on Nil Channel: Blocks Forever
package main
func main() {
var ch chan int // nil
ch <- 42 // Blocks forever—never proceeds
}
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
package main
import "fmt"
func main() {
var ch chan int // nil
value := <-ch // Blocks forever—never proceeds
fmt.Println(value)
}
Same blocking behavior as send—the operation never completes.
Close Nil Channel: Panics
package main
func main() {
var ch chan int // nil
close(ch) // panic: close of nil channel
}
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?
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
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)
}
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():
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
// 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!
}()
}
Silent goroutine leak—no deadlock detection because other goroutines may be running.
Fix—ensure initialization or guard operations:
// 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
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:
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
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:
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
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:
// 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
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
}
}
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:
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")
}
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:
-
Initial state: Both
ch1andch2are non-nil—loop condition(ch1 != nil || ch2 != nil)is true -
Value received: Whichever channel has a value
ready, that case executes and forwards the value to
out -
Closure detected: When
ok == false, set that channel to nil to disable its select case - Continue: Loop continues with only non-nil channel(s) active
-
Exit: When both are nil, loop condition becomes
false and
defer close(out)fires
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:
Why closed channels don't work for disabling select cases:
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)
}
}
}
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:
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)
}
}
}
The nil channel case is truly skipped—all values from ch2 are received.
Pattern: Optional Channel Parameters
Nil channels enable optional functionality in APIs:
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")
}
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:
ch <- v (send)
<-ch (receive)
v, ok := <-ch
ok=true
ok=false
close(ch)
len(ch)
cap(ch)
for v := range ch
select
The last row shows why nil channels are useful—they're
automatically ignored in select statements.
Common Mistakes
make()
Using var results chan int without calling
make(). Send and receive block
forever—goroutine leak.
Always initialize: results := make(chan int). Use
constructors for struct channel fields.
Calling close(ch) on a nil channel causes
panic: close of nil channel.
Check before closing: if ch != nil { close(ch) }.
select Cases
Closing a channel to “disable” it in select. The closed channel returns zero values repeatedly—it's always ready, not disabled.
Set the channel to nil to truly disable the
select case: ch = nil.
Key Takeaways
- Nil is the zero value—uninitialized channel variables have no underlying data structure
- Send and receive block forever—by design, enabling select case disabling
- Close nil panics—you can't close something that doesn't exist
- Select ignores nil cases—set a channel to nil to dynamically disable it
- Closed channels don't disable—they return zero repeatedly, dominating select
-
Accidental nil is dangerous—always
initialize with
make()unless intentional; use constructors for struct channel fields - 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.
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.
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.
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.
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:
// Illustrative snippet — not a complete program
go func() {
wg.Wait()
close(ch)
}()
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.
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.
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.
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.
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.
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.
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.
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.
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?
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.
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
}
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:
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.
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.
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
-
The Go Programming Language Specification — Channel
types
— the normative text behind every cell of the behavior table
in §3.4, including the send statement, the receive operator,
and what
closeis defined to do. - The Go Memory Model — Channel communication — the happens-before guarantee in §3.2 stated precisely. Worth reading once you have the intuition, because it is what makes channels a synchronization primitive and not just a queue.
- Effective Go — Channels — the original short treatment. Still the clearest statement of why a channel is a rendezvous rather than a pipe.
- Share Memory By Communicating — the essay the chapter’s thesis comes from, and the source of the slogan you have probably already met.
- Go Concurrency Patterns: Pipelines and cancellation — where the sender-closes principle from §3.3 is taken all the way to a real pipeline. Chapter 7 does this properly; this is the preview.
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.