小米大佬走进 Go 之 Channel 的使用

  以下文章来源于大愚Talk ,作者大愚Talk

  对于 Golang 语言应用层面的知识,先讲如何正确的使用,然后再讲它的实现。

  Don't communicate by sharing memory, share memory by communicating.

  相信写过 Go 的同学都知道这句名言,可以说 channel 就是后边这句话的具体实现。我们来看一下到底 channel 是什么?

  channel 是一个类型安全的队列(循环队列),能够控制 groutine 在它上面读写消息的行为,比如:阻塞某个 groutine ,或者唤醒某个 groutine。

  不同的 groutine 可以通过 channel 交换任意的资源,由于 channel 能够控制 groutine 的行为,所以 CSP 模型才能在 Golang 中顺利实现,它确保了不同 groutine 之间的数据同步机制。

  上面的话是不是听起来非常的不舒服?

  好吧,简单说人话就是,channel 是用来在 不同的 的 goroutine 中交换数据的。一定要注意这里 不同的 三个字。千万不要把 channel 拿来在不同函数(同一个 goroutine 中)间交换数据。

  知道了定义,我们来看具体如何使用。

  如何定义一个 channel 类型呢?

  var ch1 chan int // 定义了一个 int 类型的 channel,没有初始化,是 nil

  ch2 := make(chan int) // 定义+初始化了一个无缓冲的 int 类型 channel

  ch3 := make(chan int) // 定义+初始化了一个有缓冲的 int 类型 channel

  上面的定义方法我们都是定义的双向通道,对应的还有单向通道,但是单向通道我们一般只是做为函数参数来进行一些限制,并不会在定义、初始化时就搞一个单向通道出来。因为你定义一个单向通道没有任何实际价值,通道的存在本来就是用来交换数据的,单向通道只能满足发或者收。

  下面我们一起来看一下具体的使用,以及使用中注意的一些点。

  不管是有缓冲的通道还是无缓冲的通道都是用来交换数据的,既然是交换数据,无非就是写入、读取。我们先从发送开始。

  ch := make(chan int)

  defer close(ch)

  //ch

  go func(ch chan int) {

  num := <-ch

  fmt.Println(num)

  }(ch)

  // ch

  如果我们打开 位置一 的注释,程序是无法获得预期执行的,由于该 channel 是无缓冲的,位置一的代码会陷入阻塞,下一行的 goroutine 根本没有机会执行。整个代码会陷入死锁。

  正确的操作是,打开 位置二 的注释,因为上一行 goroutine 先行启动,他是一个独立的协程,不会阻塞主 groutine 的执行。但它内部会阻塞在 num :=

  这里先提一点,无缓冲的 channel 并不会用到内部结构体的 buf ,这部分具体会在源码部分讲解他们的数据存取、交换的方式。

  ch := make(chan int, 1) // 注意这里

  defer close(ch)

  //ch

  go func(ch chan int) {

  num := <-ch

  fmt.Println(num)

  }(ch)

  // ch

  代码基本没有改变,唯一的区别是 make 函数传入了第二个参数,这个值的含义是缓冲的大小。那么此时 位置一 与 位置二 都能够正常执行吗?

  答案是肯定的,此时的代码,无论是那个位置,打开注释后都能够正常执行。原因就在于由于 channel 有了缓存区域,位置一 写入数据不会造成主协程的阻塞,那么下一行代码的子协程就可以正常启动,并直接将位置一写入 buf 的数据读取出来打印。

  对于 位置二 ,由于子协程先启动,但是会被阻塞在 num :=

  发送需要注意几个问题:

  什么时候会被阻塞?向 nil 通道发送数据会被阻塞向无缓冲 channel 写数据,如果读协程没有准备好,会阻塞向有缓冲 channel 写数据,如果缓冲已满,会阻塞什么时候会 panic?closed的 channel,写数据会 panic就算是有缓冲的 channel ,也不是每次发送、接收都要经过缓存,如果发送的时候,刚好有等待接收的协程,那么会直接交换数据。

  有写入,必然后读取。

  还是上面的代码, num :=

  这里说下读取的两种形式。

  形式一

  multi-valued assignment

  v, ok := <-ch

  ok 是一个 bool 类型,可以通过它来判断 channel 是否已经关闭,如果关闭该值为 true ,此时 v 接收到的是 channel 类型的零值。比如:channel 是传递的 int, 那么 v 就是 0 ;如果是结构体,那么 v 就是结构体内部对应字段的零值。

  形式二

  v := <-ch

  该方式对于关闭的 channel 无法掌控,我们示例中就是该种方式。

  接收需要注意几个问题:

  什么时候会被阻塞?从 nil 通道接收数据会被阻塞从无缓冲 channel 读数据,如果写协程没有准备好,会阻塞从有缓冲 channel 读数据,如果缓冲为空,会阻塞读取的 channel 如果被关闭,并不会影响正在读的数据,它会将所有数据读取完毕,并不会立即就失败或者返回零值

  对于 channel 的关闭,在什么地方去关闭呢?因为上面也讲到向 closed 的 channel 写或者继续 close 都会导致 panic问题。

  一般的建议是谁写入,谁负责关闭。如果涉及到多个写入的协程、多个读取的协程?又该如何关闭?总的来说就是加入一个标记避免重复关闭。不过真的不建议搞的太复杂,否则后续维护代码会疯掉。

  关闭需要注意几个问题:

  什么时候会 panic?closed 的 channel,再次关闭 close 会 panic

  我们常常会用 for-range 来读取 channel的数据。

  ch := make(chan int, 1)

  go func(ch chan int) {

  for i := 0; i < 10; i++ {

  ch

  }

  close(ch)

  }(ch)

  for val := range ch {

  fmt.Println(val)

  }

  该语句的一个特色是如果 channel 已经被关闭,它还是会继续执行,直到所有值被取完,然后退出执行。而如果通道没有关闭,但是channel没有可读取的数据,它则会阻塞在 range 这句位置,直到被唤醒。但是如果 channel 是 nil,那么同样符合我们上面说的的原则,读取会被阻塞,也就是会一直阻塞在 range位置。

  select 是跟 channel 关系最亲密的语句,它是被专门设计出来处理通道的,因为每个 case 后面跟的都是通道表达式,可以是读,也可以是写。

  ch := make(chan int)

  q := make(chan int)

  go func(ch, q chan int) {

  for i := 0; i < 10; i++ {

  num := <-ch

  fmt.Println(num)

  }

  q

  }(ch, q)

  fibonacci := func(ch, q chan int) {

  x, y := 0, 1

  for {

  select {

  case ch

  x, y = y, x+y

  break // 你觉得是否会影响 for 语句的循环?

  case

  fmt.Println("quit")

  return

  }

  }

  }

  fibonacci(ch, q)

  上面的代码是利用 channel 实现的一个斐波拉契数列。select 还可以有 default 语句,该语句会在其它 case 都被阻塞的情况下执行。

  关注的问题

  select 只要有默认语句,就不会被阻塞,换句话说,如果没有 default,然后 case 又都不能读或者写,则会被阻塞nil 的 channel,不管读写都会被阻塞select 不能够像 for-range 一样发现 channel 被关闭而终止执行,所以需要结合 multi-valued assignment 来处理如果同时有多个 case 满足了条件,会使用伪随机选择一个 case 来执行select 语句如果不配合 for 语句使用,只会对 case 表达式求值一次每次 select 语句的执行,是会扫码完所有的 case 后才确定如何执行,而不是说遇到合适的 case 就直接执行了。

  本文内容很简单易懂,希望大家彻底掌握了 channel 的使用。一切源码的研究都是为了更好的使用,后面的文章将开始研究 channel 的源码实现。

  本文几个重要问题再次总结下,也是经常面试的常考点。

  向 close 的 channel 写数据、再次 close 都会触发 runtime panic。向 nil channel 写、读取数据,都会阻塞,可以利用这点来优化 for + select 的用法。channel 的关闭最好在写入方处理,读的协程不要去关闭 channel,可以通过单向通道来表明 channel 在该位置的功能。如果有多个写协程的 channel 需要关闭,可以使用额外的 channel 来标记,也可以使用 sync.Once 或者 sync.Mutex 来处理。channel 不管是读写都是并发安全的,不会出现多个协程同时读或者写的情况,从而实现了 CSP。

posted @ 2022-02-17 15:48  ebuybay  阅读(107)  评论(0编辑  收藏  举报