Skip to content

reflect.Select over cases known at run time

Level: 301 · anyone merging a number of channels that a config file, not the source, decides

One line: A select statement names its cases in the source; reflect.Select takes a []SelectCase built in a loop, blocks like the statement, and returns the index it chose, the received Value and recvOK — a nil or zero Chan switches a case off, SelectDefault makes it non-blocking, and each call allocates where the statement does not.

Four sensors, each a chan int, are put into a slice of reflect.SelectCase with Dir: SelectRecv. The program fills one channel at a time and checks that chosen is that channel's index; fills all four and sorts what it receives, because which comes first is random; adds a default case and calls with nothing ready; closes a sensor and reads the zero value back with recvOK == false; switches that case off two ways; sends through a SelectSend case; and counts allocations per call against a two-case select statement.

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

sensor 0 ready:  chosen 0, value 20, recvOK true
sensor 1 ready:  chosen 1, value 21, recvOK true
sensor 2 ready:  chosen 2, value 22, recvOK true
sensor 3 ready:  chosen 3, value 23, recvOK true
all four ready:  received [30 31 32 33]
nothing ready:   chosen 4 (the default), recv.IsValid false, recvOK false
sensor 2 closed: chosen 2, value 0, recvOK false
sensor 2 nil:    chosen 4 (the default)
sensor 2 zero:   chosen 4 (the default)
send case:       chosen 0, recv.IsValid false, recvOK false, delivered "sensor 2 offline"
allocations per call, select statement, 2 cases: 0
allocations per call, reflect.Select,   2 cases: 2

Reading the output

chosen is the index of the case that ran. With one channel ready at a time, reflect.Select returns that channel's position in the slice, its value as a reflect.Value (recv.Int() reads it) and recvOK == true because a real send produced the value. The docs ↗ describe the function as a select statement: it blocks until at least one case can proceed, chooses uniformly at random among the ready ones, and executes exactly that one. That randomness is why the "all four ready" line prints a sorted slice rather than the order of arrival — the same rule as select chooses at random.

A default case returns its own index and no value. withDefault appends {Dir: SelectDefault} as case 4. With nothing ready, chosen is 4, recv is the zero Value (IsValid false) and recvOK is false. This is the non-blocking select of select waits on many channels, with the default case appended rather than written last.

A closed channel is always ready, with recvOK false. After close(sensors[2]), reflect.Select(cases) picks case 2 immediately and hands back 0 — the zero value of the channel's element type — with recvOK == false, exactly as v, ok := <-ch would (Closing a channel ends a range). A loop that leaves the closed case in the slice would spin on it for ever, which is why the next two lines matter.

Switching a case off: a nil channel or a zero Chan. Setting cases[2].Chan to a reflect.Value of a nil chan int makes that case one that can never proceed — the rule of A nil channel disables a case, and the default is chosen. Setting Chan to the zero reflect.Value{} does the same by a different route: the SelectCase docs ↗ say a receive case whose Chan is the zero Value is ignored. The zero Value is the cleaner switch for a slice built at run time, because it needs no channel type; the kata uses it.

A send case carries its value in Send. {Dir: SelectSend, Chan: ValueOf(alerts), Send: ValueOf("sensor 2 offline")} is chosen when the buffered alerts has room; recv is invalid and recvOK false because nothing was received, and the string comes out of the channel afterwards. A Send on a receive case, a value of the wrong type, or a receive-only channel in a send case are run-time panics from reflect.Select itself.

Two allocations per call. The select statement over two channels allocates nothing; reflect.Select over the same two channels allocates twice per call on Go 1.25 — the runtime's copy of the case list and the box for the received value. Reflection costs allocations puts the same measurement beside a benchmark. The docs also set a ceiling: Select supports a maximum of 65536 cases.

reflect_select_over_cases_known_at_run_time_go.go

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

