How Go's select Picks a Random Ready Case (and Why the Order Is Random)
Go's select shuffles the order in which it polls its cases with cheaprandn and takes the first ready one, which is why a select over two always-ready channels picks either at random.
TL;DR:
selectdoes not “pick a random case” by rolling dice on the ready set. It shuffles the order it polls the cases in usingcheaprandn, then takes the first case that turns out to be ready. Random order plus first-match is exactly equivalent to a uniform random choice among the ready cases, and it costs one random number per case, no allocation and no second scan.
Everyone knows the rule: if several cases of a select are ready, Go picks
one at random. Almost nobody knows how, and the “how” is where the
interesting engineering lives — because the naive implementation of “pick
one at random” is surprisingly expensive.
This is what the Go 1.28 runtime actually does, read from
src/runtime/select.go.
The case descriptor
The compiler hands the runtime a flat array of cases, sends first:
// src/runtime/select.go:20
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}and an array of uint16 order slots:
// src/runtime/select.go:118
// selectgo returns the index of the chosen scase, which matches the
// ordinal position of its respective select{recv,send,default} call.
// Also, if the chosen scase was a receive operation, it reports whether
// a value was received.
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool) {
gp := getg()
if debugSelect {
print("select: cas0=", cas0, "\n")
}
// NOTE: In order to maintain a lean stack size, the number of scases
// is capped at 65536.
cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
ncases := nsends + nrecvs
scases := cas1[:ncases:ncases]
pollorder := order1[:ncases:ncases]
lockorder := order1[ncases:][:ncases:ncases]Two order arrays share one backing allocation: pollorder is the
randomised order in which cases are polled, and lockorder is the order
in which the channels are locked (sorted by address, to avoid deadlock).
Both live on the goroutine’s stack, so a select allocates nothing.
Step one: shuffle the poll order
Here is the randomness, in full:
// src/runtime/select.go:167
// generate permuted order
norder := 0
allSynctest := true
for i := range scases {
cas := &scases[i]
// Omit cases without channels from the poll and lock orders.
if cas.c == nil {
cas.elem = nil // allow GC
continue
}
if cas.c.bubble != nil {
if getg().bubble != cas.c.bubble {
fatal("select on synctest channel from outside bubble")
}
} else {
allSynctest = false
}
if cas.c.timer != nil {
cas.c.timer.maybeRunChan(cas.c)
}
j := cheaprandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)
norder++
}
pollorder = pollorder[:norder]
lockorder = lockorder[:norder]This is an insertion shuffle — the classic Fisher-Yates “select a random slot and swap” trick, done inline as the cases are collected. For each case, pick a random position among the ones seen so far and swap it into place. The result is a uniformly random permutation of the cases.
cheaprandn is the cheap, unbiased version:
// src/runtime/rand.go:317
//go:linkname cheaprandn
//go:nosplit
func cheaprandn(n uint32) uint32 {
// See https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/
return uint32((uint64(cheaprand()) * uint64(n)) >> 32)
}That is Lemire’s multiply-shift method: instead of rand() % n, which is
biased because 2^32 is not a multiple of n, it multiplies a 32-bit
random number by n and keeps the top 32 bits. One multiply, one shift,
no division, and no modulo bias.
Note the cas.c == nil check at the top of the loop. A select case
with a nil channel is dropped from the poll order entirely. It is not
“a case that blocks forever” — it is not a case at all. That is the
documented way to disable a branch of a select at runtime, and it costs
one branch in the shuffle loop.
Step two: work out the lock order
Before the runtime can look at any channel, it has to decide the order in
which to lock them. Two goroutines running two selects over the same two
channels in opposite orders would deadlock if they locked in case order,
so the lock order is sorted by channel address:
// src/runtime/select.go:206
// sort the cases by Hchan address to get the locking order.
// simple heap sort, to guarantee n log n time and constant stack footprint.
for i := range lockorder {
j := i
// Start with the pollorder to permute cases on the same channel.
c := scases[pollorder[i]].c
for j > 0 && scases[lockorder[(j-1)/2]].c.sortkey() < c.sortkey() {
k := (j - 1) / 2
lockorder[j] = lockorder[k]
j = k
}
lockorder[j] = pollorder[i]
}A heap sort, written out longhand, because sort would allocate and the
runtime cannot. The sort key is the channel’s address:
// src/runtime/select.go:545
func (c *hchan) sortkey() uintptr {
return uintptr(unsafe.Pointer(c))
}Address order is a total order that every select over the same set of
channels agrees on, which is exactly what a deadlock-free lock ordering
requires.
The locks are then taken in that order, and released in reverse:
// src/runtime/select.go:34
func sellock(scases []scase, lockorder []uint16) {
var c *hchan
for _, o := range lockorder {
c0 := scases[o].c
if c0 != c {
c = c0
lock(&c.lock)
}
}
}Note that a channel appearing in several cases is locked once — sellock
skips a repeat of the previous channel.
Step three: pass one, poll for something already ready
Now the actual selection. The runtime holds every channel’s lock and walks the randomised poll order, taking the first case that can proceed immediately:
// src/runtime/select.go:251
// lock all the channels involved in the select
sellock(scases, lockorder)
var (
sg *sudog
c *hchan
k *scase
sglist *sudog
sgnext *sudog
qp unsafe.Pointer
nextp **sudog
)
// pass 1 - look for something already waiting
var casi int
var cas *scase
var caseSuccess bool
var caseReleaseTime int64 = -1
var recvOK bool
for _, casei := range pollorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
if casi >= nsends {
sg = c.sendq.dequeue()
if sg != nil {
goto recv
}
if c.qcount > 0 {
goto bufrecv
}
if c.closed != 0 {
goto rclose
}
} else {
if raceenabled {
racereadpc(c.raceaddr(), casePC(casi), chansendpc)
}
if c.closed != 0 {
goto sclose
}
sg = c.recvq.dequeue()
if sg != nil {
goto send
}
if c.qcount < c.dataqsiz {
goto bufsend
}
}
}This is the whole selection algorithm. It is a single pass over the shuffled cases, and the first one that is ready wins. There is no counter of ready cases, no second pass to pick an index, no array of ready flags.
Why is “shuffle then take the first ready” the same as “pick uniformly at random among the ready ones”? Because the permutation is uniform: for any set of ready cases, each of them is equally likely to be first in the permutation, so each is equally likely to be the one that is found first. The shuffling trick lets the runtime avoid the bookkeeping of finding the ready set before choosing from it — it just finds the first one while scanning.
Note also that this pass does not sleep, allocate or block: it only inspects the channels under their locks. For a send case, “ready” means there is a waiting receiver or there is buffer space; for a receive case, “ready” means there is a waiting sender, or there is a value in the buffer, or the channel is closed.
The default case
If the pass finds nothing and the select has a default, the runtime gives
up immediately:
// src/runtime/select.go:303
if !block {
selunlock(scases, lockorder)
casi = -1
goto retc
}block is false exactly when the compiler saw a default clause. The
-1 is the “no case chosen” sentinel, which the compiler turns back into
“run the default branch”.
So a select with a default never parks. It is a poll.
Step four: pass two, park on everything
If nothing was ready and there is no default, the goroutine has to
commit to waiting. It registers itself as a waiter on every channel in
the select — this is the part that makes a select expensive:
// src/runtime/select.go:309
// pass 2 - enqueue on all chans
if gp.waiting != nil {
throw("gp.waiting != nil")
}
nextp = &gp.waiting
for _, casei := range lockorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
sg := acquireSudog()
sg.g = gp
sg.isSelect = true
// No stack splits between assigning elem and enqueuing
// sg on gp.waiting where copystack can find it.
sg.elem.set(cas.elem)
sg.releasetime = 0
if t0 != 0 {
sg.releasetime = -1
}
sg.c.set(c)
// Construct waiting list in lock order.
*nextp = sg
nextp = &sg.waitlink
if casi < nsends {
c.sendq.enqueue(sg)
} else {
c.recvq.enqueue(sg)
}
if c.timer != nil {
blockTimerChan(c)
}
}One sudog per case, each enqueued on its channel’s wait queue, and the
sudogs also linked into gp.waiting in lock order — that link order
is what lets the park machinery unlock the channels in the right sequence.
Then the goroutine parks:
// src/runtime/select.go:344
// wait for someone to wake us up
gp.param = nil
// Signal to anyone trying to shrink our stack that we're about
// to park on a channel. The window between when this G's status
// changes and when we set gp.activeStackChans is not safe for
// stack shrinking.
gp.parkingOnChan.Store(true)
gopark(selparkcommit, nil, waitReason, traceBlockSelect, 1)
gp.activeStackChans = false
sellock(scases, lockorder)selparkcommit is the unlockf that releases every channel lock:
// src/runtime/select.go:63
func selparkcommit(gp *g, _ unsafe.Pointer) bool {
// There are unlocked sudogs that point into gp's stack. Stack
// copying must lock the channels of those sudogs.
// Set activeStackChans here instead of before we try parking
// because we could self-deadlock in stack growth on a
// channel lock.
gp.activeStackChans = true
// Mark that it's safe for stack shrinking to occur now,
// because any thread acquiring this G's stack for shrinking
// is guaranteed to observe activeStackChans after this store.
gp.parkingOnChan.Store(false)
// Make sure we unlock after setting activeStackChans and
// unsetting parkingOnChan. The moment we unlock any of the
// channel locks we risk gp getting readied by a channel operation
// and so gp could continue running before everything before the
// unlock is visible (even to gp itself).
// This must not access gp's stack (see gopark). In
// particular, it must not access the *hselect. That's okay,
// because by the time this is called, gp.waiting has all
// channels in lock order.
var lastc *hchan
for sg := gp.waiting; sg != nil; sg = sg.waitlink {
if sg.c.get() != lastc && lastc != nil {
// As soon as we unlock the channel, fields in
// any sudog with that channel may change,
// including c and waitlink. Since multiple
// sudogs may have the same channel, we unlock
// only after we've passed the last instance
// of a channel.
unlock(&lastc.lock)
}
lastc = sg.c.get()
}
if lastc != nil {
unlock(&lastc.lock)
}
return true
}The unlocking walks gp.waiting — which was built in lock order — so the
channels are released in exactly the reverse of the order they were taken.
That is what keeps two selects over the same channels from deadlocking.
Step five: pass three, clean up
When the goroutine is woken by one of the channels, it has to remove
itself from the other channels’ wait queues. This is the part people
forget: a select over five channels parks five sudogs and has to
dequeue four of them.
// src/runtime/select.go:360
// pass 3 - dequeue from unsuccessful chans
// otherwise they stack up on quiet channels
// record the successful case, if any.
// We singly-linked up the SudoGs in lock order.
casi = -1
cas = nil
caseSuccess = false
sglist = gp.waiting
// Clear all elem before unlinking from gp.waiting.
for sg1 := gp.waiting; sg1 != nil; sg1 = sg1.waitlink {
sg1.isSelect = false
sg1.elem.set(nil)
sg1.c.set(nil)
}
gp.waiting = nil
for _, casei := range lockorder {
k = &scases[casei]
if k.c.timer != nil {
unblockTimerChan(k.c)
}
if sg == sglist {
// sg has already been dequeued by the G that woke us up.
casi = int(casei)
cas = k
caseSuccess = sglist.success
if sglist.releasetime > 0 {
caseReleaseTime = sglist.releasetime
}
} else {
c = k.c
if int(casei) < nsends {
c.sendq.dequeueSudoG(sglist)
} else {
c.recvq.dequeueSudoG(sglist)
}
}
sgnext = sglist.waitlink
sglist.waitlink = nil
releaseSudog(sglist)
sglist = sgnext
}The successful case is the sudog that the waking goroutine already
dequeued from the queue; every other sudog has to be unlinked by hand.
This is also where the “select on a closed channel” result comes from:
caseSuccess is false if the wake-up was caused by a close rather than a
value transfer.
The two immediate cases
Pass one does not handle the “value is sitting in the buffer” cases inline; it jumps to labels that do the copy. A buffered receive:
// src/runtime/select.go:446
bufrecv:
// can receive from buffer
if raceenabled {
if cas.elem != nil {
raceWriteObjectPC(c.elemtype, cas.elem, casePC(casi), chanrecvpc)
}
racenotify(c, c.recvx, nil)
}
if msanenabled && cas.elem != nil {
msanwrite(cas.elem, c.elemtype.Size_)
}
if asanenabled && cas.elem != nil {
asanwrite(cas.elem, c.elemtype.Size_)
}
recvOK = true
qp = chanbuf(c, c.recvx)
if cas.elem != nil {
typedmemmove(c.elemtype, cas.elem, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
selunlock(scases, lockorder)
goto retcand a buffered send:
// src/runtime/select.go:474
bufsend:
// can send to buffer
if raceenabled {
racenotify(c, c.sendx, nil)
raceReadObjectPC(c.elemtype, cas.elem, casePC(casi), chansendpc)
}
if msanenabled {
msanread(cas.elem, c.elemtype.Size_)
}
if asanenabled {
asanread(cas.elem, c.elemtype.Size_)
}
typedmemmove(c.elemtype, chanbuf(c, c.sendx), cas.elem)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
selunlock(scases, lockorder)
goto retcBoth unlock every channel before returning. A select that completes
immediately still paid for locking all of the channels.
reflect.Select is the same function
The reflection-based select does not have its own implementation. It
rebuilds the case array and calls selectgo directly:
// src/runtime/select.go:568
//go:linkname reflect_rselect reflect.rselect
func reflect_rselect(cases []runtimeSelect) (int, bool) {
if len(cases) == 0 {
block()
}
sel := make([]scase, len(cases))
orig := make([]int, len(cases))
nsends, nrecvs := 0, 0
dflt := -1
for i, rc := range cases {
var j int
switch rc.dir {
case selectDefault:
dflt = i
continue
case selectSend:
j = nsends
nsends++
case selectRecv:
nrecvs++
j = len(cases) - nrecvs
}
sel[j] = scase{c: rc.ch, elem: rc.val}
orig[j] = i
}
// Only a default case.
if nsends+nrecvs == 0 {
return dflt, false
}
// Compact sel and orig if necessary.
if nsends+nrecvs < len(cases) {
copy(sel[nsends:], sel[len(cases)-nrecvs:])
copy(orig[nsends:], orig[len(cases)-nrecvs:])
}
order := make([]uint16, 2*(nsends+nrecvs))
var pc0 *uintptr
if raceenabled {
pcs := make([]uintptr, nsends+nrecvs)
for i := range pcs {
selectsetpc(&pcs[i])
}
pc0 = &pcs[0]
}
chosen, recvOK := selectgo(&sel[0], &order[0], pc0, nsends, nrecvs, dflt == -1)
// Translate chosen back to caller's ordering.
if chosen < 0 {
chosen = dflt
} else {
chosen = orig[chosen]
}
return chosen, recvOK
}The interesting part is the reordering: reflect.Select takes its cases
in the caller’s order, but the runtime wants sends first and receives
after, so it shuffles them into sel and keeps an orig map to translate
the chosen index back. And unlike a compiled select, reflect.Select
allocates: sel, orig and order are all heap slices.
The single-case special case
A select with exactly one case and a default does not go through
selectgo at all. The compiler rewrites it into a plain non-blocking
channel operation:
// src/runtime/chan.go:761
// compiler implements
//
// select {
// case c <- v:
// ... foo
// default:
// ... bar
// }
//
// as
//
// if selectnbsend(c, v) {
// ... foo
// } else {
// ... bar
// }
func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
return chansend(c, elem, false, sys.GetCallerPC())
}and the receive twin:
// src/runtime/chan.go:781
// compiler implements
//
// select {
// case v, ok = <-c:
// ... foo
// default:
// ... bar
// }
//
// as
//
// if selected, ok = selectnbrecv(&v, c); selected {
// ... foo
// } else {
// ... bar
// }
func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected, received bool) {
return chanrecv(c, elem, false)
}So select { case v := <-c: default: } costs one lock-free check and,
if it fails, one lock. It never locks more than one channel and never
randomises anything — there is nothing to randomise between.
What a select costs
Let’s put numbers on it. For a select over N channels:
| Step | Cost |
|---|---|
| Shuffle | N calls to cheaprandn, N swaps |
| Sort | N log N comparisons, heap sort, no allocation |
| Lock | up to N mutex acquisitions |
| Poll | up to N queue checks under those locks |
| Park (if nothing ready) | N sudogs allocated, N enqueues, N dequeues on wake-up |
Compare that with a plain channel operation: one lock, one check, maybe one copy.
So a select over N channels is roughly N times the cost of a single
channel operation even in the best case where one case is immediately
ready, because all N channels are locked before any of them is inspected.
This is the single most useful practical fact in this article:
// hot path: a select that usually has exactly one ready case
for {
select {
case v := <-a:
handle(v)
case v := <-b:
handle(v)
}
}is measurably slower than the same thing written as two separate
receives guarded by len() checks, or as two goroutines feeding one
channel. The select buys you the ability to wait on many channels at
once; it charges you for all of them, always.
The mitigation is to keep the number of cases small and to use default
where a non-blocking poll is enough.
Trade-offs
What random order buys. Fairness. If select always preferred the
first ready case in source order, then a channel earlier in the list would
starve the ones after it under load. Shuffling makes every case equally
likely to win, so no case is systematically favoured.
What it costs. One multiply and one shift per case, plus the swap. For a two-case select that is a couple of nanoseconds — negligible compared to the locking.
What the locking buys. The runtime must hold all the channel locks while it decides, otherwise two goroutines could both decide to take the same value. The address-ordered lock acquisition is what makes that safe without deadlock.
What it costs. A select over N channels blocks N mutexes. If two goroutines select over the same channels in different orders, the address sort saves them. But if they select over overlapping channel sets, the locks are held for the duration of the decision — which includes the parking setup.
Caveats
The randomness is not crypto-strength and not seeded per call. It comes from the per-M cheap random generator, whose state advances on every use. Two selects in a row from the same goroutine get different orders; but you should never build a protocol on the exact case chosen.
A select on a nil channel never fires. The case is dropped from the
poll order during the shuffle, so it is not merely “blocked” — it is
invisible. This is the documented way to disable a branch, and it is also
why select {} with no cases blocks forever.
select {} with no cases blocks forever. The runtime does it with an
explicit park:
// src/runtime/select.go:103
func block() {
gopark(nil, nil, waitReasonSelectNoCases, traceBlockForever, 1) // forever
}A send on a closed channel in a select panics, even if other cases
were ready. The panic happens in the sclose label after the unlock, so
the other channels are released properly and the panic does not wedge the
runtime:
// src/runtime/select.go:539
sclose:
// send on closed channel
selunlock(scases, lockorder)
panic(plainError("send on closed channel"))
}Receiving from a closed channel in a select succeeds immediately with
the zero value and ok == false, and it competes with other ready cases
like any other.
Descriptions of select that say “the runtime counts the ready cases and
picks one at random” describe an older implementation. The code in this
tree does not count anything: it shuffles the poll order and takes the
first ready case. The observable behaviour is the same, but the cost is
different, and the cost is what matters when you are deciding whether a
select is affordable in a hot loop.
Wrapping up
The one thing to remember: select shuffles the order it polls its
cases in with cheaprandn and takes the first ready one. Random order plus
first-match is a uniform random choice among the ready cases, computed in
one pass with no allocation.
The part people get wrong: a select locks every channel it
mentions, before it knows whether any of them is ready. A select over N
channels is not “N cheap polls”; it is N mutex acquisitions plus N queue
inspections, even when the first case would have sufficed.
What to go do about it: measure it. Write a benchmark that selects over
two always-ready channels and compare it with two plain receives on
separate channels feeding a single select with one case. If the select is
the bottleneck, the fix is almost never the language — it is fewer cases,
or a default, or restructuring so that one channel carries everything.
Further reading
src/runtime/select.go—selectgo,sellock,selunlock,selparkcommit,reflect_rselectsrc/runtime/rand.go—cheaprandnand the multiply-shift reductionsrc/runtime/chan.go—chansend,chanrecv,selectnbsend,selectnbrecvsrc/runtime/runtime2.go— thesudogstruct and theg.selectDoneflagsrc/reflect/value.go—reflect.SelectandruntimeSelect