Go语言36讲笔记--10通道的基本操作

173 阅读10分钟

Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。) 通道类型恰恰是后半句话的完美实现,我们可以利用通道在多个 goroutine 之间传递数据。

前导内容:通道的基础知识

通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。

1. 声明、初始化channel

在声明并初始化一个通道的时候,我们需要用到 Go 语言的内建函数make。就像用make初始化切片那样,我们传给这个函数的第一个参数应该是代表了通道的具体类型的类型字面量

声明一个通道类型变量的时候,我们首先要确定该通道类型的元素类型,这决定了我们可以通过这个通道传递什么类型的数据。(即,make函数的第一个参数)

比如,类型字面量chan int,其中的chan是表示通道类型的关键字,而int则说明了该通道类型的元素类型。又比如,chan string代表了一个元素类型为string的通道类型。

初始化通道的时候,make函数除了必须接收这样的类型字面量作为参数,还可以接收一个int类型的参数。(即make的第二个,可选参数)

后者是可选的,用于表示该通道的容量。所谓通道的容量,就是指通道最多可以缓存多少个元素值。由此,虽然这个参数是int类型的,但是它是不能小于0的。

当容量为0时,我们可以称通道为非缓冲通道,也就是不带缓冲的通道。而当容量大于0时,我们可以称为缓冲通道,也就是带有缓冲的通道。非缓冲通道和缓冲通道有着不同的数据传递方式

2. 通道的特性

一个通道相当于一个先进先出(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)
}

对通道的发送和接收操作都有哪些基本的特性?

典型回答

它们的基本特性如下。

  1. 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。
  2. 发送操作和接收操作中对元素值的处理都是不可分割的。
  3. 发送操作在完全完成之前会被阻塞。接收操作也是如此。

问题解析(逐条解析这几个特性)

解析第一个特性:互斥。

同一时刻,只能执行一个读or写操作,并发也不行。(channel的读写都是会改变通道内元素的)

在同一时刻,Go 语言的runtime system只会执行对同一个通道的任意个发送操作中的某一个。直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。

类似的,在同一时刻,runtime system也只会执行对同一个通道的任意个接收操作中的某一个。直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。

即使这些操作是并发执行的也是如此。

这里所谓的并发执行,可以认为:多个代码块分别在不同的 goroutine 之中,并有机会在同一个时间内被执行。

对第一个操作的一个补充:对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。

例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。

这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。

另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步第一步是生成正在通道中的这个元素值的副本,并准备给到接收方第二步是删除在通道中的这个元素值

解析第二个特性: “不可分割”

“不可分割”,即处理元素值时都是一气呵成的,绝不会被打断。

例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况

又例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。

这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。

解析第三个特性:阻塞

一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。(需要实现阻塞的原因其实就是因为这些操作底层的逻辑都不是一步完成的)

在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除

更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的 goroutine,以使它去争取继续运行代码的机会

另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。

在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的 goroutine 收到了运行时系统的通知并重新获得运行机会为止。

说到这里,你可能已经感觉到,如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。

知识扩展

问题 1:发送操作和接收操作在什么时候可能被长时间的阻塞?
对缓冲通道

full 时,发送操作会阻塞,直到channel中的元素被接收走。此时channel优先通知最早被阻塞的goroutine,后者再次执行发送操作。

nil 时,所有接收操作阻塞,执行逻辑与上述描述相同。 对非缓冲通道 无论是发送还是接收操作,开始都会直接阻塞,直到配对操作开始执行。

由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。

并且,数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。相比之下,缓冲通道则在用异步的方式传递数据。 一些特殊情况 在大多数情况下,缓冲通道会作为收发双方的中间件:元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。

但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。 以上说的都是在正确使用通道的前提下会发生的事情。

next,介绍由于错误使用通道而造成的阻塞。

对于值为nil的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的 goroutine 中的任何代码,都不再会被执行。

注意,由于通道类型是引用类型,所以它的零值就是nil。换句话说,当我们只声明该类型的变量但没有用make函数对它进行初始化时,该变量的值就会是nil。(so,通道的使用必须声明+初始化)

问题 2:发送操作和接收操作在什么时候会引发 panic?

对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。但是通道一旦关闭,再对它进行发送操作,就会引发 panic。

另外,如果我们试图关闭一个已经关闭了的通道,也会引发 panic。 接收表达式可以判断当前channel是关闭 接收操作是可以感知到通道的关闭的,并能够安全退出。

更具体地说,当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定bool类型。它的值如果为false就说明通道已经关闭,并且再没有元素值可取了。 接收表达式的不足 若通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是true

因此,通过接收表达式的第二个结果值,来判断通道是否关闭是可能有延时的

大多数情况,不要让接收方关闭通道,而应当让发送方关闭。

总结

今天我们讲到了通道的一些常规操作,包括初始化、发送、接收和关闭。通道类型是 Go 语言特有的。

首先是在初始化通道时设定其容量的意义,这有时会让通道拥有不同的行为模式。对通道的发送操作和接收操作都有哪些基本特性,也是我们必须清楚的。

涉及了它们什么时候会互斥,什么时候会造成阻塞,什么时候会引起 panic,以及它们收发元素值的顺序是怎样的,它们是怎样保证元素值的完整性的,元素值通常会被复制几次,等等。

通道也是 Go 语言的并发编程模式中重要的一员。

思考题

  1. 通道的长度代表着什么?它在什么时候会通道的容量相同?

通道的长度代表它当前包含的元素值的个数。当通道已满时,其长度会与容量相同。

  1. 元素值在经过通道传递时会被复制,那么这个复制是浅表复制还是深层复制呢?

浅表复制。实际上,在 Go 语言中并不存在深层次的复制,除非我们自己来做。(C++中有这个概念)

  • 浅层复制: 实现对象间数据元素的一一对应复制。
  • 深层复制: 当被复制的对象数据成员是指针类型时,不是复制该指针成员本身,而是将指针所指对象进行复制。