Module 3 · Lesson 2 · ~30 min read
Channels are typed conduits between goroutines. They unify synchronization and message passing into one primitive. select waits on multiple channels at once — it's the switch of concurrency. Get these two right and most concurrent Go follows.
ch := make(chan int) // unbuffered
ch := make(chan int, 10) // buffered with capacity 10
ch <- 42 // send
v := <-ch // receive
v, ok := <-ch // receive with "channel closed?" flag
close(ch) // close — only the sender should close
for v := range ch { ... } // receives until closed
An unbuffered channel send blocks until a receiver is ready. A receive blocks until a sender appears. The two goroutines synchronize at the send/receive — both move forward together. Useful when the send itself is the signal: "I have completed."
A buffered channel can hold N values. Sends block when full; receives block when empty. Useful as a bounded queue — and the bound itself is your backpressure mechanism.
func producer(out chan<- Event) { ... } // send-only
func consumer(in <-chan Event) { ... } // receive-only
Channel direction is part of the type. Constrain it in function signatures — makes intent clear and catches bugs at compile time. The arrow points the way the data flows.
Three rules:
Receiving from a closed channel returns the zero value with ok=false. for range over a channel ends cleanly when the channel closes — that's the most common shutdown pattern.
func producer() <-chan int {
out := make(chan int)
go func() {
defer close(out) // signals "no more values" to the consumer
for i := 0; i < 5; i++ {
out <- i
}
}()
return out
}
for v := range producer() {
fmt.Println(v)
}
Producer owns the channel, defers its close, returns it as receive-only. Caller ranges. Clean.
select {
case v := <-ch1:
handle(v)
case ch2 <- compute():
log.Println("sent")
case <-time.After(100 * time.Millisecond):
log.Println("timeout")
default:
log.Println("nothing ready right now")
}
Each case is a channel operation. Select waits until any one of them can proceed; when multiple are ready, picks one pseudorandomly; runs that case's body.
The default clause makes select non-blocking — picks default if nothing else is immediately ready.
The time.After trick is the canonical timeout idiom. Lesson 4 (context) generalizes it.
// fan-out: distribute work across N workers
func fanOut(in <-chan Job, workers int) []<-chan Result {
outs := make([]<-chan Result, workers)
for i := 0; i < workers; i++ {
out := make(chan Result)
outs[i] = out
go func() {
defer close(out)
for job := range in {
out <- process(job)
}
}()
}
return outs
}
// fan-in: merge N channels into one
func merge(cs ...<-chan Result) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for _, c := range cs {
wg.Add(1)
go func(c <-chan Result) {
defer wg.Done()
for r := range c { out <- r }
}(c)
}
go func() { wg.Wait(); close(out) }()
return out
}
This is the canonical structure for parallel processing of a stream — multiple workers, one downstream consumer. Real Canton-adjacent code uses this exact pattern for things like parallel command submission.
func worker(done <-chan struct{}, in <-chan Job) {
for {
select {
case <-done:
return // graceful shutdown
case j, ok := <-in:
if !ok { return } // in closed
process(j)
}
}
}
chan struct{} is "channel of nothing" — used for pure signaling, since the empty struct is zero bytes. Closing it broadcasts "done" to every goroutine reading from it (because reads on closed channels never block).
Lesson 4 wraps this whole pattern in context.Context, which is what real production code uses. The done-channel pattern still shows up in code that predates contexts or in code that needs more nuance.
Sends and receives on a nil channel block forever. This sounds useless but turns out to be a useful select trick: set a case's channel to nil to disable it.
var in chan Event = upstream
var out chan Event // nil — disabled
var pending Event
for {
select {
case v, ok := <-in:
if !ok { in = nil; continue }
pending = v
out = downstream // enable the send case
case out <- pending:
out = nil // disable until we have something to send again
}
}
This is how you build a buffered transformer with at-most-one in-flight value. It looks weird, but you'll see it in production stream-processing code.
Wrap a mutex in a channel. If you need to protect a counter, use sync.Mutex or sync/atomic. Channels are heavier than mutexes — they allocate, they do scheduler-aware blocking. Channels are for passing values, not for guarding fields.
Have multiple owners closing the same channel. One panics. Use a coordinator pattern, or sync.Once on close, or a different design.
Send to a channel without knowing who reads. If the receive path can disappear (e.g. caller cancels), you've leaked the goroutine. Always have a select-default-or-context-cancel escape hatch.
ok=false.for range over a channel = consume until closed. The default consumer pattern.select waits on multiple channel ops; default makes it non-blocking; time.After adds timeout.