之前的博客中已经粗略探究了一下 golang channel 的实现原理,本文总结一下使用 channel 的各种姿势。

先看一下对不同状态的 channel 的读,写,关闭操作的结果

1. 使用 for range 读取 channel

场景: 当需要不断从 channel 里读数据时

这是最常用的方式,又安全又便利,当channel 被关闭时,for 循环自动退出。 用法不再赘述。

2. 使用 _, ok 判断 channel 是否关闭

场景: 读 channel,但需要判断 channel 是否已关闭。

读 channel 的操作 <- chan 既可以返回一个值,也可以返回两个值,这里就是用的两个返回值的方式。

举例:

if v, ok := <- ch; ok {
    // can read channel 
    fmt.Println(v)
}
  • 读到数据,并且通道没有关闭时,ok 的值为 true
  • 通道关闭,无数据读到时,ok 的值为 false

3. 与 select 搭配使用

场景: 需要对多个通道进行处理,或者设置超时

举例:

func (h *Handler) handle(job *Job) {
    select {
    case h.jobCh<-job:
        return 
    case <-h.stopCh:
        return
    case <- time.After(2):
    }
}

这里有一个坑需要注意: 当 channel 为 nil,也就是没有初始化时,无论对应的 select case 是读还是写,都会立即返回。 而一般情况下,对 nil channel 的 写操作是要panic的。

另一个技巧是 select 的时候配置 default,实现 非阻塞 channel , 这一用法在 channel 的实现中也提及。

select {
    // 如果chan1成功读到数据,则进行该case处理语句
    case <- chan1: 

    // 如果成功向chan2写入数据,则进行该case处理语句
    case chan2 <- 1:

    // 如果上面都没有成功,则进入default处理流程
    default:
}

另外,上面两个例子都提到了 case chan2 <- 1 这样的用法,这个用法表示:如果能向channel 写入数据,则进行该 case 的处理。

4. 选择性的返回 chan 为只读或只写

这算是一个编程技巧,当把 channel 变量作为参数或者函数返值时,

如果参数定义是 <-chan int, 那么对这个 channel 变量只有读权限,无法写入数据。

同理,如果参数定义是 chan <- int,那么对这个 channel 变量只有写权限,无法读数据,否则编译报错。

举例,Golang的标准库里就有很多例子,比如 time.After(), context.Done() 等

func After(d Duration) <-chan Time {
	return NewTimer(d).C
}

除此之外,另举一个例子

func main() {
    c := make(chan int)
    go send(c)
    go recv(c)
}

//只能向chan里写数据
func send(c chan<- int) {
    for i := 0; i < 10; i++ {
        c <- i
    }
}

//只能取channel中的数据
func recv(c <-chan int) {
    for i := range c {
        fmt.Println(i)
    }
}

5. 使用 close(ch) 关闭所有 goroutine

问题: 假设有 100 个 goroutine,我想让它们同时退出该怎么办?

答案是 close(channel), 而不是往 chan 里发数据,发数据每次只会有一个 goroutine 会受到,如果要关闭 100 个,那么要发100 次。 而关闭一个 chan 后,所有的 goroutine 会同时读到0值(见文章开头图表)。

func function(id int, wg *sync.WaitGroup, quit <-chan int) {
	defer wg.Done()

	select {
	case <- quit:
		fmt.Println(id, "goroutine exit")
		return
	}
}

func main() {
	quit := make(chan int)
	wg := &sync.WaitGroup{}

	for i := 0; i < 10; i ++ {
		wg.Add(1)
		go function(i, wg, quit)
	}

	fmt.Println("10 gorutine start...")
	time.Sleep(time.Second*3)
	close(quit)   // 关闭 chan,所有 goroutine 退出
	wg.Wait()
}

6. nil 的 channel

在第 3 小节中已经提到了 nil 的 channel,但是只是教条的记住了语法,仍然不知道怎么用,这里通过一个场景来了解 select 和 nil 的用法。

问题: 有一个 goroutine 中 select 的 case 有多个 channel,我希望所有 case 的chan 都关闭后,goroutine才能退出

答案就是利用 select 不会在 nil 的 channel 上进行等待,因此将channel赋值为nil即可。此外,还需要利用channel的ok值。

var wg sync.WaitGroup

func worker(in1, in2 <-chan int) {
  defer wg.Done()

  for {
    select {
    case _, ok := <-in1:
      if !ok {
        fmt.Println("收到退出信号 1")
        in1 = nil
      }
    case _, ok := <-in2:
      if !ok {
        fmt.Println("收到退出信号 2")
        in2 = nil
      }
    }

    // 都为nil则结束当前goroutine
    if in1 == nil && in2 == nil {
      return
    }
  }
}

func main() {
  in1 := make(chan int) 
  in2 := make(chan int)

  wg.Add(2)

  go worker(in1, in2)
  go worker(in2, in2)
  
  close(in1)
  time.Sleep(time.Second)

  close(in2)
  wg.Wait()
}

不过执行上面的代码最后看到如下输出,按理说应该 1 和 2 各打印一条 log,为什么实际是 4 条呢 ?

收到退出信号 1
收到退出信号 1
收到退出信号 2
收到退出信号 2

总结

channel 是 golang 语言的精髓之一,灵活把 select, channel 和 goroutine 结合起来使用可以达到事半功倍的效果。

在使用 channel 时:

  1. 发送协程主动关闭 chan,接收协程不关闭 chan。 把接收方的通道入参声明为只读(<-chan),如果接收协程关闭只读协程,编译时就会报错。
  2. gorutine 处理1个 channel,并且是读时,goroutine 优先使用for-range,因为range可以在 chan 关闭时自动退出循环。
  3. _, ok 可以处理多个读通道关闭,需要关闭当前使用for-select的协程。

参考资料