通道(channel)介绍

通道是Go中的一种一等公民类型。它是Go的招牌特性之一。 和另一个招牌特性​协程​一起,这两个招牌特性使得使用Go进行并发编程(concurrent programming)变得十分方便和有趣,并且大大降低了并发编程的难度。

通道的主要作用是用来实现​并发同步

Go提供了一种独特的并发同步技术来实现通过通讯来共享内存。此技术即为通道。 我们可以把一个通道看作是在一个程序内部的一个先进先出(FIFO:first in first out)数据队列。

一些协程可以向此通道发送数据,另外一些协程可以从此通道接收数据。

随着一个数据值的传递(发送和接收),一些数据值的所有权从一个协程转移到了另一个协程。 当一个协程发送一个值到一个通道,我们可以认为此协程释放了一些值的所有权。

当一个协程从一个通道接收到一个值,我们可以认为此协程获取了一些值的所有权。


通道类型和值

和数组、切片以及映射类型一样,每个通道类型也有一个元素类型。 一个通道只能传送它的(通道类型的)元素类型的值。

通道可以是双向的,也可以是单向的。

  • 字面形式​chan T​​表示一个元素类型为​​T​​的双向通道类型。 编译器允许从此类型的值中接收和向此类型的值中发送数据。
  • 字面形式​chan<- T​​表示一个元素类型为​​T​​的单向发送通道类型。 编译器不允许从此类型的值中接收数据。(只写)
  • 字面形式​<-chan T​​表示一个元素类型为​​T​​的单向接收通道类型。 编译器不允许向此类型的值中发送数据。(只读)


每个通道值有一个容量属性。此属性的意义将在下一节中得到解释。 一个容量为0的通道值称为一个非缓冲通道(unbuffered channel),一个容量不为0的通道值称为一个缓冲通道(buffered channel)。


为了让解释简单清楚,在本文后续部分,通道将被归为三类:

  1. 零值(nil)通道;
  2. 非零值但已关闭的通道;
  3. 非零值并且尚未关闭的通道

下表简单地描述了三种通道操作施加到三类通道的结果。

操作

一个零值nil通道

一个非零值但已关闭的通道

一个非零值且尚未关闭的通道

关闭

产生恐慌

产生恐慌

成功关闭(C)

发送数据

永久阻塞

产生恐慌

阻塞或者成功发送(B)

接收数据

永久阻塞

永不阻塞(D)

阻塞或者成功接收(A)

对于上表中的五种未打上标的情形,规则很简单:

  • 关闭一个nil通道或者一个已经关闭的通道将产生一个恐慌。
  • 向一个已关闭的通道发送数据也将导致一个恐慌。
  • 向一个nil通道发送数据或者从一个nil通道接收数据将使当前协程永久阻塞。

下面将详细解释其它四种被打了上标(A/B/C/D)的情形。

为了更好地理解通道和为了后续讲解方便,先了解一下通道类型的大致内部实现是很有帮助的。

我们可以认为一个通道内部维护了三个队列(均可被视为先进先出队列):

  1. 接收数据协程队列(可以看做是先进先出队列但其实并不完全是,见下面解释)。此队列是一个没有长度限制的链表。 此队列中的协程均处于阻塞状态,它们正等待着从此通道接收数据。
  2. 发送数据协程队列(可以看做是先进先出队列但其实并不完全是,见下面解释)。此队列也是一个没有长度限制的链表。 此队列中的协程亦均处于阻塞状态,它们正等待着向此通道发送数据。 此队列中的每个协程将要发送的值(或者此值的指针,取决于具体编译器实现)和此协程一起存储在此队列中。
  3. 数据缓冲队列。这是一个循环队列(绝对先进先出),它的长度为此通道的容量。此队列中存放的值的类型都为此通道的元素类型。 如果此队列中当前存放的值的个数已经达到此通道的容量,则我们说此通道已经处于满槽状态。 如果此队列中当前存放的值的个数为零,则我们说此通道处于空槽状态。 对于一个非缓冲通道(容量为零),它总是同时处于满槽状态和空槽状态。

每个通道内部维护着一个互斥锁用来在各种通道操作中防止数据竞争。

