How Go channels work, by example.
"Don't communicate by sharing memory; share memory by communicating." The slogan is twenty years old. The mechanism it names is still the cleanest way to think about goroutine coordination.
What is a Go channel?
A typed pipe with synchronization built in.
Go channels are typed pipes that goroutines use to communicate, with synchronisation built in. Tony Hoare's CSP (Communicating Sequential Processes, 1978) is the inspiration; Rob Pike brought the model to Go. Channels can be buffered or unbuffered; select lets a goroutine wait on multiple channels.
A channel is a typed FIFO queue with a fixed capacity (zero or more), specified in the Go language spec. One end sends; the other end receives. The runtime arbitrates: if both sides are ready, the value is handed off; if not, one side parks the goroutine until the other arrives.
That parking is the headline feature. You do not write locks, condition variables, or atomics. You write ch <- v on the send side and v := <-ch on the receive side. The runtime plus the channel's own buffer rule give you mutual exclusion, signalling, and back-pressure for free — closer in spirit to a message queue than to a shared variable.
Buffered vs unbuffered channels, live
Send and receive in any order and watch the channel decide what happens.
Toggle the buffer size. Press Send and Recv in any order. Watch how the channel behaves: hand-off if a peer is waiting, buffer if there's room, otherwise park.
An unbuffered channel makes both sides wait for each other
The send returns only once a receiver has taken the value.
An unbuffered channel (capacity 0) is a synchronization primitive in disguise. The send returns only when a peer has received; the receive returns only when a peer has sent. Both goroutines synchronise on that point. This is the textbook CSP rendezvous.
Use this for handoff semantics — when you need both sides to agree on the moment of exchange. Worker hand-off, request/reply, "done" signalling. The cost is contention if you have many senders or receivers; for that, you want a buffered channel.
// done-signal pattern, idiomatic Go
done := make(chan struct{})
go func() {
work()
close(done)
}()
<-done // blocks until close
A buffered channel is a bounded queue that pushes back
Senders run free until the buffer fills, then they block.
A buffered channel is a typed FIFO with capacity N — a tiny in-process cousin of a ring buffer. Senders proceed without rendezvous as long as the buffer has room. When it fills, senders park — exactly the back-pressure behaviour you want from a bounded queue, without the manual drop/abort policy.
Channels are not for unbounded queueing. Pick a capacity that matches your service's tolerance for in-flight work. "How many can be ahead of me before I should slow down the producer?" is the right question; that answer is your buffer size.
select: wait on several channels, act on the first ready
The one construct that gives you timeouts, cancellation, and fan-in.
select is the multiplexer. It blocks on a set of channel operations and proceeds with the first one ready. If multiple are ready, the runtime picks pseudo-randomly. With a default case it becomes non-blocking. With a time.After case it becomes a timeout.
Select is what makes channels expressive. Cancellation, timeouts, fan-in, multi-source coordination — all collapse into a select. Without it, channels would just be queues. With it, they become a tiny scheduling DSL.
select {
case v := <-data:
handle(v)
case <-ctx.Done():
return ctx.Err() // cancelled or timed out
case <-time.After(50 * time.Millisecond):
return errSlow // request-level timeout
}Channel idioms that recur in Go
Fan-out, fan-in, and pipelines, named so you spot them in reviews.
Three patterns turn up everywhere in real Go code. Knowing them by name shortens many code reviews. The fan-out variant is essentially a thread pool in disguise; the pipeline variant is closer to an event loop stitched out of stages.
Worker pool.
Many goroutines read from one input channel. Idiomatic for parallelizing CPU-bound work over a fixed number of cores.
Merge.
Many input channels merged into one output. A goroutine per input forwards into the shared sink. Used for collecting results from a pool.
Stages.
Each stage reads from its input channel and writes to its output. Buffer depths set staging slack; close cascades on EOF.
Three classic ways channels leak goroutines
Closed-channel panics, blocked receivers, and for-range that never ends.
Send on closed channel panics. Establish a clear ownership rule: only the sender closes. Multi-producer fan-in needs an extra coordinator goroutine.
Goroutine leak via blocked channel. A goroutine that <-ch's and is never sent to lives forever, holding stack pages that garbage collection cannot reclaim. Always pair channel ops with a context or timeout in long-lived servers.
Forgotten close on for-range. for v := range ch ends only when the channel closes. If you never close, the loop never exits — silent hang. The producer must close on completion.
Channel performance, and when to reach for a mutex instead
What a send actually costs, and where a plain lock wins.
Channels are not free. The runtime/chan.go implementation uses a mutex around a circular buffer plus parked-goroutine queues; every send and receive takes a lock-acquire-release plus, when blocked, a goroutine park (~50–100 ns minimum). Approximate single-machine numbers (Go 1.22, Apple M2):
- Unbuffered channel send/recv
- ~120 ns per pair (sender + receiver synchronisation).
- Buffered channel (capacity ≥ 1)
- ~80 ns per send when buffer has room; ~150 ns when contended.
- Mutex + slice queue
- ~25 ns per push/pop — channels are 3-5× slower than a hand-rolled queue.
- Atomic counter
- ~5 ns. For pure counters, use atomic, not channels.
When channels lose to mutexes. Russ Cox and Bryan Mills (Go team) repeatedly point out: channels are about communication and ownership transfer; for shared mutable state guarded by simple invariants, sync.Mutex is faster, simpler, and easier to debug. The Go proverb "Don't communicate by sharing memory; share memory by communicating" is widely misread — the proverb is about which to reach for first, not "channels for everything."
Where channels shine. Pipelines (data flowing through transformation stages); fan-out workers consuming a job queue; select-based timeouts and cancellation; signalling (close-only channels for "done" semantics); rate-limiting via a buffered channel as a token pool. The unifying property: a single producer or single consumer per channel, with the channel modelling work flowing one direction.
Channel patterns real Go services rely on
Worker pools, errgroups, pipelines, cancellation, and rate limiting.
1. Worker pool with a job channel. The simplest fan-out pattern. jobs := make(chan Job, 100); N goroutines each for j := range jobs; producer closes jobs when done. Used in Kubernetes' workqueue package, the Prometheus scrape pool, and roughly every Go HTTP server with custom backend dispatch.
2. Errgroup — bounded parallelism with the first-error semantics. The golang.org/x/sync/errgroup package wraps WaitGroup and a context-cancellation channel: launch N tasks, return as soon as any one errors (cancelling the rest), or all succeed. Used heavily in CockroachDB, etcd, and most production microservices for parallel sub-request fan-out.
3. Pipeline — stages connected by channels. Stage 1 reads input, sends to ch1; Stage 2 receives from ch1, processes, sends to ch2; Stage 3 receives from ch2, writes output. Each stage is a separate goroutine; channels provide the natural backpressure. The Go blog's "Go Concurrency Patterns: Pipelines and cancellation" (2014) is still the canonical reference.
4. Done-channel cancellation. A chan struct{} that's closed (not sent to) signals "stop." Every goroutine in a hierarchy selects on <-done alongside its real work. Largely superseded by context.Context in modern code, but the underlying mechanism is the same: a closed channel is the cancellation signal.
5. Rate limiting via a buffered channel. tokens := make(chan struct{}, 10); before each operation, tokens <- struct{}{} blocks if 10 are already in flight. The defer-receive returns the token. Cleaner than a semaphore for many cases; visible in go-redis's connection pool, Cloudflare's Wrangler tooling, and Kubernetes' API rate limiter.
Channels are not always the right tool — sometimes a mutex really is what you want — but when ownership flows naturally with data, channels make code shockingly easy to read. The Go runtime hides a great deal of scheduling cleverness behind those two arrows.
Further reading on Go channels
Primary sources, in order.
- go.devShare Memory by CommunicatingThe original Go blog post that codified the slogan. Short and worth re-reading.
- go.devGo concurrency patterns: Pipelines and cancellationWalks through fan-in, fan-out, and the cancellation propagation idiom.
- Semicolony guideEvent loopsA different concurrency model, useful to compare and contrast.
- Semicolony guideThread poolsThe other half of "fan-out worker pool" — what the runtime is actually scheduling underneath.