Day 20 — Channels
Day 20 — Channels
Stage III · ~3h (theory-heavy)
Goal: Use channels as typed pipes with ownership rules—not as a substitute for every mutex—and implement a correct producer/consumer pipeline.
Channels coordinate goroutines that pass values. Prefer them when ownership transfers; prefer mutexes when many readers/writers share a structure in place (Day 22).
Why this day exists
Channels look simple (ch <- v, v := <-ch) and then surprise you with:
- Deadlocks on unbuffered sends
- Panic on send to closed channel
- Zero-value receives after close
- Goroutine leaks when nobody receives
- “Who closes?” ownership fights
Master the rules, then the patterns (Days 21–27) compose cleanly.
Theory 1 — Channel as a value and a type
var ch chan int // nil channel
ch = make(chan int) // unbuffered
ch = make(chan int, 8) // buffered capacity 8| Kind | Behavior |
|---|---|
| Unbuffered | Send blocks until a receiver is ready (and vice versa)—rendezvous |
| Buffered | Send blocks only when buffer is full; receive blocks when empty |
| Nil | Send and receive block forever (useful in select—Day 21) |
| Closed | Receive gets remaining values then zero value + ok==false; send panics |
Channels are reference types: assignment copies the handle, not the queue.
a := make(chan int, 1)
b := a
b <- 1
fmt.Println(<-a) // 1 — same channelDirectional types (API clarity)
func produce(out chan<- int) { out <- 1 } // send-only
func consume(in <-chan int) int { return <-in } // receive-onlyConverters: a bidirectional chan T can be passed where chan<- T or <-chan T is expected; not the reverse.
Theory 2 — Close semantics and the comma-ok idiom
v, ok := <-ch
// ok == true → v is a real send
// ok == false → ch is closed and drained; v is zero value of TRange over a channel:
for v := range ch {
// receives until ch is closed and empty
}Who closes?
Rule: the sender (or the last of N senders, carefully) closes. Receivers almost never close.
Why: close is a signal “no more values.” Multiple closers → panic. Receivers closing while senders still send → panic.
Multiple senders
Usually do not let each sender close. Options:
- One coordinator closes after WaitGroup of producers
- Use
sync.Oncearound close
- Prefer a single producer goroutine
Theory 3 — Ownership and “don’t communicate by sharing memory”
The slogan is directional advice, not law:
Prefer passing ownership of data through channels over mutating shared structures.
Good fit for channels:
- Pipeline stages (read → parse → write)
- Task queues with backpressure (buffer size)
- Fan-in of completed results
Poor fit for channels:
- Incrementing a counter (use atomic/mutex)
- Shared caches with random access (mutex map / specialized store)
- Every function parameter “just in case”
Theory 4 — Blocking, deadlock, and main
Classic deadlock:
ch := make(chan int)
ch <- 1 // blocks forever: no receiver
fmt.Println(<-ch)Unbuffered send in main before a receiver exists → hang.
Buffered escape hatch (limited):
ch := make(chan int, 1)
ch <- 1 // ok
fmt.Println(<-ch)Buffer is not a free pass to avoid design—it only absorbs bursts of size N.
Detecting deadlocks
The runtime detects some global deadlocks (all goroutines asleep). Partial deadlocks (one leaked G while others run) need discipline and later tooling (Day 25, Day 36, Go 1.26 leak profile).
Theory 5 — Zero values after close (silent bugs)
ch := make(chan int, 2)
ch <- 1
close(ch)
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 0, ok=false if checked
fmt.Println(<-ch) // 0 forever after closeIf you ignore ok, a closed channel looks like an infinite stream of zeros—dangerous for int/bool protocols. Prefer:
for v := range ch { ... }
// or
v, ok := <-ch
if !ok { return }Theory 6 — Producer/consumer contracts
Write contracts down:
| Role | Responsibility |
|---|---|
| Producer | Creates values; closes when done |
| Consumer | Reads until closed; does not close |
| Buffer size | Max in-flight items (backpressure) |
Backpressure
A full buffered channel slows producers (send blocks). That is a feature: it prevents unbounded memory growth. Unbounded go spawn without a full buffer is the real memory bomb (Day 23 pools).
Worked example — single producer, single consumer
package main
import (
"fmt"
"sync"
)
func producer(out chan<- int, n int) {
defer close(out)
for i := 1; i <= n; i++ {
out <- i
}
}
func consumer(in <-chan int, sum *int) {
for v := range in {
*sum += v
}
}
func main() {
ch := make(chan int, 4)
var sum int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
consumer(ch, &sum)
}()
producer(ch, 10) // runs in main; closes when done
wg.Wait()
fmt.Println(sum) // 55
}Note: sum is only written by the consumer goroutine after join—no race. If both sides mutated sum, you would need synchronization.
Worked example — multi-producer, single closer
package main
import (
"fmt"
"sync"
)
func main() {
out := make(chan int, 16)
var wg sync.WaitGroup
for id := 0; id < 4; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 3; i++ {
out <- id*10 + i
}
}(id)
}
go func() {
wg.Wait()
close(out) // single closer
}()
var n int
for range out {
n++
}
fmt.Println("received", n) // 12
}Lab 1 — Unbuffered rendezvous
Workspace: ~/lab/90daysofx/01-go/day20
mkdir -p ~/lab/90daysofx/01-go/day20 && cd ~/lab/90daysofx/01-go/day20
go mod init example.com/day20- Two goroutines: one sends
"ping", the other receives and replies"pong"on a second channel.
- Join both; print the exchange.
- Run with
-race.
go run -race .Lab 2 — Producer/consumer with buffer sizing
Build a pipeline:
- Producer generates integers
1..N(N=1000).
- Stage squares values.
- Consumer sums squares.
Requirements:
- Close channels correctly
- Use directional types in function signatures
- Experiment with buffer sizes
0,1,64
- Journal qualitative backpressure observations
go run -race .Optional: print stage progress every 100 items (careful of races on shared counters—use atomic or print only from one G).
Lab 3 — Deliberate panics and hangs (safe learning)
In throwaway files:
- Send on closed channel → observe panic.
- Double
close→ panic.
- Unbuffered send with no receiver → hang (Ctrl-C).
- Receive loop without close → hang.
Write the error messages into your notes. Fear of close goes away when rules are mechanical.
Lab 4 — Nil channel behavior
var ch chan int
// <-ch // blocks forever
// ch <- 1 // blocks foreverConfirm with a program that uses select with default (preview Day 21) or a timeout parent. Journal: “nil channel never ready.”
Common gotchas
| Gotcha | Fix |
|---|---|
| Send on closed channel | Only senders close; track lifecycle |
| Forgetting to close | range never ends; leak or hang |
| Closing from receivers | Don’t—transfer ownership to sender side |
| Using buffer to “fix” deadlocks | Fix structure; buffer only for burst |
| Sharing channel and mutating payload race | Prefer copy or hand off exclusive ownership |
len(ch) for control logic |
Unreliable for synchronization decisions |
Checkpoint
- Explain unbuffered vs buffered in terms of blocking
- Explain nil and closed channel behavior
- Implemented producer/consumer with correct close
- Used
chan<-/<-chanin at least one API
- Multi-producer uses a single closer
go run -raceclean on your pipeline
- Documented one panic and one hang you caused on purpose
Commit
git add .
git commit -m "day20: channels — producer/consumer, close ownership, -race"Write three personal gotchas before continuing.
Tomorrow
Day 21 — select & timeouts: multiplex channels, non-blocking ops, cancellation previews, and fan-in without busy loops.