通道操作情形A: 当一个协程​​R​​​尝试从一个非零且尚未关闭的通道接收数据的时候,此协程​​R​​将首先尝试获取此通道的锁,成功之后将执行下列步骤,直到其中一个步骤的条件得到满足。

  1. 如果此通道的缓冲队列不为空(这种情况下,接收数据协程队列必为空),此协程​​R​​​将从缓冲队列取出接收一个值。 如果发送数据协程队列不为空,一个发送协程将从此队列中弹出,此协程欲发送的值将被推入缓冲队列。 此发送协程将恢复至运行状态。 接收数据协程​​R​​继续运行,不会阻塞。对于这种情况,此数据接收操作为一个非阻塞操作
  2. 否则(即此通道的缓冲队列为空),如果发送数据协程队列不为空(这种情况下,此通道必为一个非缓冲通道), 一个发送数据协程将从此队列中弹出,此协程欲发送的值将被接收数据协程​​R​​​接收。此发送协程将恢复至运行状态。 接收数据协程​​R​​继续运行,不会阻塞。对于这种情况,此数据接收操作为一个非阻塞操作
  3. 对于剩下的情况(即此通道的缓冲队列和发送数据协程队列均为空),此接收数据协程​​R​​将被推入接收数据协程队列,并进入阻塞状态。 它以后可能会被另一个发送数据协程唤醒而恢复运行。 对于这种情况,此数据接收操作为一个阻塞操作

通道操作情形B: 当一个协程​​S​​​尝试向一个非零且尚未关闭的通道发送数据的时候,此协程​​S​​将首先尝试获取此通道的锁,成功之后将执行下列步骤,直到其中一个步骤的条件得到满足。

  1. 如果此通道的接收数据协程队列不为空(这种情况下,缓冲队列必为空), 一个接收数据协程将从此队列中弹出,此协程将接收到发送协程​​S​​​发送的值。此接收协程将恢复至运行状态。 发送数据协程​​S​​继续运行,不会阻塞。对于这种情况,此数据发送操作为一个非阻塞操作
  2. 否则(接收数据协程队列为空),如果缓冲队列未满(这种情况下,发送数据协程队列必为空), 发送协程欲​​S​​​发送的值将被推入缓冲队列,发送数据协程​​S​​继续运行,不会阻塞。 对于这种情况,此数据发送操作为一个非阻塞操作
  3. 对于剩下的情况(接收数据协程队列为空,并且缓冲队列已满),此发送协程​​S​​将被推入发送数据协程队列,并进入阻塞状态。 它以后可能会被另一个接收数据协程唤醒而恢复运行。 对于这种情况,此数据发送操作为一个阻塞操作

上面已经提到过,一旦一个非零通道被关闭,继续向此通道发送数据将产生一个恐慌。 注意,向关闭的通道发送数据属于一个非阻塞操作

通道操作情形C: 当一个协程成功获取到一个非零且尚未关闭的通道的锁并且准备关闭此通道时,下面两步将依次执行:

  1. 如果此通道的接收数据协程队列不为空(这种情况下,缓冲队列必为空),此队列中的所有协程将被依个弹出,并且每个协程将接收到此通道的元素类型的一个零值,然后恢复至运行状态。
  2. 如果此通道的发送数据协程队列不为空,此队列中的所有协程将被依个弹出,并且每个协程中都将产生一个恐慌(因为向已关闭的通道发送数据)。 这就是我们在上面说并发地关闭一个通道和向此通道发送数据这种情形属于不良设计的原因。 事实上,并发地关闭一个通道和向此通道发送数据将产生数据竞争。

注意:当一个缓冲队列不为空的通道被关闭之后,它的缓冲队列不会被清空,其中的数据仍然可以被后续的数据接收操作所接收到。详见下面的对情形D的解释。

通道操作情形D: 一个非零通道被关闭之后,此通道上的后续数据接收操作将永不会阻塞。 此通道的缓冲队列中存储数据仍然可以被接收出来。 伴随着这些接收出来的缓冲数据的第二个可选返回(类型不确定布尔)值仍然是​​true​​​。 一旦此缓冲队列变为空,后续的数据接收操作将永不阻塞并且总会返回此通道的元素类型的零值和值为​​false​​​的第二个可选返回结果。 上面已经提到了,一个接收操作的第二个可选返回(类型不确定布尔)结果表示一个接收到的值是否是在此通道被关闭之前发送的。 如果此返回值为​​false​​,则第一个返回值必然是一个此通道的元素类型的零值。

知道哪些通道操作是阻塞的和哪些是非阻塞的对正确理解后面将要介绍的select流程控制机制非常重要。

