Chapter 6: Directional Channels and API Design
Chapters 3 through 5 established channel mechanics: creation, send/receive semantics, closing, and buffering. You can build working concurrent programs with these primitives. But “working” isn’t the same as “correct by construction.” Directional channel types transform channels from data pipes into self-documenting API contracts—encoding ownership, intent, and safety guarantees in your type signatures.
Go 1.25+, the same baseline as the rest of the
book. It matters twice here. The compiler messages quoted in
§6.1 and §6.2 are what go build prints
today — the wording changed when
go/types took over error reporting, so older articles
quote a different form. And the note on time.Tick in
§6.5 reflects the Go 1.23 timer changes that
Chapter 4 §4.4 works through in detail.
-
Send-only channels (
chan<- T): syntax, permitted operations, and use cases -
Receive-only channels (
<-chan T): syntax, permitted operations, and use cases - Compile-time safety: what the type system prevents and why it matters
- Ownership patterns: who creates, who sends, who receives, who closes
-
The output channel pattern: returning
<-chan Tfrom functions - The generator pattern: functions that produce value streams
- API design guidelines: principles for concurrent function signatures
- Channel-based patterns (pipelines, fan-out/fan-in, worker pools)—Chapter 7
- Context for cancellation—Chapter 13
- Error handling across goroutine boundaries—Chapter 14
This chapter focuses on the type system and API design. Chapter 7 builds on these foundations to implement production patterns.
You should understand channel lifecycle from Chapter 3 (especially
the sender-closes principle from Section 3.3), buffered channel
semantics from Chapter 5, and basic select usage from
Chapter 4.
Consider this coordinator that spawns workers:
// Illustrative snippet — not a complete program
func coordinator(tasks []Task) []Result {
ch := make(chan Result, len(tasks))
for _, task := range tasks {
go worker(task, ch)
}
var results []Result
for range tasks {
results = append(results, <-ch)
}
return results
}
func worker(task Task, ch chan Result) { // Bidirectional channel
result := process(task)
ch <- result
close(ch) // BUG: Worker closes shared channel
}
Multiple workers close the same channel. The first worker closes
successfully. When subsequent workers try to send or close, the program
panics—either
panic: send on closed channel or
panic: close of closed channel, depending on timing.
The coordinator owns the channel and expects
len(tasks) results. But nothing in the type signature tells
the worker this. The worker receives chan Result—a
bidirectional channel—and the compiler permits sending, receiving,
and closing. The programmer’s intent—“workers should
only send, not control lifecycle”—exists only in
documentation.
This bug compiles. The type system sees
chan Result and allows all operations.
A worker taking a plain bidirectional channel. Arrows fan out from the parameter to every operation the compiler will allow: send, receive, and close. Nothing in the signature says which of them the worker is supposed to do.
Directional channel types solve half of this problem:
// Illustrative snippet — not a complete program
func worker(task Task, results chan<- Result) {
result := process(task)
results <- result
// close(results) // ✓ Compiles—but don’t
_ = <-results // ✗ Compile error
}
The type chan<- Result declares: “this function
sends results.” The compiler prevents the worker from
receiving—a common bug where producers accidentally
consume their own output. Closing is still technically permitted
(closing is a sender operation), but the type documents intent: this
function contributes values, it doesn’t control lifecycle.
The same worker taking a send-only channel. The receive arrow is struck out — the compiler rejects it — so the signature itself now says the worker produces and never consumes.
The type chan<- Result prevents
receiving—a common bug where producers
accidentally consume their own output. But it does NOT prevent
closing. Both chan T and
chan<- T can call close(). The
multiple-close bug in the opening example requires
ownership patterns (Section 6.4), not just
directional types. The type documents intent and prevents
wrong-direction operations; convention and patterns ensure correct
lifecycle management.
This chapter covers both:
- Sections 6.1–6.3: What directional types enforce at compile time
- Sections 6.4–6.6: Ownership patterns that complement type safety
6.1 Send-Only Channels: chan<- T
A send-only channel restricts a channel reference to sender operations. The holder can deposit values and signal completion, but cannot receive or observe the channel’s contents. This constraint, partially enforced at compile time and partially by convention, prevents an entire class of bugs.
Syntax
The arrow points into the channel—values flow in, nothing comes out:
// Illustrative snippet — not a complete program
chan<- T // Send-only: can send T values in
// ^^^
// └── Arrow points INTO chan: "send into"
Memory aid: Read chan<- as “I
send to channel” or “values flow from me into the
channel.” The arrow shows the direction values flow
from your code’s perspective.
Three columns: your code, the channel, and other code. An arrow runs from your code into the channel marked allowed; the arrow coming back out is blocked by the compiler. A send-only channel lets you push values in and never pull them out.
Permitted and Prohibited Operations
A send-only channel permits sender operations and prohibits receiver operations:
ch <- v (send)close(ch)<-ch (receive)for v := range ch
len(ch), cap(ch)
// Illustrative snippet — not a complete program
// Won’t compile—demonstrates valid and invalid operations
func demonstrateSendOnly(ch chan<- int) {
ch <- 42 // ✓ Send permitted
close(ch) // ✓ Close permitted
_ = len(ch) // ✓ Buffer inspection
_ = cap(ch) // ✓ Capacity inspection
_ = <-ch // ✗ Compile error
for range ch { // ✗ Compile error
}
}
Compiler error for receive:
What Send-Only Actually Prevents
The primary value of chan<- T is
preventing accidental receives:
// Illustrative snippet — not a complete program
// ✗ WITHOUT send-only: bug compiles
func worker(results chan Result, task Task) {
result := process(task)
results <- result
// Oops—accidentally received!
if other := <-results; other.Valid {
// Worker consumes results
// meant for the coordinator
}
}
// ✓ WITH send-only: bug caught
func worker(results chan<- Result, task Task) {
result := process(task)
results <- result
if other := <-results; other.Valid {
// ✗ Compile error
}
}
In concurrent systems, accidentally receiving from a channel you should only send to causes subtle bugs: stolen results, deadlocks, or corrupted data flow. The type system catches this at compile time.
Closing Behavior
Send-only channels can close—closing is a
sender operation that signals “no more values will be
sent.” The Go specification permits close() on
bidirectional (chan T) and send-only (chan<- T) channels, but prohibits it on receive-only (<-chan T)—aligning with the sender-closes principle from Section 3.3.
But should a given producer close? The answer depends
on ownership.
Single Producer: Close When Done
When a function is the sole producer, it owns the channel’s lifecycle and should close:
// Illustrative snippet — not a complete program
func generateSequence(out chan<- int, count int) {
defer close(out) // ✓ Single producer closes
for i := 0; i < count; i++ {
out <- i
}
}
Multiple Producers: Coordinate Closing
When multiple goroutines share a channel, individual producers shouldn’t close—they don’t know when all senders are done:
// Illustrative snippet — not a complete program
func worker(id int, results chan<- int) {
results <- id * 10
// Don’t close here—other workers are still sending
// close(results) // Legal, but WRONG for this pattern
}
func main() {
results := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Go(func() {
worker(i, results)
})
}
// Coordinator closes after ALL finish
go func() {
wg.Wait()
close(results) // Owner closes
}()
for r := range results {
fmt.Println(r)
}
}
Conversion: Bidirectional to Send-Only
Go implicitly converts bidirectional channels to directional channels. This is safe because it only removes capabilities:
// Illustrative snippet — not a complete program
func main() {
ch := make(chan int) // Bidirectional
var sendOnly chan<- int = ch // ✓ Implicit
sendOnly <- 42 // ✓ Can send
// Reverse is NOT allowed:
var bidir chan int = sendOnly // ✗ Compile error
}
A conversion map. A bidirectional channel sits at the top with arrows down to send-only and receive-only, both labeled as permitted. Every arrow back upward, and every arrow across between the two directional types, is marked forbidden.
This enables the key pattern: create bidirectional, pass directional:
// Illustrative snippet — not a complete program
func main() {
ch := make(chan int) // Full control here
go sender(ch) // → chan<- int (implicit)
receiver(ch) // → <-chan int (implicit)
}
func sender(ch chan<- int) { ch <- 42; close(ch) }
func receiver(ch <-chan int) { fmt.Println(<-ch) }
The creator maintains full control; callees receive only the capabilities they need.
Once a channel is restricted, you cannot unrestrict it—even with type assertions:
// Illustrative snippet — not a complete program
ch := make(chan int)
sendOnly := (chan<- int)(ch) // Restrict
// All attempts to convert back fail
_ = (chan int)(sendOnly) // ✗ Compile error
_ = sendOnly.(chan int) // ✗ Not an interface
_ = interface{}(sendOnly).(chan int) // ✗ Runtime panic
This is intentional—if you could convert back, the type safety would be meaningless.
When to Use Send-Only Channels
The type tells you the function’s role in the channel’s lifecycle.
chan<- T
chan<- T
Section 6.3 presents the complete matrix covering all channel types
and roles, including consumers (<-chan T) and
coordinators (chan T).
Common Mistakes
Mistake 1: Creating Directional Channels Directly
// Illustrative snippet — not a complete program
// ✗ WRONG: Compiles, but useless—nothing can ever receive from this
sendOnly := make(chan<- int) // Legal Go, but creates a dead channel
// ✓ CORRECT: Make bidirectional, convert when passing
ch := make(chan int)
go sender(ch) // Implicit conversion to chan<- int
Go allows make on any channel type, including directional
ones—this compiles without error. But the result is useless: you
get a channel you can send to, but nothing can ever receive from it
(the directional restriction is permanent). Always create
bidirectional, then narrow via conversion.
Mistake 2: Closing in Multi-Producer Scenarios
// Illustrative snippet — not a complete program
// ✗ WRONG: Individual producer closes shared channel
func worker(results chan<- int, id int) {
results <- id
close(results) // Compiles, but causes panic
}
// ✓ CORRECT: Let coordinator close
func worker(results chan<- int, id int) {
results <- id
// Coordinator closes after all workers finish
}
Mistake 3: Returning Send-Only Channels
// Illustrative snippet — not a complete program
// ✗ WRONG: Caller can't receive—useless return value
func generate() chan<- int {
ch := make(chan int)
go func() {
ch <- 42
close(ch)
}()
return ch // Caller can only send to this!
}
// ✓ CORRECT: Return receive-only (Section 6.5)
func generate() <-chan int {
ch := make(chan int)
go func() {
ch <- 42
close(ch)
}()
return ch // Caller receives; function retains send
}
Key Takeaways
-
chan<- Tprevents receives at compile time—the primary safety value, catching producer-consuming-own-output bugs - Closing is permitted but governed by ownership—single producer closes; multiple producers let the coordinator close
-
Create bidirectional, pass directional—
chan T→chan<- Tconversion is implicit and permanent -
make(chan<- T)is legal but useless—always createchan Tand narrow at the call site - The type signature documents intent—readers know the function’s role without reading the body
Next: Section 6.2 covers receive-only channels (<-chan T)—the complement to send-only channels, used for consumer
functions that cannot send or close.
chan<- T says “this function produces.”
The compiler will reject a receive or a range on it,
but not a close—so the type buys
you capability, and who closes is still a decision you have to make.
6.2 Receive-Only Channels: <-chan T
Section 6.1 showed how send-only channels prevent producers from receiving. The complement is receive-only channels—restricting a reference to consumer operations only.
Consider a coordinator that collects results:
// Illustrative snippet — not a complete program
func coordinator(tasks []Task) []Result {
results := make(chan Result, len(tasks))
for _, task := range tasks {
go worker(task, results)
}
return collectResults(results, len(tasks))
}
func collectResults(
ch chan Result, // Bidirectional
count int,
) []Result {
var out []Result
for i := 0; i < count; i++ {
out = append(out, <-ch)
}
// Bug: consumer shouldn't close
close(ch) // Violates sender-closes
return out
}
The collectResults function should only consume results.
But chan Result permits anything—including closing.
Here the buffer happens to absorb all sends before close, but the
pattern is fragile—reduce the buffer, change the timing, or add
more workers, and you get
panic: send on closed channel.
This bug compiles. The bidirectional type allows the collector to close, even though closing violates the sender-closes principle.
A collector taking a bidirectional channel: it can receive, but it can also send into the channel it is meant to be draining, and close it. Both are bugs the signature does nothing to prevent.
Receive-only channels solve this:
// Illustrative snippet — not a complete program
func collectResults(
ch <-chan Result, // Receive-only
count int,
) []Result {
var out []Result
for i := 0; i < count; i++ {
out = append(out, <-ch)
}
close(ch) // ✗ Compile error
return out
}
The compiler enforces the sender-closes principle. The collector
cannot close—it’s a compile-time
guarantee. The type <-chan Result declares:
“this function consumes results; it doesn’t control the
channel’s lifecycle.”
The same collector taking a receive-only channel. Send and close are both rejected by the compiler, leaving receive as the only thing the function can do with it.
Syntax
The arrow points out of the channel—values flow out to your code:
// Illustrative snippet — not a complete program
<-chan T // Receive-only: receive T values
// ^^^
// └── Arrow points OUT OF chan: "receive from"
Memory aid: Read <-chan as “I
receive from channel” or “values flow from channel to
me.” The arrow shows the direction values flow
from your code’s perspective.
Three columns again, mirrored. An arrow runs from other code through the channel into your code; your attempt to send back into it is blocked. A receive-only channel lets you pull values out and never push them in.
Permitted and Prohibited Operations
A receive-only channel permits receiver operations and prohibits sender operations:
<-ch (receive)v, ok := <-ch
for v := range ch
ch <- v (send)close(ch)len(ch), cap(ch)
See Section 6.3 for the complete matrix comparing all channel types.
// Illustrative snippet — not a complete program
// Won't compile—demonstrates valid and invalid operations
func demonstrateReceiveOnly(ch <-chan int) {
v := <-ch // ✓ Receive permitted
v, ok := <-ch // ✓ Comma-ok permitted
_ = ok
for value := range ch { // ✓ Range permitted
fmt.Println(value)
}
_ = len(ch) // ✓ Buffer inspection
_ = cap(ch) // ✓ Capacity inspection
ch <- 42 // ✗ Compile error
close(ch) // ✗ Compile error
_ = v
}
Compiler errors:
Directionality doesn’t change nil channel behavior (Section 3.4):
// Illustrative snippet — not a complete program
var sendOnly chan<- int // nil
var recvOnly <-chan int // nil
sendOnly <- 42 // Blocks forever (goroutine leak)
v := <-recvOnly // Blocks forever (goroutine leak)
close(sendOnly) // Panic: close of nil channel
The directional restriction is orthogonal to nil behavior. Always
initialize channels with make() before use.
Why Receive-Only Channels Cannot Close
Unlike send-only channels (which can close), receive-only channels cannot close. This asymmetry reflects the sender-closes principle from Section 3.3:
Closing is a sender operation. It signals: “No more values will be sent.” Only code that sends—or coordinates senders—has the knowledge to make this declaration. Receivers don’t know if other senders exist or when senders will finish.
Two perspectives on closing. The sender knows it has sent its last value and can say so. The receiver only knows it has not received anything lately, which is not the same thing — and is why closing belongs to the sender.
Conversion: Bidirectional to Receive-Only
Go implicitly converts bidirectional channels to receive-only. This is safe because it removes capabilities:
// Illustrative snippet — not a complete program
func main() {
ch := make(chan int) // Bidirectional
var recvOnly <-chan int = ch // ✓ Implicit
v := <-recvOnly // ✓ Can receive
// Reverse is NOT allowed:
var bidir chan int = recvOnly // ✗ Compile error
_ = v
}
The same conversion rules and “create bidirectional, pass
directional” pattern from Section 6.1 apply symmetrically. The
reverse conversion (<-chan T →
chan T) is a compile error—restrictions are
permanent.
What Receive-Only Prevents
The primary value of <-chan T is
preventing two classes of bugs that commonly occur
when consumers have too much power:
1. Accidental Sends (Feedback Loops)
// Illustrative snippet — not a complete program
// ✗ WITHOUT receive-only: bug compiles
func processResults(results chan int) {
for v := range results {
if v%2 == 0 {
results <- v * 2 // Oops: sends back to own input
// Deadlock (unbuffered) or infinite loop (buffered)
}
handle(v)
}
}
// ✓ WITH receive-only: bug caught
func processResults(results <-chan int) {
for v := range results {
if v%2 == 0 {
results <- v * 2 // ✗ Compile error
}
handle(v)
}
}
In the broken version, the consumer sends values back into the channel it’s consuming. With an unbuffered channel this deadlocks immediately; with a buffered channel it creates an infinite loop. Receive-only types make this bug a compile error.
2. Consumer Closing (Violates Sender-Closes)
// Illustrative snippet — not a complete program
// ✗ WITHOUT receive-only: violates sender-closes
func consumer(data chan int) {
for v := range data {
process(v)
}
close(data) // Double close: range exited because sender closed
}
// ✓ WITH receive-only: compiler enforces
func consumer(data <-chan int) {
for v := range data {
process(v)
}
// close(data) ✗ Compile error
}
Consumers should never close channels (Section 3.3). Receive-only types make this violation impossible.
The Idiomatic Pattern: for range
The most common use of receive-only channels is with
for range:
// Illustrative snippet — not a complete program
func processStream(data <-chan int) {
for value := range data {
process(value)
}
// Loop exits when channel closes
}
The for range statement only receives from the channel,
making it the natural fit for <-chan T. This is the
idiomatic way to consume values until the producer closes.
Directional Channels in Select
Directional types work naturally with select. Each
channel can only appear in cases matching its direction:
// Illustrative snippet — not a complete program
func processWithShutdown(
in <-chan int,
out chan<- int,
done <-chan struct{},
) {
for {
select {
case v, ok := <-in: // ✓ Receive
if !ok {
return // Input closed
}
out <- v * 2 // ✓ Send
case <-done: // ✓ Receive
return // Shutdown
}
}
}
The compiler enforces correct usage:
-
in <- vwould fail: cannot send to receive-only channel -
<-outwould fail: cannot receive from send-only channel -
close(in)would fail: cannot close receive-only channel
This compile-time checking prevents entire categories of bugs.
Complete Example: Worker Pool
package main
import (
"fmt"
"sync"
"time"
)
type Task struct {
ID int
Data string
}
// Worker receives tasks, processes them. It takes a receive-only
// channel: the signature says it consumes and never closes.
func worker(id int, tasks <-chan Task) {
for task := range tasks {
fmt.Printf(
"Worker %d processing task %d: %s\n",
id, task.ID, task.Data,
)
time.Sleep(50 * time.Millisecond)
}
fmt.Printf("Worker %d finished\n", id)
}
func main() {
tasks := make(chan Task, 10)
var wg sync.WaitGroup
// Start 3 workers
for i := 1; i <= 3; i++ {
wg.Go(func() { worker(i, tasks) })
}
// Producer sends tasks
for i := 1; i <= 9; i++ {
tasks <- Task{
ID: i,
Data: fmt.Sprintf("data-%d", i),
}
}
close(tasks) // Producer closes
wg.Wait() // Wait for workers
}
Key points:
-
workerreceives<-chan Task—compiler ensures it can only receive - Workers cannot send tasks back or close the channel
- Producer (main) closes the channel when done sending
-
All workers exit cleanly when the channel closes (via
for range) - Multiple consumers share one channel—each task delivered to exactly one worker (fan-out pattern, Chapter 7)
Receive-Only as Return Type (Preview)
Receive-only channels are commonly used as return types:
// Illustrative snippet — not a complete program
func generateNumbers(count int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < count; i++ {
out <- i
}
}()
return out // Caller gets receive-only
}
This pattern—returning <-chan T to provide a
read-only stream—is powerful for API design. The function
retains internal control (to send and close), while giving the caller
only consumption rights. We explore this in depth in Section 6.5
(Output Channel Pattern).
Common Mistakes
Mistake 1: Creating Directional Channels Directly
// Illustrative snippet — not a complete program
// ✗ WRONG: Legal Go, but useless—no one can send
recvOnly := make(<-chan int) // Dead channel
// ✓ CORRECT: Make bidirectional, convert when passing
ch := make(chan int)
consumer(ch) // Implicit conversion to <-chan int
Mistake 2: Returning Bidirectional When Receive-Only Suffices
// Illustrative snippet — not a complete program
// ✗ SUBOPTIMAL: Exposes too much capability
func generate() chan int {
ch := make(chan int)
go func() {
ch <- 1; ch <- 2
close(ch)
}()
return ch // Caller can send to or close this!
}
// ✓ BETTER: Return receive-only (Section 6.5)
func generate() <-chan int {
ch := make(chan int)
go func() {
ch <- 1; ch <- 2
close(ch)
}()
return ch // Caller can only receive
}
Key Takeaways
-
<-chan Tpermits only receive and range—send and close are compile errors - Cannot close (unlike send-only)—receivers lack lifecycle knowledge, enforcing the sender-closes principle
- Prevents feedback loops and consumer-closes bugs—two common categories caught at compile time
-
Create bidirectional, pass directional—
chan T→<-chan Tconversion is implicit and permanent - The type signature documents intent—readers know the function consumes values without reading the body
Next: Section 6.3 examines compile-time safety guarantees—what directional types prevent, what they don’t, and how to design APIs that maximize compiler-enforced correctness.
<-chan T is the fully constrained one: receive,
comma-ok, range, len and
cap are permitted; sending and closing are compile
errors. That is what makes it the right return type for anything
handing a stream to a caller.
6.3 Compile-Time Safety Guarantees
Sections 6.1 and 6.2 established that directional channels restrict operations at compile time. But what exactly does "compile-time safety" mean? And what problems remain beyond the compiler's reach?
Consider these two bugs:
// Illustrative snippet — not a complete program
// Bug 1: Consumer closes channel
func consumer(ch <-chan int) {
for v := range ch {
process(v)
}
close(ch) // ✗ Compile error
}
// Bug 2: Multiple producers close shared channel
func worker(id int, out chan<- int) {
out <- id * 10
close(out) // ✓ Compiles—but causes panic
}
The first bug cannot compile. The type system prevents it absolutely. The second bug compiles successfully and manifests at runtime as a panic.
A two-column summary of the boundary. On one side, what the type system prevents outright: consumers sending into inputs, consumers closing, producers receiving their own output, cross-direction conversions. On the other, what it cannot: multiple producers closing a shared channel, send after close inside one function, leaks, and deadlocks.
This section examines the boundary: what directional types guarantee, what they don't, and how to design APIs that maximize compiler-enforced correctness.
What the Compiler Enforces
Directional channel types provide compile-time guarantees—violations are impossible to express in valid Go code. The compiler rejects the program before it runs.
Complete Channel Operations Reference
This is the authoritative reference for all channel type operations:
An operations matrix. Rows are send, receive, comma-ok, range, close, len and cap; columns are the bidirectional, send-only and receive-only types. Send-only permits send, close, len and cap. Receive-only permits receive, comma-ok, range, len and cap. Bidirectional permits everything.
Red ✗ = compile error. These operations are impossible to express in valid Go code.
Every ✗ in this table represents a class of bugs that cannot exist in your program. The compiler makes them impossible.
What Directional Types Prevent
Sections 6.1 and 6.2 demonstrated three compile-time guarantees: consumers cannot send (preventing feedback loops), consumers cannot close (enforcing sender-closes), and producers cannot receive (enforcing role separation). A fourth guarantee completes the picture:
Conversions Are One-Way Only
Capability can only be removed, never added:
// Illustrative snippet — not a complete program
ch := make(chan int)
// Removing capability: always allowed
var sendOnly chan<- int = ch // ✓
var recvOnly <-chan int = ch // ✓
// Adding capability: always forbidden
var bidir1 chan int = sendOnly // ✗ Compile error
var bidir2 chan int = recvOnly // ✗ Compile error
// Cross-conversion: always forbidden
var send2 chan<- int = recvOnly // ✗ Compile error
var recv2 <-chan int = sendOnly // ✗ Compile error
// Even through any: runtime panic
var a any = sendOnly
_ = a.(chan int) // panic: interface conversion
Once you restrict a channel reference, that restriction cannot be circumvented.
What Directional Types Do NOT Prevent
Directional types are powerful but not complete. Several critical bugs remain runtime concerns.
Limitation 1: Multiple Producers Closing
// Illustrative snippet — not a complete program
// ✓ COMPILES but causes runtime panic
func worker1(out chan<- int) {
out <- 1
close(out) // Whichever worker gets here first
}
func worker2(out chan<- int) {
out <- 2
close(out) // Rarely reached: the send above
// panics first if worker1 closed
}
func main() {
ch := make(chan int, 2) // Buffer lets both send
go worker1(ch)
go worker2(ch)
fmt.Println(<-ch, <-ch) // Receive both values
time.Sleep(10 * time.Millisecond)
}
Why types can't help: Both
chan<- T references can close. The compiler doesn't
track whether another goroutine has already closed.
Prevention: Use ownership patterns (Section 6.4)—only the coordinator closes.
Limitation 2: Send After Close
// Illustrative snippet — not a complete program
// ✓ COMPILES but panics at runtime
func producer(out chan<- int) {
out <- 1
close(out)
out <- 2 // Panic: send on closed channel
}
Why types can't help: chan<- T can
both send and close. The compiler doesn't track channel state.
Prevention: Use defer close() to ensure
close happens at function exit.
Limitation 3: Goroutine Leaks
// Illustrative snippet — not a complete program
// ✓ COMPILES but goroutine leaks
func leakyProducer(out chan<- int) {
for i := 0; ; i++ {
out <- i // Blocks forever if receiver stops
}
}
func main() {
ch := make(chan int)
go leakyProducer(ch)
fmt.Println(<-ch) // Receive one value
// Stop receiving—producer leaks forever
}
Why types can't help: Types restrict operations, not duration. Cancellation requires explicit coordination (done channels or Chapter 13: Context).
Prevention: Accept context.Context and
check ctx.Done() in select.
Limitation 4: Deadlocks
// Illustrative snippet — not a complete program
// ✓ COMPILES but deadlocks
func main() {
ch := make(chan int) // Unbuffered
go func() {
<-ch // Blocks waiting to receive
}()
<-ch // Main also blocks—no sender exists
// Runtime: "all goroutines are asleep - deadlock!"
}
Why types can't help: Types verify what operations are present, not what operations should be present. Missing sends or receives can't be detected from types alone.
Prevention: Design review, testing, and Go’s runtime deadlock detector (Chapter 10).
Limitation 5: Data Races on Sent Values
// Illustrative snippet — not a complete program
// ✓ COMPILES but has data race
func main() {
ch := make(chan *Data)
data := &Data{Value: 1}
go sender(ch, data) // May access data anytime
data.Value = 2 // DATA RACE
<-ch
}
func sender(out chan<- *Data, d *Data) {
d.Value = 3 // DATA RACE: concurrent modification
out <- d // Channel op is safe; data is not
}
Why types can't help: Channel types restrict channel operations, not pointer operations. The underlying data can be accessed by both sender and receiver.
When you send a pointer or slice through a channel, you’re conventionally transferring ownership—the sender should stop using it. But the compiler cannot enforce this:
// Illustrative snippet — not a complete program
func sender(out chan<- []int) {
data := []int{1, 2, 3}
out <- data
data[0] = 999 // Legal but wrong—data was "transferred"
}
Safe options: send copies, send immutable data, or use value types (not references).
Capability vs. Convention
A key insight: directional types express capability, but correct usage often requires convention.
A table separating capability from convention. A receive-only channel is fully constrained by the type. A send-only channel is only partly constrained, because closing remains legal — so avoiding a double close is still a convention the type cannot enforce.
Receive-only is fully constrained—the type prevents all misuse. Send-only permits closing, so convention determines correctness in multi-producer scenarios.
Maximizing Compile-Time Safety
Given these boundaries, design APIs that maximize what the compiler catches.
Principle 1: Least Privilege
Give each function only the channel capabilities it needs.
// Illustrative snippet — not a complete program
// ✗ WEAK: Exposes too much capability
func process(ch chan int) {
for v := range ch {
handle(v)
}
}
// ✓ STRONG: Minimal capability—bugs impossible
func process(ch <-chan int) {
for v := range ch {
handle(v)
}
}
With chan int, a bug like
ch <- v compiles. With <-chan int,
it's impossible.
Principle 2: Return Receive-Only from Generators
Returning <-chan T instead of chan T
protects the producer’s control—callers can only consume,
never send or close. Sections 6.1 and 6.2 both demonstrated this
pattern; Section 6.5 (Output Channel Pattern) explores it in depth.
Principle 3: Bidirectional Only for Owners
Reserve chan T for channel owners—functions that create
channels and control their lifecycle:
// Illustrative snippet — not a complete program
func coordinator() {
ch := make(chan int) // Owner holds chan T
go producer(ch) // Receives chan<- int
go consumer(ch) // Receives <-chan int
// Owner controls lifecycle
}
func producer(out chan<- int) { /* sends only */ }
func consumer(in <-chan int) { /* receives only */ }
Only the creator has bidirectional access. Everyone else gets restricted views.
Principle 4: Document Closing Responsibility
When a function receives chan<- T, document whether it
should close:
// Illustrative snippet — not a complete program
// generateFibonacci sends n Fibonacci numbers
// then closes out.
func generateFibonacci(
out chan<- int,
n int,
) {
defer close(out) // This function owns closing
a, b := 0, 1
for i := 0; i < n; i++ {
out <- a
a, b = b, a+b
}
}
// worker sends results to out.
// Caller is responsible for closing out
// after all workers complete.
func worker(
id int,
in <-chan Task,
out chan<- Result,
) {
for task := range in {
out <- process(task)
}
// Does NOT close—coordinator closes
}
The type permits closing in both cases. Documentation clarifies responsibility.
Directional channel types are a compile-time feature only:
-
Zero memory overhead:
chan T,chan<- T, and<-chan Tare identical at runtime - Zero performance overhead: No runtime checks or type conversions
-
Zero allocation overhead: Conversion from
chan Tto directional is purely a compile-time annotation—at runtime, all channel types are represented identically
The safety is completely free. Use directional types everywhere—they make code safer without any performance cost.
Summary
Two lists. The absolute compile-time guarantees, each paired with the bug it removes. And the guarantees the type system does not give, each paired with the discipline that has to cover it instead.
Key Takeaways
- Compile-time guarantees are absolute—wrong-direction operations and reverse conversions cannot exist in valid code
-
Receive-only is fully constrained; send-only is partial—
<-chan Tprevents all misuse, butchan<- Tstill permits closing (convention needed for multi-producer) - Types enforce capability, not correctness—goroutine leaks, deadlocks, and data races on sent references remain runtime concerns
-
Least privilege: give functions only what they need—reserve
chan Tfor owners; everyone else gets a directional view - Zero runtime cost—directional types are purely compile-time; use them everywhere
Next: Section 6.4 explores ownership patterns—the conventions that determine who creates channels, who sends, who receives, and who closes, building on type safety to create correct, maintainable concurrent systems.
Directional types catch four whole classes of bug at compile time, and are honest about four more they cannot touch—multiple producers closing, send-after-close inside one function, leaks, and deadlocks. Knowing which list a hazard is on tells you whether to reach for a type or a pattern.
6.4 Ownership Patterns: Who Creates, Who Closes
Sections 6.1–6.3 established what directional types enforce and
what they don’t. The type chan<- T permits
closing—but that doesn’t mean every function with
chan<- T should close.
Ownership patterns determine who should.
Consider this buggy worker pool:
// Illustrative snippet — not a complete program
func main() {
tasks := make(chan Task, 100)
results := make(chan Result, 100)
// Start 5 workers
for i := 0; i < 5; i++ {
go worker(tasks, results)
}
// Send tasks
for _, t := range getAllTasks() {
tasks <- t
}
close(tasks) // Signal no more tasks
// Collect results—but who closes results?
for r := range results {
process(r)
}
}
func worker(tasks <-chan Task, results chan<- Result) {
for task := range tasks {
results <- process(task)
}
close(results) // BUG: Each worker closes!
}
Each worker closes results when it finishes. The first
worker to finish closes successfully. The second causes
panic: close of closed channel. Even if that didn’t
panic, the main goroutine’s range
would exit prematurely.
The type chan<- Result permits closing.
The bug is an ownership violation: workers don’t own the results
channel—they share it.
The question posed as a decision. The wrong answer is whoever finishes first, which is what produces double closes. The right answer follows ownership: whoever created the channel and is the only one sending on it.
This section defines five ownership patterns that cover virtually all concurrent Go code.
The Four Questions of Channel Ownership
For every channel in your program, answer these questions:
- Who creates the channel? (The creator typically owns it)
- Who sends to the channel? (One sender? Many?)
- Who receives from the channel? (One receiver? Many?)
- Who closes the channel? (Only the owner should close)
A decision tree starting from who creates the channel. Each branch leads to a rule about who is then responsible for closing it, and to the ownership pattern that fits.
Pattern 1: Single Producer (Creator Closes)
The simplest pattern: one goroutine creates a channel, sends values, and closes when done.
// Illustrative snippet — not a complete program
func generateNumbers(max int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // Producer owns; producer closes
for i := 0; i < max; i++ {
out <- i
}
}()
return out
}
func main() {
for n := range generateNumbers(5) {
fmt.Println(n)
}
}
generateNumbers (internal goroutine)
<-chan int)
Key insight: The producer knows when it’s done
sending. No coordination needed—just defer close().
Pattern one: a single producer creates the channel, sends on it, and closes it before returning. One arrow in, one close, no coordination needed.
Pattern 2: Multiple Producers with Coordinator
When multiple goroutines send to one channel, a coordinator must close—not individual producers.
// Illustrative snippet — not a complete program
func processInParallel(tasks []Task) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
// Launch workers
for _, task := range tasks {
wg.Go(func() {
results <- process(task)
})
}
// Coordinator closes after all workers finish
go func() {
wg.Wait()
close(results)
}()
return results
}
processInParallel
<-chan Result)
wg.Wait())
Key insight: Individual workers don’t know when
all workers are done. Only the coordinator—via
WaitGroup—has that knowledge.
Pattern two: several producers send into one channel and none of them may close it. A separate coordinator waits for all of them and performs the single close.
Why a Separate Closer Goroutine?
This pattern trips up many developers:
// Illustrative snippet — not a complete program
go func() {
wg.Wait()
close(results)
}()
Why not close after waiting in the main function?
// Illustrative snippet — not a complete program
// ✗ WRONG: Deadlocks
func coordinator(tasks []Task) []Result {
results := make(chan Result)
var wg sync.WaitGroup
// ... spawn workers ...
wg.Wait() // ← Blocks here forever
close(results) // Never reached
var collected []Result
for r := range results {
collected = append(collected, r)
}
return collected
}
The deadlock: wg.Wait() blocks until
workers call wg.Done(). But workers can’t complete
their sends to results until someone receives from
results. The coordinator is waiting for workers; workers
are waiting for the coordinator. Classic deadlock.
The solution: A separate closer goroutine runs concurrently with consumption:
A timeline of the coordinator pattern. The caller receives the channel and starts ranging over it immediately, while a closer goroutine waits on the WaitGroup and closes afterwards — which is why the wait must not happen before the caller starts consuming.
- Function returns channel immediately
- Caller starts consuming (draining the channel)
- Workers send results as they complete
-
Closer goroutine waits for all workers (
wg.Wait()) -
When last worker finishes, closer calls
close(results) - Consumer’s
rangeterminates
This pattern appears constantly in production Go code.
Pattern 3: Passed-In Channel (Caller Owns)
Sometimes the caller creates and owns the channel, passing it to functions that contribute values:
// Illustrative snippet — not a complete program
func main() {
results := make(chan int, 100) // Caller creates and owns
var wg sync.WaitGroup
// Multiple contributors
wg.Add(3)
go contribute(1, 10, results, &wg)
go contribute(11, 20, results, &wg)
go contribute(21, 30, results, &wg)
// Caller closes after all contributors finish
go func() {
wg.Wait()
close(results) // Owner closes
}()
for r := range results {
fmt.Println(r)
}
}
// contribute sends values to out.
// Caller is responsible for closing out
// after all contributors complete.
func contribute(
start, end int,
out chan<- int,
wg *sync.WaitGroup,
) {
defer wg.Done()
for i := start; i <= end; i++ {
out <- i
}
// Does NOT close—caller owns the channel
}
main)
Key insight: The function receives
chan<- T but doesn’t close—the caller
retains ownership.
Documentation must clarify this.
Pattern three: the caller creates the channel and passes it in. The function sends but must not close, because it does not own what it did not create.
Pattern 4: Transformer (Non-Owner)
A transformer receives from one channel and sends to another—owning neither:
// Illustrative snippet — not a complete program
func square(in <-chan int, out chan<- int) {
for v := range in {
out <- v * v
}
// Does NOT close out—caller owns both
}
func main() {
input := make(chan int)
output := make(chan int)
go func() {
defer close(input) // Owner closes
for i := 1; i <= 5; i++ {
input <- i
}
}()
go func() {
square(input, output)
close(output) // Caller closes after transformer
}()
for v := range output {
fmt.Println(v)
}
}
main)
Key insight: The transformer’s signature (in <-chan T, out chan<- T) shows it owns neither channel. Closing responsibility lies with
whoever owns each channel.
Pattern four: a transformer sits between a producer and a consumer. It receives from an upstream channel it does not own, and creates and closes a new downstream channel that it does.
A common variation: the goroutine running the transformer closes output when the transformer returns:
// Illustrative snippet — not a complete program
go func() {
square(input, output) // Returns when input closes
close(output) // Then close output
}()
This chains closure: when input closes,
square exits, then output closes. The
transformer itself doesn’t close; the wrapping goroutine does.
Pattern 5: Long-Lived Channels (Never Close)
Some channels live for the program’s duration—closing them isn’t necessary or useful:
// Illustrative snippet — not a complete program
var (
eventBus = make(chan Event, 100)
)
func main() {
go eventProcessor()
// Events generated throughout
// program lifetime
eventBus <- Event{Type: "startup"}
// ... rest of program ...
// Channel never closed—program
// terminates instead
}
func eventProcessor() {
for event := range eventBus {
handle(event)
}
}
Key insight: Closing signals “no more values.” For channels that logically never run out of values, closing is meaningless. Program termination cleans up.
Long-lived channels work for program-lifetime resources. They don’t work when you need graceful shutdown:
// Illustrative snippet — not a complete program
// ✗ WRONG: Can't signal processor to exit gracefully
func stopProcessing() {
// How do we tell eventProcessor to stop?
// Closing eventBus would require
// coordinating all senders
}
For graceful shutdown, use context cancellation (Chapter 13) alongside the channel:
// Illustrative snippet — not a complete program
func eventProcessor(ctx context.Context) {
for {
select {
case <-ctx.Done():
return // Graceful exit
case event := <-eventBus:
handle(event)
}
}
}
Ownership Patterns Quick Reference
Key code:
defer close(out)Use case: Generators, streams
Key code:
go func() { wg.Wait(); close(ch) }()Use case: Worker pools, fan-in
Key code: Document “caller closes”
Use case: Reusable components
Key code:
func(in <-chan T, out chan<- U)Use case: Pipeline stages
Key code: Context for shutdown
Use case: Server channels
Quick decision rule: If you create the channel, you own its lifecycle (including closing).
Choosing the Right Pattern
Use this decision tree:
A decision tree for choosing among the ownership patterns, starting from whether your function creates the channel at all, then how many senders there are, then who needs to observe the close.
Common Mistakes and Fixes
Mistake 1: Worker Closes Shared Channel
// Illustrative snippet — not a complete program
// ✗ WRONG
func worker(results chan<- int) {
results <- compute()
close(results) // Other workers will panic
}
// ✓ CORRECT
func worker(results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
results <- compute()
// Coordinator closes after wg.Wait()
}
Mistake 2: Forgetting to Close
// Illustrative snippet — not a complete program
// ✗ WRONG: Consumer blocks forever
func generate(out chan<- int) {
for i := 0; i < 10; i++ {
out <- i
}
// Forgot to close!
}
// ✓ CORRECT: Sole sender closes
// (closing responsibility delegated by caller)
func generate(out chan<- int) {
defer close(out)
for i := 0; i < 10; i++ {
out <- i
}
}
Mistake 3: Closing Before All Sends Complete
// Illustrative snippet — not a complete program
// ✗ WRONG: Race between send and close
func process(in <-chan int, out chan<- int) {
for v := range in {
go func() {
out <- v * 2 // May happen after close!
}()
}
close(out) // Closes while goroutines still sending
}
// ✓ CORRECT: Wait for all goroutines
// process acts as coordinator for its internal
// goroutines, so it closes out when they finish.
func process(in <-chan int, out chan<- int) {
var wg sync.WaitGroup
for v := range in {
wg.Go(func() {
out <- v * 2
})
}
wg.Wait()
close(out)
}
Note: Unlike Mistake 4 below,
process spawns internal goroutines that send to
out—it acts as a
coordinator (Pattern 2) for its workers, not a pure
transformer. The closing responsibility is part of its API contract.
Mistake 4: Transformer Closing Passed-In Channel
// Illustrative snippet — not a complete program
// ✗ WRONG: Transformer closes channel it doesn't own
func double(in <-chan int, out chan<- int) {
for v := range in {
out <- v * 2
}
close(out) // Wrong—caller owns out
}
// ✓ CORRECT: Transformer owns neither channel
func double(in <-chan int, out chan<- int) {
for v := range in {
out <- v * 2
}
// Caller closes out when appropriate
}
Key Takeaways
- Ownership determines closing responsibility—the creator owns the lifecycle
- Multiple producers need a coordinator—WaitGroup + separate closer goroutine prevents deadlock
- Passed-in channels and transformers don’t close—caller retains ownership
- Workers never close shared channels—they don’t know when all work is done
- Long-lived channels may never close—use context for graceful shutdown
Next: Section 6.5 introduces the output channel pattern—functions that create channels, spawn producers, and return receive-only references. This pattern encapsulates concurrency behind clean APIs.
Closing belongs to whoever owns the channel, and ownership follows creation. One producer closes its own output; several producers hand the close to a coordinator; a caller who supplies the channel keeps the close; a transformer closes only the channel it made.
6.5 The Output Channel Pattern
Section 6.4 established ownership patterns—who creates, sends, and closes channels. The most powerful pattern for API design combines several principles: the function creates a channel, spawns a goroutine to produce values, and returns a receive-only reference. The caller gets a clean, simple API; the implementation details are encapsulated.
Consider the difference:
// Illustrative snippet — not a complete program
// ✗ CALLER MANAGES CONCURRENCY
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}
// ✓ FUNCTION ENCAPSULATES CONCURRENCY
func main() {
for v := range generate(10) {
fmt.Println(v)
}
}
func generate(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < n; i++ {
out <- i
}
}()
return out
}
The second version is cleaner: the caller doesn’t manage
goroutines, channels, or closing. The function returns
<-chan int—a stream of values. The
implementation is hidden.
The output channel pattern in four numbered steps: create the channel inside the function, start a goroutine that sends into it, close it when the work is done, and return it narrowed to receive-only so no caller can interfere.
This is the output channel pattern: return
<-chan T to provide a stream of values while hiding
the production mechanism.
Anatomy of the Pattern
Every output channel function has four parts:
// Illustrative snippet — not a complete program
func producer() <-chan T {
// 1. CREATE: Make the channel
out := make(chan T)
// 2. SPAWN: Start the producer goroutine
go func() {
// 3. CLOSE: Producer owns closing
defer close(out)
// Produce values
for /* condition */ {
out <- value
}
}()
// 4. RETURN: Give caller receive-only access
return out
}
Why this structure works:
- Create internally: Function controls channel configuration (buffered vs unbuffered)
- Spawn before return: Goroutine starts producing immediately
-
defer close(out): Guarantees closure even on panic or early exit -
Return
<-chan T: Caller can only receive—cannot send or close
Basic Examples
Finite Sequence Generator
// Illustrative snippet — not a complete program
func countdown(from int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := from; i >= 0; i-- {
out <- i
}
}()
return out
}
// Usage
for n := range countdown(5) {
fmt.Println(n) // 5, 4, 3, 2, 1, 0
}
Transforming a Slice
// Illustrative snippet — not a complete program
func squareAll(nums []int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n * n
}
}()
return out
}
// Usage
for sq := range squareAll([]int{1, 2, 3, 4, 5}) {
fmt.Println(sq) // 1, 4, 9, 16, 25
}
File Line Reader
// Illustrative snippet — not a complete program
func readLines(filename string) (<-chan string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
out := make(chan string)
go func() {
defer close(out)
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
out <- scanner.Text()
}
// scanner.Err() ignored for brevity
}()
return out, nil
}
// Usage
lines, err := readLines("data.txt")
if err != nil {
log.Fatal(err)
}
for line := range lines {
process(line)
}
Error handling (e.g., scanner.Err()) is omitted
here—the Error Handling subsection below covers
production-ready patterns. Also note: if the consumer stops
receiving early, the producer goroutine leaks. The Handling
Infinite Producers subsection shows how context prevents this.
Why Return <-chan T Instead of chan T?
Returning the directional type provides compile-time protection:
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Returns bidirectional
func generate() chan int {
ch := make(chan int)
go func() {
defer close(ch)
ch <- 1
ch <- 2
ch <- 3
}()
return ch
}
func main() {
ch := generate()
ch <- 999 // Compiles! Corrupts the stream
close(ch) // Compiles! Causes panic in producer
for v := range ch { /* ... */ }
}
// ✓ SAFE: Returns receive-only
func generate() <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
ch <- 1
ch <- 2
ch <- 3
}()
return ch
}
func main() {
ch := generate()
ch <- 999 // ✗ Compile error: cannot send
close(ch) // ✗ Compile error: cannot close
for v := range ch { /* correct usage */ }
}
As Section 6.3 established, the receive-only type blocks sending and
closing at compile time.
Always return <-chan T from producer
functions.
Handling Infinite Producers
What if the producer never naturally completes?
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Infinite producer with no shutdown
func infiniteCounter() <-chan int {
out := make(chan int)
go func() {
// No defer close—never closes!
for i := 0; ; i++ {
out <- i // If receiver stops, blocks forever
}
}()
return out
}
func main() {
ch := infiniteCounter()
fmt.Println(<-ch) // 0
fmt.Println(<-ch) // 1
// Stop receiving—producer goroutine leaks forever
}
The producer blocks on out <- i when no one receives.
The goroutine never exits—it leaks.
Solution: Use context for cancellation:
// Illustrative snippet — not a complete program
func infiniteCounter(
ctx context.Context,
) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return // Exit when cancelled
case out <- i:
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(
context.Background(),
)
defer cancel() // Ensure cleanup
ch := infiniteCounter(ctx)
for i := 0; i < 5; i++ {
fmt.Println(<-ch)
}
cancel() // Signal producer to stop
// Producer exits cleanly, goroutine doesn't leak
}
The same pattern for a producer with no natural end. A context is threaded in, the send is wrapped in a select alongside ctx.Done, and cancellation is what terminates the goroutine and closes the channel.
Any producer that doesn’t naturally terminate should accept
context.Context and check ctx.Done() in
a select. Otherwise, stopped consumers cause goroutine leaks.
Chapter 13 covers context patterns in depth.
Error Handling
Producers may encounter errors. Several patterns handle this:
Pattern 1: Result Struct
Bundle values and errors in a struct:
// Illustrative snippet — not a complete program
type Result struct {
Value int
Err error
}
func processItems(items []Item) <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for _, item := range items {
value, err := process(item)
out <- Result{Value: value, Err: err}
}
}()
return out
}
// Usage
for result := range processItems(items) {
if result.Err != nil {
log.Printf("error: %v", result.Err)
continue
}
handle(result.Value)
}
Advantages: Simple, all results delivered, caller decides how to handle errors.
Pattern 2: Separate Error Channel
Return two channels—one for values, one for errors:
// Illustrative snippet — not a complete program
func processItems(
items []Item,
) (<-chan int, <-chan error) {
values := make(chan int)
errs := make(chan error, 1) // Buffered
go func() {
defer close(values)
defer close(errs)
for _, item := range items {
value, err := process(item)
if err != nil {
errs <- err
return // Stop on first error
}
values <- value
}
}()
return values, errs
}
// Usage: drain values, then check error
values, errs := processItems(items)
for v := range values {
handle(v)
}
if err := <-errs; err != nil {
log.Printf("processing stopped: %v", err)
}
Advantages: Can stop on first error, errors are typed (not wrapped in struct).
Disadvantages: More complex consumption—need select to handle both channels.
Pattern 3: Error Callback
Accept an error handler function:
// Illustrative snippet — not a complete program
func processItems(
items []Item,
onError func(error),
) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, item := range items {
value, err := process(item)
if err != nil {
onError(err)
continue
}
out <- value
}
}()
return out
}
// Usage
for value := range processItems(
items,
func(err error) {
log.Printf("error: %v", err)
},
) {
handle(value)
}
Advantages: Clean consumption (single channel), flexible error handling.
Disadvantages: Callback invoked from producer goroutine—must be thread-safe.
The Result struct pattern is most common—simple consumption
via single channel, all results delivered, works naturally with
for range. Use separate error channels only when the
first error should halt processing.
Common Variations
Buffered Output Channel
Use buffering when producer is faster than consumer:
// Illustrative snippet — not a complete program
func generate(n int) <-chan int {
out := make(chan int, 100) // Buffer reduces blocking
go func() {
defer close(out)
for i := 0; i < n; i++ {
out <- i
}
}()
return out
}
Buffering lets the producer run ahead, reducing synchronization overhead.
Multiple Return Values
Return channel plus metadata:
// Illustrative snippet — not a complete program
func search(
query string,
) (<-chan Result, int) {
results := make(chan Result)
totalExpected := estimateResults(query)
go func() {
defer close(results)
for result := range executeSearch(query) {
results <- result
}
}()
return results, totalExpected
}
// Usage
results, total := search("golang")
fmt.Printf(
"Expecting approximately %d results\n",
total,
)
for r := range results {
process(r)
}
Initialization Error Handling
Handle errors that occur before production starts:
// Illustrative snippet — not a complete program
func streamFromDB(
dsn string,
) (<-chan Record, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
rows, err := db.Query("SELECT * FROM records")
if err != nil {
db.Close()
return nil, fmt.Errorf("query: %w", err)
}
out := make(chan Record)
go func() {
defer close(out)
defer rows.Close()
defer db.Close()
for rows.Next() {
var r Record
if err := rows.Scan(&r.ID, &r.Name); err != nil {
return
}
// A caller that abandons this channel strands the
// goroutine here — and with it a pooled DB
// connection, since the defers never run. A real
// version takes a ctx and selects on ctx.Done().
out <- r
}
}()
return out, nil
}
Pattern: Return (<-chan T, error).
Check error before consuming channel.
Pipeline Composition
Output channel functions compose naturally into pipelines:
// Illustrative snippet — not a complete program
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func filter(
in <-chan int,
predicate func(int) bool,
) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
if predicate(n) {
out <- n
}
}
}()
return out
}
func main() {
// Pipeline: generate → square → filter
numbers := generate(
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
)
squared := square(numbers)
evenOnly := filter(squared, func(n int) bool {
return n%2 == 0
})
for n := range evenOnly {
fmt.Println(n) // 4, 16, 36, 64, 100
}
}
Three stages composed into a pipeline: generate feeds square, square feeds filter. Each stage takes a receive-only input and returns a receive-only output, so the types alone describe the direction of flow through the whole chain.
Key property: Closing propagates forward.
When generate closes its output,
square’s range exits, so
square closes its output, which causes
filter to close its output. The consumer’s
range then exits naturally. For large or infinite data,
combine with context cancellation to enable
backward shutdown—Chapter 7 covers this in detail.
generate closes → square’s
range exits → square closes →
filter’s range exits →
filter closes → consumer’s range exits.
Each stage’s defer close(out) ensures closure
propagates through the entire pipeline.
time.Tick
The Go standard library’s time.Tick
demonstrates the output channel pattern:
func Tick(d Duration) <-chan Time
It returns a receive-only channel delivering time values at regular intervals. The function owns the channel’s lifecycle; callers can only receive.
The one thing it cannot do:
time.Tick takes no context, so there is
no way to tell it to stop. That is the argument for giving your
own infinite generators a context parameter—not because the
ticker leaks.
It is worth being precise here, because the old advice is still
everywhere. A ticker is not a goroutine—it
is an entry in the runtime’s timer heap, and always has
been. Creating a thousand of them adds
zero goroutines. And since
Go 1.23 an unreferenced ticker is reclaimed
by the garbage collector whether or not you stopped it: 200,000
abandoned tickers leave the heap flat — a couple of dozen
live objects, not two hundred thousand. The standard library
documentation now says so outright — “There is no
longer any reason to prefer NewTicker when
Tick will do.” This is the same Go 1.23
timer change Chapter 4 §4.4 works through for
time.After.
runtime.NumGoroutine() by zero
every time, and 200,000 abandoned, never-stopped tickers changed
HeapObjects by between +15 and +24 after a GC. Your
own numbers will land somewhere else in that neighborhood; what
will not change is that neither figure scales with the number of
tickers.
When to Use the Output Channel Pattern
(T, error)—a channel is overkill
[]T—simpler and often more efficient
// Illustrative snippet — not a complete program
// ✗ OVERKILL: Channel for single value
func fetchConfig() <-chan Config {
out := make(chan Config, 1)
go func() {
defer close(out)
out <- loadConfig()
}()
return out
}
// ✓ SIMPLER: Direct return
func fetchConfig() (Config, error) {
return loadConfig()
}
// ✗ OVERKILL: Channel for small slice
func getTopThree() <-chan Item {
out := make(chan Item)
go func() {
defer close(out)
for _, item := range computeTopThree() {
out <- item
}
}()
return out
}
// ✓ SIMPLER: Return slice
func getTopThree() []Item {
return computeTopThree()
}
Rule of thumb: Use output channels when values arrive over time or quantity is large/unknown. Use direct returns when you have all values immediately.
Common Mistake: Blocking Before Spawning
// Illustrative snippet — not a complete program
// ✗ WRONG: Blocks caller during setup
func readLargeFile(path string) (<-chan string, error) {
// Reads the ENTIRE file before returning!
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
ch := make(chan string)
go func() {
defer close(ch)
for _, line := range strings.Split(string(data), "\n") {
ch <- line
}
}()
return ch, nil
}
// ✓ CORRECT: Return immediately, do work in goroutine
func readLargeFile(path string) (<-chan string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
ch := make(chan string)
go func() {
defer close(ch)
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
ch <- scanner.Text()
}
}()
return ch, nil
}
Key Takeaways
-
Return
<-chan Twithdefer close(out)—the core pattern: create, spawn, close, return -
Infinite producers must accept
context.Context—otherwise stopped consumers cause goroutine leaks -
Use Result structs for streaming errors; return
(<-chan T, error)for initialization errors—fail fast before streaming -
Pipelines compose naturally—closing
propagates through stages via
defer close - Don’t over-use—direct returns are simpler for single values or small collections
Next: Section 6.6 synthesizes everything—directional types, ownership patterns, and the output channel pattern—into comprehensive API design guidelines for concurrent Go functions.
Create inside, send from a goroutine, close when done, return
<-chan T. The narrowing on the way out is what makes
the lifecycle unforgeable by a caller—and for producers with
no natural end, a context is what supplies the ending.
6.6 API Design Guidelines
Sections 6.1–6.5 introduced the building blocks: directional types for compile-time safety, ownership patterns for lifecycle management, and the output channel pattern for encapsulation. This section synthesizes these into practical guidelines for designing concurrent APIs.
Good concurrent API design answers three questions:
- What types? Which directional types express the function’s role?
- Who owns what? Who creates, sends, receives, and closes each channel?
- What patterns? Which established patterns apply?
Three questions to ask when designing a channel API: what capabilities each participant needs, who owns the channel's lifecycle, and how cancellation reaches the producer.
Principle 1: Use the Most Restrictive Type
Every function should receive the minimum capability it needs. This maximizes compile-time checking.
// Illustrative snippet — not a complete program
// ✗ WEAK: Exposes full capability
func process(ch chan int) {
for v := range ch {
handle(v)
}
}
// ✓ STRONG: Minimal capability
func process(ch <-chan int) {
for v := range ch {
handle(v)
}
}
<-chan T
chan<- T
chan T
chan T (internal only)
A decision tree for choosing a parameter type, starting from what the function actually does with the channel — sends only, receives only, or genuinely both.
If a function takes chan T, ask: does it really need
both send and receive? Usually, the answer is no. Common
exceptions: channel creation/ownership functions, tests that need
full control, and adapters between differently-typed channels.
Principle 2: Return Receive-Only from Producers
Functions that produce values should return
<-chan T, not chan T.
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Caller can interfere
func generate(n int) chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < n; i++ {
ch <- i
}
}()
return ch
}
// ✓ SAFE: Caller can only consume
func generate(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < n; i++ {
ch <- i
}
}()
return ch
}
<-chan T
chan T
chan<- T
Principle 3: Document Ownership Clearly
When ownership isn’t obvious from types, document it:
// Illustrative snippet — not a complete program
// ProcessTasks spawns workers to process tasks concurrently.
// The returned channel receives results as they complete.
// The channel closes automatically when all tasks finish.
func ProcessTasks(tasks []Task) <-chan Result {
// ...
}
// SendResults sends processed results to out.
// Caller is responsible for closing out after all
// senders complete. This function does not close out.
func SendResults(results []Result, out chan<- Result) {
for _, r := range results {
out <- r
}
}
// Transform reads from in and writes transformed
// values to out.
// This function does not close either channel.
// Caller is responsible for channel lifecycle.
func Transform(in <-chan int, out chan<- int) {
for v := range in {
out <- v * 2
}
}
Documentation Checklist
- Who creates each channel?
- Who closes each channel?
- What happens when input channels close?
- Are there any blocking conditions?
- Is context required for cancellation?
Principle 4: Accept Context for Long-Running Operations
Any function that might block indefinitely should accept
context.Context:
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: No way to cancel
func streamEvents() <-chan Event {
ch := make(chan Event)
go func() {
for {
ch <- waitForEvent()
}
}()
return ch
}
// ✓ SAFE: Cancellable
func streamEvents(ctx context.Context) <-chan Event {
ch := make(chan Event)
go func() {
defer close(ch)
for {
select {
case <-ctx.Done():
return
case ch <- waitForEvent():
}
}
}()
return ch
}
Principle 5: Prefer Returning Channels Over Accepting Them
When designing a producer, prefer the output channel pattern:
// Illustrative snippet — not a complete program
// ✗ AWKWARD: Caller must manage channel
func Generate(n int, out chan<- int) {
defer close(out)
for i := 0; i < n; i++ {
out <- i
}
}
// Usage is clunky:
ch := make(chan int)
go Generate(10, ch)
for v := range ch {
// ...
}
// ✓ CLEANER: Function manages channel
func Generate(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < n; i++ {
ch <- i
}
}()
return ch
}
// Usage is simple:
for v := range Generate(10) {
// ...
}
Exceptions where accepting a channel is appropriate:
- Multiple functions send to the same channel (fan-in)
- Caller needs to control buffering
- Reusable component in varying contexts
Principle 6: Use Consistent Signatures Across Your API
Related functions should follow consistent patterns:
// Illustrative snippet — not a complete program
// ✓ CONSISTENT: All generators return <-chan T
func GenerateInts(n int) <-chan int { ... }
func GenerateStrings(words []string) <-chan string { ... }
func GenerateFromFile(path string) (<-chan Line, error) { ... }
// ✓ CONSISTENT: All transformers return <-chan T
func Map(in <-chan int, f func(int) int) <-chan int { ... }
func Filter(in <-chan int, pred func(int) bool) <-chan int { ... }
func Reduce(in <-chan int, f func(int, int) int) <-chan int { ... }
// ✓ CONSISTENT: All workers take <-chan Task, chan<- Result
func Worker(tasks <-chan Task, results chan<- Result) { ... }
func BatchWorker(tasks <-chan []Task, results chan<- []Result) { ... }
Consistency makes the API predictable and reduces cognitive load.
Common API Patterns
Pattern A: Generator Function
// Illustrative snippet — not a complete program
func Generate(/* params */) <-chan T {
out := make(chan T)
go func() {
defer close(out)
// produce values
}()
return out
}
Use for: Creating streams of values from parameters or computation.
Pattern B: Generator with Context
// Illustrative snippet — not a complete program
func Generate(ctx context.Context, /* params */) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for /* condition */ {
select {
case <-ctx.Done():
return
case out <- value:
}
}
}()
return out
}
Use for: Long-running or infinite producers.
Pattern C: Generator with Error
// Illustrative snippet — not a complete program
func Generate(/* params */) (<-chan T, error) {
// Initialization that might fail
resource, err := acquire()
if err != nil {
return nil, err
}
out := make(chan T)
go func() {
defer close(out)
defer resource.Release()
// produce values
}()
return out, nil
}
Use for: Producers that require fallible initialization.
Pattern D: Transformer
// Illustrative snippet — not a complete program
func Transform(in <-chan T) <-chan U {
out := make(chan U)
go func() {
defer close(out)
for v := range in {
out <- transform(v)
}
}()
return out
}
Use for: Pipeline stages that transform streams.
Pattern E: Fan-In (Multiple Inputs)
// Illustrative snippet — not a complete program
func Merge(ctx context.Context, channels ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Go(func() {
for v := range ch {
select {
case <-ctx.Done():
return
case out <- v:
}
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Use for: Combining multiple streams into one.
Pattern F: Fan-Out (Multiple Outputs)
// Illustrative snippet — not a complete program
func Broadcast(in <-chan T, n int) []<-chan T {
outs := make([]chan T, n)
for i := range outs {
outs[i] = make(chan T)
}
go func() {
defer func() {
for _, out := range outs {
close(out)
}
}()
for v := range in {
for _, out := range outs {
out <- v
}
}
}()
// Convert to receive-only
result := make([]<-chan T, n)
for i, out := range outs {
result[i] = out
}
return result
}
Use for: Sending values to multiple consumers. Note: a slow consumer blocks all others—Chapter 7 covers non-blocking broadcast variants.
Pattern G: Worker Pool
// Illustrative snippet — not a complete program
func ProcessWithWorkers(
ctx context.Context,
tasks <-chan Task,
numWorkers int,
) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Go(func() {
for task := range tasks {
// Work happens BEFORE the select: all case
// expressions are evaluated up front (Ch 4
// §4.1), so process(task) written inside a
// case would run even when ctx is done.
r := process(task)
select {
case <-ctx.Done():
return
case results <- r:
}
}
})
}
go func() {
wg.Wait()
close(results)
}()
return results
}
Use for: Parallel processing with controlled concurrency.
Anti-Patterns to Avoid
Anti-Pattern 1: Unclear Closing Responsibility
// Illustrative snippet — not a complete program
// ✗ ANTI-PATTERN: Who closes?
func Process(in chan int, out chan int) {
for v := range in {
out <- v * 2
}
// Does this close out? Who knows!
}
// ✓ CORRECT: Clear from types and docs
// Transform reads from in and writes to out.
// Does not close either channel.
func Transform(in <-chan int, out chan<- int) {
for v := range in {
out <- v * 2
}
}
Anti-Pattern 2: Missing Context for Blocking Operations
// Illustrative snippet — not a complete program
// ✗ ANTI-PATTERN: Can't cancel
func WatchFile(path string) <-chan Change {
ch := make(chan Change)
go func() {
for {
ch <- waitForChange(path) // Blocks forever
}
}()
return ch
}
// ✓ CORRECT: Cancellable
func WatchFile(ctx context.Context, path string) <-chan Change {
ch := make(chan Change)
go func() {
defer close(ch)
for {
select {
case <-ctx.Done():
return
default: // Not cancelled—proceed
change, err := waitForChangeWithTimeout(
path, time.Second)
if err == nil {
select {
case ch <- change:
case <-ctx.Done():
return
}
}
}
}
}()
return ch
}
Design Checklist
Before finalizing a concurrent API, verify:
<-chan T)
defer close() used in producers
context.Context
(<-chan T, error)
Complete Design Example
Here’s a well-designed API incorporating all principles:
// Illustrative snippet — not a complete program
// Package pipeline provides concurrent data
// processing utilities.
package pipeline
import (
"context"
"sync"
)
// Result holds a processed value or an error.
type Result struct {
Value int
Err error
}
// Generate produces integers from start to end
// (inclusive). The returned channel closes when
// all values have been sent.
func Generate(start, end int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := start; i <= end; i++ {
out <- i
}
}()
return out
}
// GenerateWithContext produces integers until
// cancelled or limit reached. Returns immediately;
// production happens in background. The channel
// closes when context is cancelled or limit reached.
func GenerateWithContext(
ctx context.Context,
limit int,
) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < limit; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
return out
}
// Transform applies f to each value from in.
// Returns a new channel with transformed values.
// Output channel closes when input channel closes.
func Transform(
in <-chan int,
f func(int) int,
) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- f(v)
}
}()
return out
}
// Filter returns values from in that satisfy
// predicate. Output closes when input closes.
func Filter(
in <-chan int,
predicate func(int) bool,
) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
if predicate(v) {
out <- v
}
}
}()
return out
}
// ProcessParallel applies f to each value using
// n workers. Results may arrive out of order.
// Output closes when all input has been processed.
func ProcessParallel(
ctx context.Context,
in <-chan int,
n int,
f func(int) Result,
) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Go(func() {
for v := range in {
result := f(v)
select {
case <-ctx.Done():
return
case out <- result:
}
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}
// Merge combines multiple input channels into one.
// Output closes when all inputs are exhausted.
func Merge(
ctx context.Context,
channels ...<-chan int,
) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Go(func() {
for v := range ch {
select {
case <-ctx.Done():
return
case out <- v:
}
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Usage example:
// Illustrative snippet — not a complete program
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
// Build pipeline:
// generate → transform → filter → process
numbers := Generate(1, 100)
squared := Transform(numbers, func(n int) int {
return n * n
})
evens := Filter(squared, func(n int) bool {
return n%2 == 0
})
results := ProcessParallel(ctx, evens, 4,
func(n int) Result {
time.Sleep(10 * time.Millisecond)
return Result{Value: n, Err: nil}
},
)
for r := range results {
if r.Err != nil {
log.Printf("error: %v", r.Err)
continue
}
fmt.Println(r.Value)
}
}
Key Takeaways
-
Use the most restrictive channel type—give
each function only the capability it needs (
<-chan Tfor consumers,chan<- Tfor producers) -
Return
<-chan Tfrom producers—the output channel pattern encapsulates concurrency and protects internal control - Document what types can’t express—closing responsibility, blocking behavior, and cancellation mechanism
-
Accept
context.Contextfor anything that might block—finite helpers are optional; infinite producers and I/O operations require it - Verify with the design checklist before shipping—types, ownership, lifecycle, error handling, and documentation
Next: Before moving to Chapter 7, work through the complete runnable example that demonstrates all Chapter 6 concepts in action, then test your understanding with the self-check questions.
Three questions settle most channel APIs: what capability each side needs, who owns the lifecycle, and how cancellation reaches the producer. Because directional types cost nothing at runtime, the answer to the first is almost always to narrow.
Complete Runnable Example
Before moving to Chapter 7, here’s a complete program demonstrating all Chapter 6 concepts:
package main
import (
"context"
"fmt"
"sync"
"time"
)
// =============================================
// PATTERN: Output Channel (Section 6.5)
// Function creates channel internally,
// returns receive-only view
// =============================================
func generateNumbers(
ctx context.Context,
max int,
) <-chan int {
out := make(chan int)
go func() {
defer close(out) // Producer owns closing (6.4)
for i := 1; i <= max; i++ {
select {
case <-ctx.Done():
return // Context cancellation (6.5)
case out <- i:
}
}
}()
return out // Caller gets <-chan int (6.2)
}
// =============================================
// PATTERN: Transformer (Section 6.4, Pattern 4)
// Receives from one channel, sends to another,
// owns neither
// =============================================
func squareNumbers(
in <-chan int, // can only receive (6.2)
out chan<- int, // can only send (6.1)
) {
for n := range in {
out <- n * n
}
// Does NOT close out—caller owns it
}
// =============================================
// PATTERN: Coordinator with Multiple Producers
// (Section 6.4, Pattern 2)
// Coordinator creates channel, workers send,
// coordinator closes after WaitGroup
// =============================================
func fanIn(
ctx context.Context,
channels ...<-chan int,
) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Go(func() {
for v := range ch {
select {
case <-ctx.Done():
return
case out <- v:
}
}
})
}
// Separate closer goroutine (see 6.4)
go func() {
wg.Wait()
close(out) // Coordinator closes
}()
return out
}
// =============================================
// MAIN: Compose all patterns
// =============================================
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
// Generate two number streams
stream1 := generateNumbers(ctx, 5)
stream2 := generateNumbers(ctx, 5)
// Transform each stream
squared1 := make(chan int)
squared2 := make(chan int)
go func() {
squareNumbers(stream1, squared1)
close(squared1) // This goroutine owns closing
}()
go func() {
squareNumbers(stream2, squared2)
close(squared2) // This goroutine owns closing
}()
// Merge streams (coordinator pattern)
merged := fanIn(ctx, squared1, squared2)
// Consume all results
fmt.Println("Squared numbers from both streams:")
for v := range merged {
fmt.Printf(" %d\n", v)
}
fmt.Println("Done! All channels closed, all goroutines exited.")
}
Run it:
Verify thread safety:
You should see no race warnings—proper channel design with directional types guides us toward race-free programs.
Expected output (order varies—streams run concurrently):
generateNumbers returns
<-chan int
squareNumbers takes chan<- int
squareNumbers takes <-chan int
squareNumbers owns neither channel
fanIn with WaitGroup and closer goroutine
ctx.Done()
Chapter 6 Complete: Type-Safe Channel APIs
You now have the tools to design channel-based APIs that are correct by construction:
chan<- T)—permit
send/close, prevent receive
<-chan T)—permit
receive only, enforce sender-closes
<-chan T
Connecting the Concepts
A summary of the chapter as layers. Sections 6.1 and 6.2 restrict capability at the type level, 6.3 draws the line between what types enforce and what they do not, 6.4 assigns ownership, 6.5 packages both into the output channel pattern, and 6.6 turns the result into API guidelines.
Chapter 6 Self-Check
Test your understanding of the concepts covered in this chapter. Click each question to reveal the answer.
func generate() chan int and
func generate() <-chan int?
The first returns bidirectional—the
caller can send, receive, or close, potentially interfering with
the generator. The second returns receive-only—the caller
can only consume values, protecting the generator’s
control over sending and closing. Always return
<-chan T from generators.
chan<- int?
What about <-chan int?
chan<- int: send (✓), close (✓), len/cap (✓), receive
(✗), range (✗).
<-chan int: receive (✓), range (✓), len/cap (✓), send
(✗), close (✗). The key asymmetry: send-only can
close (closing is a sender operation); receive-only cannot.
func process(ch chan int) but only reads from the
channel. What’s wrong, and how should it be fixed?
The signature exposes too much capability. With
chan int, the function can accidentally send to,
close, or otherwise interfere with the channel.
Fix:
func process(ch <-chan int). The compiler then
prevents sends and closes, matching the function’s actual
role as a consumer.
func fetch(urls []string, results chan<- Response). Who creates the channel? Who closes it? How do you know?
Caller creates and owns the channel. The
signature chan<- T as parameter means “I
send to a channel you provide.” The function sends but
shouldn’t close (though the type permits it). The caller
coordinates completion and closes. This is the
Passed-In Channel pattern (Section 6.4, Pattern
3). Documentation should clarify closing responsibility.
chan<- Result.
Worker 1 finishes and calls close(). What happens
when Worker 2 tries to send? What happens when Worker 3 calls
close()?
Worker 2’s send panics: “panic: send on closed channel”. Worker 3’s close panics: “panic: close of closed channel”. This is why individual producers must NOT close shared channels—only a coordinator who knows when ALL producers are done should close (Section 6.4, Pattern 2).
chan<- T close but
<-chan T cannot? What principle does this
reflect?
Closing is a sender operation—it signals
“no more values will be sent.” Only code that sends
has the knowledge to make this declaration. Receivers
don’t know if other senders exist or when sending will
complete. This reflects the
sender-closes principle (Section 3.3). The type
system enforces it: <-chan T makes closing a
compile error.
chan int instead of
<-chan int. What bugs does this enable that the
stricter return type would prevent?
Returning chan int allows callers to:
(a) send values back—creating feedback loops or corrupting
the stream, (b) close the channel—potentially while the
producer is still sending, causing a panic. With
<-chan int, both are compile errors. The
producer retains exclusive control over sending and closing.
<-chan int back to
chan int using a type assertion through
interface{}? What happens if you try?
No—it causes a runtime panic.
interface{}(recvOnly).(chan int) panics with
“interface conversion: interface {} is <-chan int, not
chan int”. Directional restriction cannot be circumvented
through interface conversion, type assertions, or reflection.
Once restricted, the restriction is permanent. This is
intentional—if you could convert back, type safety would
be meaningless.
context.Context, and why can’t closing alone
solve the problem it addresses?
Accept context.Context for infinite or
long-running generators.
An infinite generator that only closes on completion never
completes—so it never closes. If the consumer stops
receiving (times out, errors, loses interest), the producer
blocks forever on send, leaking the goroutine. Closing
doesn’t help because the producer would need to
close, but it doesn’t know the consumer stopped. Context
enables the producer to detect cancellation via
select on ctx.Done() and exit cleanly.
Important caveat: Context cancellation is
cooperative—it only takes effect when the goroutine
reaches a select statement. If
readEvent() takes 5 seconds and you cancel after 1
second, the goroutine still runs for 4 more seconds before the
next select iteration detects
ctx.Done().
Next: Chapter 7 explores Channel Patterns—pipelines, fan-out/fan-in, worker pools, and more. You’ll apply the directional types and ownership patterns from this chapter to build production-ready concurrent systems.
Exercise 6.1 — Narrow It Until It’s Safe
Give the close an owner, then let the type say so
This is the chapter in one function. FanIn has both
problems it warns about: no one owns the close, and the signature
promises the caller more than it should.
package ch06
import "sync"
// TODO(reader): FanIn merges several input channels into one. Two
// things are wrong with it, and this chapter fixes both.
//
// 1. Every goroutine closes `out` when its own input dries up.
// With more than one input that is a double close, and the
// panic you see is a race: usually `send on closed channel`
// (another worker was mid-send), sometimes `close of closed
// channel`. §6.4 Pattern 2 says why: no single producer can
// know it is the last, so the close belongs to a coordinator
// that waits for all of them.
//
// 2. The signature hands the caller a bidirectional `chan int`, so
// nothing stops them sending into it or closing it early. §6.5
// says what to return instead — and the type system will then
// enforce it for you.
//
// Do not change the parameter type, and do not buffer your way out of
// the panic: a buffer would only postpone it.
func FanIn(inputs []<-chan int) chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, in := range inputs {
wg.Go(func() {
for v := range in {
out <- v
}
close(out) // <- your move
})
}
return out
}
package ch06
import (
"reflect"
"slices"
"testing"
"time"
)
func feed(values ...int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for _, v := range values {
ch <- v
}
}()
return ch
}
func TestFanInReturnsEveryValue(t *testing.T) {
in := []<-chan int{feed(1, 2, 3), feed(10, 20), feed(100)}
done := make(chan []int, 1)
go func() {
var got []int
for v := range FanIn(in) {
got = append(got, v)
}
done <- got
}()
select {
case got := <-done:
slices.Sort(got)
want := []int{1, 2, 3, 10, 20, 100}
if !slices.Equal(got, want) {
t.Fatalf("FanIn gave %v, want %v", got, want)
}
case <-time.After(2 * time.Second):
t.Fatal("FanIn never closed its output channel")
}
}
// This chapter's own thesis, as a test: a function that hands a stream
// to a caller should return <-chan T, so the caller cannot send into
// it or close it. §6.5.
func TestFanInReturnsAReceiveOnlyChannel(t *testing.T) {
out := reflect.TypeOf(FanIn).Out(0)
if out.Kind() != reflect.Chan {
t.Fatalf("FanIn returns %v, want a channel", out)
}
if out.ChanDir() != reflect.RecvDir {
t.Fatalf("FanIn returns %v; want <-chan int so callers "+
"cannot send into it or close it. See §6.5.", out)
}
}
The first test fails immediately, and loudly:
The second test is the more interesting one, because it is this chapter’s own thesis turned into an assertion. Fix the close and leave the return type alone, and it still fails:
A correct FanIn is safe at runtime and says so
in its signature. Those are separate properties, which is why they are
separate tests.
go test -race ./... in
code/ch06/ reports ok for both tests, with
every value forwarded exactly once. Don’t change the parameter
type, and don’t buffer your way out — a buffer only
postpones the panic.
labs/go-concurrency/code/ch06/. A worked answer sits in
solution/fanin.go.txt.
Further reading
- The Go Programming Language Specification — Channel types — three sentences that define everything in §6.1 and §6.2, including the one that matters most: the conversion from bidirectional to directional is assignability, and it does not run in reverse.
- Effective Go — Channels — the original argument for narrowing at function boundaries, written before most of the patterns in §6.4 had names.
- Go Concurrency Patterns: Pipelines and cancellation — the output channel pattern of §6.5 taken to a full pipeline, and the source of the coordinator-closes idiom. Chapter 7 builds directly on it.
-
time.Tick— worth reading the current text rather than the folklore. It states plainly that since Go 1.23 there is no longer any reason to preferNewTickerwhenTickwill do, which is the correction §6.5 makes. - Google Go Style Decisions — a second opinion on the API questions of §6.6, from a codebase large enough that the cost of an ambiguous signature is measurable.
You can now say what a channel type promises, where that promise stops, who is allowed to close, and how to hand a stream to a caller in a form they cannot misuse. Every pattern so far has been a single stage — one producer, one consumer, one channel. Chapter 7 composes them: pipelines built from the output channel pattern, fan-out and fan-in across stages, and how cancellation propagates backwards through a chain when the consumer at the end stops listening.