• 1. 前言
  • 2. 热身环节
    • 2.1 题目1
    • 2.2 题目2
    • 2.3 题目3
    • 2.4 题目4
  • 3. 实现原理
    • 3.1 case数据结构
    • 3.2 select实现逻辑
  • 4. 总结

    1. 前言

    select是Golang在语言层面提供的多路IO复用的机制,其可以检测多个channel是否ready(即是否可读或可写),使用起来非常方便。

    本章试图根据源码总结其实现原理,从而发现一些使用误区或解释一些不太常见的现象。

    2. 热身环节

    我们先看几个题目,用于测试对select的了解程度,每个题目代表一个知识点,本章后面的部分会进行略为详细的介绍。

    2.1 题目1

    下面的程序输出是什么?

    1. package main
    2. import (
    3. "fmt"
    4. "time"
    5. )
    6. func main() {
    7. chan1 := make(chan int)
    8. chan2 := make(chan int)
    9. go func() {
    10. chan1 <- 1
    11. time.Sleep(5 * time.Second)
    12. }()
    13. go func() {
    14. chan2 <- 1
    15. time.Sleep(5 * time.Second)
    16. }()
    17. select {
    18. case <-chan1:
    19. fmt.Println("chan1 ready.")
    20. case <-chan2:
    21. fmt.Println("chan2 ready.")
    22. default:
    23. fmt.Println("default")
    24. }
    25. fmt.Println("main exit.")
    26. }

    程序中声明两个channel,分别为chan1和chan2,依次启动两个协程,分别向两个channel中写入一个数据就进入睡眠。select语句两个case分别检测chan1和chan2是否可读,如果都不可读则执行default语句。

    参考答案:select中各个case执行顺序是随机的,如果某个case中的channel已经ready,则执行相应的语句并退出select流程,如果所有case中的channel都未ready,则执行default中的语句然后退出select流程。另外,由于启动的协程和select语句并不能保证执行顺序,所以也有可能select执行时协程还未向channel中写入数据,所以select直接执行default语句并退出。所以,以下三种输出都有可能:

    可能的输出一:

    1. chan1 ready.
    2. main exit.

    可能的输出二:

    1. chan2 ready.
    2. main exit.

    可能的输出三:

    1. default
    2. main exit.

    2.2 题目2

    下面的程序执行到select时会发生什么?

    1. package main
    2. import (
    3. "fmt"
    4. "time"
    5. )
    6. func main() {
    7. chan1 := make(chan int)
    8. chan2 := make(chan int)
    9. writeFlag := false
    10. go func() {
    11. for {
    12. if writeFlag {
    13. chan1 <- 1
    14. }
    15. time.Sleep(time.Second)
    16. }
    17. }()
    18. go func() {
    19. for {
    20. if writeFlag {
    21. chan2 <- 1
    22. }
    23. time.Sleep(time.Second)
    24. }
    25. }()
    26. select {
    27. case <-chan1:
    28. fmt.Println("chan1 ready.")
    29. case <-chan2:
    30. fmt.Println("chan2 ready.")
    31. }
    32. fmt.Println("main exit.")
    33. }

    程序中声明两个channel,分别为chan1和chan2,依次启动两个协程,协程会判断一个bool类型的变量writeFlag来决定是否要向channel中写入数据,由于writeFlag永远为false,所以实际上协程什么也没做。select语句两个case分别检测chan1和chan2是否可读,这个select语句不包含default语句。

    参考答案:select会按照随机的顺序检测各case语句中channel是否ready,如果某个case中的channel已经ready则执行相应的case语句然后退出select流程,如果所有的channel都未ready且没有default的话,则会阻塞等待各个channel。所以上述程序会一直阻塞。

    2.3 题目3

    下面程序有什么问题?

    1. package main
    2. import (
    3. "fmt"
    4. )
    5. func main() {
    6. chan1 := make(chan int)
    7. chan2 := make(chan int)
    8. go func() {
    9. close(chan1)
    10. }()
    11. go func() {
    12. close(chan2)
    13. }()
    14. select {
    15. case <-chan1:
    16. fmt.Println("chan1 ready.")
    17. case <-chan2:
    18. fmt.Println("chan2 ready.")
    19. }
    20. fmt.Println("main exit.")
    21. }

    程序中声明两个channel,分别为chan1和chan2,依次启动两个协程,协程分别关闭两个channel。select语句两个case分别检测chan1和chan2是否可读,这个select语句不包含default语句。

    参考答案:select会按照随机的顺序检测各case语句中channel是否ready,考虑到已关闭的channel也是可读的,所以上述程序中select不会阻塞,具体执行哪个case语句具是随机的。

    2.4 题目4

    下面程序会发生什么?

    1. package main
    2. func main() {
    3. select {
    4. }
    5. }

    上面程序中只有一个空的select语句。

    参考答案:对于空的select语句,程序会被阻塞,准确的说是当前协程被阻塞,同时Golang自带死锁检测机制,当发现当前协程再也没有机会被唤醒时,则会panic。所以上述程序会panic。

    3. 实现原理

    Golang实现select时,定义了一个数据结构表示每个case语句(含defaut,default实际上是一种特殊的case),select执行过程可以类比成一个函数,函数输入case数组,输出选中的case,然后程序流程转到选中的case块。

    3.1 case数据结构

    源码包src/runtime/select.go:scase定义了表示case语句的数据结构:

    1. type scase struct {
    2. c *hchan // chan
    3. kind uint16
    4. elem unsafe.Pointer // data element
    5. }

    scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。scase.kind表示该case的类型,分为读channel、写channel和default,三种类型分别由常量定义:

    • caseRecv:case语句中尝试读取scase.c中的数据;
    • caseSend:case语句中尝试向scase.c中写入数据;
    • caseDefault: default语句

    scase.elem表示缓冲区地址,跟据scase.kind不同,有不同的用途:

    • scase.kind == caseRecv : scase.elem表示读出channel的数据存放地址;
    • scase.kind == caseSend : scase.elem表示将要写入channel的数据存放地址;

    3.2 select实现逻辑

    源码包src/runtime/select.go:selectgo()定义了select选择case的函数:

    1. func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)

    函数参数:

    • cas0为scase数组的首地址,selectgo()就是从这些scase中找出一个返回。
    • order0为一个两倍cas0数组长度的buffer,保存scase随机序列pollorder和scase中channel地址序列lockorder
      • pollorder:每次selectgo执行都会把scase序列打乱,以达到随机检测case的目的。
      • lockorder:所有case语句中channel序列,以达到去重防止对channel加锁时重复加锁的目的。
    • ncases表示scase数组的长度

    函数返回值:

    1. int: 选中case的编号,这个case编号跟代码一致
    2. bool: 是否成功从channle中读取了数据,如果选中的case是从channel中读数据,则该返回值表示是否读取成功。

    selectgo实现伪代码如下:

    1. func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
    2. //1. 锁定scase语句中所有的channel
    3. //2. 按照随机顺序检测scase中的channel是否ready
    4. // 2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
    5. // 2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
    6. // 2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
    7. //3. 所有case都未ready,且没有default语句
    8. // 3.1 将当前协程加入到所有channel的等待队列
    9. // 3.2 当将协程转入阻塞,等待被唤醒
    10. //4. 唤醒后返回channel对应的case index
    11. // 4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
    12. // 4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
    13. }

    特别说明:对于读channel的case来说,如case elem, ok := <-chan1:, 如果channel有可能被其他协程关闭的情况下,一定要检测读取是否成功,因为close的channel也有可能返回,此时ok == false。

    4. 总结

    • select语句中除default外,每个case操作一个channel,要么读要么写
    • select语句中除default外,各case执行顺序是随机的
    • select语句中如果没有default语句,则会阻塞等待任一case
    • select语句中读操作要判断是否成功读取,关闭的channel也可以读取