// reflect.Select is a select statement whose cases are a slice built at run
// time. It returns the index of the case it took, the received Value, and
// whether that value came from a send (false: the channel is closed).
//
//  go build reflect_select_over_cases_known_at_run_time_go.go && ./reflect_select_over_cases_known_at_run_time_go
package main

import (
    "fmt"
    "reflect"
    "sort"
    "testing"
)

var sink int

func main() {
    // Four sensors: a number a config file could set, so no select statement can name them.
    sensors := make([]chan int, 4)
    cases := make([]reflect.SelectCase, len(sensors))
    for i := range sensors {
        sensors[i] = make(chan int, 1)
        cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(sensors[i])}
    }

    // One channel ready at a time: the chosen index is that channel's.
    for i := range sensors {
        sensors[i] <- 20 + i
        chosen, recv, recvOK := reflect.Select(cases)
        fmt.Printf("sensor %d ready:  chosen %d, value %d, recvOK %t\n", i, chosen, recv.Int(), recvOK)
    }

    // All ready at once: the order is random, so collect and sort.
    for i := range sensors {
        sensors[i] <- 30 + i
    }
    var got []int
    for range sensors {
        _, recv, _ := reflect.Select(cases)
        got = append(got, int(recv.Int()))
    }
    sort.Ints(got)
    fmt.Println("all four ready:  received", got)

    // Nothing ready and a default case: the default's index, and no value.
    chosen, recv, recvOK := reflect.Select(withDefault(cases))
    fmt.Printf("nothing ready:   chosen %d (the default), recv.IsValid %t, recvOK %t\n", chosen, recv.IsValid(), recvOK)

    // A closed channel is always ready: the zero value, and recvOK false.
    close(sensors[2])
    chosen, recv, recvOK = reflect.Select(cases)
    fmt.Printf("sensor 2 closed: chosen %d, value %d, recvOK %t\n", chosen, recv.Int(), recvOK)

    // Switch it off: a nil channel is never ready, and a zero Value Chan is skipped.
    cases[2].Chan = reflect.ValueOf((chan int)(nil))
    chosen, _, _ = reflect.Select(withDefault(cases))
    fmt.Printf("sensor 2 nil:    chosen %d (the default)\n", chosen)
    cases[2].Chan = reflect.Value{}
    chosen, _, _ = reflect.Select(withDefault(cases))
    fmt.Printf("sensor 2 zero:   chosen %d (the default)\n", chosen)

    // A send case: Send holds the value; recv comes back invalid.
    alerts := make(chan string, 1)
    send := []reflect.SelectCase{{Dir: reflect.SelectSend, Chan: reflect.ValueOf(alerts), Send: reflect.ValueOf("sensor 2 offline")}}
    chosen, recv, recvOK = reflect.Select(send)
    fmt.Printf("send case:       chosen %d, recv.IsValid %t, recvOK %t, delivered %q\n", chosen, recv.IsValid(), recvOK, <-alerts)

    // What it costs, in allocations per call, against a select statement.
    a, b := make(chan int, 1), make(chan int, 1)
    two := []reflect.SelectCase{{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(a)}, {Dir: reflect.SelectRecv, Chan: reflect.ValueOf(b)}}
    fmt.Println("allocations per call, select statement, 2 cases:", testing.AllocsPerRun(100, func() {
        a <- 1
        select {
        case v := <-a:
            sink = v
        case v := <-b:
            sink = v
        }
    }))
    fmt.Println("allocations per call, reflect.Select,   2 cases:", testing.AllocsPerRun(100, func() {
        a <- 1
        _, v, _ := reflect.Select(two)
        sink = int(v.Int())
    }))
}

// withDefault returns a copy of cases with a default case appended.
func withDefault(cases []reflect.SelectCase) []reflect.SelectCase {
    out := append([]reflect.SelectCase(nil), cases...)
    return append(out, reflect.SelectCase{Dir: reflect.SelectDefault})
}

