Day 21 — Select & timeouts
Day 21 — Select & timeouts
Stage III · ~3h (theory-heavy)
Goal: Multiplex channel operations with select, implement timeouts and non-blocking sends/receives, and build a correct fan-in.
select is the control structure of concurrent Go—like switch, but cases are channel ops. Master fairness, default, and nil-case disabling.
Why this day exists
Without select you can only wait on one channel at a time. Real systems need:
- “Whichever result arrives first”
- Timeouts and deadlines
- Optional cancellation (context—Day 24)
- Turning cases on/off dynamically (nil channels)
Misusing select causes busy loops, random starvation myths, and leaked timers.
Theory 1 — select as multi-way wait
select {
case v := <-a:
fmt.Println("from a", v)
case b <- x:
fmt.Println("sent to b")
case <-time.After(time.Second):
fmt.Println("timeout")
}Rules:
- If multiple cases are ready, one is chosen pseudo-randomly.
- If none are ready and there is no
default, the goroutine blocks.
- If none are ready and there is
default, it runs immediately.
- A
caseon a nil channel is never selected (disabled case).
There is no priority among cases—order in source does not matter for readiness.
Theory 2 — Non-blocking send/receive
select {
case ch <- v:
// sent
default:
// would block; drop, count, or retry later
}select {
case v := <-ch:
use(v)
default:
// nothing available
}Use sparingly: non-blocking ops are for optional work or instrumentation, not for spinning:
// BAD: busy loop burns CPU
for {
select {
case v := <-ch:
use(v)
default:
// spin
}
}Prefer blocking select or for v := range ch.
Theory 3 — Timeouts with time.After vs timers
Simple timeout
select {
case res := <-work:
return res, nil
case <-time.After(2 * time.Second):
return zero, errTimeout
}time.After returns a channel that receives after duration. In a hot loop, it can leak timers until they fire—prefer time.NewTimer + Stop/Reset patterns for high-frequency timeouts.
Reusable timer sketch
t := time.NewTimer(2 * time.Second)
defer t.Stop()
select {
case res := <-work:
if !t.Stop() {
<-t.C // drain if already fired
}
return res, nil
case <-t.C:
return zero, errTimeout
}For request-scoped deadlines, context (Day 24) is usually clearer than ad-hoc timers everywhere.
Theory 4 — Fan-in (merge many channels into one)
func fanIn[T any](cs ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(cs))
for _, c := range cs {
go func(c <-chan T) {
defer wg.Done()
for v := range c {
out <- v
}
}(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}Notes:
- Each input is drained by its own goroutine
- Single closer on
outafter all inputs finish
- Backpressure: slow consumer slows all producers through
out
Generics here are optional; you can write fanInInt if Day 31 hasn’t landed for you yet—but the pattern is the same.
Theory 5 — Nil channel: dynamic enabling
var out chan int
if !ready {
out = nil // disable send case
} else {
out = realOut
}
select {
case v := <-in:
// ...
case out <- v:
// only if out != nil and buffer/receiver ready
}This is how sophisticated state machines pause a direction without spawning extra goroutines.
Theory 6 — Cancellation case (preview of context)
select {
case <-ctx.Done():
return ctx.Err()
case job := <-jobs:
return handle(job)
}You will wire this for real on Day 24. Today, practice with a done channel:
done := make(chan struct{})
// somewhere: close(done)struct{} signals carry no data—only the event of close.
Worked example — first response wins
package main
import (
"fmt"
"time"
)
func mirror(url string, d time.Duration) <-chan string {
ch := make(chan string, 1)
go func() {
time.Sleep(d) // pretend network
ch <- url
}()
return ch
}
func main() {
a := mirror("https://a.example", 120*time.Millisecond)
b := mirror("https://b.example", 40*time.Millisecond)
select {
case u := <-a:
fmt.Println("winner", u)
case u := <-b:
fmt.Println("winner", u)
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout")
}
}Losing requests may still complete in background—leak/waste unless cancelled (context later).
Worked example — tick + quit
package main
import (
"fmt"
"time"
)
func main() {
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
done := time.After(1 * time.Second)
for {
select {
case t := <-tick.C:
fmt.Println("tick", t.Format("15:04:05.000"))
case <-done:
fmt.Println("done")
return
}
}
}Always Stop tickers you create to free resources.
Lab 1 — Multiplex two producers
Workspace: ~/lab/90daysofx/01-go/day21
mkdir -p ~/lab/90daysofx/01-go/day21 && cd ~/lab/90daysofx/01-go/day21
go mod init example.com/day21- Producer A sends ints every 100ms; producer B every 150ms.
- Main
selects between them for ~2s, then exits.
- Use a timeout or
donechannel to stop cleanly.
go run -race .
Journal how many values each side produced (atomics if counting from multiple Gs).
Lab 2 — Fan-in three channels
Implement fanIn for string (or generic).
- Three producers each send 5 labeled messages then close.
- Consumer ranges over merged channel and prints.
- Assert total 15 messages.
- Race-clean.
Lab 3 — Non-blocking drop metrics
Simulate a full metrics channel:
metrics := make(chan int, 2)
// try send 10 values non-blocking; count dropsPrint sent vs dropped. Discuss when dropping is acceptable (telemetry) vs never (money transfers).
Lab 4 — Timeout a slow function
Wrap a function that sleeps 500ms; select with 100ms timeout. Return an error on timeout. Re-run with 1s timeout success path.
go test -race ./...If you write tests, use t.Parallel carefully (Day 36).
Common gotchas
| Gotcha | Fix |
|---|---|
Empty select {} blocks forever |
Intentional only for block-main hacks; prefer WaitGroup |
default in a hot loop |
Busy-spin; remove default or block |
time.After in tight loop |
Use Timer or context deadline |
Forgetting Ticker.Stop |
Defer Stop |
| Assuming case order = priority | No priority; random among ready |
| Ignoring loser goroutines | Cancel with context (Day 24) |
Checkpoint
- Explain
selectreadiness + random choice
- Implemented timeout with
time.AfterorTimer
- Built fan-in with single close on output
- Used
defaultcorrectly once (non-spin)
- Explained nil-channel case disabling
go run -race/go test -raceclean
Commit
git add .
git commit -m "day21: select, timeouts, fan-in, non-blocking ops"Write three personal gotchas before continuing.
Tomorrow
Day 22 — sync primitives: Mutex, RWMutex, WaitGroup, Once, atomic—and when not to replace a mutex with a channel.