The one construct that waits on many channels at once
— timeout · fan-in/out · nil channel
<-ch1 first and no matter how fast ch2 is, you sit waiting on ch1// one channel: this is enough msg := <-ch1 // the moment there are two — a := <-ch1 // even if ch2 is first b := <-ch2 // we never get here // select: takes whichever is ready select { case m := <-ch1: case m := <-ch2: }
select is the syntax that lines up several channel operations and picks the one that becomes possible first. Every pattern today derives from it.
| Part | Title | Focus |
|---|---|---|
| Part 1 | Overview and Goroutine Basics | unit of execution |
| Part 2 | Channels in Depth | communication |
| Part 3 | Select and Advanced Channel Patterns | choice · composition |
| Part 4 · 5 | The sync Package / The Context Package | sync · lifecycle |
| Part 6 · 7 | Concurrency Patterns / Error Handling | design patterns |
| Part 8 · 9 | Memory Model and Atomic / Race Detector | low level · debugging |
| Part 10 · 11 | Real Project / go tool trace | practice · tooling |
Part 1 gave the unit of execution, Part 2 the means of communication. Today weaves the two together.
time.After · context.WithTimeout · which one to pickIt looks like switch, but what it chooses over is different. We look at what decides which case runs, and what happens when several are ready at once.
<-chselect { case msg := <-ch1: // if ch1 is first fmt.Println("ch1:", msg) case msg := <-ch2: // if ch2 is first fmt.Println("ch2:", msg) } // neither ready? it waits right here
false means the channel is closedok is where the Chapter 5 nil channel trick starts.
ok early. Say the sentence "a closed channel does not block" out loud once before moving on — it comes back on slide 24.ch1Count, ch2Count := 0, 0 for range 1000 { ch1 <- 1 // make both ready ch2 <- 2 select { case <-ch1: ch1Count++ case <-ch2: ch2Count++ } drain(ch1, ch2) // clear leftovers } // ch1: 516, ch2: 484 — roughly 50:50
One default line changes the whole character of a select.
From a structure that waits to one that only checks what is possible now.
select { case val := <-ch: fmt.Println("received:", val) default: // empty? straight to here fmt.Println("no data") }
ch := make(chan int, 1) ch <- 1 // buffer is full select { case ch <- 2: fmt.Println("sent") default: fmt.Println("buffer full") }
| Shape | Behavior | Where |
|---|---|---|
case only | Blocks until one is ready | the basic form |
+ default | Returns at once if nothing is ready | polling · drop-tolerant send |
+ time.After | Wakes up when the time is up | timeout |
case <-nilCh | That case is never chosen | dynamic disabling |
select {} | Blocks forever | deadlock — almost always a bug |
default inside a for loop and you have busy-wait. Nothing is happening, yet one CPU core spins at 100%. Where you must wait, drop default and add a timeout case.
Go has no separate timeout syntax. You just add one more case — a channel that delivers a value once the time passes.
Where that channel comes from is the difference between time.After and context.
ch := make(chan string) go func() { time.Sleep(200 * time.Millisecond) // slow work ch <- "result" }() select { case msg := <-ch: // result first: normal path t.Log("received:", msg) case <-time.After(50 * time.Millisecond): // over 50ms t.Log("timeout!") } // → timeout! (work 200ms > limit 50ms)
time.After(d) returns a channel that receives one value after d. To select it is simply one more ordinary receive case.
func simulateAPICall(ctx context.Context, d time.Duration) (string, error) { ch := make(chan string, 1) // buffer 1 — reason below go func() { time.Sleep(d) ch <- "api response" }() select { case result := <-ch: // if the response wins return result, nil case <-ctx.Done(): // deadline hit · cancelled upstream return "", ctx.Err() // context.DeadlineExceeded } }
ctx, cancel := context.WithTimeout(...) + defer cancel(). Point out that forgetting cancel leaves the timer alive even when the work finishes early. The buffer-1 story connects to the leak slide in part 1 — a concrete case of "build the exit path when you build the thing".| Aspect | time.After | context.WithTimeout |
|---|---|---|
| Cancel propagation | None — only I bail out | Reaches child goroutines |
| Scope | one select | the whole call chain |
| Cleanup | None needed | defer cancel() required |
| Reason | None | ctx.Err() — deadline or cancel |
| Where | tests · one-off waits | API calls · server handlers |
time.After. If everyone you called must stop too, context. Production code is mostly the latter.
Hand the work out to many (fan-out), then gather the scattered results back into one (fan-in). Join the two and that is a concurrent pipeline.
The arrows show where the data flows. All three workers compete to pull from the same jobs channel, and who takes how many is not fixed — whoever finishes sooner takes more (load balancing for free).
jobs, results := make(chan int, 10), make([]chan int, 3) for i := range results { results[i] = make(chan int, 10) } var wg sync.WaitGroup for i := range results { wg.Add(1) go func() { // all three share one jobs defer wg.Done() for job := range jobs { // pulls until jobs is closed results[i] <- job * job } close(results[i]) }() } for i := 1; i <= 9; i++ { jobs <- i } close(jobs) // without it, workers wait forever wg.Wait()
close(jobs) the range never ends and all three workers stay alive.
② Capturing the loop variable i straight into the goroutine is only safe on Go 1.22+.
func(i int) once.One goroutine per source forwards values into merged. The consumer only has to watch one channel. In exchange, arrival order is not guaranteed — if you need order, pick a design other than fan-in.
func fanIn(channels ...<-chan string) <-chan string { var wg sync.WaitGroup merged := make(chan string) for _, ch := range channels { wg.Add(1) go func() { // one goroutine per source defer wg.Done() for v := range ch { merged <- v } // ends on close }() } go func() { wg.Wait() // wait for all of them, then close(merged) // close merged at that point }() return merged }
fanIn never returns. Values only drain once the caller runs range merged, and the function is stuck before that — deadlock.
func Process(in <-chan Job) <-chan Result. Then you can chain several such blocks into a multi-stage pipeline and tune the worker count of each stage separately. Say part 6 covers more.A nil channel can do nothing — send blocks, receive blocks. Inside a select, that bug-looking property becomes a switch that turns a case off.
nil to the variable alone turns the case offvar ch chan int // nil <-ch // blocks forever ch <- 1 // blocks forever select { case <-ch: // never chosen case <-other: // only this one lives }
var a, b = (<-chan int)(ch1), (<-chan int)(ch2) // hold them as receive-only for a != nil || b != nil { // both nil = all drained select { case v, ok := <-a: if !ok { a = nil // closed → turn this case off continue } results = append(results, v) case v, ok := <-b: if !ok { b = nil continue } results = append(results, v) } }
0s and burns CPU. Turning it off is the only way.
default makes it non-blocking. Put that in a loop and you get busy-waittime.After or ctx.Done()context.WithTimeout, and give the result channel buffer 1WaitGroup sets the momentnil to a channel variable and its case turns off — the idiom for drained sourcestime.After case. → Slide 11ctx.Err() tells deadline from cancel. time.After binds to that one select. → Slide 15nil when ok == false to turn the case off. → Slide 23 · 24So far it has all been about passing values. But moments come when many goroutines
must touch the same memory. There, a lock beats a channel.
Next up is the tool outside CSP — the sync package.
WaitGroup three times today and never explained it.
Mutex · RWMutex · Once get a proper look next time.
References
github.com/kenshin579/tutorials-go /golang/concurrencyadvenoh.pe.kr