原文

select有什么作用

Golang 的 select 机制可以理解为是在语言层面实现了和 select, poll, epoll 相似的功能:监听多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。
golang 的 select 机制是,监听多个channel,每一个 case 是一个事件,可以是读事件也可以是写事件,随机选择一个执行,可以设置default,它的作用是:当监听的多个事件都阻塞住会执行default的逻辑。

select的源码在 (runtime/select.go)[https://github.com/golang/go/blob/master/src/runtime/select.go] ,看的时候建议是重点关注 pollorder 和 lockorder. pollorder保存的是scase的序号,乱序是为了之后执行时的随机性。 lockorder保存了所有case中channel的地址,这里按照地址大小堆排了一下lockorder对应的这片连续内存。对chan排序是为了去重,保证之后对所有channel上锁时不会重复上锁。

协程的退出方式

goroutine作为Golang并发的核心,我们不仅要关注它们的创建和管理,当然还要关注如何合理的退出这些协程,不(合理)退出不然可能会造成阻塞、panic、程序行为异常、数据结果不正确等问题。goroutine在退出方面,不像线程和进程,不能通过某种手段强制关闭它们,只能等待goroutine主动退出。

goroutine的优雅退出方法有三种:

使用for-range退出

for-range是使用频率很高的结构,常用它来遍历数据,range能够感知channel的关闭,当channel被发送数据的协程关闭时,range就会结束,接着退出for循环。
它在并发中的使用场景是:当协程只从1个channel读取数据,然后进行处理,处理后协程退出。下面这个示例程序,当in通道被关闭时,协程可自动退出。

  1. go func(in <-chan int) {
  2. // Using for-range to exit goroutine
  3. // range has the ability to detect the close/end of a channel
  4. for x := range in {
  5. fmt.Printf("Process %d\n", x)
  6. }
  7. }(in)

使用select case ,ok退出

for-select也是使用频率很高的结构,select提供了多路复用的能力,所以for-select可以让函数具有持续多路处理多个channel的能力。但select没有感知channel的关闭,这引出了2个问题:

  • 继续在关闭的通道上读,会读到通道传输数据类型的零值,如果是指针类型,读到nil,继续处理还会产生nil。
  • 继续在关闭的通道上写,将会panic。

问题2可以这样解决,通道只由发送方关闭,接收方不可关闭,即某个写通道只由使用该select的协程关闭,select中就不存在继续在关闭的通道上写数据的问题。
问题1可以使用,ok-idiom 来检测通道的关闭,使用情况有2种。

第一种:如果某个通道关闭后,需要退出协程,直接return即可。示例代码中,该协程需要从in通道读数据,还需要定时打印已经处理的数量,有2件事要做,所有不能使用for-range,需要使用for-select,当in关闭时,ok=false,我们直接返回。

  1. go func() {
  2. // in for-select using ok to exit goroutine
  3. for {
  4. select {
  5. case x, ok := <-in:
  6. if !ok {
  7. return
  8. }
  9. fmt.Printf("Process %d\n", x)
  10. processedCnt++
  11. case <-t.C:
  12. fmt.Printf("Working, processedCnt = %d\n", processedCnt)
  13. }
  14. }
  15. }()

第二种:如果某个通道关闭了,不再处理该通道,而是继续处理其他case,退出是等待所有的可读通道关闭。我们需要使用select的一个特征:select不会在nil的通道上进行等待。这种情况,把只读通道设置为nil即可解决。

  1. go func() {
  2. // in for-select using ok to exit goroutine
  3. for {
  4. select {
  5. case x, ok := <-in1:
  6. if !ok {
  7. in1 = nil
  8. }
  9. // Process
  10. case y, ok := <-in2:
  11. if !ok {
  12. in2 = nil
  13. }
  14. // Process
  15. case <-t.C:
  16. fmt.Printf("Working, processedCnt = %d\n", processedCnt)
  17. }
  18. // If both in channel are closed, goroutine exit
  19. if in1 == nil && in2 == nil {
  20. return
  21. }
  22. }
  23. }()

使用退出通道退出

使用,ok来退出使用for-select协程,解决是当读入数据的通道关闭时,没数据读时程序的正常结束。
接收的协程要退出了,如果它直接退出,不告知发送协程,发送协程将阻塞。启动了一个工作协程处理数据,如何通知它退出?
使用一个专门的通道,发送退出的信号,可以解决这类问题。以第2个场景为例,协程入参包含一个停止通道stopCh,当stopCh被关闭,case <-stopCh会执行,直接返回即可。
当我启动了100个worker时,只要main()执行关闭stopCh,每一个worker都会都到信号,进而关闭。如果main()向stopCh发送100个数据,这种就低效了。

  1. func worker(stopCh <-chan struct{}) {
  2. go func() {
  3. defer fmt.Println("worker exit")
  4. // Using stop channel explicit exit
  5. for {
  6. select {
  7. case <-stopCh:
  8. fmt.Println("Recv stop signal")
  9. return
  10. case <-t.C:
  11. fmt.Println("Working .")
  12. }
  13. }
  14. }()
  15. return
  16. }

通过channel控制子goroutine的方法可以总结为:循环监听一个channel,一般来说是for循环里放一个select监听channel以达到通知子goroutine的效果。再借助Waitgroup,主进程可以等待所有协程优雅退出后再结束自己的运行,这就通过channel实现了优雅控制goroutine并发的开始和结束。
因此在退出协程的时候需要注意:

  • 发送协程主动关闭通道,接收协程不关闭通道。使用技巧:把接收方的通道入参声明为只读,如果接收协程关闭只读协程,编译时就会报错
  • 协程处理1个通道,并且是读时,协程优先使用for-range,因为range可以关闭通道的关闭自动退出协程。
  • ok可以处理多个读通道关闭,需要关闭当前使用for-select的协程。
  • 显式关闭通道stopCh可以处理主动通知协程退出的场景(Context包也可以)。