Written by with ♥ on in

数据 Linux 的开发者听到 select 都会想到系统调用,谈到 I/O 模型时会想到 select、epoll 等 IO 多路复用模型。

go 语言中的 select 关键字其实与 C 语言中的 select 有许多相似之处。

c 语言中 select 关键字可以同时监听多个文件描述符的可读或可写状态,在文件描述符状态发送改变之前,一直阻塞当前线程。go 语言中的 select 关键字类似,能够让一个 goroutine 同时等待多个 channel 达到准备状态。

select 的每个 case 都与 channel 的读写操作有关:

func fibonacci(c, quit chan int) {
    x, y := 0, 1
    for {
        select {
        case c <- x:
            x, y = y, x+y
        case <-quit:
            fmt.Println("quit")
            return
        }
    }
}

c <- x<-quit 任意一个成功,就是指向 case 里面的代码,如果同时能成功,会随机执行一个 case

结构

select 在 go 源码中不对应任何的结构体,而是控制结构中的 case 对应 scase 结构体:

type scase struct {
    c           *hchan
    elem        unsafe.Pointer
    kind        uint16
    pc          uintptr
    releasetime int64
}

由于 case 与 channel 发送和接收数据有关,所以内部的 c 字段用于存储 case 使用的 channel。

elem 用于接收或发送数据的地址

kind 表示当前 case 的种类,总共有四种:

const (
    caseNil = iota
    caseRecv
    caseSend
    caseDefault
)

现象

非阻塞收发

如果 select 包含 default 表达式,那么不会阻塞等待其他的 channel 准备就绪:

func main() {
    ch := make(chan int)
    select {
    case i := <-ch:
        println(i)

    default:
        println("default")
    }
}

随机执行

多个 case 就绪后如何选择的问题:

func main() {
    ch := make(chan int)
    go func() {
        for range time.Tick(1 * time.Second) {
            ch <- 0
        }
    }()

    for {
        select {
        case <-ch:
            println("case1")

        case <-ch:
            println("case2")
        }
    }
}

$ go run main.go
case1
case2
case1
case2
case2
case1
...

编译期间

select 转换成 OSELECT 节点,持有一系列的 OCASE 节点。

编译器会根据不同的情况进行优化:

  1. 不存在任何 case 的 select
  2. 只存在一个 case
  3. 存在两个 case,其中一个是 default
  4. 通用的 select 条件

这个过程在 walkselectcases 函数中。

直接阻塞

func walkselectcases(cases *Nodes) []*Node {
    n := cases.Len()

    if n == 0 {
        return []*Node{mkcall("block", nil, nil)}
    }
    // ...
}

直接将 select {} 空语句,转换成对 block 函数调用。

func block() {
    gopark(nil, nil, waitReasonSelectNoCases, traceEvGoStop, 1)
}

函数内部调用 gopark 让出当前 goroutine 对处理器的使用权,进入永久休眠状态,无法被其他 goroutine 唤醒,我们可以看到传入的等会原因是 waitReasonSelectNoCases

独立情况

如果只包含一个 case,会执行优化策略把 select 语句改成 if 语句:

select {
case v, ok <-ch:
    // ...    
}

if ch == nil {
    block()
}
v, ok := <-ch
// ...

如果 ch 是 nil channel,永远不会有数据 recv,还是直接调用 block 函数,剩下的和正常写一样。

非阻塞操作

在优化策略执行之前 walkselectcases 函数先将 case 中的所有 channel 都转换成指向 channel 地址,为优化做准备,改写完成之后进行代码优化,触发条件是有一个 default,分成两种情况:

发送

select {
case ch <- i:
    // ...
default:
    // ...
}

if selectnbsend(ch, i) {
    // ...
} else {
    // ...
}

关键函数是 selectnbsend(select no block send),作用是非阻塞地向 channel 中发送数据:

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

chansend 函数包含一个 block 参数,决定这一次发送是不是阻塞的,这里传入的参数为 fasle。

当前发送过程是不阻塞的,哪怕没有接收方、缓冲区空间不足导致失败也会立即返回。

接收

