45. `select`: waiting on several channels

📖 Reading · 11 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

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.