🛑 Analogy
A conveyor belt has an emergency stop. Whatever it’s carrying, wherever the items are, hitting the button halts the belt at once. The or-done channel is that emergency stop wired around a stream: the consumer keeps it simple, and one signal stops the flow.
The problem
When you consume a channel from code you don’t own, a plain for v := range c can’t be interrupted — if the producer stalls, your loop blocks forever. The fix is to select on a cancellation channel at every receive… which clutters every consumer with the same boilerplate. Or-done writes that boilerplate once and hands you back a channel you can range normally.
Structure
graph LR
SRC["source chan<br/>(you don't own)"] --> OD["orDone<br/>select on done"]
DONE["done chan"] -.-> OD
OD --> C["consumer<br/>plain range"]
Idiomatic Go
orDone relays values from c but exits the instant done closes — so main just ranges. Edit and Run:
package main
import "fmt"
// orDone wraps c so iteration also stops when done is closed.
func orDone(done <-chan struct{}, c <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case <-done:
return
case v, ok := <-c:
if !ok {
return
}
select { // sending can block too — guard it as well
case out <- v:
case <-done:
return
}
}
}
}()
return out
}
// an infinite source we don't control
func numbers() <-chan int {
c := make(chan int)
go func() {
for i := 1; ; i++ {
c <- i
}
}()
return c
}
func main() {
done := make(chan struct{})
got := 0
for v := range orDone(done, numbers()) { // clean range; cancellation handled
fmt.Println(v)
if got++; got == 3 {
close(done) // stop after three values
}
}
fmt.Println("stopped cleanly — consumer never blocked")
}
🐹 Straight from 'Concurrency in Go'
This helper (popularized by Katherine Cox-Buday’s Concurrency in Go) keeps pipeline consumers readable: every stage can range orDone(done, in) instead of repeating selects. In modern code, done is usually a context.Context — the wrapper selects on ctx.Done() instead. It pairs naturally with Pipeline, Generator, and Fan-in.
Pitfalls
⚠️ It guards the consumer, not the producer
In the example, numbers() has no cancellation of its own, so after done closes it’s left blocked on a send — a leaked goroutine (harmless here only because the program exits). Or-done keeps your loop from hanging; it does not magically stop upstream. In real pipelines, thread the same done/ctx into the producer too.
When to use it — and when not
✅ Reach for it when
- You consume a channel you don't own and must honor cancellation without sprinkling selects everywhere.
- You want a plain `for v := range …` loop that still respects a done/ctx signal.
- You're composing pipeline stages and want to keep each consumer simple.
⛔ Think twice when
- You own the producer and can thread done/ctx into it directly.
- The channel is finite and small — a plain range is fine.
Related patterns
Process a stream of data through a series of stages connected by channels, where each stage is a goroutine.
CONCURRENCY GeneratorProduce a stream of values from a goroutine over a channel, lazily and on demand.
CONCURRENCY Context & CancellationPropagate cancellation, deadlines, and request-scoped values across API boundaries and goroutine trees with context.Context.
CONCURRENCY Fan-out / Fan-inDistribute work across multiple goroutines (fan-out) and merge their results back into one stream (fan-in).
Check your understanding
Score: 0 / 31. What problem does the or-done channel solve?
Instead of writing select { case <-done; case v := <-c } at every consumption site, orDone wraps the stream once so you can simply `range` it.
2. Why are there TWO selects inside orDone?
Receiving and sending can each block. Guarding both with <-done means cancellation interrupts the wrapper whether it's waiting for input or waiting to hand a value on.
3. Does or-done protect the upstream producer too?
orDone stops the consumer from blocking, but an upstream generator with no done of its own will still leak. Cancellation must reach the producer as well.