Skip to content

select

select is a structure that can simultaneously listen to multiple channel states. Its syntax is similar to switch:

go
import (
  "context"
  "log/slog"
  "os"
  "os/signal"
  "time"
)

func main() {
  finished := make(chan struct{})
  ctx, stop := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt)
  defer stop()
  slog.Info("running")

  go func() {
    time.Sleep(time.Second * 2)
    finished <- struct{}{}
  }()

  select {
  case <-ctx.Done():
    slog.Info("shutting down")
  case <-finished:
    slog.Info("finished")
  }
}

This code implements a simple graceful shutdown logic by combining context, channel, and select. The select simultaneously listens to two channels: ctx.Done and finished. It has two exit conditions: first, the operating system sends an exit signal; second, the finished channel has data to read, meaning user code task is complete. This allows us to do cleanup work when the program exits.

As we know, select has two very important characteristics: first, non-blocking. In the source code of channel send and receive, you can see that select is handled specially, allowing you to check if a channel is available without blocking. Second, randomization. If multiple channels are available, it randomly selects one to execute. Not following a fixed order ensures each channel is executed relatively fairly; otherwise, in extreme cases, some channels might never be processed. Because all its work is related to channels, it's recommended to read the chan article first. Understanding channels before learning about select will make things much clearer.

Structure

At runtime, there's only a runtime.scase struct representing a select branch. Each case at runtime is represented as scase:

go
type scase struct {
  c    *hchan         // chan
  elem unsafe.Pointer // data element
}

Here c refers to the channel, and elem is the pointer to the receive or send element. Actually, the select keyword refers to the runtime.selectgo function.

Principles

Go divides select usage into four cases for optimization. This can be seen in the cmd/compile/internal/walk.walkSelectCases function, which handles these four cases:

go
func walkSelectCases(cases []*ir.CommClause) []ir.Node {
  ncas := len(cases)
  sellineno := base.Pos

  // optimization: zero-case select
  if ncas == 0 {
    return []ir.Node{mkcallstmt("block")}
  }

  // optimization: one-case select: single op.
  if ncas == 1 {
    ...
  }

  // optimization: two-case select but one is default: single non-blocking op.
  if ncas == 2 && dflt != nil {
    ...
  }

  ...
  return init
}

Optimization

The compiler optimizes the first three cases. The first case is when case count is 0, i.e., an empty select. We all know that an empty select statement causes the current goroutine to block permanently:

go
select{}

The reason it blocks is because the compiler translates it into a direct call to runtime.block function:

go
func block() {
  gopark(nil, nil, waitReasonSelectNoCases, traceBlockForever, 1) // forever
}

The block function calls runtime.gopark function, changing the current goroutine to _Gwaiting state and entering permanent blocking, never to be scheduled again.

The second case: only one case and it's not default. In this case, the compiler directly translates it into a channel send/receive operation, and it's blocking. For example, the following code:

go
func main() {
  ch := make(chan int)
  select {
  case <-ch:
        // do something
  }
}

It's translated into a direct call to runtime.chanrecv1 function, which can be seen from the assembly code:

go
TEXT  main.main(SB), ABIInternal, $2
...
LEAQ  type:chan int(SB), AX
XORL  BX, BX
PCDATA  $1, $0
CALL  runtime.makechan(SB)
XORL  BX, BX
NOP
CALL  runtime.chanrecv1(SB)
ADDQ  $16, SP
POPQ  BP
...

In the case of only one case, sending data to channel follows the same principle. It's translated into a direct call to runtime.chansend1 function, also blocking.

The third case: two cases where one is default:

go
func main() {
  ch := make(chan int)
  select {
  case ch <- 1:
        // do something
  default:
        // do something
  }
}

This case is translated into an if statement calling runtime.selectnbsend:

go
if selectnbsend(ch, 1) {
  // do something
} else {
  // do something
}

If receiving channel data, it's translated into a call to runtime.selectnbrecv:

go
ch := make(chan int)
select {
  case x, ok := <-ch:
      // do something
  default:
      // do something
}
go
if selected, ok = selectnbrecv(&v, c); selected {
  // do something
} else {
  // do something
}

It's worth noting that in this case, channel receive or send is non-blocking. We can clearly see that the block parameter is false:

