Golang | 深入理解Select

引言

在Golang中,select是专门为channel设计的关键词,select 结合 channel实现了多路复用模型。

如果你对channel还不够了解,强烈建议你先看这篇文章 从源码分析Channel

本文基于 go1.16.17

简介

  • select 类似Java的switch ,不同的是,Golang中的select是专为channel而设计的。
  • select 主要用于监听多个channel是否可以收发消息,select会尝试执行case语句。当任何一个case满足条件则会执行,若没有可执行的case,就会执行default分支。如果default也不满足,程序会跳出select语句块.

使用

先来一段简单的,看看程序会输出什么

func tmp1() {
   ch := make(chan int, 1)
   ch <- 1  // 这里需要注意,如果读一个有缓冲且无数据的channel 会panic。因此先推一条数据进入channel
   select {
   case data, ok := <-ch:
      if ok {
         log.Printf("recv-data:[%v]", data)
      } else {
         log.Println("channel-closed!")
      }
   default:
      log.Println("into-default")
   }
   log.Println("return")
}
复制代码

不出意外,程序从channel中读出一条数据,跳出了select语句块

image.png

那么我想一直监听select语句块怎么办呢 ?用for循环就好了!

func tmp1() {
   ch := make(chan int, 1)
   ch <- 1
   for{
      select {
      case data, ok := <-ch:
         if ok {
            log.Printf("recv-data:[%v]", data)
         } else {
            log.Println("channel-closed!")
         }
      }
   }
   log.Println("return")
}
复制代码

select中的case执行了一次,随后由于 尝试从有缓冲区且无数据的channel读数据,程序panic了 image.png

这里需要注意,如果其他case一直不能被满足,那么不能有default分支,否则会发生cpu空转

image.png

每个select只能监听一个case么?当然不是。那么case的执行顺序是怎么样的呢?我们写一段代码来看看

代码新建了4个channel ,分别往channel里推1w条数据,随后执行select 统计每个case执行的频率


func tmp3() {
   countMap := make(map[int]int, 0)
   mu := sync.Mutex{}
   ch1 := make(chan int, 100000)
   ch2 := make(chan int, 100000)
   ch3 := make(chan int, 100000)
   ch4 := make(chan int, 100000)
   wg := &sync.WaitGroup{}
   wg.Add(4)
   go func() {
      defer wg.Done()
      write(ch1, 1)
   }()
   go func() {
      defer wg.Done()
      write(ch2, 2)
   }()
   go func() {
      defer wg.Done()
      write(ch3, 3)
   }()
   go func() {
      defer wg.Done()
      write(ch4, 4)
   }()
   wg.Wait()

   time.Sleep(time.Second * 2)
   for i := 0; i < 10000; i++ {
      select {
      case data, ok := <-ch1:
         if ok {
            mu.Lock()
            countMap[data]++
            mu.Unlock()
         } else {
            log.Println("ch1-channel-closed!")
         }
      case data, ok := <-ch2:
         if ok {
            mu.Lock()
            countMap[data]++
            mu.Unlock()
         } else {
            log.Println("ch2-channel-closed!")
         }
      case data, ok := <-ch3:
         if ok {
            mu.Lock()
            countMap[data]++
            mu.Unlock()
         } else {
            log.Println("ch3-channel-closed!")
         }
      case data, ok := <-ch4:
         if ok {
            mu.Lock()
            countMap[data]++
            mu.Unlock()
         } else {
            log.Println("ch4-channel-closed!")
         }
      }
   }
   log.Println(countMap)
}

func write(ch chan int, num int) {
   for i := 0; i < 10001; i++ {
      ch <- num
   }
}
复制代码

最后看一下我们的统计:

image.png 可以看到,我们每个case执行频率基本相同,下面让我们一起看看select底层是如何实现的

数据结构

select在底层不存在单独的结构体,我们使用runtime.scase表示select 中的 case

type scase struct {
	c    *hchan         // chan
	elem unsafe.Pointer // data element
}
复制代码

可以看到,每个case块持有一个chan指针default是一个特殊的scase ,用retc表示

重点函数分析

准备阶段

在准备阶段,有三种情况时,编译器会优化:

  1. 没有case和default情况下,直接调用gopark()使当前协程永远阻塞 。
  2. 如果只有一个case,那么select会被优化成一个if语句.
  3. 只有一个case和default.
    • 如果这个case是发送操作,会直接调用channel.chansend()进行一次非阻塞的发送
    • 如果是接收操作,会直接调用chan.chanrecv()进行一次非阻塞的接收操作

执行过程

select 执行过程位于 runtime.selectgo 方法中。我们来看一下

