前言
继《[译]Go语言如何优雅地关闭channel》之后的另一篇译文。
原文介绍
原文来自Go101,该项目被托管在Github上。
原文链接为Channels in Go,如果可以,阅读原文是最好的选择。
译文内容
通道是Go语言中一个重要的内置功能。它是让Go语言变得独特的功能之一。与另一个独特的功能:协程一起,通道让并发编程变得方便、有趣并且降低了并发编程的难度。
通道主要充当并发与同步技术。这篇文章将会列举通道相关的概念、语法和规则。为了更好的理解通道,也会简单的描述通道的内部结构和一些在标准Go编译器/运行时里的实现细节。
对于刚接触Go的gophers,这篇文章的内容可能有一点轻微的挑战,这篇文章的一些部分可能需要读几次才能完全理解。
通道介绍
Rob Pike提出的一个关于并发编程的建议是:不要(让计算机指令)通过共享内存来通信,而是(让它们)通过通信来共享内存(通过通道)。(在Go语言编程中,我们可以将每个指令视为一个协程)
通过共享内存通信和通过通信来共享内存是并发编程中的两种不同的编程方式。当协程通过通信来共享内存时,我们使用传统的并发同步技术来保护共享的内存避免数据竞争,比如互斥锁。我们可以使用通道来实现通过通信的方式共享内存。
Go提供了一种独特的并发同步技术,通道。通道使协程以通信的方式来共享内存。我们可以将一个通道视为程序内部的FIFO(先进先出)的队列。一些协程发送值到队列(通道),其他协程从队列中获取值。
与数据传输(通过通道)一起,一些值的所有权也在协程之间转换。当协程发送值到通道时,我们可以看作该协程释放有的值的所有权。当一个协程从通道接收一个值时,我们可以看作该协程获得一些值的所有权。
当然,在通道通信中也可能没有任何所有权被转移。
值(所有权被转移的)通常被传输的数据引用(但是并不被要求引用)。这里请注意,当我们谈论所有权时,我们指的是逻辑层面的所有权。不像Rust语言,Go语言不从语法层面保证数据的所有权,Go通道可以帮助编程者轻松地编写不存在数据竞争的代码,但不能从语法层面阻止编程者编写糟糕的并发代码。
虽然Go语言也支持传统的并发同步技术,但是在Go中只有通道是最好的选择。通道在Go中是一种类型,所以我们可以不引用任何package的使用通道。另一方面,在sync
和sync/atomic
标准包中提供着这些传统的并发同步技术。
公平地讲,每一种并发同步技术都有它自己最好的应用场景,但是通道有更广泛的和更多样的应用,通道的一个问题是,使用通道编程的经历是如此的令人愉悦和有趣,以至于在通道并不是最好选择的场景中,开发者通常更喜欢使用通道。
通道类型和值
像数组、切片和map一样,每个通道都有一个元素类型,一个通道只能传输通道元素类型对应的值。
通道可以是单向或者双向的,假设T
为任意类型,
chan T
表示一个双向的通道,编译器允许向通道接收和发送数据。chan<- T
表示只写通道,编译器不允许从只写通道接收数据。<-chan T
表示只读通道,编译器不允许向只读通道发送数据。
T
是通道的元素类型。
双向通道chan T
可以隐式地转换为只写通道chan<- T
和只读通道<-chan T
,但是反过来却不行(即使显示转换也不行)。只写通道chan<- T
不能被转换为只读通道<-chan T
,反之亦然。注意通道中的<-
符号是修饰语。
每个通道类型的值都有一个容量,这将会在下一部分中解释。一个容量为0的通道被称之为不带缓冲的通道,一个非0容量的通道被称之为带缓冲的通道。
通道类型的零值是预先声明的标识符nil
。一个非空的通道必须通过使用内置函数make
创建,比如:make(chan int, 10)
将会创建一个元素类型为int
的通道。make
函数的第二个参数指明新创建的通道的容量,第二个参数是可选的并且它的默认值为0。
通道值的比较
所有通道类型是可比较的类型。
从值部分这篇文章中,我们知道非空通道变量的值是多部分组成的。如果一个通道变量被分配给另一个,则这两个通道共享相同的底层部分,换句话说,这两个通道代表相同的通道内部对象,它们比较的结果为true
。
通道操作
有五种指定的通道操作,假设ch
表示通道,通道的这些操作的语法和函数调用列举如下:
通过使用下面的函数调用来关闭通道
1
close(ch)
close
是一个内置函数。close
函数调用的参数必须是通道值,并且通道ch
必须不能是只读通道。通过使用下面的语法发送一个值
v
到通道1
ch <- v
v
必须是通道ch
对应的元素类型,并且通道ch
不能是只读通道,注意这里的<-
是一个通道发送操作符。通过使用下面的语法从通道接收数据
1
<-ch
通道接收操作总是返回至少一个结果,返回结果的类型是通道对应的元素类型,并且通道
ch
不能是只写类型的通道。注意这里的<-
是通道接收操作符。没错,通道接收操作符的表示跟通道发送操作符一样。对于大多数应用场景,通道接收操作被当作一个单值的表达式。然而,当一个通道操作在声明中被用作唯一源数据表达式时,可以获取第二个无类型的可选的布尔值变成一个多值表达式。这个布尔值表示第一个返回值是否是通道关闭之前发送出来的。(下面我们将学习到我们可以从一个已经关闭的通道中接收无数个值)
用作源数据分配的两个通道接收操作:
1
2v = <- ch
v, sentBeforeClosed = <-ch通过下面的函数调用查看通道的值缓冲容量
1
cap(ch)
cap
是一个已经在Go语言容器中介绍过的内置函数。cap
函数调用的的返回值是一个int
类型的值。通过下面的函数调用查询通道缓冲区中当前值的数量(或者长度)
1
len(ch)
len
同样也是之前已经介绍过的内置函数。len
函数的返回值是一个int
类型的值。返回的值是已经被成功发送到被查询通道缓冲区且还没有被接收(取出)的元素的个数。
Go中大多数基本的操作不是同步的,换句话说,它们不是并发安全的。这些操作包括赋值、参数传递和容器元素的操作等等。但是,所有刚刚介绍的这些通道操作已经是同步的,所以除了在通道中同时发送和关闭操作以外,其他的不再需要进一步的同步操作来安全地执行这些操作。这些例外情形应该在代码设计中避免,因为这是一个糟糕的设计(原因将会在下面解释)。
像Go中大部分其他操作一样,通道赋值不是同步的,相似地,分配接收的值给另一个值同样也不是同步的,尽管任何通道接收操作是同步的。
如果被查询的通道是空值,内置的cap
和len
函数都返回0。这两个查询操作很简单,所以后面将不再更多的介绍它们,实际上,这两个操作在实际应用中很少被用到。
下一部分将会详细介绍通道的发送、接收和关闭操作。
通道操作的详细介绍
为了使通道操作的解释简单明了,在本文的剩余部分,通道将会被分为三类:
- 空通道
- 非空但已关闭的通道
- 非空且未关闭的通道
下面的表格简要概括了应用在上述三种通道上的所有操作的行为:
操作 | 空通道 | 已关闭的通道 | 非空且未关闭的通道 |
---|---|---|---|
关闭 | panic | panic | 关闭成功 |
发送数据 | 永久阻塞 | panic | 阻塞或者关闭成功 |
接收数据 | 永久阻塞 | 不会阻塞 | 阻塞或者接收成功 |
对于五种没有上标的情形,行为非常清晰。
- 在当前协程中关闭一个空的或者已经关闭的通道会造成panic。
- 在当前协程中往已关闭的通道中发送数据也会造成panic
- 往一个空通道中发送或者从一个空通道中接收数据会使当前协程进入阻塞并且永远保持阻塞状态。
下面将会对列出的四种情形(A,B,C和D)做更多的解释。
为了更好的理解通道类型和值和让解释更简单,查看通道的内部对象结构是非常有帮助的。
我们可以认为每个通道由三个内部队列(都可以看作FIFO队列)组成:
- 接收协程队列。这个队列是一个没有大小限制的链表,在这个队列中的所有协程都处于阻塞状态,并且等待从通道中接收数据。
- 发送协程队列。这个队列也是一个没有大小限制的链表,在这个队列中的所有协程都处于阻塞状态,并且等待往通道中发送数据。每个协程试图发送的数据(或者数据地址,取决于编译器实现)也与该协程一起存储在队列中。
- 数据缓冲队列。这是一个循环队列,它的大小与通道的容量大小相同。存储在这个缓冲区队列中数据的类型是通道的元素类型。如果当前缓冲区队列中的数据数量达到了通道的容量大小,该通道被称之为已满的状态。如果当前没有数据存储在缓冲队列中,该通道被称之为空的状态。对于一个无缓冲区的通道,总是同时处于满和空的状态。
每个通道内部维持了一个互斥锁,用来避免所有类型操作中的数据竞争。
通道操作情形A: 当协程Gr
试图从一个非空且没有关闭的通道中接收数据,协程Gr
将会首先获得与通道相关联的锁,然后执行下列的步骤直到一个步骤的条件被满足。
- 如果通道的数据缓冲队列非空,在这种情况下,通道的接收协程队列必须是空的。协程
Gr
将会从数据缓冲区队列中接收一个数据,如果通道的发送协程队列同样也不是空的,一个发送协程将会被从发送协程队列中移除并且被再次恢复到运行状态,被移除的发送协程试图发送的数据将会被推送到通道的数据缓冲区队列。接收协程Gr
继续运行,对于这个场景,通道的接收操作被称之为非阻塞操作。 - 否则(通道的数据缓冲队列是空的),如果通道的发送协程队列非空,在这种情况下,通道必须是一个不带缓冲的通道,接收通道
Gr
将会从通道的发送协程队列中移除一个发送协程并且接收刚刚被移除的发送协程试图发送的数据。刚刚被移除的发送协程将变成非阻塞并且再次恢复到运行的状态。接收协程Gr
继续运行,对于这种场景,通道接收操作被称之为非阻塞操作。 - 如果通道的缓冲队列和发送协程队列都是空的,协程
Gr
将会被推送到通道的接收协程队列,并且进入(并且保持)阻塞状态,稍后当另一个协程发送一个数据到通道时,它有可能被恢复到运行状态。对于这种场景,通道的接收操作被称之为阻塞操作。
通道规则情形B: 当协程Gs
试图发送一个数据到一个没有关闭且非空的通道中时,协程Gs
将会首先获得通道相关联的锁,然后执行下列步骤直到一个步骤的条件被满足。
- 如果通道的接收协程队列非空,在这种情况下,通道的数据缓冲队列必须是空的,发送协程
Gs
将会从通道的接收协程队列中移除一个接收协程并且发送数据到刚移除的接收协程中。刚移除的接收协程将会变为非阻塞的并且再次恢复到运行的状态。发送协程Gs
继续运行。对于这种场景,通道的发送操作被称之为非阻塞操作。 - 否则(接收协程队列是空的),如果通道的数据缓冲队列未满,在这种情况下,发送协程队列也必须是空的,发送协程
Gs
试图发送的数据将会被推送到数据缓冲队列,并且发送协程Gs
继续运行。对于这种场景,通道的发送操作被称之为非阻塞操作。 - 如果通道的接收协程队列是空的并且数据缓冲队列已经满了,发送协程
Gs
将会被推送到通道的发送协程队列中,然后进入(并且保持)阻塞状态,稍后当另一个协程从通道中接收一个数据时,它有可能恢复到运行的状态。对于这种场景,通道的发送曹祖被称之为阻塞操作。
上面已经提到过,在当前协程中,一旦一个非空的通道被关闭,再向其发送数据将会造成运行时panic。请注意,向一个已关闭的通道发送数据被视为一次非阻塞操作
通道操作情形C: 当一个协程尝试关闭一个非空且未关闭的通道,一旦该程获得了该通道的锁,以下两个步骤都会按照下面的顺序被执行:
- 如果通道的接收协程队列不是空的,在这种情况下,通道的数据缓冲区必须是空的,在通道接收协程队列中的所有协程将会被一个接一个的移除,每个协程都将会收到一个通道元素类型的零值并且被恢复到运行状态。
- 如果通道的发送协程队列不是空的,在发送协程队列中的所有协程将会被一个接一个的移除,每个协程都将会造成一个panic,因为它们向一个已关闭的通道发送数据。这就是为什么我们应该避免相同的通道上同时发生的发送和关闭操作。实际上,在同时发生的发送和关闭操作中产生了数据竞争。
注意:在一个通道关闭之后,已经被推送到通道数据缓冲区中的数据仍然在那儿。请详细阅读紧接着的情形D解释。
通道操作情形D: 在非空通道关闭后,该通道上的通道接收操作(译者注:从该通道接收数据的操作)将永远不会阻塞。该通道数据缓冲区中的数据仍然可以被接收,附随的第二个可选的布尔返回值仍然为true
。一旦数据缓冲区中的所有数据被取出和接收,无数个通道元素类型的零值将会被任何该通道接收操作对应的通道所接收。正如上面提到的,通道接收操作可选的第二个返回结果是一个无类型的布尔值,它暗示着接收操作的第一个返回值是否是在通道关闭之前发送的。如果第二个返回结果为false
,此时接收操作的第一个返回值一定是通道元素类型的零值。
知道什么是阻塞和非阻塞的通道发送或者接收操作对理解接下来将要介绍的select
控制语句块很重要。
在上面的解释中,如果一个协程从通道的队列(发送或者接收协程队列)中被移除,该协程因为在select
控制流代码块中被推送到队列中而变为阻塞的,那么该协程在select
控制流代码块执行步骤9中将会被恢复到运行状态。在select
控制流代码块中涉及到的多个通道对应的协程队列中,它可能被移除。
根据上文列出的解释,我们可以得出一些关于通道内部队列的一些事实:
- 如果通道已关闭,它的发送协程队列和接收协程队列必须都是空的,但是它的数据缓冲队列可能不为空。
- 任何时候,如果数据缓冲区不是空的,则它的接收协程队列必须是空的。
- 任何时候,如果数据缓冲区不是满的,则它的发送协程队列必须是空的。
- 如果通道是带缓冲区的,则在任何时候,它的发送协程队列和接收协程队列中的一个必须是空的。
- 如果通道不带缓冲区,则在任何时候,通常情况下它的发送协程队列和接收协程队列中的一个必须是空的,除了一个例外情况:当执行一个
select
控制语句块的时候一个协程可能被推送到两个队列中。
一些通道使用实例
在文章的最后一部分中,让我们看一些使用通道的例子来增强理解。
一个简单的请求/响应的例子。在这个例子中两个协程通过一个不带缓冲的通道相互通信。
1 | package main |
输出:
1 | 9 |
一个使用带缓冲通道的demo。这个程序不是并发程序,它仅仅展示了如何使用带缓冲的通道。
1 | package main |
一个永远不会结束的足球游戏。
1 | package main |
更多的通道使用实例请阅读通道使用情形
通道元素通过拷贝来传输
当数据从一个协程传输到另一个协程的时候,数据将会被至少拷贝一次。如果传输数据是从通道缓冲区中取出来的,则传输过程中将会发生两次拷贝。一次拷贝发生在当数据从发送协程进入数据缓冲区中时,另一次拷贝是从缓冲区进入接收协程时发生。像值分配和函数参数传递一样,当一个数据被转移,只有上层部分被拷贝(译者注:也就是说两个值可能共享一个底层数据结构)。
对于标准Go编译器,通道的元素类型大小必须小于65536
。然而,通常情况下,为了避免数据转移过程中协程之间发生耗费太大的拷贝,我们不应该为占用空间很大的元素类型创建通道。所以为了避免占用空间大的数据拷贝,如果传递的数据占用空间很大,最好使用指针元素类型代替。
关于通道和协程垃圾回收
注意,在通道发送或者接收协程通道中,通道被所有的协程所引用,所以如果两个队列没有一个是空的,通道肯定不会被垃圾回收。在另一方面,如果通道发送或者接收协程队列中的一个协程被阻塞了,通道同样不会被垃圾回收,即使通道只被该协程引用。实际上,只有当协程已经退出了的时候它才可以被垃圾回收。
通道发送和接收操作是简单语句
通道发送操作和接收操作是简单语句。一个通道接收操作可以总是被当成单值表达式使用。简单语句和表达式可以在基础的控制流程块中使用。
通道发送和接收操作作为简单语句在两个for
循环控制流程块中出现。
1 | package main |
通道中的for-range
应用于通道的for-range
控制流语句块语句块,循环试图迭代地接收发送给通道的数据,直到通道被关闭并且通道的缓冲区变为空。不像数组、切片和map中的for-range
语法,通道的for-range
语法中,允许大多数是一个用于保存数据的迭代变量。
1 | for v = range aChannel { |
等同于
1 | for { |
当然,这里的aChannel
不能是一个只写的通道。如果通道的值为nil,那么循环将会永远阻塞在这儿。
例如,在最后一部分的第二个for
循环例子可以被简化为:
1 | for x := range c { |
select-case
控制流代码块
select-case
代码块语法是专门为通道设计的,这个语法更像是switch-case
语法块。例如,在select-case
代码中可以有多个case
分支和最多一个default
分支,但是两者之间仍然有明显的不同:
- 在
select
关键字前不允许有表达式和语句(在{
前面)。 - 在
case
分支中不允许使用fallthrough
语句。 - 在
select-case
代码块中每个case
关键字后的语句必须是通道发送或者接收操作语句。通道接收操作可以作为简单赋值语句的源数据出现。稍后,case
关键字后的通道操作将会被称之为case
操作。 - 以防万一有一些非阻塞的
case
操作,Go运行时将会随机地选择它们中的一个去执行,然后继续执行相匹配的case
分支。 - 以防万一
select-case
代码块中所有的case
操作是阻塞的操作,如果default
分支出现,则default
分支将会被选择执行。如果没有default
分支,当前的协程将会被推送到case
操作涉及的每个通道对应的发送协程或者接收协程队列中,然后进入阻塞状态。
根据规则,一个没有任何分支的select-case
代码块,将会使当前协程永久进入阻塞状态。
下面的程序将会进入default
分支:
1 | package main |
展示如何使用try-send和try-receive的例子:
1 | package main |
下面的例子有50%的可能行panic,这个例子中的两个case
操作都是非阻塞的。
1 | package main |
Select机制的实现
Go中的select机制是一个重要并且独特的功能。这里列出了Go运行时实现的select机制的步骤。
执行一个select-case
块有几个步骤:
- 从上到下,从左到右,评估涉及的所有可能在
case
操作中被发送的通道表达式和值表达式。对于接收操作(作为源数据)赋值的目的地值此时不需要被评估。 - 为步骤5中的轮询对分支顺序进行随机化,
default
分支总是被放在随机结果最后的位置。case
操作中的通道可能是重复的。 - 为了避免在下一步死锁(与其他协程),对在
case
操作中涉及的所有通道进行排序,在第一个排序好了的N
通道中没有重复的通道,N
是case
操作中涉及到的通道数量。下面,通道顺序锁是对有序的结果中第一个N
通道的一个概念。 - 用上一步生成的通道顺序锁锁住(请求获得锁)所有涉及到的通道。
- 根据步骤2中随机化的顺序对select块中的每个分支进行投票:
- 如果这是一个
case
分支并且相应的通道操作是一个发送数据到已关闭的通道的操作,则逆序的通过通道锁解锁所有的通道并且让当前协程panic,跳转到步骤12。 - 如果这是一个
case
分支并且相应的通道操作是非阻塞的,则执行通道操作,并且逆序的通过通道锁解锁所有的通道,然后执行对应case
分支的内容。该通道操作可能叫醒另一个阻塞状态的协程。跳转到步骤12。 - 如果这是一个
default
分支,则逆序的通过通道锁解锁所有的通道,并且执行default
分支的内容,跳转到步骤12
(截止到这里,没有default
分支,所有的case
操作是阻塞的操作)
- 如果这是一个
- 在每个
case
操作中,将当前协程(携带着对应case
分支的信息)推送到涉及到的通道的接收或者发送协程队列中。当前的协程可能被多次推送到通道的队列中,因为在多个case中涉及到的通道可能是同一个。 - 让当前协程进入阻塞状态并且逆序解锁所有的通道。
- 在阻塞状态中等待,直到其他通道操作唤醒当前的协程。
- 当前的协程被其他协程中的通道操作唤醒,该通道操作可能是通道关闭操作或者发送/接收操作。如果是发送/接收操作,必须有一个接收/发送操作的
case
(在当前的select-case
块中)与它相对应(通过数据转移)。在协作中,当前协程将会从通道的接收/发送协程队列中出列。 - 通过通道顺序锁锁住所有涉及到的通道。
- 在每个
case
操作中将当前协程从所涉及到的通道接收协程队列或者发送协程队列中出列。- 如果当前协程被一个协程关闭操作唤醒,跳转到步骤5
- 如果当前协程被通道发送/接收操作唤醒,与之协作的接收/发送操作对应的
case
分支早已在出列过程中被发现,所以只需逆序解锁所有的通道并且执行对应的case
分支。
- 结束。
从实现中我们可以知道:
- 一个协程可能同时在多个通道的发送协程队列和接收协程队列中存在。它甚至可以同时存在于一个通道的发送协程队列和接收协程队列中。
- 当一个协程在一个
select-case
代码块中变成阻塞的然后获得释放,它将被从select-case
代码块中case
关键字后的通道操作所涉及到的每个通道对应的所有发送协程队列和接收协程队列中移除。
更多
我们可以从这篇文章发现更多的通道使用实例。
虽然通道可以帮助我们轻松地编写正确的并发代码,像其他数据同步技术一样,通道并不会阻止我们编写不适当的并发代码。
对于所有的数据同步使用案例来说,通道也许不总是最好的解决方案。获取更多的Go同步技术请阅读这篇文章和这篇文章。
最后
欢迎指正!
Thanks!