go
func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
  return chansend(c, elem, false, getcallerpc())
}

func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected, received bool) {
  return chanrecv(c, elem, false)
}

Whether sending or receiving channel data, when block is false, there's a fast path that can check if send or receive is possible without locking. As shown below:

go
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
  if !block && empty(c) {
        if atomic.Load(&c.closed) == 0 {
      return
    }
    return true, false
  }
  ...
}

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    if !block && c.closed == 0 && full(c) {
    return false
  }
    ...
}

When reading from channel, if channel is empty it returns directly. When writing to channel, if channel is not closed and already full, it returns directly. In general cases, these would cause goroutine blocking, but when combined with select, they don't.

Processing

The above three cases are optimizations for special cases. Normally used select keyword is translated into a call to runtime.selectgo function, whose processing logic is over 400 lines long:

go
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool)

The compiler collects all case statements into a scase array and passes it to selectgo function. After processing, it returns two values:

  1. First is the randomly selected channel index, indicating which channel was processed. Returns -1 if none.
  2. Second indicates whether read channel operation was successful.

Here's a simple explanation of its parameters:

  • cas0: head pointer of scase array. First half stores write channel cases, second half stores read channel cases, distinguished by nsends.
  • order0: its length is twice the scase array. First half is allocated to pollorder array, second half to lockorder array.
  • nsends and nrecvs: number of read/write channel cases. Their sum is the total case count.
  • block: whether to block. If there's a default case, it represents non-blocking, value is false, otherwise true.
  • pc0: points to a [ncases]uintptr array head, used for race analysis. Can be ignored later; it doesn't help understand select.

Suppose we have the following code:

go
func main() {
  ch := make(chan int)
  select {
  case ch <- 1:
    println(1)
  case ch <- 2:
    println(2)
  case ch <- 3:
    println(3)
  case ch <- 4:
    println(4)
  default:
    println("default")
  }
}

View its assembly form. For clarity, some code is omitted:

go
0x0000 00000 TEXT  main.main(SB), ABIInterna
...
0x0023 00035 CALL  runtime.makechan(SB)
0x0028 00040 MOVQ  $1, main..autotmp_2+72(SP) // temporary variables 1 2 3 4
0x0031 00049 MOVQ  $2, main..autotmp_4+64(SP)
0x003a 00058 MOVQ  $3, main..autotmp_6+56(SP)
0x0043 00067 MOVQ  $4, main..autotmp_8+48(SP)
...
0x00c8 00200 CALL  runtime.selectgo(SB) // call runtime.selectgo function
0x00cd 00205 TESTQ  AX, AX
0x00d0 00208 JLT  352 // jump to default branch
0x00d6 00214 PCDATA  $1, $-1
0x00d6 00214 JEQ  320 // jump to branch 4
0x00d8 00216 CMPQ  AX, $1
0x00dc 00220 JEQ  288 // jump to branch 3
0x00de 00222 NOP
0x00e0 00224 CMPQ  AX, $2
0x00e4 00228 JNE  258 // jump to branch 2
0x00e6 00230 PCDATA  $1, $0
0x00e6 00230 CALL  runtime.printlock(SB)
0x00eb 00235 MOVL  $3, AX
0x00f0 00240 CALL  runtime.printint(SB)
0x00f5 00245 CALL  runtime.printnl(SB)
0x00fa 00250 CALL  runtime.printunlock(SB)
0x00ff 00255 NOP
0x0100 00256 JMP  379
0x0102 00258 CALL  runtime.printlock(SB)
0x0107 00263 MOVL  $4, AX
0x010c 00268 CALL  runtime.printint(SB)
0x0111 00273 CALL  runtime.printnl(SB)
0x0116 00278 CALL  runtime.printunlock(SB)
0x011b 00283 JMP  379
0x011d 00285 NOP
0x0120 00288 CALL  runtime.printlock(SB)
0x0125 00293 MOVL  $2, AX
0x012a 00298 CALL  runtime.printint(SB)
0x012f 00303 CALL  runtime.printnl(SB)
0x0134 00308 CALL  runtime.printunlock(SB)
0x0139 00313 JMP  379
0x013b 00315 NOP
0x0140 00320 CALL  runtime.printlock(SB)
0x0145 00325 MOVL  $1, AX
0x014a 00330 CALL  runtime.printint(SB)
0x014f 00335 CALL  runtime.printnl(SB)
0x0154 00340 CALL  runtime.printunlock(SB)
0x0159 00345 JMP  379
0x015b 00347 NOP
0x0160 00352 CALL  runtime.printlock(SB)
0x0165 00357 LEAQ  go:string."default\n"(SB)
0x016c 00364 MOVL  $8, BX
0x0171 00369 CALL  runtime.printstring(SB)
0x0176 00374 CALL  runtime.printunlock(SB)
0x017b 00379 PCDATA  $1, $-1
0x017b 00379 ADDQ  $160, SP
0x0182 00386 POPQ  BP
0x0183 00387 RET

