Skip to content
gohacks.dev
Concurrency

Select Fairness in Go and What Random Really Means

Measure how Go's select chooses among ready cases, why it cannot give priority, the nested select idiom for urgent channels, and the case order trap.

6 min read
Summarize in AI
Flat illustration of a buffered channel between a sender and a receiver.

The Go specification says something very specific about a select with more than one ready case: a single one is chosen via a uniform pseudo-random selection. People read that and draw conclusions that don't always follow. Some assume the order of the cases in the source is a priority. Some assume a busy channel can starve a quiet one. Some assume the randomness is a bug waiting to bite when they need determinism. This article measures what the runtime does, shows the shape of the guarantee, and gives the standard idiom for the thing select will not do for you, which is prefer one channel over another.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It follows how channels are implemented, where the shuffle lives in selectgo, assumes the select statement, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6.

Measuring the choice

The experiment is direct. Three buffered channels are kept permanently full, so all three cases are ready every time. A loop runs 900,000 selects and counts which case won:

main.goGo
package main

import "fmt"

func main() {
	a, b, c := make(chan int, 1), make(chan int, 1), make(chan int, 1)
	counts := [3]int{}

	for range 900_000 {
		// Refill whatever was taken so all three are ready at every select.
		for _, ch := range []chan int{a, b, c} {
			select {
			case ch <- 1:
			default:
			}
		}
		select {
		case <-a:
			counts[0]++
		case <-b:
			counts[1]++
		case <-c:
			counts[2]++
		}
	}
	fmt.Printf("a=%d b=%d c=%d (a third is %d)\n", counts[0], counts[1], counts[2], 900_000/3)
}
Text
a=300533 b=299380 c=300087 (a third is 300000)

Each case won about a third of the time. The first case in the source gets no advantage over the last, and a case does not lose because it appears late. The counts will differ by a few hundred from run to run, as random draws do, and each will land within a fraction of a percent of 300,000. The runtime achieves this by shuffling the order in which it polls the cases on every call to selectgo, then taking the first ready one.

What fairness means here

The guarantee is statistical. Over many selects with all cases ready, each is chosen about equally. It says nothing about any single decision, and nothing about patterns: a run of the same case several times in a row is possible and expected, with probability one in nine for two repeats of three equally likely cases. Do not write code that requires alternation.

It also only covers cases that are ready. A case that isn't ready can't be chosen, however long it has waited. When two sources are both ready, each wins about half the calls, so a source that is always ready and a source that has one message every ten milliseconds get equal treatment at the moment of choice; the slow one is ready less often. Fairness between ready channels is not fairness between producers, and it is not a rate limit: a loop that handles each message with the same effort will still spend most of its time on the busy source, because that is where the messages are.

The randomness also means starvation in the strict sense cannot happen to a ready case: a case that is continuously ready has probability 1/n of winning every call, so the chance of never being chosen in k calls shrinks geometrically. That is the property people usually want when they ask whether select is fair.

What select cannot do: priority

Because the choice is uniform, select can't express "take from urgent if there's anything there, otherwise from normal". Case order is not priority, and a program that assumes it is has a bug that appears under load, when both channels are ready at once. Typical victims are shutdown signals: a worker loop selects on jobs and quit, and under a steady stream of jobs it keeps choosing jobs about half the time while quit is also ready, so shutdown takes longer than expected but eventually works, which hides the problem until the jobs never stop.

The idiom is two selects. The first checks the urgent channel with a default so it never blocks. If it fires, handle it and loop. Otherwise fall through to a second select that waits on everything:

main.goGo
package main

import "fmt"

func main() {
	urgent := make(chan string, 8)
	normal := make(chan string, 8)
	for i := 1; i <= 4; i++ {
		normal <- fmt.Sprintf("normal-%d", i)
	}
	urgent <- "urgent-A"
	urgent <- "urgent-B"

	// Without priority, both channels are ready and the order is random.
	// With the nested select, urgent work always goes first.
	for range 6 {
		select {
		case m := <-urgent:
			fmt.Println(m)
			continue
		default:
		}
		select {
		case m := <-urgent:
			fmt.Println(m)
		case m := <-normal:
			fmt.Println(m)
		}
	}
}
Text
urgent-A
urgent-B
normal-1
normal-2
normal-3
normal-4

Both urgent messages come out first, in order, and the four normal messages follow. The first select is the priority check; the second is the ordinary wait. The second still includes the urgent case, which matters: if both channels are empty when the first select runs and an urgent message arrives while the goroutine is blocked in the second, it must be able to wake. Without that case the worker would miss it until a normal message arrived.

This is a weak priority. Between the first check and the second select, an urgent message may arrive while a normal one is also ready, and the second select may choose the normal one; the urgent message is picked up on the next loop iteration, which is a delay of one item. For shutdown and cancellation that is exactly right. If you need strict ordering for more than two levels, the usual approach is to keep one queue per level and drain the highest non-empty one, or to use a single priority queue under a lock.

The default case and busy loops

A select with a default never blocks. In a loop, that turns into a spin unless something in the loop does block:

Go
for {
	select {
	case m := <-in:
		handle(m)
	default:
		// nothing ready: this loop now burns a core
	}
}

This is the most common way a program ends up using 100% of a CPU while doing nothing. Remove the default so the select blocks until work arrives, or if you really are polling, sleep or use a ticker. A default is for a single non-blocking attempt (try to send, else drop; try to receive, else proceed), as in the refill loop in the first experiment and in load shedding, where "drop if the queue is full" is the whole point.

Other things the randomness affects

Tests that depend on order. If a test has two goroutines racing into a select, whichever channel wins is random, and the test is flaky by construction. Make the readiness deterministic by sending one value at a time and waiting for the result, or control order with extra synchronization.

Timeouts in a select. A time.After case and a data case can both be ready when the timeout and the data arrive close together. The pick between them is random, so a "timeout" can fire a moment after data was available. Design the handler so either outcome is acceptable, or re-check the data channel after a timeout.

Closed channels. A closed channel is always ready, so a select with a closed channel among its cases will keep choosing it (about its fair share of the time) unless you set the variable to nil after you notice the close, which removes it from consideration (nil channels in select).

The behaviour is part of the language: the specification of the select statement states the uniform pseudo-random rule, and the Go blog's concurrency patterns article uses nested selects in its cancellation examples. If you need to reason about select more precisely, the implementation notes in the previous article give the polling and lock order.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.