What to do

  • Reach for reflect.Select only when the number of cases is not known when you write the code. For two channels and a cancellation, write the statement. For "one goroutine per input channel, all forwarding into one output", Fan-out, fan-in needs no reflection and usually reads better.
  • Test recvOK on every receive, and switch the case off by setting its Chan to reflect.Value{} when it is false; a closed channel left in the slice is always ready.
  • Keep an open count and stop when it reaches zero: a reflect.Select whose every case is switched off, with no default, blocks for ever.
  • Read the received Value with the method for its Kind — Int, String, Interface — and remember that the zero value of a closed channel's element type is a legitimate Value, not an invalid one.
  • Budget for it. Two allocations and a runtime call per iteration is fine for a control loop and not for a hot path.

Practice

Write merge(inputs []<-chan string) (orders []string, rounds int) that receives every value from any number of channels with one reflect.Select loop, drops a channel's case when it closes, ends when all have closed, and returns the values sorted plus the number of Select calls it made. Feed it three warehouse goroutines that send two, three and one order and close.

Solution

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

// Kata: merge any number of order channels into one slice with reflect.Select,
// dropping each channel's case when it closes (recvOK false) so the loop ends
// when every channel has closed. The result is sorted, so the output does not
// depend on which warehouse was fastest.
//
//  go build reflect_select_over_cases_known_at_run_time_kata_go.go && ./reflect_select_over_cases_known_at_run_time_kata_go
package main

import (
    "fmt"
    "reflect"
    "sort"
)

func merge(inputs []<-chan string) (orders []string, rounds int) {
    cases := make([]reflect.SelectCase, len(inputs))
    for i, ch := range inputs {
        cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)}
    }
    for open := len(cases); open > 0; rounds++ {
        i, v, ok := reflect.Select(cases)
        if !ok {
            cases[i].Chan = reflect.Value{} // a zero Chan is ignored from now on
            open--
            continue
        }
        orders = append(orders, v.String())
    }
    sort.Strings(orders)
    return orders, rounds
}

func warehouse(name string, count int) <-chan string {
    out := make(chan string)
    go func() {
        defer close(out)
        for i := 1; i <= count; i++ {
            out <- fmt.Sprintf("%s-order-%d", name, i)
        }
    }()
    return out
}

func main() {
    inputs := []<-chan string{warehouse("east", 2), warehouse("west", 3), warehouse("north", 1)}
    orders, rounds := merge(inputs)
    fmt.Println(len(orders), "orders from", len(inputs), "channels in", rounds, "rounds of reflect.Select")
    for _, o := range orders {
        fmt.Println(" ", o)
    }
}

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

6 orders from 3 channels in 9 rounds of reflect.Select
  east-order-1
  east-order-2
  north-order-1
  west-order-1
  west-order-2
  west-order-3

In other languages

  • Concurrency library — How does one thread wait on several channels at once? ↗ is a stub that asks this question of six languages; Go's answer is the statement, and this page is the run-time form of it.
  • Rust — the Rust library's crossbeam channels ↗ covers the select! macro, whose arms are fixed in the source like Go's statement; the crate's Select ↗ type is the run-time form, built by adding recv and send operations in a loop and returning the index of the ready one, as reflect.Select does — without reflection, because the operations are typed closures.
  • Python has no channels; asyncio.wait ↗ over a list of tasks with FIRST_COMPLETED is the nearest thing to selecting over a number of sources known only at run time.

Sources

  • reflect.Select ↗ and reflect.SelectCase ↗ — the semantics, the zero-Chan rule, and the 65536-case maximum.
  • The Go specification, Select statements ↗ — the statement this function reproduces.
  • src/reflect/value.go at go1.25.5 ↗ — Select, and the panics for a Send on a receive case or a receive-only channel in a send case.
  • Adam Freeman, Pro Go (Apress, 2022), chapter 29, "Using Reflection, Part 3" — "Working with Channel Values" and "Selecting from Multiple Channels".
  • Burak Serdar, Effective Concurrency in Go (Packt, 2023), chapter 5, "Worker Pools and Pipelines" — the side note on reflect.Select for a fan-in over a dynamic number of channels.