Channel 是 Go 语言内建的 first-class 类型,也是 Go 语言与众不同的特性之一。Go 语言的 Channel 设计精巧简单,以至于也有人用其它语言编写了类似 Go 风格的 Channel 库,比如docker/libchan、tylertreat/chan,但是并不像 Go 语言一样把 Channel 内置到了语言规范中。从这一点,你也可以看出来,Channel 的地位在编程语言中的地位之高,比较罕见。

我们就来学习下 Channel。

Channel 的发展

**
要想了解 Channel 这种 Go 编程语言中的特有的数据结构,我们要追溯到 CSP 模型,学习一下它的历史,以及它对 Go 创始人设计 Channel 类型的影响。

CSP 是 Communicating Sequential Process 的简称,中文直译为通信顺序进程,或者叫做交换信息的循序进程,是用来描述并发系统中进行交互的一种模式。

CSP 最早出现于计算机科学家 Tony Hoare 在 1978 年发表的论文中(你可能不熟悉 Tony Hoare 这个名字,但是你一定很熟悉排序算法中的 Quicksort 算法,他就是 Quicksort 算法的作者,图灵奖的获得者)。最初,论文中提出的 CSP 版本在本质上不是一种进程演算,而是一种并发编程语言,但之后又经过了一系列的改进,最终发展并精炼出 CSP 的理论。CSP 允许使用进程组件来描述系统,它们独立运行,并且只通过消息传递的方式通信。
**
就像 Go 的创始人之一 Rob Pike 所说的:“每一个计算机程序员都应该读一读 Tony Hoare 1978 年的关于 CSP 的论文。”他和 Ken Thompson 在设计 Go 语言的时候也深受此论文的影响,并将 CSP 理论真正应用于语言本身(Russ Cox 专门写了一篇文章记录这个历史),通过引入 Channel 这个新的类型,来实现 CSP 的思想。

Channel 类型是 Go 语言内置的类型,你无需引入某个包,就能使用它。虽然 Go 也提供了传统的并发原语,但是它们都是通过库的方式提供的,你必须要引入 sync 包或者 atomic 包才能使用它们,而 Channel 就不一样了,它是内置类型,使用起来非常方便。

Channel 和 Go 的另一个独特的特性 goroutine 一起为并发编程提供了优雅的、便利的、与传统并发控制不同的方案,并演化出很多并发模式。接下来,我们就来看一看 Channel 的应用场景。

Channel 的应用场景
首先,我想先带你看一条 Go 语言中流传很广的谚语:

Don’t communicate by sharing memory, share memory by communicating.
Go Proverbs by Rob Pike

这是 Rob Pike 在 2015 年的一次 Gopher 会议中提到的一句话,虽然有一点绕,但也指出了使用 Go 语言的哲学,我尝试着来翻译一下:“执行业务处理的 goroutine 不要通过共享内存的方式通信,而是要通过 Channel 通信的方式分享数据。”
**
“communicate by sharing memory”和“share memory by communicating”是两种不同的并发处理模式。“communicate by sharing memory”是传统的并发编程处理方式,就是指,共享的数据需要用锁进行保护,goroutine 需要获取到锁,才能并发访问数据。

“share memory by communicating”则是类似于 CSP 模型的方式,通过通信的方式,一个 goroutine 可以把数据的“所有权”交给另外一个 goroutine(虽然 Go 中没有“所有权”的概念,但是从逻辑上说,你可以把它理解为是所有权的转移)。

从 Channel 的历史和设计哲学上,我们就可以了解到,Channel 类型和基本并发原语是有竞争关系的,它应用于并发场景,涉及到 goroutine 之间的通讯,可以提供并发的保护,等等。

综合起来,我把 Channel 的应用场景分为五种类型。这里你先有个印象,这样你可以有目的地去学习 Channel 的基本原理。下节课我会借助具体的例子,来带你掌握这几种类型。

  1. 数据交流:当作并发的 buffer 或者 queue,解决生产者 - 消费者问题。
  2. 多个 goroutine 可以并发当作生产者(Producer)和消费者(Consumer)。
  3. 数据传递:一个 goroutine 将数据交给另一个 goroutine,相当于把数据的拥有权 (引用) 托付出去。
  4. 信号通知:一个 goroutine 可以将信号 (closing、closed、data ready 等) 传递给另一个或者另一组 goroutine 。
  5. 任务编排:可以让一组 goroutine 按照一定的顺序并发或者串行的执行,这就是编排的功能。锁:利用 Channel 也可以实现互斥锁的机制。


