Go 的通道channel
Posted 技术能量站
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go 的通道channel相关的知识,希望对你有一定的参考价值。
1、前言
作为 Go 语言最有特色的数据类型,通道(channel)完全可以与 goroutine(也可称为 go 程)并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学。
Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。)
而通道类型恰恰是后半句话的完美实现,我们可以利用通道在多个 goroutine 之间传递数据。
1.1 通道的基础知识
通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。它使用起来十分简单,并不会徒增我们的心智负担。
在声明并初始化一个通道的时候,我们需要用到 Go 语言的内建函数make。就像用make初始化切片那样,我们传给这个函数的第一个参数应该是代表了通道的具体类型的类型字面量。
在声明一个通道类型变量的时候,我们首先要确定该通道类型的元素类型,这决定了我们可以通过这个通道传递什么类型的数据。
- 比如,类型字面量
chan int
,其中的chan是表示通道类型的关键字,而int则说明了该通道类型的元素类型。 - 又比如,
chan string
代表了一个元素类型为string的通道类型。
在初始化通道的时候,make函数除了必须接收这样的类型字面量作为参数,还可以接收一个int类型的参数。
后者是可选的,用于表示该通道的容量。所谓通道的容量,就是指通道最多可以缓存多少个元素值。由此,虽然这个参数是int类型的,但是它是不能小于0的。
- 当容量为0时,我们可以称通道为非缓冲通道,也就是不带缓冲的通道。
- 而当容量大于0时,我们可以称为缓冲通道,也就是带有缓冲的通道。
非缓冲通道和缓冲通道有着不同的数据传递方式,这个我在后面会讲到。
一个通道相当于一个先进先出(FIFO)的队列。也就是说,通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。元素值的发送和接收都需要用到操作符<-
。我们也可以叫它接送操作符。一个左尖括号紧接着一个减号形象地代表了元素值的传输方向。
package main
import "fmt"
func main()
ch1 := make(chan int, 3)
ch1 <- 2
ch1 <- 1
ch1 <- 3
elem1 := <-ch1
fmt.Printf("The first element received from channel ch1: %v\\n",
elem1)
在上面案例中,我声明并初始化了一个元素类型为int、容量为3的通道ch1,并用三条语句,向该通道先后发送了三个元素值2、1和3。
这里的语句需要这样写:依次敲入通道变量的名称(比如ch1)、接送操作符<-以及想要发送的元素值(比如2),并且这三者之间最好用空格进行分割。
这显然表达了“这个元素值将被发送该通道”这个语义。由于该通道的容量为 3,所以,我可以在通道不包含任何元素值的时候,连续地向该通道发送三个值,此时这三个值都会被缓存在通道之中。
当我们需要从通道接收元素值的时候,同样要用接送操作符<-,只不过,这时需要把它写在变量名的左边,用于表达“要从该通道接收一个元素值”的语义。
比如:<-ch1,这也可以被叫做接收表达式。在一般情况下,接收表达式的结果将会是通道中的一个元素值。
如果我们需要把如此得来的元素值存起来,那么在接收表达式的左边就需要依次添加赋值符号(=或:=)和用于存值的变量的名字。因此,语句elem1 := <-ch1会将最先进入ch1的元素2接收来并存入变量elem1。
现在我们来看一道与此有关的题目。今天的问题是:对通道的发送和接收操作都有哪些基本的特性?
它们的基本特性如下。
- 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的
- 发送操作和接收操作中对元素值的处理都是不可分割的
- 发送操作在完全完成之前会被阻塞。接收操作也是如此
问题解析
我们先来看第一个基本特性。 在同一时刻,Go 语言的运行时系统(以下简称运行时系统)只会执行对同一个通道的任意个发送操作中的某一个。
直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。
类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。
直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。
这里所谓的并发执行,你可以这样认为,多个代码块分别在不同的 goroutine 之中,并有机会在同一个时间段内被执行。
另外,对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。
这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。
另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步:
- 第一步是生成正在通道中的这个元素值的副本,并准备给到接收方
- 第二步是删除在通道中的这个元素值。
顺着这个细节再来看第二个基本特性。 这里的“不可分割”的意思是,它们处理元素值时都是一气呵成的,绝不会被打断。
例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况。
又例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。
这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。
再来说第三个基本特性。 一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。
在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除。
更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的 goroutine,以使它去争取继续运行代码的机会。
另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。
在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的 goroutine 收到了运行时系统的通知并重新获得运行机会为止。
说到这里,你可能已经感觉到,如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。
下面我来说一个关于通道操作阻塞的问题。
2、通道实现原理
首先来说说单向通道。我们在说“通道”的时候指的都是双向通道,即:既可以发也可以收的通道。
2.1 单向通道
所谓单向通道就是,只能发不能收,或者只能收不能发的通道。一个通道是双向的,还是单向的是由它的类型字面量体现的。
就是我们在上面说过的接收操作符<-
,如果我们把它用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作了,而是表示通道的方向。比如:
var uselessChan = make(chan<- int, 1)
我声明并初始化了一个名叫uselessChan的变量。这个变量的类型是chan<- int,容量是1。
请注意紧挨在关键字chan右边的那个<-,这表示了这个通道是单向的,并且只能发而不能收。
类似的,如果这个操作符紧挨在chan的左边,那么就说明该通道只能收不能发。所以,前者可以被简称为发送通道,后者可以被简称为接收通道。
注意,与发送操作和接收操作对应,这里的“发”和“收”都是站在操作通道的代码的角度上说的。
从上述变量的名字上你也能猜到,这样的通道是没用的。通道就是为了传递数据而存在的,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义。那么,单向通道的用途究竟在哪儿呢?
问题:单向通道有什么应用价值?
概括地说,单向通道最主要的用途就是约束其他代码的行为。
问题解析
这需要从两个方面讲,都跟函数的声明有些关系。
- 这种约束一般会出现在接口类型声明中的某个方法定义上
- 在函数声明的结果列表中使用单向通道
先来看下面的代码:
func SendInt(ch chan<- int)
ch <- rand.Intn(1000)
我用func关键字声明了一个叫做SendInt的函数。这个函数只接受一个chan<- int类型的参数。在这个函数中的代码只能向参数ch发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。
你可能会问,我自己写的函数自己肯定能确定操作通道的方式,为什么还要再约束?好吧,这个例子可能过于简单了。在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。请看这个叫Notifier的接口类型声明:
type Notifier interface
SendInt(ch chan<- int)
在接口类型声明的花括号中,每一行都代表着一个方法的定义。接口中的方法定义与函数声明很类似,但是只包含了方法名称、参数列表和结果列表。
一个类型如果想成为一个接口类型的实现类型,那么就必须实现这个接口中定义的所有方法。因此,如果我们在某个方法的定义中使用了单向通道类型,那么就相当于在对它的所有实现做出约束。
在这里,Notifier接口中的SendInt方法只会接受一个发送通道作为参数,所以,在该接口的所有实现类型中的SendInt方法都会受到限制。这种约束方式还是很有用的,尤其是在我们编写模板代码或者可扩展的程序库的时候。
顺便说一下,我们在调用SendInt函数的时候,只需要把一个元素类型匹配的双向通道传给它就行了,没必要用发送通道,因为 Go 语言在这种情况下会自动地把双向通道转换为函数所需的单向通道。
intChan1 := make(chan int, 3)
SendInt(intChan1)
在另一个方面,我们还可以在函数声明的结果列表中使用单向通道。如下所示:
func getIntChan() <-chan int
num := 5
ch := make(chan int, num)
for i := 0; i < num; i++
ch <- i
close(ch)
return ch
函数getIntChan会返回一个<-chan int类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。这实际上就是对函数调用方的一种约束了。
另外,我们在 Go 语言中还可以声明函数类型,如果我们在函数类型中使用了单向通道,那么就相等于在约束所有实现了这个函数类型的函数。
我们再顺便看一下调用getIntChan的代码:
intChan2 := getIntChan()
for elem := range intChan2
fmt.Printf("The element in intChan2: %v\\n", elem)
我把调用getIntChan得到的结果值赋给了变量intChan2,然后用for语句循环地取出了该通道中的所有元素值,并打印出来。
这里的for语句也可以被称为带有range子句的for语句。它的用法我在后面讲for语句的时候专门说明。现在你只需要知道关于它的三件事:
- 上述for语句会不断地尝试从通道intChan2中取出元素值。即使intChan2已经被关闭了,它也会在取出所有剩余的元素值之后再结束执行。
- 通常,当通道intChan2中没有元素值时,这条for语句会被阻塞在有for关键字的那一行,直到有新的元素值可取。不过,由于这里的getIntChan函数会事先将intChan2关闭,所以它在取出intChan2中的所有元素值之后会直接结束执行。
- 倘若通道intChan2的值为nil,那么这条for语句就会被永远地阻塞在有for关键字的那一行。
这就是带range子句的for语句与通道的联用方式。不过,它是一种用途比较广泛的语句,还可以被用来从其他一些类型的值中获取元素。除此之外,Go 语言还有一种专门为了操作通道而存在的语句:select语句。
3、知识拓展
问题 1:发送操作和接收操作在什么时候可能被长时间的阻塞?
先说针对缓冲通道的情况。
- 如果通道已满,那么对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走。
这时,通道会优先通知最早因此而等待的、那个发送操作所在的 goroutine,后者会再次执行发送操作。由于发送操作在这种情况下被阻塞后,它们所在的 goroutine 会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。相对的,
- 如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的 goroutine,并使它再次执行接收操作。
因此而等待的、所有接收操作所在的 goroutine,都会按照先后顺序被放入通道内部的接收等待队列。
对于非缓冲通道,情况要简单一些。
无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。
并且,数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。相比之下,缓冲通道则在用异步的方式传递数据。
在大多数情况下,缓冲通道会作为收发双方的中间件。正如前文所述,元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。
但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。
以上说的都是在正确使用通道的前提下会发生的事情。下面我特别说明一下,由于错误使用通道而造成的阻塞。
- 对于值为nil的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的 goroutine 中的任何代码,都不再会被执行。
package main
import "fmt"
func main()
var name int
// 定义一个int类型的通道
c := make(chan int)
c <- name
fmt.Println(<-c)
// 输出结果
//fatal error: all goroutines are asleep - deadlock!
//
//goroutine 1 [chan send]:
//main.main()
注意,由于通道类型是引用类型,所以它的零值就是nil。换句话说,当我们只声明该类型的变量但没有用make函数对它进行初始化时,该变量的值就会是nil。我们一定不要忘记初始化通道!
问题 2:发送操作和接收操作在什么时候会引发 panic?
对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。但是通道一旦关闭,再对它进行发送操作,就会引发 panic。
另外,如果我们试图关闭一个已经关闭了的通道,也会引发 panic。注意,接收操作是可以感知到通道的关闭的,并能够安全退出。
更具体地说,当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定bool类型。它的值如果为false就说明通道已经关闭,并且再没有元素值可取了。
注意,如果通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是true。
因此,通过接收表达式的第二个结果值,来判断通道是否关闭是可能有延时的。
由于通道的收发操作有上述特性,所以除非有特殊的保障措施,我们千万不要让接收方关闭通道,而应当让发送方做这件事。
问题 3:select语句与通道怎样联用,应该注意些什么?
select语句只能与通道联用,它一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行。
select语句的分支分为两种,
- 一种叫做候选分支,
- 另一种叫做默认分支。
候选分支总是以关键字case开头,后跟一个case表达式和一个冒号,然后我们可以从下一行开始写入当分支被选中时需要执行的语句。
默认分支其实就是 default case,因为,当且仅当没有候选分支被选中时它才会被执行,所以它以关键字default开头并直接后跟一个冒号。同样的,我们可以在default:的下一行写入要执行的语句。
由于select语句是专为通道而设计的,所以每个case表达式中都只能包含操作通道的表达式,比如接收表达式。
当然,如果我们需要把接收表达式的结果赋给变量的话,还可以把这里写成赋值语句或者短变量声明。下面展示一个简单的例子。
// 准备好几个通道。
intChannels := [3]chan int
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
// 随机选择一个通道,并向它发送元素值。
index := rand.Intn(3)
fmt.Printf("The index: %d\\n", index)
intChannels[index] <- index
// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。
select
case <-intChannels[0]:
fmt.Println("The first candidate case is selected.")
case <-intChannels[1]:
fmt.Println("The second candidate case is selected.")
case elem := <-intChannels[2]:
fmt.Printf("The third candidate case is selected, the element is %d.\\n", elem)
default:
fmt.Println("No candidate case is selected!")
我先准备好了三个类型为chan int、容量为1的通道,并把它们存入了一个叫做intChannels的数组。
然后,我随机选择一个范围在[0, 2]的整数,把它作为索引在上述数组中选择一个通道,并向其中发送一个元素值。
最后,我用一个包含了三个候选分支的select语句,分别尝试从上述三个通道中接收元素值,哪一个通道中有值,哪一个对应的候选分支就会被执行。后面还有一个默认分支,不过在这里它是不可能被选中的。
在使用select语句的时候,我们首先需要注意下面几个事情。
- 如果像上述示例那样加入了默认分支,那么无论涉及通道操作的表达式是否有阻塞,select语句都不会被阻塞。如果那几个表达式都阻塞了,或者说都没有满足求值的条件,那么默认分支就会被选中并执行。
- 如果没有加入默认分支,那么一旦所有的case表达式都没有满足求值条件,那么select语句就会被阻塞。直到至少有一个case表达式满足条件为止。
- 还记得吗?我们可能会因为通道关闭了,而直接从通道接收到一个其元素类型的零值。所以,在很多时候,我们需要通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,我们就应该及时地屏蔽掉对应的分支或者采取其他措施。这对于程序逻辑和程序性能都是有好处的。
- select语句只能对其中的每一个case表达式各求值一次。所以,如果我们想连续或定时地操作其中的通道的话,就往往需要通过在for语句中嵌入select语句的方式实现。但这时要注意,简单地在select语句的分支中使用break语句,只能结束当前的select语句的执行,而并不会对外层的for语句产生作用。这种错误的用法可能会让这个for语句无休止地运行下去。
下面是一个简单的示例。
intChan := make(chan int, 1)
// 一秒后关闭通道。
time.AfterFunc(time.Second, func()
close(intChan)
)
select
case _, ok := <-intChan:
if !ok
fmt.Println("The candidate case is closed.")
break
fmt.Println("The candidate case is selected.")
我先声明并初始化了一个叫做intChan的通道,然后通过time包中的AfterFunc函数约定在一秒钟之后关闭该通道。
后面的select语句只有一个候选分支,我在其中利用接收表达式的第二个结果值对intChan通道是否已关闭做了判断,并在得到肯定结果后,通过break语句立即结束当前select语句的执行。
上面这些注意事项中的一部分涉及到了select语句的分支选择规则。我觉得很有必要再专门整理和总结一下这些规则。
问题 4:select语句的分支选择规则都有哪些?
规则如下面所示。
- 对于每一个case表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。比如,如果case表达式是包含了接收表达式的短变量声明时,那么在赋值符号左边的就可以是一个或两个表达式,不过此处的表达式的结果必须是可以被赋值的。当这样的case表达式被求值时,它包含的多个表达式总会以从左到右的顺序被求值。
- select语句包含的候选分支中的case表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。结合上一条规则,在select语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。仅当最上边的候选分支中的所有表达式都被求值完毕后,从上边数第二个候选分支中的表达式才会被求值,顺序同样是从左到右,然后是第三个候选分支、第四个候选分支,以此类推。
- 对于每一个case表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该case表达式的求值就是不成功的。在这种情况下,我们可以说,这个case表达式所在的候选分支是不满足选择条件的。
- 仅当select语句中的所有case表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么select语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,select语句(或者说它所在的 goroutine)就会被唤醒,这个候选分支就会被执行。
- 如果select语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使select语句是在被唤醒时发现的这种情况,也会这样做。
- 一条select语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。
- select语句的每次执行,包括case表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的case表达式以及分支中,是否包含并发不安全的代码了。
4、总结
今天我们讲到了通道的一些常规操作,包括初始化、发送、接收和关闭。通道类型是 Go 语言特有的,所以你一开始肯定会感到陌生,其中的一些规则和奥妙还需要你铭记于心,并细心体会。
首先是在初始化通道时设定其容量的意义,这有时会让通道拥有不同的行为模式。对通道的发送操作和接收操作都有哪些基本特性,也是我们必须清楚的。
这涉及了它们什么时候会互斥,什么时候会造成阻塞,什么时候会引起 panic,以及它们收发元素值的顺序是怎样的,它们是怎样保证元素值的完整性的,元素值通常会被复制几次,等等。
select语句是专门为通道而设计的,它可以包含若干个候选分支,每个分支中的case表达式都会包含针对某个通道的发送或接收操作。
当select语句被执行时,它会根据一套分支选择规则选中某一个分支并执行其中的代码。如果所有的候选分支都没有被选中,那么默认分支(如果有的话)就会被执行。注意,发送和接收操作的阻塞是分支选择规则的一个很重要的依据。
最后别忘了,通道也是 Go 语言的并发编程模式中重要的一员。
以上是关于Go 的通道channel的主要内容,如果未能解决你的问题,请参考以下文章
go语言学习笔记 — 进阶 — 并发编程:通道(channel) —— 各种各样的通道