Channels
Channels are typed conduits. One goroutine sends; another receives. The famous Go proverb: "Do not communicate by sharing memory; share memory by communicating." A channel is the canonical synchronization primitive — and when used right, it eliminates whole categories of race conditions.
1 · The intuition
A channel is a typed pipe between goroutines. ch <- v sends;
v := <-ch receives. The arrow points the direction the value
travels. A channel without a buffer is unbuffered — a send blocks until
a receiver is ready, and vice versa. That synchronization is the feature.
2 · Try it — unbuffered
package main
import "fmt"
func main() {
ch := make(chan int) // unbuffered
go func() {
ch <- 42 // blocks until main is ready to receive
fmt.Println("sent")
}()
v := <-ch // blocks until someone sends
fmt.Println("got:", v)
}An unbuffered send and receive happen at the same moment — they
synchronize. The "sent" prints after "got" because the goroutine doesn't
continue past the send until main has received.
3 · Buffered channels
package main
import "fmt"
func main() {
ch := make(chan int, 3) // buffer of 3
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // would BLOCK here — buffer full, no receiver
fmt.Println(<-ch, <-ch, <-ch) // drains in FIFO order
}Buffer size = capacity. The channel acts like a fixed-size FIFO queue. Sends block when the buffer is full; receives block when it's empty. Used for decoupling producers and consumers when you can tolerate some latency.
4 · close + range — clean shutdown
package main
import "fmt"
func producer(ch chan<- int) { // send-only direction
for i := 1; i <= 5; i++ {
ch <- i * 10
}
close(ch) // signal "no more values"
}
func main() {
ch := make(chan int, 3)
go producer(ch)
// range reads until the channel is closed
for v := range ch {
fmt.Println(v)
}
fmt.Println("done")
}close. Only the sender
should close. Sending on a closed channel panics. Receiving from a closed channel
returns the zero value immediately — use the comma-ok form
v, ok := <-ch to detect "closed".5 · Directional channels — the type system as documentation
package main
import "fmt"
func send(ch chan<- int) { // only allowed to send
ch <- 1
}
func receive(ch <-chan int) { // only allowed to receive
fmt.Println(<-ch)
}
func main() {
ch := make(chan int, 1)
send(ch)
receive(ch)
}Using chan<- T (send-only) and <-chan T (receive-only)
in function signatures lets the type checker enforce intent. A bidirectional
chan T converts to either direction freely.
6 · Pipelines — composition through channels
package main
import "fmt"
// Each stage takes an input channel, returns an output channel.
func gen(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 v := range in { out <- v * v }
}()
return out
}
func main() {
for v := range square(gen(1, 2, 3, 4, 5)) {
fmt.Println(v)
}
}Each stage owns one goroutine that reads from its input and writes to its output. Composing pipelines becomes "feed one stage's output as another's input". This is how high-throughput Go services structure stream processing.
7 · From the wild
type Interface interface {
Stop()
ResultChan() <-chan Event
}
type Watcher struct {
result chan Event
stopCh chan struct{}
}
// Producer goroutine inside the watcher
func (w *Watcher) run() {
defer close(w.result)
for {
select {
case <-w.stopCh:
return
case ev := <-w.upstream:
w.result <- ev
}
}
}
// Consumer side
for ev := range watcher.ResultChan() {
handle(ev)
}8 · Coming from another language?
| If you know… | The bridge |
|---|---|
| Erlang | Like message-passing between processes, but bounded by buffer size and typed. |
| Rust | ≈ tokio::sync::mpsc or crossbeam::channel. Unbuffered = rendezvous channel. |
| Python | ≈ queue.Queue but synchronous-by-default (unbuffered). |
| Java | ≈ SynchronousQueue (unbuffered) or LinkedBlockingQueue (buffered). |
| JavaScript | No direct equivalent. Async iterators come closest. Channels don't fit JS's event-loop model naturally. |
9 · Common mistakes
- Sending on a closed channel. Panics. The sender should close, not the receiver, and only after no more sends will happen.
- Closing twice. Panics. Ensure single ownership.
- Reading from nil channel. Blocks forever. Useful trick for "disable a case in select" but a footgun if accidental.
- Goroutine leak via channel. Receiver waiting on a channel nobody will ever send to. Always pair with a cancellation path.
- Channel-as-queue at scale. Channels are great up to ~10⁵ ops/sec. Beyond that, look at
sync.Poolor specialized ring buffers. - Choosing channels when a mutex is simpler. Channels are for pipelining; mutexes are for protecting shared state. Don't channel-ify a counter.
10 · Exercises (~15 min)
- Ping-pong. Two goroutines bouncing a value between them via two channels. Print "ping", "pong", alternating, 5 times.
- Worker pool. One
jobschannel, 4 workers, oneresultschannel. Feed 20 jobs in, drain 20 results. - Fan-in. Three producers each feed their own channel. Combine into one output channel using a goroutine per source.
- Closed-channel detection. Write a function that drains a channel and reports how many values it read before close.
11 · When it clicks
- You reach for channels when goroutines need to talk; mutexes when they need to share.
- You always know which goroutine owns "close" rights on each channel.
- Function signatures use
chan<- T/<-chan Tfor documented intent. - You can sketch a 3-stage pipeline on a napkin and it works the first time.