As you can see, after calling selectgo function, there's a judgment + jump logic. From these, we can easily deduce its original form:

go
casei, ok := runtime.selectgo()
if casei == -1 {
    println("default")
} else if casei == 3 {
    println(4)
} else if casei == 2 {
    println(3)
} else if casei == 1 {
    println(2)
} else {
    println(1)
}

The actual code generated by compiler may differ from this, but the general idea is similar. So after calling selectgo function, the compiler uses if statements to determine which channel gets executed. Before the call, the compiler also generates a for loop to collect scase array, but this is omitted here.

After understanding how selectgo function is used externally, let's understand how selectgo function works internally. It first initializes several arrays. nsends+nrecvs represents total case count. From the code below, you can see the maximum case count is 1 << 16. pollorder determines channel execution order, lockorder determines channel lock order:

go
cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
// Its length is twice the scase array, first half allocated to pollorder array, second half to lockorder array.
order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))

ncases := nsends + nrecvs
scases := cas1[:ncases:ncases]
pollorder := order1[:ncases:ncases]
lockorder := order1[ncases:][:ncases:ncases]

Next, initialize pollorder array, which stores scases array indices of channels to be executed:

go
norder := 0
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
    }

    j := fastrandn(uint32(norder + 1))
    pollorder[norder] = pollorder[j]
    pollorder[j] = uint16(i)
    norder++
}
pollorder = pollorder[:norder]
lockorder = lockorder[:norder]

It traverses the entire scases array, then uses runtime.fastrandn to generate a random number between [0, i], then swaps it with i. During the process, it skips cases where channel is nil. After traversal completes, we get a pollorder array with shuffled elements, as shown below:

Then pollorder array is sorted by channel address using heap sort to get lockorder array. Then call runtime.sellock to lock them in order:

go
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)
    }
  }
}

It's worth noting that sorting channels by address is to avoid deadlock, because select operation itself doesn't require locks and allows concurrency. Suppose locking by pollorder random order, then consider the following code:

go
ch1 := make(chan int)
ch2 := make(chan int)
ch3 := make(chan int)
ch4 := make(chan int)

poll := func() {
    select {
    case ch1 <- 1:
        println(1)
    case ch2 <- 2:
        println(2)
    case ch3 <- 3:
        println(3)
    case ch4 <- 4:
        println(4)
    default:
        println("default")
    }
}

// A
go poll()
// B
go poll()
// C
go poll()

Three goroutines ABC all reach this locking step, and their locking order is random and different from each other. This could cause the following situation, as shown below:

Suppose ABC locking order is the same as the figure above. The possibility of deadlock is very high. For example, A will first hold ch2 lock, then try to acquire ch1 lock. But suppose ch1 is already locked by goroutine B, goroutine B will then try to acquire ch2 lock. This causes deadlock.

If all goroutines lock in the same order, deadlock won't occur. This is the fundamental reason why lockorder is sorted by address.

After locking, the real processing phase begins. First, traverse pollorder array, accessing channels in the previously shuffled order, finding an available channel:

go
for _, casei := range pollorder {
    casi = int(casei)
    cas = &scases[casi]
    c = cas.c

    if casi >= nsends { // read channel
        sg = c.sendq.dequeue()
        if sg != nil {
            goto recv
        }
        if c.qcount > 0 {
            goto bufrecv
        }
        if c.closed != 0 {
            goto rclose
        }
    } else { // write channel
        if c.closed != 0 {
            goto sclose
        }
        sg = c.recvq.dequeue()
        if sg != nil {
            goto send
        }
        if c.qcount < c.dataqsiz {
            goto bufsend
        }
    }
}