如果一个协程被从一个通道的某个队列中(不论发送数据协程队列还是接收数据协程队列)弹出,并且此协程是在一个​​select控制流程​​​中推入到此队列的,那么此协程将在下面将要讲解的​​select控制流程的执行步骤​​中的第9步中恢复至运行状态,并且同时它会被从相应的​​select​​控制流程中的相关的若干通道的协程队列中移除掉。

根据上面的解释,我们可以得出如下的关于一个通道的内部的三个队列的各种事实:

  • 如果一个通道已经关闭了,则它的发送数据协程队列和接收数据协程队列肯定都为空,但是它的缓冲队列可能不为空。
  • 在任何时刻,如果缓冲队列不为空,则接收数据协程队列必为空。
  • 在任何时刻,如果缓冲队列未满,则发送数据协程队列必为空。
  • 如果一个通道是缓冲的,则在任何时刻,它的发送数据协程队列和接收数据协程队列之一必为空。
  • 如果一个通道是非缓冲的,则在任何时刻,一般说来,它的发送数据协程队列和接收数据协程队列之一必为空, 但是有一个例外:一个协程可能在一个select流程控制中同时被推入到此通道的发送数据协程队列和接收数据协程队列中。

一些通道的使用例子

来看一些通道的使用例子来加深一下对上一节中的解释的理解。

一个简单的通过一个非缓冲通道实现的请求/响应的例子:

package main

import (
"fmt"
"time"
)

func main() {
c := make(chan int) // 一个非缓冲通道
go func(ch chan<- int, x int) {
time.Sleep(time.Second)
// <-ch // 此操作编译不通过
ch <- x*x // 阻塞在此,直到发送的值被接收
}(c, 3)
done := make(chan struct{})
go func(ch <-chan int) {
n := <-ch // 阻塞在此,直到有值发送到c
fmt.Println(n) // 9
// ch <- 123 // 此操作编译不通过
time.Sleep(time.Second)
done <- struct{}{}
}(c)
<-done // 阻塞在此,直到有值发送到done
fmt.Println("bye")
}


输出结果:

9
bye


下面的例子使用了一个缓冲通道。此例子程序并非是一个并发程序,它只是为了展示缓冲通道的使用。

package main

import "fmt"

func main() {
c := make(chan int, 2) // 一个容量为2的缓冲通道
c <- 3
c <- 5
close(c)
fmt.Println(len(c), cap(c)) // 2 2
x, ok := <-c
fmt.Println(x, ok) // 3 true
fmt.Println(len(c), cap(c)) // 1 2
x, ok = <-c
fmt.Println(x, ok) // 5 true
fmt.Println(len(c), cap(c)) // 0 2
x, ok = <-c
fmt.Println(x, ok) // 0 false
x, ok = <-c
fmt.Println(x, ok) // 0 false
fmt.Println(len(c), cap(c)) // 0 2
close(c) // 此行将产生一个恐慌
c <- 7 // 如果上一行不存在,此行也将产生一个恐慌。
}


一场永不休场的足球比赛:

package main

import (
"fmt"
"time"
)

func main() {
var ball = make(chan string)
kickBall := func(playerName string) {
for {
fmt.Print(<-ball, "传球", "\n")
time.Sleep(time.Second)
ball <- playerName
}
}
go kickBall("张三")
go kickBall("李四")
go kickBall("王二麻子")
go kickBall("刘大")
ball <- "裁判" // 开球
var c chan bool // 一个零值nil通道
<-c // 永久阻塞在此
}


通道的元素值的传递都是复制过程

在一个值被从一个协程传递到另一个协程的过程中,此值将被复制至少一次。 如果此传递值曾经在某个通道的缓冲队列中停留过,则它在此传递过程中将被复制两次。 一次复制发生在从发送协程向缓冲队列推入此值的时候,另一个复制发生在接收协程从缓冲队列取出此值的时候。 和赋值以及函数调用传参一样,当一个值被传递时,​​只有它的直接部分被复制​​。

对于官方标准编译器,最大支持的通道的元素类型的尺寸为65535​。 但是,一般说来,为了在数据传递过程中避免过大的复制成本,我们不应该使用尺寸很大的通道元素类型。 如果欲传送的值的尺寸较大,应该改用指针类型做为通道的元素类型。

关于通道和协程的垃圾回收

