Skip to content

select waits on many channels

Level: 101 · anyone whose goroutine has more than one channel to listen to

One line: A select blocks until one of its channel operations can proceed, then carries out exactly that one; give it a default case and it never blocks, which gives Go a send and a receive that give up instead of waiting.

Verified output of select_waits_on_many_go.go — regenerated by tools/run_examples.py, never hand-typed.

1. One select, two channels: it waits until either can proceed
   7 selects: 3 from temperatures, 4 from humidities

2. default: a receive that does not wait
   no job waiting, so default ran
   received: resize image

3. default: a send that does not wait
   queued:  order-1
   queued:  order-2
   dropped: order-3 (the buffer of 2 is full)
   dropped: order-4 (nobody waiting on the unbuffered channel)

4. Every case is evaluated on entry, whichever case is chosen
   label(order-5) ran
   chose the receive: invoice-7

Reading the output

1. Waiting on two channels. Two goroutines send readings, one on each channel. main has a single select with a receive case for each. Each time round the loop, the select blocks until one of the two senders is ready, receives from it, and leaves the other channel alone. Seven selects took seven values: the three temperatures and the four humidities. Which channel delivered first changes from run to run, and the totals cannot change, so the program prints the totals. The Go specification ↗ describes exactly this: if no communication can proceed and there is no default case, the select blocks until at least one can.

2. A receive that does not wait. The same sentence of the spec has a middle step: if nothing can proceed and there is a default case, default runs. The first tryReceive found jobs empty and ran default at once. After a job was put in the buffer, the receive case could proceed, so it ran instead.

3. A send that does not wait. A send can proceed ↗ when a buffered channel has room, or when a receiver is ready on an unbuffered one. queue has room for two orders, so the third went to default and was dropped. That is the whole of a "queue it, or shed the load if the queue is full" check. handoff is unbuffered and no goroutine was receiving from it, so its send could not proceed and default ran. Without default, that select would wait for a receiver that never comes, and All goroutines are asleep shows what the runtime does then.

4. Every case is evaluated on entry. This select had a send case whose value comes from a function call, label("order-5"), and a receive case that was ready. The receive was chosen, and label still ran, before the choice was made. The spec says that on entering a select, the channel of every receive, and the channel and value of every send, are evaluated exactly once, in source order, and that their side effects happen whichever case is selected. So a case such as case out <- summarize(batch): calls summarize every time the select is entered, including every time the send is not made.

select_waits_on_many_go.go

select_waits_on_many_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// A select waits on several channel operations at once and carries out exactly
// one of them. A default case turns it into a send or receive that never waits.
//
//  go run select_waits_on_many_go.go
package main

import "fmt"

func main() {
    fmt.Println("1. One select, two channels: it waits until either can proceed")
    temperatures := make(chan int)
    humidities := make(chan int)
    go func() {
        for _, celsius := range []int{21, 22, 23} {
            temperatures <- celsius
        }
    }()
    go func() {
        for _, percent := range []int{40, 45, 50, 55} {
            humidities <- percent
        }
    }()
    fromTemperatures, fromHumidities := 0, 0
    for range 7 {
        select { // blocks until one of the two senders is ready
        case <-temperatures:
            fromTemperatures++
        case <-humidities:
            fromHumidities++
        }
    }
    fmt.Printf("   7 selects: %d from temperatures, %d from humidities\n", fromTemperatures, fromHumidities)

    fmt.Println()
    fmt.Println("2. default: a receive that does not wait")
    jobs := make(chan string, 1)
    tryReceive := func() {
        select {
        case job := <-jobs:
            fmt.Println("   received:", job)
        default:
            fmt.Println("   no job waiting, so default ran")
        }
    }
    tryReceive()
    jobs <- "resize image"
    tryReceive()

    fmt.Println()
    fmt.Println("3. default: a send that does not wait")
    queue := make(chan string, 2)
    for _, order := range []string{"order-1", "order-2", "order-3"} {
        select {
        case queue <- order:
            fmt.Println("   queued: ", order)
        default:
            fmt.Println("   dropped:", order, "(the buffer of 2 is full)")
        }
    }
    handoff := make(chan string) // unbuffered, and no goroutine is receiving
    select {
    case handoff <- "order-4":
        fmt.Println("   handed over: order-4")
    default:
        fmt.Println("   dropped: order-4 (nobody waiting on the unbuffered channel)")
    }

    fmt.Println()
    fmt.Println("4. Every case is evaluated on entry, whichever case is chosen")
    invoices := make(chan string, 1)
    invoices <- "invoice-7"
    select {
    case handoff <- label("order-5"): // cannot proceed: nobody is receiving
        fmt.Println("   sent order-5")
    case invoice := <-invoices:
        fmt.Println("   chose the receive:", invoice)
    }
}

// label announces that it ran, so the output shows when select evaluated it.
func label(order string) string {
    fmt.Println("   label(" + order + ") ran")
    return order
}

What to do

  • Reach for select when one goroutine has more than one thing to wait for: two inputs, an input and a timeout (A timeout is a channel), or an input and a cancellation (Cancel reaches every goroutine).
  • Add default only when not waiting is the point: to poll, or to drop work when a queue is full (A buffered channel is a bounded queue). A default runs at once, so a for loop around a select whose default does nothing comes straight back round. That loop spins without ever waiting.
  • Keep work out of case expressions. Compute a value before the select, or accept that it is computed on every pass, sent or not.
  • Print what arrived, not the order it arrived in. A select over several producers interleaves them differently on every run.

In other languages

  • The Rust library's Channels ↗ page uses std::sync::mpsc, which has no select. Its Receiver can try_recv, which is Go's receive with a default, but every method waits on that one receiver. To wait on several at once, Rust code uses a crate: crossbeam_channel::select!, which also takes a default case, or tokio::select! in async code.
  • The Concurrency library's chapter 05, Message passing, which covers channels and select across languages, is planned.

Sources

  • The Go specification: Select statements ↗ and Send statements ↗.
  • A Tour of Go, Default Selection ↗.
  • Katherine Cox-Buday, Concurrency in Go (O'Reilly, 2017), chapter 3, "Go's Concurrency Building Blocks", the section on the select statement.
  • James Cutajar, Learn Concurrent Programming with Go (Manning, 2024), chapter 8, "Selecting channels".
  • Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 12, "Concurrency in Go", the section on select.