Day 21 — Select & timeouts

Updated

July 30, 2026

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.

Note

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:

  1. If multiple cases are ready, one is chosen pseudo-randomly.
  2. If none are ready and there is no default, the goroutine blocks.
  3. If none are ready and there is default, it runs immediately.
  4. A case on 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 out after 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
  1. Producer A sends ints every 100ms; producer B every 150ms.
  2. Main selects between them for ~2s, then exits.
  3. Use a timeout or done channel to stop cleanly.
  4. 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).

  1. Three producers each send 5 labeled messages then close.
  2. Consumer ranges over merged channel and prints.
  3. Assert total 15 messages.
  4. 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 drops

Print 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 select readiness + random choice
  • Implemented timeout with time.After or Timer
  • Built fan-in with single close on output
  • Used default correctly once (non-spin)
  • Explained nil-channel case disabling
  • go run -race / go test -race clean

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.