浅谈Go Channel

1,534 阅读4分钟

基本概念

Channel 作为 Golang 内置的核心数据结构之一,其表现形式与消息队列很像,经常被用于生产者消费者模型,在协程通信的场景中被广泛使用。Channel 主要分为两种类型:有缓冲和无缓冲,有缓冲Channel 内部会有一个可定义大小的缓冲区,用于存储未来得及被取走的数据。

Channel 支持三种操作 send receive close ,根据 Channel 当前的状态,这三种操作可能会产生不同的结果:

send

  • 无剩余缓冲空间:blocking
  • 无剩余缓冲空间但有挂起的receiver:直接给挂起的receiver推数据并唤醒对应receiver
  • 有剩余缓冲空间:直接写入缓冲空间
  • 已关闭:panic
// 无剩余缓冲空间:blocking
c := make(chan int// 或 c := make(chan int, 1); c <- 1
c <- 1  // current g block

// 向已关闭 channel send
close(c)
c <- 1  // panic

// 有剩余缓冲空间
c := make(chan int1)
c <- 1
fmt.Println("ok"// print "ok"

// 无剩余缓冲空间但有挂起的receiver
c := make(chan int// 或 c := make(chan int, 1); c <- 1
go func() {
 i := <- c
 fmt.Println(i) // print 1
}()
c <- 1
fmt.Println("ok"// print "ok"

receive

  • 有缓冲数据:从缓冲空间拿数据
  • 无缓冲数据:blocking
  • 无缓冲数据但是有挂起的 sender:从挂起的 sender 直接拿数据并唤醒对应的 sender
  • 已关闭但里面还有缓冲数据:从缓冲空间拿数据
  • 已关闭且没有缓冲数据:返回 channel 数据类型对应的零值和 false(如果只接收一个返回值的话则只能拿到零值)

close

  • 已关闭:panic “close of closed channel”
  • 未关闭:关闭 channel 并唤醒挂起的 receiver 和 sender

分析

从以上操作结果来看,我们可以看出 Channel 的几种操作会互相影响:

  • send:会因为没有缓冲空间可用而挂起,会被新出现的 receiver 唤醒,会唤醒已挂起的 receiver
  • receive:会因为没有缓冲数据可读而挂起,会被新出现的 sender 唤醒,会唤醒已挂起的 sender
  • close:会唤醒所有挂起的 receiver 和 sender

select 非阻塞

从上面的分析来看,无论是 send 还是 receive 都有可能会发生阻塞,那有没有什么办法可以非阻塞调用呢?我们可以通过使用 select 来做到这件事情。

select 由 casedefault 分支组成,它能够监听 channel的读写操作是否就绪(不会发生阻塞)来实现 channel 读写代码的流程控制。select 会选择一个已就绪的 case 分支来执行,如果所有分支都无法执行则会执行 default 分支(PS:如果没有 default 分支则会阻塞等待某个 case 分支就绪)

select {
case i := <- c:
fmt.Prlintln(i)
default:
fmt.Println("c not ready")
}

更多玩法

现在我们已经知道了 channel 的几种操作会互相影响,还可以使用 select 来实现非阻塞调用,我们可以通过这些特性来挖掘除了 生产消费模型 之外的更多玩法来深入理解如何使用 channel 来实现 go 语言层面的协程间通信。

通知协程退出

doneChan := make(chan int)
for i := 0; i < 5; i++ {
 go func(worker int) {
  for {
   select {
   case <-doneChan: // 监听退出信号
    fmt.Println("worker", worker, "done")
    return
   default:
    fmt.Println("worker", worker, time.Now().Unix())
    time.Sleep(1 * time.Second)
   }
  }
 }(i)
}

time.Sleep(5 * time.Second)
// 发出退出信号,这里关闭 channel 以后,上面的 case <-doneChan 就会发现已经不会阻塞了,从而执行return
close(doneChan)
time.Sleep(2 * time.Second)

等待协程退出

waitChan := make(chan struct{}, 5)
for i := 0; i < 5; i++ {
 go func(worker int) {
  for wi := 0; wi < 5; wi++ {
   fmt.Println("worker", worker, wi)
  }
  // 通知 worker 退出
  waitChan <- struct{}{}
 }(i)
}
// 监听所有 worker 退出信号
for i := 0; i < 5; i++ {
 <- waitChan
}

总结

Channel 作为 Go 语言核心数据结构,在日常开发以及官方标准库中都有被广泛使用(上面的两个代码例子其实在标准库中都有对应封装),任何需要使用共享变量的场景都可以优先考虑是否能够使用 Channel 来实现需求。从系统架构的角度来思考,使用通信来实现多执行单元的同步和控制也会比使用共享变量的方式要方便、安全许多。