As you can see, there are 6 cases handled for read/write channels. Let's explain them separately.

First case: read channel and sender is waiting to send. This goes to runtime.recv function, whose role has been explained. It eventually wakes up the sender goroutine. Before waking up, the callback function unlocks all channels:

go
recv:
  // can receive from sleeping sender (sg)
  recv(c, sg, cas.elem, func() { selunlock(scases, lockorder) }, 2)
  recvOK = true
  goto retc

Second case: read channel, no sender waiting, buffer element count greater than 0. This reads data directly from buffer. Its logic is completely consistent with runtime.chanrecv, then unlocks:

go
bufrecv:
  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 retc

Third case: read channel, but channel is already closed, and no remaining elements in buffer. This unlocks first then returns directly:

go
rclose:
  selunlock(scases, lockorder)
  recvOK = false
  if cas.elem != nil {
    typedmemclr(c.elemtype, cas.elem)
  }
  goto retc

Fourth case: send data to closed channel. This unlocks first then panics:

go
sclose:
  selunlock(scases, lockorder)
  panic(plainError("send on closed channel"))

Fifth case: receiver is blocking and waiting. This calls runtime.send function, and eventually wakes up the receiver goroutine. Before waking up, the callback function unlocks all channels:

go
send:
  send(c, sg, cas.elem, func() { selunlock(scases, lockorder) }, 2)
  goto retc

Sixth case: no receiver goroutine waiting, put data to be sent into buffer, then unlock:

go
bufsend:
  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 retc

Then all above cases eventually go to retc branch, which only returns the selected channel index casi and recvOk representing whether read was successful:

go
retc:
    return casi, recvOK

Seventh case: no available channel found, and code contains default branch. Unlock channels then return directly. Here returned casi is -1, indicating no available channel:

go
if !block {
    selunlock(scases, lockorder)
    casi = -1
    goto retc
}

Last case: no available channel found, and code doesn't contain default branch. Then current goroutine enters blocked state. Before this, selectgo adds current goroutine to all listening channels' recvq/sendq queues:

go
gp = getg()
nextp = &gp.waiting
for _, casei := range lockorder {
    casi = int(casei)
    cas = &scases[casi]
    c = cas.c
    sg := acquireSudog()
    sg.g = gp
    sg.isSelect = true
    sg.elem = cas.elem
    sg.releasetime = 0
    sg.c = c
    *nextp = sg
    nextp = &sg.waitlink

    if casi < nsends {
        c.sendq.enqueue(sg)
    } else {
        c.recvq.enqueue(sg)
    }
}

This creates several sudog and links them with corresponding channels, as shown below:

Then block via runtime.gopark. Before blocking, it unlocks channels. The unlock work is completed by runtime.selparkcommit function, which is passed as callback to gopark:

go
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, waitReasonSelect, traceBlockSelect, 1)
gp.activeStackChans = false

The first thing after being awakened is to unlink sudog from channels:

go
sellock(scases, lockorder)

gp.selectDone.Store(0)
sg = (*sudog)(gp.param)
gp.param = nil

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 = nil
    sg1.c = nil
}
gp.waiting = nil

Then remove sudog from previous channel wait queues:

go
for _, casei := range lockorder {
    k = &scases[casei]
    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
}

In the above process, the channel processed by the waker goroutine will definitely be found. Then make final processing based on caseSuccess. For write operations, sg.success being false means channel is closed. Moreover, throughout Go runtime, only close function actively sets this field to false. This indicates current goroutine is awakened by waker through close function. For read operations, if awakened by sender, data read operation was already completed by sender via runtime.send function before being awakened, its value is true. If awakened by close function, same as before, it returns directly:

go
c = cas.c

if casi < nsends {
    if !caseSuccess {
       goto sclose
    }
} else {
    recvOK = caseSuccess
}

selunlock(scases, lockorder)
goto retc

At this point, the entire select logic is roughly clarified. Several cases were divided above, showing select processing is still quite complex.

Golang by www.golangdev.cn edit