Channel 基本用法

你可以往 Channel 中发送数据,也可以从 Channel 中接收数据,所以,Channel 类型(为了说起来方便,我们下面都把 Channel 叫做 chan)分为只能接收、只能发送、既可以接收又可以发送三种类型。下面是它的语法定义:

  1. ChannelType = ( "chan" | "chan" "<-" | "<-" "chan" ) ElementType .

相应地,Channel 的正确语法如下:

  1. chan string // 可以发送接收string
  2. chan<- struct{} // 只能发送struct{}
  3. <-chan int // 只能从chan接收int

我们把既能接收又能发送的 chan 叫做双向的 chan,把只能发送和只能接收的 chan 叫做单向的 chan。其中,“<-”表示单向的 chan,如果你记不住,我告诉你一个简便的方法:这个箭头总是射向左边的,元素类型总在最右边。如果箭头指向 chan,就表示可以往 chan 中塞数据;如果箭头远离 chan,就表示 chan 会往外吐数据。
**
chan 中的元素是任意的类型,所以也可能是 chan 类型,我来举个例子,比如下面的 chan 类型也是合法的:

  1. chan<- chan int
  2. chan<- <-chan int
  3. <-chan <-chan int
  4. chan (<-chan int)

可是,怎么判定箭头符号属于哪个 chan 呢?其实,“<-”有个规则,总是尽量和左边的 chan 结合(The <- operator associates with the leftmost chan possible:),因此,上面的定义和下面的使用括号的划分是一样的:

  1. chan<- chan int // <- 和第一个chan结合
  2. chan<- (<-chan int // 第一个<-和最左边的chan结合,第二个<-和左边第二个chan结合
  3. <-chan (<-chan int // 第一个<-和最左边的chan结合,第二个<-和左边第二个chan结合
  4. chan (<-chan int) // 因为括号的原因,<-和括号内第一个chan结合

通过 make,我们可以初始化一个 chan,未初始化的 chan 的零值是 nil。你可以设置它的容量,比如下面的 chan 的容量是 9527,我们把这样的 chan 叫做 buffered chan;如果没有设置,它的容量是 0,我们把这样的 chan 叫做 unbuffered chan。

  1. make(chan int, 9527)

如果 chan 中还有数据,那么,从这个 chan 接收数据的时候就不会阻塞,如果 chan 还未满(“满”指达到其容量),给它发送数据也不会阻塞,否则就会阻塞。unbuffered chan 只有读写都准备好之后才不会阻塞,这也是很多使用 unbuffered chan 时的常见 Bug。

还有一个知识点需要你记住:nil 是 chan 的零值,是一种特殊的 chan,对值是 nil 的 chan 的发送接收调用者总是会阻塞。

下面,我来具体给你介绍几种基本操作,分别是发送数据、接收数据,以及一些其它操作。学会了这几种操作,你就能真正地掌握 Channel 的用法了。

1. 发送数据
**
往 chan 中发送一个数据使用“ch<-”,发送数据是一条语句:

  1. ch <- 2000

这里的 ch 是 chan int 类型或者是 chan <-int。

2. 接收数据
**
从 chan 中接收一条数据使用“<-ch”,接收数据也是一条语句:

  1. x := <-ch // 把接收的一条数据赋值给变量x
  2. foo(<-ch) // 把接收的一个的数据作为参数传给函数
  3. <-ch // 丢弃接收的一条数据

这里的 ch 类型是 chan T 或者 <-chan T。接收数据时,还可以返回两个值。第一个值是返回的 chan 中的元素,很多人不太熟悉的是第二个值。第二个值是 bool 类型,代表是否成功地从 chan 中读取到一个值,如果第二个参数是 false,chan 已经被 close 而且 chan 中没有缓存的数据,这个时候,第一个值是零值。所以,如果从 chan 读取到一个零值,可能是 sender 真正发送的零值,也可能是 closed 的并且没有缓存元素产生的零值。

3. 其它操作
**
Go 内建的函数 close、cap、len 都可以操作 chan 类型:close 会把 chan 关闭掉,cap 返回 chan 的容量,len 返回 chan 中缓存的还未被取走的元素数量。

  1. func main() {
  2. var ch = make(chan int, 10)
  3. for i := 0; i < 10; i++ {
  4. select {
  5. case ch <- i:
  6. case v := <-ch:
  7. fmt.Println(v)
  8. }
  9. }
  10. }

chan 还可以应用于 for-range 语句中,比如:

  1. for v := range ch {
  2. fmt.Println(v)
  3. }

或者是忽略读取的值,只是清空 chan:

  1. for range ch {
  2. }

Channel 的实现原理

后续更新,先会用

使用 Channel 容易犯的错误

根据 2019 年第一篇全面分析 Go 并发 Bug 的论文,那些知名的 Go 项目中使用 Channel 所犯的 Bug 反而比传统的并发原语的 Bug 还要多。主要有两个原因:一个是,Channel 的概念还比较新,程序员还不能很好地掌握相应的使用方法和最佳实践;第二个是,Channel 有时候比传统的并发原语更复杂,使用起来很容易顾此失彼。

使用 Channel 最常见的错误是 panic 和 goroutine 泄漏。
**
首先,我们来总结下会 panic 的情况,总共有 3 种:

  1. close 为 nil 的 chan;
  2. send 已经 close 的 chan;
  3. close 已经 close 的 chan。

goroutine 泄漏的问题也很常见,下面的代码也是一个实际项目中的例子:

  1. func process(timeout time.Duration) bool {
  2. ch := make(chan bool)
  3. go func() {
  4. // 模拟处理耗时的业务
  5. time.Sleep((timeout + time.Second))
  6. ch <- true // block
  7. fmt.Println("exit goroutine")
  8. }()
  9. select {
  10. case result := <-ch:
  11. return result
  12. case <-time.After(timeout):
  13. return false
  14. }
  15. }

在这个例子中,process 函数会启动一个 goroutine,去处理需要长时间处理的业务,处理完之后,会发送 true 到 chan 中,目的是通知其它等待的 goroutine,可以继续处理了。我们来看一下第 10 行到第 15 行,主 goroutine 接收到任务处理完成的通知,或者超时后就返回了。这段代码有问题吗?

如果发生超时,process 函数就返回了,这就会导致 unbuffered 的 chan 从来就没有被读取。我们知道,unbuffered chan 必须等 reader 和 writer 都准备好了才能交流,否则就会阻塞。超时导致未读,结果就是子 goroutine 就阻塞在第 7 行永远结束不了,进而导致 goroutine 泄漏。

解决这个 Bug 的办法很简单,就是将 unbuffered chan 改成容量为 1 的 chan,这样第 7 行就不会被阻塞了。

Go 的开发者极力推荐使用 Channel,不过,这两年,大家意识到,Channel 并不是处理并发问题的“银弹”,有时候使用并发原语更简单,而且不容易出错。所以,我给你提供一套选择的方法:

  1. 共享资源的并发访问使用传统并发原语;
  2. 复杂的任务编排和消息传递使用 Channel;
  3. 消息通知机制使用 Channel,除非只想 signal 一个 goroutine,才使用 Cond;
  4. 简单等待所有任务的完成用 WaitGroup,也有 Channel 的推崇者用 Channel,都可以;
  5. 需要和 Select 语句结合,使用 Channel;
  6. 需要和超时配合时,使用 Channel 和 Context。

总结

han 的值和状态有多种情况,而不同的操作(send、recv、close)又可能得到不同的结果,这是使用 chan 类型时经常让人困惑的地方。

为了帮助快速地了解不同状态下各种操作的结果,总结了一个表格,你一定要特别关注下那些 panic 的情况,另外还要掌握那些会 block 的场景,它们是导致死锁或者 goroutine 泄露的罪魁祸首。

还有一个值得注意的点是,只要一个 chan 还有未读的数据,即使把它 close 掉,你还是可以继续把这些未读的数据消费完,之后才是读取零值数据。

Channel:另辟蹊径,解决并发问题 - 图1

思考题

1.有一道经典的使用 Channel 进行任务编排的题,你可以尝试做一下:有四个 goroutine,编号为 1、2、3、4。每秒钟会有一个 goroutine 打印出它自己的编号,要求你编写一个程序,让输出的编号总是按照 1、2、3、4、1、2、3、4、……的顺序打印出来。

2.chan T 是否可以给 <- chan T 和 chan<- T 类型的变量赋值?反过来呢?