方法运行过程主要分为4个阶段

  1. 按照规则生成遍历顺序 pollorder && lockorder
  2. 按pollorder顺序尝试channel是否ready
  3. 如果 (2)都不满足
    3.1 如果有default语句 ,执行default语句后返回
    3.2 如果没有default语句 ,阻塞当前协程,将当前协程压入所有channel的等待队列&&接收队列中,等待唤醒
  4. 被唤醒后,按lockorder顺序解锁scases,从其他channel中将本sudog出队,返回 caseIndex
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool) {

   // 为了保持堆栈大小,scase 的数量上限为 65536。
   // 即 每个select最多有65536个case语句
    
   // 计算pollorder && lockorder ,他们的的底层数组未由编译器进行零初始化
   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]
 
   // dosomething
    
   // 阶段1 生成pollorder && lockorder 遍历顺序
   
   // pollorder:随机顺序
   norder := 0
   for i := range scases {
      cas := &scases[i
      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]

   // lockorder:按chan在堆上的地址进行堆排序生成的顺序
   
   // 省略堆排序过程

   // 按照lockorder顺序对scases内存加锁
   sellock(scases, lockorder)
   
   // 阶段2 按照pollorder顺序遍历scases,尝试读 && 写 channel。
   
   // 按pollorder顺序遍历scases
   for _, casei := range pollorder {
      casi = int(casei)
      cas = &scases[casi]
      c = cas.c
      // 是否是send操作
      if casi >= nsends {
         // 1. hchan是否有阻塞的发送者 ,如果有,调用chan.recv方法
         sg = c.sendq.dequeue()
         if sg != nil {
            goto recv
         }
         // 2. hchan缓冲区中有数据,读缓冲区数据,返回
         if c.qcount > 0 {
            goto bufrecv
         }
         // 3. channel被关闭,此时如果是 读操作,清空channel内容。如果是写操作,panic
         if c.closed != 0 {
            goto rclose
         }
      } else {
         // recv操作
         // 1. channel被关闭,按lockorder解锁内存 && 抛出panic错误
         if c.closed != 0 {
            goto sclose
         }
         // hchan是否有阻塞的接收者 ,如果有,按lockorder解锁内存 && 调用chan.send方法
         sg = c.recvq.dequeue()
         if sg != nil {
            goto send
         }
         // hchan缓冲区未满,数据写入缓冲区,返回
         if c.qcount < c.dataqsiz {
            goto bufsend
         }
      }
   }
   // 如果有default,进入这里 ,说明所有case都没有被触发
   // 直接解锁所有channel,随后return
   if !block {
      selunlock(scases, lockorder)
      casi = -1
      goto retc
   }

   // 阶段3  将sudog打包到所有的chan队列中,等待被唤醒
   
   // 获得当前运行的goroutine
   gp = getg()
   if gp.waiting != nil {
      throw("gp.waiting != nil")
   }
   // 根据lockorder顺序
   for _, casei := range lockorder {
   
      // 获得sudog ,将当前g与sudog绑定
      sg := acquireSudog()
      sg.g = gp
      
      // 区分发送 && 接收 场景
      if casi < nsends {
         // 发送场景。sudog压入发送队列
         c.sendq.enqueue(sg)
      } else {
         // 接收场景。sudog压入接收队列
         c.recvq.enqueue(sg)
      }
   }

 
   gp.param = nil

   // 进入阻塞,等待唤醒
   atomic.Store8(&gp.parkingOnChan, 1)
   gopark(selparkcommit, nil, waitReasonSelect, traceEvGoBlockSelect, 1)
   
   // 被唤醒时,按lockorder顺序解锁
   sellock(scases, lockorder)
   
   // 阶段4 被唤醒后,获得顺序,并将sudog从其他chan的队列中出队
   
   for _, casei := range lockorder {
      k = &scases[casei]
      if sg == sglist {
         // 当前sudog是被该case唤醒的
         casi = int(casei)
         cas = k
         caseSuccess = sglist.success
         if sglist.releasetime > 0 {
            caseReleaseTime = sglist.releasetime
         }
      } else {
         // sudog从其他chan的队列中出队
         c = k.c
         if int(casei) < nsends {
            c.sendq.dequeueSudoG(sglist)
         } else {
            c.recvq.dequeueSudoG(sglist)
         }
      }
   }

   if cas == nil {
      throw("selectgo: bad wakeup")
   }

   c = cas.c
  
   // 按lockorder顺序解锁所有scase
   selunlock(scases, lockorder)
   goto retc

   *** 
   后面是一大堆goto跳出的代码块,已经在上文中分析了
}
复制代码

我们可能会好奇,为什么要多一个lockorder

  • 主要目的是解决死锁
  • lockorder 按堆内存的顺序排序,破坏了死锁产生的循环等待条件

高级用法

阻塞main协程

使用 select{}可以将main协程阻塞住,但需要注意,被select{}阻塞的主协程,需要有活跃或者休眠(time.Sleep)的协程与之相关联,否则将发生panic.
猜测是与gopark有关。

超时限制

我们可以通过监听一个 退出通道 来做到超时控制。
退出通道可以用time.After() 也可以用context.Done(),或者是一个程序级别的closeChannel

func doWithTimeOut(timeout time.Duration) (int, error) {
    select {
    case ret := <-do():
        return ret, nil
    case <-time.After(timeout):
        return 0, errors.New("timeout")
    }
}

func do() <-chan int {
    outCh := make(chan int)
    go func() {
        // do work...
    }()
    return outCh
}
复制代码

猜你喜欢

转载自juejin.im/post/7098008563649511432