Go 通道

admin
admin 2020年03月24日
  • 在其它设备中阅读本文章

Go 语言设计团队的首任负责人 Rob Pike 对并发编程的一个建议是不要让计算通过共享内存来通讯,而应该让它们通过通讯来共享内存。通道机制就是这种哲学的一个设计结果。

通过 共享内存来通讯 和通过 通讯来共享内存 是并发编程中的两种编程风格。当通过共享内存来通讯的时候,我们需要一些传统的并发同步技术(比如互斥锁)来避免数据竞争。Go 提供了一种独特的并发同步技术来实现通过通讯来共享内存。此技术即为通道。我们可以把一个通道看作是在一个程序内部的一个先进先出(FIFO:first in first out)数据队列。一些协程可以向此通道发送数据,另外一些协程可以从此通道接收数据。

一、通道类型和值

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

  • 字面形式 chan T 表示一个元素类型为 T 的双向通道类型。编译器允许从此类型的值中接收和向此类型的值中发送数据。
  • 字面形式 chan<- T 表示一个元素类型为 T 的单向发送通道类型。编译器不允许从此类型的值中接收数据。
  • 字面形式 <-chan T 表示一个元素类型为 T 的单向接收通道类型。编译器不允许向此类型的值中发送数据。
    双向通道 chan T 的值可以被隐式转换为单向通道类型 chan<- T 和 <-chan T,但反之不行(即使显式也不行)。类型 chan<- T 和 <-chan T 的值也不能相互转换。每个通道值有一个容量属性。一个容量为 0 的通道值称为一个 非缓冲通道 (unbuffered channel),一个容量不为 0 的通道值称为一个 缓冲通道 (buffered channel)。
    通道类型的零值也使用预声明的 nil 来表示。一个非零通道值 必须通过内置的 make 函数来创建 。比如 make(chan int, 10) 将创建一个元素类型为 int 的通道值。第二个参数指定了欲创建的通道的容量。此第二个实参是可选的,它的默认值为 0。

二、通道操作

Go 中有五种通道相关的操作。假设一个通道(值)为 ch,下面列出了这五种操作的语法或者函数调用。

调用内置函数 close 来关闭一个通道:

close(ch)

传给 close 函数调用的实参必须为一个通道值,并且此通道值不能为单向接收的。

使用下面的语法向通道 ch 发送一个值 v:

ch <- v

v 必须能够赋值给通道 ch 的元素类型。ch 不能为单向接收通道。<- 称为数据发送操作符。

使用下面的语法从通道 ch 接收一个值:

<-ch

如果一个通道操作不永久阻塞,它总会返回至少一个值,此值的类型为通道 ch 的元素类型。ch 不能为单向发送通道。<- 称为数据接收操作符,是的它和数据发送操作符的表示形式是一样的。

在大多数场合下,一个数据接收操作可以被认为是一个单值表达式。但是,当一个数据接收操作被用做一个赋值语句中的唯一的源值的时候,它可以返回第二个可选的类型不确定的布尔值返回值从而成为一个多值表达式。此类型不确定的布尔值表示第一个接收到的值是否是在通道被关闭前发送的。

v = <-ch
v, sentBeforeClosed = <-ch

查询一个通道的容量:

cap(ch)

其中 cap 是一个已经在容器类型一文中介绍过的内置函数。cap 的返回值的类型为内置类型 int。

查询一个通道的长度:

len(ch)

len 的返回值的类型也为内置类型 int。一个通道的长度是指当前有多少个已被发送到此通道但还未被接收出去的元素值。
Go 中大多数的基本操作都是未同步的。换句话说,它们都不是并发安全的。这些操作包括赋值、传参、和各种容器值操作等。但是,除了并发地关闭一个通道和向此通道发送数据这种情形,上面这些所有列出的操作都已经同步过了,因此它们可以在并发协程中安全运行而无需其它同步操作。我们在编程中应该避免并发地关闭一个通道和向此通道发送数据这种情形,因为这种情形属于不良设计。注意:通道的赋值和其它类型值的赋值一样,是未同步的。同样,将刚从一个通道接收出来的值赋给另一个值也是未同步的。如果被查询的通道为一个 nil 零值通道,则 cap 和 len 函数调用都返回 0。这两个操作是如此简单,所以后面将不再对它们进行详解。事实上,这两个操作在实践中很少使用。

三、通道详解

