Module 3 · Lesson 2 · ~30 min read

Channels and select

Channels are typed conduits between goroutines. They unify synchronization and message passing into one primitive. select waits on multiple channels at once — it's the switch of concurrency. Get these two right and most concurrent Go follows.

Channel basics

ch := make(chan int)        // unbuffered
ch := make(chan int, 10)    // buffered with capacity 10

ch <- 42                    // send
v := <-ch                    // receive
v, ok := <-ch                // receive with "channel closed?" flag
close(ch)                    // close — only the sender should close

for v := range ch { ... }    // receives until closed

Unbuffered: rendezvous

An unbuffered channel send blocks until a receiver is ready. A receive blocks until a sender appears. The two goroutines synchronize at the send/receive — both move forward together. Useful when the send itself is the signal: "I have completed."

Buffered: queue with backpressure

A buffered channel can hold N values. Sends block when full; receives block when empty. Useful as a bounded queue — and the bound itself is your backpressure mechanism.

Channel directions in signatures

func producer(out chan<- Event) { ... }   // send-only
func consumer(in <-chan Event) { ... }    // receive-only

Channel direction is part of the type. Constrain it in function signatures — makes intent clear and catches bugs at compile time. The arrow points the way the data flows.

Closing channels — who and when

Three rules:

  1. Only the sender closes a channel. Never the receiver — receivers can't know if other sends are coming.
  2. If multiple goroutines send on the channel, none of them owns it. You need an external coordinator (a separate "all done" signal) to decide when to close.
  3. Sending on a closed channel panics. Closing an already-closed channel panics. Both are programmer errors.

Receiving from a closed channel returns the zero value with ok=false. for range over a channel ends cleanly when the channel closes — that's the most common shutdown pattern.

The classic shutdown pattern

func producer() <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)        // signals "no more values" to the consumer
        for i := 0; i < 5; i++ {
            out <- i
        }
    }()
    return out
}

for v := range producer() {
    fmt.Println(v)
}

Producer owns the channel, defers its close, returns it as receive-only. Caller ranges. Clean.

select — multi-channel wait

select {
case v := <-ch1:
    handle(v)
case ch2 <- compute():
    log.Println("sent")
case <-time.After(100 * time.Millisecond):
    log.Println("timeout")
default:
    log.Println("nothing ready right now")
}

Each case is a channel operation. Select waits until any one of them can proceed; when multiple are ready, picks one pseudorandomly; runs that case's body.

The default clause makes select non-blocking — picks default if nothing else is immediately ready.

The time.After trick is the canonical timeout idiom. Lesson 4 (context) generalizes it.

Patterns

Fan-out / fan-in

// fan-out: distribute work across N workers
func fanOut(in <-chan Job, workers int) []<-chan Result {
    outs := make([]<-chan Result, workers)
    for i := 0; i < workers; i++ {
        out := make(chan Result)
        outs[i] = out
        go func() {
            defer close(out)
            for job := range in {
                out <- process(job)
            }
        }()
    }
    return outs
}

// fan-in: merge N channels into one
func merge(cs ...<-chan Result) <-chan Result {
    out := make(chan Result)
    var wg sync.WaitGroup
    for _, c := range cs {
        wg.Add(1)
        go func(c <-chan Result) {
            defer wg.Done()
            for r := range c { out <- r }
        }(c)
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

This is the canonical structure for parallel processing of a stream — multiple workers, one downstream consumer. Real Canton-adjacent code uses this exact pattern for things like parallel command submission.

Done channel — the cancellation primitive

func worker(done <-chan struct{}, in <-chan Job) {
    for {
        select {
        case <-done:
            return           // graceful shutdown
        case j, ok := <-in:
            if !ok { return } // in closed
            process(j)
        }
    }
}

chan struct{} is "channel of nothing" — used for pure signaling, since the empty struct is zero bytes. Closing it broadcasts "done" to every goroutine reading from it (because reads on closed channels never block).

Lesson 4 wraps this whole pattern in context.Context, which is what real production code uses. The done-channel pattern still shows up in code that predates contexts or in code that needs more nuance.

nil channels — the surprising trick

Sends and receives on a nil channel block forever. This sounds useless but turns out to be a useful select trick: set a case's channel to nil to disable it.

var in chan Event = upstream
var out chan Event          // nil — disabled
var pending Event

for {
    select {
    case v, ok := <-in:
        if !ok { in = nil; continue }
        pending = v
        out = downstream  // enable the send case
    case out <- pending:
        out = nil          // disable until we have something to send again
    }
}

This is how you build a buffered transformer with at-most-one in-flight value. It looks weird, but you'll see it in production stream-processing code.

Anti-patterns

Don't

Wrap a mutex in a channel. If you need to protect a counter, use sync.Mutex or sync/atomic. Channels are heavier than mutexes — they allocate, they do scheduler-aware blocking. Channels are for passing values, not for guarding fields.

Don't

Have multiple owners closing the same channel. One panics. Use a coordinator pattern, or sync.Once on close, or a different design.

Don't

Send to a channel without knowing who reads. If the receive path can disappear (e.g. caller cancels), you've leaked the goroutine. Always have a select-default-or-context-cancel escape hatch.

Takeaways