Channels and select: the whole API
ch := make(chan T) // unbuffered: a handoff, sender blocks until a receiver arrives
ch := make(chan T, 8) // buffered: an asynchrony window of exactly 8
ch <- v // send
v := <-ch // receive
v, ok := <-ch // receive with closed-check
close(ch) // "no more values"; only the SENDER may close
for v := range ch { } // drains until close
func f(in <-chan T, out chan<- T) // direction types, enforced by the compiler
That is the entire language feature. Everything else — pipelines, worker pools, fan-in — is composition of those lines, not a framework.
package main
import (
"fmt"
"sync"
"time"
)
func producer(name string, out chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 2; i++ {
select {
case out <- fmt.Sprintf("%s-%d", name, i):
case <-time.After(100 * time.Millisecond): // never block forever on a handoff
return
}
time.Sleep(5 * time.Millisecond)
}
}
func main() {
out := make(chan string) // unbuffered: every value is a rendezvous
var wg sync.WaitGroup
wg.Add(2)
go producer("a", out, &wg)
go producer("b", out, &wg)
go func() { wg.Wait(); close(out) }() // the closer waits for all senders
for msg := range out {
fmt.Println("got", msg)
}
fmt.Println("channel closed, loop ended")
}
BlockingQueue<String> out = new LinkedBlockingQueue<>(8); // always bounded, always a queue
producer.submit(() -> { out.put("a-0"); out.put("a-1"); });
producer.submit(() -> { out.put("b-0"); out.put("b-1"); });
// How do you know when to stop? Sentinel values, or count-down latches — there is no close().
for (int i = 0; i < 4; i++) System.out.println(out.take());
// No channels: async iteration over an async generator is the closest primitive
async function* produce(name: string) { yield `${name}-0`; yield `${name}-1`; }
for await (const msg of merge(produce("a"), produce("b"))) console.log(msg);
select = one select, no priority, no fallthrough
package main
import (
"fmt"
"time"
)
func main() {
ready := make(chan string, 1)
ready <- "job-42"
select {
case v := <-ready:
fmt.Println("got", v)
case ready <- "other": // a send case: only taken if a receiver is waiting
fmt.Println("sent")
case <-time.After(50 * time.Millisecond):
fmt.Println("timeout")
default:
fmt.Println("nothing ready — non-blocking path")
}
var nilCh chan int // a nil channel blocks forever, in every select case
select {
case <-nilCh:
fmt.Println("never")
default:
fmt.Println("nil channel → default wins")
}
}
select picks pseudo-randomly among ready cases — there is no ordering, so never rely on "the fast one wins". A
default makes the whole thing non-blocking. A nil channel disables its case permanently, which is a legitimate
technique for turning off branches of a loop.
The five rules
- Only the sender closes. Receiving from a closed channel yields the zero value instantly, forever — a
for rangeover it simply ends. Sending on a closed channel panics. - Unbuffered means rendezvous.
ch <- vreturns only when someone received it. That is a synchronisation point, not a queue: use it when you want the sender to wait. - Buffer size is a design decision, not a default.
make(chan T, 1000)"just in case" hides backpressure. Pick 0 (handoff), 1 (signal), or N where N is a real concurrency limit. - Channels are for ownership transfer; mutexes are for state. If you are using a channel to protect a map,
you wanted
sync.Mutex. - Every blocking operation needs an escape hatch. Either the producer selects on
ctx.Done(), or the consumer does. Otherwise you have written a goroutine leak with extra steps.
Trap:
time.Afterinside a hot loop allocates a timer per iteration and keeps it alive until it fires. In loops, uset := time.NewTimer(d)+t.Reset(d), or better acontext.WithTimeoutfor the whole loop.
Trap: a deadlock in Go is a runtime panic only when all goroutines are asleep. If even one goroutine can make progress, you get a silent stall instead — the reason every wait needs a timeout or a
ctx.
Proof checkpoint
- Build fan-in from three producers into one consumer, closed correctly, with no leak (assert
NumGoroutinereturns to baseline). - Implement a 200 ms timeout around a slow stage, twice: once with
time.After, once withcontext.WithTimeout. Say what differs. - Explain why
close(ch)from the receiver is a bug even when it "works" in a test.