注意,一个通道被其发送数据协程队列和接收数据协程队列中的所有协程引用着。因此,如果一个通道的这两个队列只要有一个不为空,则此通道肯定不会被垃圾回收。 另一方面,如果一个协程处于一个通道的某个协程队列之中,则此协程也肯定不会被垃圾回收,即使此通道仅被此协程所引用。 事实上,一个协程只有在退出后才能被垃圾回收。

数据接收和发送操作都属于简单语句

数据接收和发送操作都属于​​简单语句​​​。 另外一个数据接收操作总是可以被用做一个单值表达式。 简单语句和表达式可以被用在​​一些控制流程​​的某些部分。

在下面这个例子中,数据接收和发送操作被用在两个​​for​​循环的初始化和步尾语句。


package main

import (
"fmt"
"time"
)

func main() {
fibonacci := func() chan uint64 {
c := make(chan uint64)
go func() {
var x, y uint64 = 0, 1
for ; y < (1 << 63); c <- y { // 步尾语句
x, y = y, x+y
}
close(c)
}()
return c
}
c := fibonacci()
for x, ok := <-c; ok; x, ok = <-c { // 初始化和步尾语句
time.Sleep(time.Second)
fmt.Println(x)
}
}

​for-range​​应用于通道

​for-range​​​循环控制流程也适用于通道。 此循环将不断地尝试从一个通道接收数据,直到此通道关闭并且它的缓冲队列中为空为止。 和应用于数组/切片/映射的​​for-range​​​语法不同,应用于通道的​​for-range​​语法中最多只能出现一个循环变量,此循环变量用来存储接收到的值。

for v = range aChannel {
// 使用v
}

等价于

for {
v, ok = <-aChannel
if !ok {
break
}
// 使用v
}


当然,这里的通道​​aChannel​​​一定不能为一个单向发送通道。 如果它是一个nil零值,则此​​for-range​​循环将使当前协程永久阻塞。

上一节中的例子中的最后一个​​for​​循环可以改写为下面这样:

for x := range c {
time.Sleep(time.Second)
fmt.Println(x)
}


​select-case​​分支流程控制代码块