由于接收 channel 数据可能会返回一个或两个值,相对于发送情况更复杂一些:

select {
case v <- ch: // case v, received <- ch:
    // ...
default:
    // ...
}

if selectnbrecv(&v, ch) { // if selectnbrecv2(&v, &received, ch) {
    // ...
} else {
    // ...
}

返回值数量不同会导致使用的函数不同,但内部是一样的,只是参数不同:

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

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

都是非阻塞调用 chanrecv 函数。

通用情况

在默认情况下,select 语句会在编译阶段进行如下处理过程:

  1. 将所有 case 转换成包括 channel 以及类型等信息的 scase 结构体;
  2. 调用运行时函数 selectgo 获取被选择的 scase 结构体索引,如果当前scase 是一个接收数据的操作,会返回一个指示当前 case 是否是接收的布尔值
  3. 通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case

一个包含三个 case 的正常 select 语句会转换成如下逻辑:

selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
    c := scase{}
    c.kind = ...
    c.elem = ...
    c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
    // ...
    break
}
if chosen == 1 {
    // ...
    break
}
if chosen == 2 {
    // ...
    break
}

selectgo 选中执行的 case,通过 if 判断执行 case 中的表达式,因此下面介绍的重点是 selectgo 函数

运行时

在充分了解编译过程后,下面介绍 selectgo 函数的实现原理:

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
}

selectgo 在运行时执行,从多个 case 中选择一个需要执行的 case,随后 if 语句选择需要执行的内部表达式,这里其实与内部表达式无关。

初始化

selectgo 函数首先会进行执行必要的一些初始化操作,也就是决定处理 case 的两个顺序,其中一个是 pollOrder 另一个是 lockOrder:

selv := [3]scase{}
order := [6]uint16
chosen, revcOK := chosen, revcOK := selectgo(selv, order, 3)

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
    cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
    order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))

    scases := cas1[:ncases:ncases] // 取出所有的 scases
    pollorder := order1[:ncases:ncases]
    lockorder := order1[ncases:][:ncases:ncases]

    for i := range scases {
        cas := &scases[i]
        if cas.c == nil && cas.kind != caseDefault {
            *cas = scase{}
        }
    }

    for i := 1; i < ncases; i++ {
        j := fastrandn(uint32(i + 1))
        pollorder[i] = pollorder[j]
        pollorder[j] = uint16(i)
    }

    // sort the cases by Hchan address to get the locking order.
    // ...

    sellock(scases, lockorder)

    // ...
}

轮询的顺序由 fastrandn 随机生成,如果有多个 channel 同时响应,就会随机选择一个执行;

另一个 lockOrder 根据 channel 的地址确定的,根据相同的顺序锁定 channel 避免死锁的发生,最后调用 sellock 会按照之前生成的顺序锁定所有的 channel。

循环

在随机确定 select 语句轮询和锁定的顺序并锁定了所有的 channel 之后,进入 select 的主循环,查找或者等待 channel 准备就绪。

循环中会遍历所有的 case 并找到需要被唤起的 sudog 结构体,在这段循环代码中,分四种不同的情况处理 select 中的 case:

  1. caseNil:当前 case 不包含任何 channel,直接被跳过,无效语句
  2. caseRecv:当前 case 从 channel 中接收数据:
    1. 如果当前 channel 的 sendq 上有等待的 goroutine 就会直接跳到 recv 标签所在的代码段,从 goroutine 中获取最新发送的数据
    2. 如果当前 channel 的缓冲区不为空就会跳到 bufrecv 标签从缓冲区获取数据
    3. 如果当前 channel 已经被关闭就会跳到 rclose 做一些清除的收尾工作
  3. caseSend:当前 case 会向 channel 发送数据
    • 如果 channel 被关闭,直接跳到 sclose 代码段
    • 如果 channel 的 recvq 上有等待的 goroutine 会跳到 send 代码段向 channel 发送数据
  4. caseDefault:表示默认情况,如果循环执行到了这种情况就表示前面所有的 case 都没有被执行,这里会直接解锁所有的 channel 并退出 selectgo 函数,也意味当前 select 是非阻塞的