gopherpath
0%

Channels and select: the whole API

45 min · lesson 2 of 4 · #concurrency · #channels

Go
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.

Go
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")
}
Java
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());
TypeScript
// 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

Go
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

  1. Only the sender closes. Receiving from a closed channel yields the zero value instantly, forever — a for range over it simply ends. Sending on a closed channel panics.
  2. Unbuffered means rendezvous. ch <- v returns only when someone received it. That is a synchronisation point, not a queue: use it when you want the sender to wait.
  3. 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.
  4. Channels are for ownership transfer; mutexes are for state. If you are using a channel to protect a map, you wanted sync.Mutex.
  5. 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.After inside a hot loop allocates a timer per iteration and keeps it alive until it fires. In loops, use t := time.NewTimer(d) + t.Reset(d), or better a context.WithTimeout for 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 NumGoroutine returns to baseline).
  • Implement a 200 ms timeout around a slow stage, twice: once with time.After, once with context.WithTimeout. Say what differs.
  • Explain why close(ch) from the receiver is a bug even when it "works" in a test.
stored in this browser only