Go中有一个专门为通道设计的​​select-case​​​分支流程控制语法。 此语法和​​switch-case​​​分支流程控制语法很相似。 比如,​​select-case​​​流程控制代码块中也可以有若干​​case​​​分支和最多一个​​default​​​分支。 但是,这两种流程控制也有很多不同点。在一个​​select-case​​流程控制中,

  • ​select​​​关键字和​​{​​之间不允许存在任何表达式和语句。
  • ​fallthrough​​语句不能被使用.
  • 每个​​case​​​关键字后必须跟随一个通道接收数据操作或者一个通道发送数据操作。 通道接收数据操作可以做为源值出现在一条简单赋值语句中。 以后,一个​​case​​​关键字后跟随的通道操作将被称为一个​​case​​操作。
  • 所有的非阻塞​​case​​​操作中将有一个被随机选择执行(而不是按照从上到下的顺序),然后执行此操作对应的​​case​​分支代码块。
  • 在所有的​​case​​​操作均为阻塞的情况下,如果​​default​​​分支存在,则​​default​​分支代码块将得到执行; 否则,当前协程将被推入所有阻塞操作中相关的通道的发送数据协程队列或者接收数据协程队列中,并进入阻塞状态。

按照上述规则,一个不含任何分支的​​select-case​​​代码块​​select{}​​将使当前协程处于永久阻塞状态。

在下面这个例子中,​​default​​​分支将铁定得到执行,因为两个​​case​​分支后的操作均为阻塞的。

package main

import "fmt"

func main() {
var c chan struct{} // nil
select {
case <-c: // 阻塞操作
case c <- struct{}{}: // 阻塞操作
default:
fmt.Println("Go here.")
}
}

下面这个例子中实现了尝试发送(try-send)和尝试接收(try-receive)。 它们都是用含有一个​​case​​​分支和一个​​default​​​分支的​​select-case​​代码块来实现的。

package main

import "fmt"

func main() {
c := make(chan string, 2)
trySend := func(v string) {
select {
case c <- v:
default: // 如果c的缓冲已满,则执行默认分支。
}
}
tryReceive := func() string {
select {
case v := <-c: return v
default: return "-" // 如果c的缓冲为空,则执行默认分支。
}
}
trySend("Hello!") // 发送成功
trySend("Hi!") // 发送成功
trySend("Bye!") // 发送失败,但不会阻塞。
// 下面这两行将接收成功。
fmt.Println(tryReceive()) // Hello!
fmt.Println(tryReceive()) // Hi!
// 下面这行将接收失败。
fmt.Println(tryReceive()) // -
}

下面这个程序有50%的几率会因为恐慌而崩溃。 此程序中​​select-case​​​代码块中的两个​​case​​​操作均不阻塞,所以随机一个将被执行。 如果第一个​​case​​操作(向已关闭的通道发送数据)被执行,则一个恐慌将产生。

package main

func main() {
c := make(chan struct{})
close(c)
select {
case c <- struct{}{}: // 若此分支被选中,则产生一个恐慌
case <-c:
}
}

​select-case​​流程控制的实现机理

​select-case​​​流程控制是Go中的一个重要和独特的特性。 下面列出了官方标准运行时中​​select-case​​​流程控制的​​实现步骤​​。

  1. 将所有​​case​​操作中涉及到的通道表达式和发送值表达式按照从上到下,从左到右的顺序一一估值。 在赋值语句中做为源值的数据接收操作对应的目标值在此时刻不需要被估值。
  2. 将所有分支的随机排序。​​default​​​分支总是排在最后。 所有​​case​​操作中相关的通道可能会有重复的。
  3. 为了防止在下一步中造成(和其它协程互相)死锁,对所有​​case​​​操作中相关的通道进行排序。 排序依据并不重要,官方Go标准编译器使用通道的地址顺序进行排序。 排序结果中前​​N​​​个通道不存在重复的情况。​​N​​​为所有​​case​​操作中涉及到的不重复的通道的数量。 下面,通道锁顺序是针对此排序结果中的前​​N​​个通道来说的,通道锁逆序是指此顺序的逆序。
  4. 按照上一步中的生成通道锁顺序获取所有相关的通道的锁。
  5. 按照第2步中生成的分支顺序检查相应分支:
  1. 如果这是一个​​case​​分支并且相应的通道操作是一个向关闭了的通道发送数据操作,则按照通道锁逆序解锁所有的通道并在当前协程中产生一个恐慌。 跳到第12步。
  2. 如果这是一个​​case​​​分支并且相应的通道操作是非阻塞的,则按照通道锁逆序解锁所有的通道并执行相应的​​case​​分支代码块。 (此相应的通道操作可能会唤醒另一个处于阻塞状态的协程。) 跳到第12步。
  3. 如果这是​​default​​​分支,则按照通道锁逆序解锁所有的通道并执行此​​default​​分支代码块。 跳到第12步。
  4. (到这里,​​default​​​分支肯定是不存在的,并且所有的​​case​​操作均为阻塞的。)
  1. 将当前协程(和对应​​case​​​分支信息)推入到每个​​case​​​操作中对应的通道的发送数据协程队列或接收数据协程队列中。 当前协程可能会被多次推入到同一个通道的这两个队列中,因为多个​​case​​操作中对应的通道可能为同一个。
  2. 使当前协程进入阻塞状态并则按照通道锁逆序解锁所有的通道。
  3. ...,当前协程处于阻塞状态,等待其它协程通过通道操作唤醒当前协程,...
  4. 当前协程被另一个协程中的一个通道操作唤醒。 此唤醒通道操作可能是一个通道关闭操作,也可能是一个数据发送/接收操作。 如果它是一个数据发送/接收操作,则(当前正被解释的​​select-case​​​流程中)肯定有一个相应​​case​​​操作与之配合传递数据。 在此配合过程中,当前协程将从相应​​case​​操作相关的通道的接收/发送数据协程队列中弹出。
  5. 按照第3步中的生成的通道锁顺序获取所有相关的通道的锁。
  6. 将当前协程从各个​​case​​操作中对应的通道的发送数据协程队列或接收数据协程队列中(可能以非弹出的方式)移除。
  1. 如果当前协程时被一个通道关闭操作所唤醒,则跳到第5步。
  2. 如果当前协程时被一个数据发送/接收操作所唤醒,则相应的​​case​​分支已经在第9步中知晓。 按照通道锁逆序解锁所有的通道并执行此​​case​​分支代码块。
  1. 完毕。

从此实现中,我们得知

  • 一个协程可能同时多次处于同一个通道的发送数据协程队列或接收数据协程队列中。
  • 当一个协程被阻塞在一个​​select-case​​流程控制中并在以后被唤醒时,它可能会从多个通道的发送数据协程队列和接收数据协程队列中被移除。

相关文献:golang 并发编程 通道用例大全