Module 2 of 6
Module 2: Channels as a Design Tool
Learn how Go channels encode synchronization intent into the type system — fan-out/fan-in, done-channel cancellation, and the nil channel trick.
Module 2: Channels as a Design Tool
The mental model shift
When I first saw Go channels, I thought "oh, it's a thread-safe queue." That's like looking at a Swiss Army knife and saying "oh, it's a bottle opener." Technically true, but you're missing the point entirely.
Channels are a synchronization primitive with optional buffering — the communication is the synchronization. When one goroutine sends on a channel and another receives, the send and receive operations themselves synchronize the two goroutines. The data flowing through is almost a side effect.
Go's concurrency mantra gets quoted a lot but rarely explained: "Do not communicate by sharing memory; share memory by communicating." Here's what it actually means in practice.
Say you have a shared map that multiple goroutines need to read and write. In most languages, you'd wrap it with a mutex. In Go, you can give the map to a single goroutine and have everyone else send requests to that goroutine through a channel. Only one goroutine ever touches the map. No mutex needed. The channel is the synchronization.
The deeper pattern: channels let you express intent in the type system. A function that takes <-chan T (receive-only) is declaring "I am a consumer." A function that takes chan<- T (send-only) is declaring "I am a producer." These compile-time annotations document your data flow in a way that comments never can. When I see a function signature with directional channels, I already know half the architecture.
Key concepts
make(chan T)— unbuffered: sender blocks until receiver is ready. This is a rendezvous — both sides meet at the channel.make(chan T, n)— buffered: sender blocks only when the buffer is full. Use sparingly — unbuffered channels force you to think about synchronization.close(ch)— signals "no more values." Receivers drain remaining values, then get zero value +falsefromv, ok := <-ch.select— multiplex over multiple channel operations. Adddefaultfor non-blocking attempts.for v := range ch— drains a channel until it's closed. Clean, idiomatic, preferred.- A nil channel blocks forever — this sounds like a bug, but it's actually a feature. Set a channel to nil inside a
selectto dynamically disable that case.
Drill 1 — Fan-out / fan-in
package main
import (
"fmt"
"sync"
)
// fan-out: one input channel, N workers
func fanOut(in <-chan int, workers int) []<-chan int {
outs := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
out := make(chan int)
outs[i] = out
go func(o chan<- int) {
for v := range in {
o <- v * v // square it
}
close(o)
}(out)
}
return outs
}
// fan-in: N channels merged into one
func fanIn(cs []<-chan int) <-chan int {
var wg sync.WaitGroup
merged := make(chan int)
output := func(c <-chan int) {
defer wg.Done()
for v := range c {
merged <- v
}
}
wg.Add(len(cs))
for _, c := range cs {
go output(c)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
func main() {
in := make(chan int)
go func() {
for i := 1; i <= 10; i++ {
in <- i
}
close(in)
}()
workers := fanOut(in, 3)
for v := range fanIn(workers) {
fmt.Println(v)
}
}What to observe: The output order is non-deterministic. Three workers are competing to process values. This is fine when order doesn't matter (think: processing independent HTTP requests). But what if order matters?
Exercise: How would you preserve input order in the output? Hint: you'd need to tag each value with its original position and sort at the end. Channels alone don't guarantee order — that's your responsibility.
Notice the close(o) in the fan-out workers. This is the signal that tells the fan-in goroutines "this worker is done." The fan-in goroutines range over each output channel, and range automatically exits when the channel is closed. This pattern — close to signal completion — is everywhere in Go.
Drill 2 — Done channel cancellation
In Module 1 we learned that goroutines need exit plans. Here's the most fundamental exit plan pattern:
package main
import (
"fmt"
"time"
)
func producer(done <-chan struct{}) <-chan int {
out := make(chan int)
go func() {
defer close(out)
i := 0
for {
select {
case <-done:
fmt.Println("producer: cancelled")
return
case out <- i:
i++
time.Sleep(time.Millisecond * 50)
}
}
}()
return out
}
func main() {
done := make(chan struct{})
nums := producer(done)
for i := 0; i < 5; i++ {
fmt.Println("received:", <-nums)
}
close(done) // signal all producers to stop
time.Sleep(time.Millisecond * 200) // give the producer time to print and exit
}The pattern: close(done) broadcasts to every goroutine listening on done. A closed channel never blocks — it's immediately readable, returning the zero value. So every case <-done: fires simultaneously. This is the cleanest way to cancel multiple goroutines at once.
This pattern is so fundamental that Go standardized it as context.Context. Jump to Module 6 for the full story, but know that under the hood, ctx.Done() is exactly this — a channel that closes when cancellation happens.
Drill 3 — Select with the nil channel trick
This one looks like black magic the first time you see it, but it's one of the most elegant patterns in Go:
package main
import "fmt"
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // nil channel is never selected — disables this case
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
}()
return out
}
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func main() {
for v := range merge(gen(1, 3, 5), gen(2, 4, 6)) {
fmt.Println(v)
}
}The trick: A nil channel is never ready for select. If you set a = nil when channel a closes, the case <-a: branch is permanently disabled. The select statement effectively removes that case without restructuring the loop. You're dynamically reshaping the select as channels come and go.
I use this pattern constantly — merging event streams, aggregating results from multiple backends, fanning in from a dynamic set of workers.
Capstone — Pipeline with backpressure
Build a 3-stage pipeline: generate → transform → sink.
generateproduces integers 1 through 1000transformsquares each number, but is artificially slow (time.Sleep(1ms))sinkprints results and tracks throughput
The twist: use buffered channels between stages and experiment with buffer sizes. With unbuffered channels, every send blocks until the receiver is ready — this is maximum backpressure. With large buffers, the pipeline decouples and runs faster, but uses more memory. Try buffer sizes of 0, 1, 10, and 100. How does throughput change?
Also: add a done channel so the pipeline cancels cleanly if sink exits early (after 50 items). Print items processed, wall time, and items per second.
This is the capstone where channels, wait groups, and cancellation all come together. You'll feel it when it clicks.