45. `select`: waiting on several channels
select blocks until one of several channel operations can proceed, then
runs that case. It's the control structure that makes real concurrent
programs possible — timeouts, cancellation, merging streams, non-blocking
checks all come from it.
The basic shape
package main
import (
"fmt"
"sync"
)
func main() {
nums := make(chan int)
words := make(chan string)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
nums <- 1
words <- "hello"
nums <- 2
close(nums)
close(words)
}()
open := 2
for open > 0 {
select {
case v, ok := <-nums:
if !ok {
nums = nil
open--
continue
}
fmt.Println("number:", v)
case s, ok := <-words:
if !ok {
words = nil
open--
continue
}
fmt.Println("word:", s)
}
}
wg.Wait()
fmt.Println("both channels drained")
}
It looks like a switch, but every case is a channel operation and the
choice is made by readiness, not by value.
The nums = nil line is a real technique, not a trick: a receive from a
nil channel blocks forever, so setting a drained channel to nil removes its
case from the select permanently. Without it, the closed channel would be
ready every time and spin the loop.
default makes it non-blocking
package main
import "fmt"
func main() {
ch := make(chan int, 1)
select {
case v := <-ch:
fmt.Println("got", v)
default:
fmt.Println("nothing ready, moving on")
}
ch <- 42
select {
case v := <-ch:
fmt.Println("got", v)
default:
fmt.Println("nothing ready")
}
select {
case ch <- 1:
fmt.Println("sent without blocking")
default:
fmt.Println("would have blocked, skipped the send")
}
}
With a default case, select never blocks: if no channel is ready, it runs
default immediately. This is the correct way to do a non-blocking send or
receive — unlike a len(ch) check, the whole thing is atomic.
Be careful not to put a bare select+default in a tight for loop: that's
a busy-wait burning a core. If you're polling, you probably want a blocking
select instead.
Timeouts
The pattern you'll use most:
package main
import (
"fmt"
"time"
)
func slowWork(d time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(d)
out <- "work finished"
}()
return out
}
func main() {
select {
case res := <-slowWork(10 * time.Millisecond):
fmt.Println("fast job:", res)
case <-time.After(100 * time.Millisecond):
fmt.Println("fast job: timed out")
}
select {
case res := <-slowWork(200 * time.Millisecond):
fmt.Println("slow job:", res)
case <-time.After(50 * time.Millisecond):
fmt.Println("slow job: timed out")
}
}
time.After(d) returns a channel that delivers a value after d. Race it
against your real work and whichever arrives first wins.
Two details: the worker channel is buffered (capacity 1) so the abandoned
goroutine can still send and exit rather than blocking forever — a leak
avoided. And slowWork returns a receive-only <-chan string, so callers
can't accidentally send into it.
For anything more than a single call, use context (two lessons on) — it
propagates cancellation down through a whole call tree instead of just this
one select.
Selection among ready cases is random
package main
import "fmt"
func main() {
a := make(chan string, 10)
b := make(chan string, 10)
for i := 0; i < 10; i++ {
a <- "a"
b <- "b"
}
counts := map[string]int{}
for i := 0; i < 20; i++ {
select {
case v := <-a:
counts[v]++
case v := <-b:
counts[v]++
}
}
fmt.Println("received", counts["a"], "from a and", counts["b"], "from b")
fmt.Println("both drained:", len(a) == 0 && len(b) == 0)
}
When several cases are ready, select picks one at random — not
top-to-bottom. That's deliberate: a deterministic order would starve the
later cases whenever an earlier one is always ready.
The practical consequence: never rely on case order for priority. If you
genuinely need priority, check the high-priority channel first in its own
non-blocking select, then fall through to the general one.
A quit channel
Before context existed, this was how you stopped a goroutine — and it's
still right for simple internal cases:
package main
import (
"fmt"
"sync"
)
func worker(jobs <-chan int, quit <-chan struct{}, wg *sync.WaitGroup, done chan<- int) {
defer wg.Done()
processed := 0
for {
select {
case j, ok := <-jobs:
if !ok {
done <- processed
return
}
processed += j
case <-quit:
fmt.Println("worker: told to stop early")
done <- processed
return
}
}
}
func main() {
jobs := make(chan int, 10)
quit := make(chan struct{})
done := make(chan int, 1)
var wg sync.WaitGroup
wg.Add(1)
go worker(jobs, quit, &wg, done)
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs)
wg.Wait()
fmt.Println("processed total:", <-done)
close(quit)
}
The for { select { ... } } loop is the shape of a long-running Go worker:
handle work on one case, handle shutdown on another, and always give the loop
a way out. A worker with no exit case is a goroutine leak.
select {} blocks forever
func main() {
go serveTraffic()
select {} // block main forever, deliberately
}
An empty select has no cases and can never proceed, so it parks the
goroutine permanently. It's the idiom for "keep the program alive while
background goroutines work". (The Go interpreter running these lessons uses
exactly this line to stay resident in your browser tab.)
Your turn
Race a worker against a timeout. The worker takes 200 ms, the timeout is 50 ms, so the timeout should win:
timed out
package main
import (
"fmt"
"time"
)
func work() <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(200 * time.Millisecond)
out <- "finished"
}()
return out
}
func main() {
// select between work() and a 50ms timeout
}
package main
import (
"fmt"
"time"
)
func work() <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(200 * time.Millisecond)
out <- "finished"
}()
return out
}
func main() {
select {
case res := <-work():
fmt.Println(res)
case <-time.After(50 * time.Millisecond):
fmt.Println("timed out")
}
}
Next: the lock-based half of Go concurrency — the sync package.