通道可以归为三类:

  • 零值(nil)通道;
  • 非零值但已关闭的通道;
  • 非零值并且尚未关闭的通道。
    下表简单地描述了三种通道操作施加到三类通道的结果。

    操作 一个零值 nil 通道 一个非零值但已关闭的通道 一个非零值且尚未关闭的通道
    关闭 产生恐慌 产生恐慌 成功关闭 (C)
    发送数据 永久阻塞 产生恐慌 阻塞或者成功发送 (B)
    接收数据 永久阻塞 永不阻塞 (D) 阻塞或者成功接收 (A)

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

  • 关闭一个 nil 通道或者一个已经关闭的通道将产生一个恐慌。
  • 向一个已关闭的通道发送数据也将导致一个恐慌。
  • 向一个 nil 通道发送数据或者从一个 nil 通道接收数据将使当前协程永久阻塞。 下面将详细解释其它四种被打了上标(A/B/C/D)的情形。

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

  • 接收数据协程队列 。此队列是一个没有长度限制的链表。此队列中的协程均处于阻塞状态,它们正等待着从此通道接收数据。
  • 发送数据协程队列 。此队列也是一个没有长度限制的链表。此队列中的协程亦均处于阻塞状态,它们正等待着向此通道发送数据。此队列中的每个协程将要发送的值(或者此值的指针,取决于具体编译器实现)和此协程一起存储在此队列中。
  • 数据缓冲队列 。这是一个循环队列,它的长度为此通道的容量。此队列中存放的值的类型都为此通道的元素类型。如果此队列中当前存放的值的个数已经达到此通道的容量,则我们说此通道已经处于满槽状态。如果此队列中当前存放的值的个数为零,则我们说此通道处于空槽状态。对于一个非缓冲通道(容量为零),它总是同时处于满槽状态和空槽状态。 每个通道内部维护着一个互斥锁用来在各种通道操作中防止数据竞争。

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

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

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

  • 如果此通道的接收数据协程队列不为空(这种情况下,缓冲队列必为空),一个接收数据协程将从此队列中弹出,此协程将接收到发送协程 Gs 发送的值。此接收协程将恢复至运行状态。发送数据协程 Gs 继续运行,不会阻塞。对于这种情况,此数据发送操作为一个非阻塞操作。
  • 否则(接收数据协程队列为空),如果缓冲队列未满(这种情况下,发送数据协程队列必为空),发送协程欲 Gs 发送的值将被推入缓冲队列,发送数据协程 Gs 继续运行,不会阻塞。对于这种情况,此数据发送操作为一个非阻塞操作。
  • 对于剩下的情况(接收数据协程队列为空,并且缓冲队列已满),此发送协程 Gs 将被推入发送数据协程队列,并进入阻塞状态。它以后可能会被另一个接收数据协程唤醒而恢复运行。对于这种情况,此数据发送操作为一个阻塞操作。 上面已经提到过,一旦一个非零通道被关闭,继续向此通道发送数据将产生一个恐慌。注意,向关闭的通道发送数据属于一个非阻塞操作。

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

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

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

我们可以得出如下的关于一个通道的内部的三个队列的各种事实:

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

通道的元素值的传递都是复制过程
在一个值被从一个协程传递到另一个协程的过程中,此值将被复制至少一次。如果此传递值曾经在某个通道的缓冲队列中停留过,则它在此传递过程中将被复制两次。一次复制发生在从发送协程向缓冲队列推入此值的时候,另一个复制发生在接收协程从缓冲队列取出此值的时候。
对于官方标准编译器,最大支持的通道的元素类型的尺寸为 65535。但是,一般说来,为了在数据传递过程中避免过大的复制成本,我们不应该使用尺寸很大的通道元素类型。如果欲传送的值的尺寸较大,应该改用指针类型做为通道的元素类型。

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

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

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

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

等价于

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

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 流程控制的实现步骤。

  • 将所有 case 操作中涉及到的通道表达式和发送值表达式按照从上到下,从左到右的顺序一一估值。在赋值语句中做为源值的数据接收操作对应的目标值在此时刻不需要被估值。
  • 将所有分支的随机排序。default 分支总是排在最后。所有 case 操作中相关的通道可能会有重复的。
  • 为了防止在下一步中造成死锁,对所有 case 操作中相关的通道进行排序。排序依据并不重要,可以按照它们的地址顺序进行排序。排序结果中前 N 个通道不存在重复的情况。N 为所有 case 操作中不重复的通道的数量。下面,通道锁顺序是针对此排序结果中的前 N 个通道来说的,通道锁逆序是指此顺序的逆序。
  • 按照上一步中的生成通道锁顺序获取所有相关的通道的锁。
  • 按照第 2 步中生成的分支顺序检查相应分支:

    • 如果这是一个 case 分支并且相应的通道操作是一个向关闭了的通道发送数据操作,则按照通道锁逆序解锁所有的通道并在当前协程中产生一个恐慌。跳到第 12 步。
    • 如果这是一个 case 分支并且相应的通道操作是非阻塞的,则按照通道锁逆序解锁所有的通道并执行相应的 case 分支代码块。(此相应的通道操作可能会唤醒另一个处于阻塞状态的协程。)跳到第 12 步。
    • 如果这是 default 分支,则按照通道锁逆序解锁所有的通道并执行此 default 分支代码块。跳到第 12 步。(到这里,default 分支肯定是不存在的,并且所有的 case 操作均为阻塞的。)
  • 将当前协程(和对应 case 分支信息)推入到每个 case 操作中对应的通道的发送数据协程队列或接收数据协程队列中。当前协程可能会被多次推入到同一个通道的这两个队列中,因为多个 case 操作中对应的通道可能为同一个。
  • 使当前协程进入阻塞状态并则按照通道锁逆序解锁所有的通道。
  • 当前协程处于阻塞状态,等待其它协程通过通道操作唤醒当前协程
  • 当前协程被另一个协程中的一个通道操作唤醒。此唤醒通道操作可能是一个通道关闭操作,也可能是一个数据发送 / 接收操作。如果它是一个数据发送 / 接收操作,则(当前正被解释的 select-case 流程中)肯定有一个相应 case 操作与之配合传递数据。在此配合过程中,当前协程将从相应 case 操作相关的通道的接收 / 发送数据协程队列中弹出。
  • 按照第 3 步中的生成的通道锁顺序获取所有相关的通道的锁。
  • 将当前协程从各个 case 操作中对应的通道的发送数据协程队列或接收数据协程队列中(可能以非弹出的方式)移除。

    • 如果当前协程时被一个通道关闭操作所唤醒,则跳到第 5 步。
    • 如果当前协程时被一个数据发送 / 接收操作所唤醒,则相应的 case 分支已经在第 9 步中知晓。按照通道锁逆序解锁所有的通道并执行此 case 分支代码块。
  • 完毕。
    从此实现中,我们得知
  • 一个协程可能同时多次处于同一个通道的发送数据协程队列或接收数据协程队列中。
  • 当一个协程被阻塞在一个 select-case 流程控制中并在以后被唤醒时,它可能会从多个通道的发送数据协程队列和接收数据协程队列中被移除。