极客时间已完结课程限时免费阅读

10 | 通道的基本操作

10 | 通道的基本操作-极客时间

10 | 通道的基本操作

讲述:黄洲君

时长12:42大小8.71M

作为 Go 语言最有特色的数据类型,通道(channel)完全可以与 goroutine(也可称为 go 程)并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学。
Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。)
这是作为 Go 语言的主要创造者之一的 Rob Pike 的至理名言,这也充分体现了 Go 语言最重要的编程理念。而通道类型恰恰是后半句话的完美实现,我们可以利用通道在多个 goroutine 之间传递数据。

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

通道类型的值本身就是并发安全的,这也是 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)
}
在 demo20.go 文件中,我声明并初始化了一个元素类型为int、容量为3的通道ch1,并用三条语句,向该通道先后发送了三个元素值213
这里的语句需要这样写:依次敲入通道变量的名称(比如ch1)、接送操作符<-以及想要发送的元素值(比如2),并且这三者之间最好用空格进行分割。
这显然表达了“这个元素值将被发送该通道”这个语义。由于该通道的容量为 3,所以,我可以在通道不包含任何元素值的时候,连续地向该通道发送三个值,此时这三个值都会被缓存在通道之中。
当我们需要从通道接收元素值的时候,同样要用接送操作符<-,只不过,这时需要把它写在变量名的左边,用于表达“要从该通道接收一个元素值”的语义。
比如:<-ch1,这也可以被叫做接收表达式。在一般情况下,接收表达式的结果将会是通道中的一个元素值。
如果我们需要把如此得来的元素值存起来,那么在接收表达式的左边就需要依次添加赋值符号(=:=)和用于存值的变量的名字。因此,语句elem1 := <-ch1会将最先进入ch1的元素2接收来并存入变量elem1
现在我们来看一道与此有关的题目。今天的问题是:对通道的发送和接收操作都有哪些基本的特性?
这个问题的背后隐藏着很多的知识点,我们来看一下典型回答
它们的基本特性如下。
对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。
发送操作和接收操作中对元素值的处理都是不可分割的。
发送操作在完全完成之前会被阻塞。接收操作也是如此。

问题解析

我们先来看第一个基本特性。 在同一时刻,Go 语言的运行时系统(以下简称运行时系统)只会执行对同一个通道的任意个发送操作中的某一个。
直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。
类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。
直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。
这里所谓的并发执行,你可以这样认为,多个代码块分别在不同的 goroutine 之中,并有机会在同一个时间段内被执行。
另外,对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。
这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。
另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步,第一步是生成正在通道中的这个元素值的副本,并准备给到接收方,第二步是删除在通道中的这个元素值。
顺着这个细节再来看第二个基本特性。 这里的“不可分割”的意思是,它们处理元素值时都是一气呵成的,绝不会被打断。
例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况。
又例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。
这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。
再来说第三个基本特性。 一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。
在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除。
更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的 goroutine,以使它去争取继续运行代码的机会。
另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。
在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的 goroutine 收到了运行时系统的通知并重新获得运行机会为止。
说到这里,你可能已经感觉到,如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。
下面我来说一个关于通道操作阻塞的问题。

知识扩展

问题 1:发送操作和接收操作在什么时候可能被长时间的阻塞?
先说针对缓冲通道的情况。如果通道已满,那么对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走。
这时,通道会优先通知最早因此而等待的、那个发送操作所在的 goroutine,后者会再次执行发送操作。
由于发送操作在这种情况下被阻塞后,它们所在的 goroutine 会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。
相对的,如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的 goroutine,并使它再次执行接收操作。
因此而等待的、所有接收操作所在的 goroutine,都会按照先后顺序被放入通道内部的接收等待队列。
对于非缓冲通道,情况要简单一些。无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。
并且,数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。相比之下,缓冲通道则在用异步的方式传递数据。
在大多数情况下,缓冲通道会作为收发双方的中间件。正如前文所述,元素值会先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。
但是,当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。
以上说的都是在正确使用通道的前提下会发生的事情。下面我特别说明一下,由于错误使用通道而造成的阻塞。
对于值为nil的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的 goroutine 中的任何代码,都不再会被执行。
注意,由于通道类型是引用类型,所以它的零值就是nil。换句话说,当我们只声明该类型的变量但没有用make函数对它进行初始化时,该变量的值就会是nil。我们一定不要忘记初始化通道!
你可以去看一下 demo21.go,我在里面用代码罗列了一下会造成阻塞的几种情况。
问题 2:发送操作和接收操作在什么时候会引发 panic?
对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。但是通道一旦关闭,再对它进行发送操作,就会引发 panic。
另外,如果我们试图关闭一个已经关闭了的通道,也会引发 panic。注意,接收操作是可以感知到通道的关闭的,并能够安全退出。
更具体地说,当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定bool类型。它的值如果为false就说明通道已经关闭,并且再没有元素值可取了。
注意,如果通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是true
因此,通过接收表达式的第二个结果值,来判断通道是否关闭是可能有延时的。
由于通道的收发操作有上述特性,所以除非有特殊的保障措施,我们千万不要让接收方关闭通道,而应当让发送方做这件事。这在 demo22.go 中有一个简单的模式可供参考。

总结

今天我们讲到了通道的一些常规操作,包括初始化、发送、接收和关闭。通道类型是 Go 语言特有的,所以你一开始肯定会感到陌生,其中的一些规则和奥妙还需要你铭记于心,并细心体会。
首先是在初始化通道时设定其容量的意义,这有时会让通道拥有不同的行为模式。对通道的发送操作和接收操作都有哪些基本特性,也是我们必须清楚的。
这涉及了它们什么时候会互斥,什么时候会造成阻塞,什么时候会引起 panic,以及它们收发元素值的顺序是怎样的,它们是怎样保证元素值的完整性的,元素值通常会被复制几次,等等。
最后别忘了,通道也是 Go 语言的并发编程模式中重要的一员。

思考题

我希望你能通过试验获得下述问题的答案。
通道的长度代表着什么?它在什么时候会通道的容量相同?
元素值在经过通道传递时会被复制,那么这个复制是浅表复制还是深层复制呢?
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 40

提建议

上一篇
09 | 字典的操作和约束
下一篇
11 | 通道的高级玩法
unpreview
 写留言

精选留言(75)

  • 忘怀
    2018-09-12
    Go里没有深copy。 即便有的话这里可能也不会用吧,创建一个指针的内存开销绝大多数情况下要比重新开辟一块内存再把数据复制过来好的多吧。 老师,这么说对吗?

    作者回复: 对,这就是传指针值的好处之一。

    56
  • colonel
    2018-09-23
    通道底层存储数据的是链表还是数组?

    作者回复: 环形链表

    共 2 条评论
    42
  • 江山如画
    2018-09-03
    老师回复我后突然感觉不对劲,结构体是值类型,通道传输的时候会新拷贝一份对象,底层数据结构会被复制,引用类型可能就不一定了,又用数组和切片试了下,发现切片在通道传输的时候底层数据结构不会被复制,改了一个另外一个也会跟着改变,所以切片这里应该是浅复制,数组一个改了对另一个没有影响是深层复制,代码: // ch := make(chan []int, 1) s1 := []int{1, 2, 3} ch <- s1 s2 := <-ch s2[0] = 100 fmt.Println(s1, s2) //[100 2 3] [100 2 3] // ch2 := make(chan [3]int, 1) s3 := [3]int{1, 2, 3} ch2 <- s3 s4 := <-ch2 s3[0] = 100 fmt.Println(s3, s4) //[100 2 3] [1 2 3]
    展开

    作者回复: 再说一遍,Go语言里没有深层复制。数组是值类型,所以会被完全复制。

    共 12 条评论
    35
  • melon
    2018-09-03
    感觉chanel有点像socket的同步阻塞模式,只不过channel的发送端和接收端共享一个缓冲,套接字则是发送这边有发送缓冲,接收这边有接收缓冲,而且socket接收端如果先close的话,发送端再发送数据的也会引发panic(linux上会触发SIG_PIPE信号,不处理程序就崩溃了)。 另使用demo21.go测试发送接收阻塞情况时需要额外空跑一个goroutine,否则会引发这样的panic(至 少1.11版是这样):fatal error: all goroutines are asleep - deadlock!
    展开

    作者回复: 对,所以注释中才会那么说。

    27
  • 来碗绿豆汤
    2018-09-03
    深copy还是浅copy,跟具体数据类型有关,引用型数据就是浅copy,数值型数据就是深copy.如,如果是切片类型则是浅copy,如果是数组则是深copy

    作者回复: 其实都是浅表复制。数组因为是值类型的,所以即使是浅复制也能完全复制过来。

    共 2 条评论
    19
  • 会哭的鱼
    2019-03-03
    老师您好,通道这里我看了好几遍了,对于评论中有一个问题一直不明白,非常希望老师能够解答一下! 同学阿拉丁的瓜的提问: 请问老师,缓冲通道内的值是被并行读出的吗? 比如两个goroutine分别为r1和r2;一个装满的容量为2的chan。 当r1正在取出先入的数据时,r2是否可以取出后入的数据;还是说r2必须阻塞,等到先入数据被完全取走之后才能开始读取后入的数据? 老师回答: 作者回复: 可以同时进行,通道是并发安全的。但是不一定哪个g拿到哪个元素值。 个人不明白,按照我看完的理解,同一个通道不管有多少并发在接收操作,同一个通道同时只能被一个goroutine操作,其他的都要在这个接收操作完成 “复制通道内的元素值”“放置副本到接收方”“删掉原值”三步完全完成后才可以继续进行的,负责就要一直阻塞才对 老师原文中是这样的: 类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。 直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。 这里所谓的并发执行,你可以这样认为,多个代码块分别在不同的 goroutine 之中,并有机会在同一个时间段内被执行。 请老师解答一下我这个疑惑,非常感谢!
    展开

    作者回复: 你理解的没错,在同一时刻,只有一个goroutine能够对某一个通道进行取出操作,其他的试图对这个通道进行取出操作的goroutine都会被阻塞,并进入通道内部的队列排队。通道会保证这种操作是互斥的,并且是原子性的(完全取走一个元素值之后,下一个元素值才有可能被取)。 我回答那位同学的意思是:两个go函数中的代码是有可能同时(在同一个很小的时间段内)执行到“取出操作”那一行代码的。不过我们完全不用在意,因为通道和运行时系统会保证这类操作的并发安全。 可能我那个回答太短了吧,咱俩没有对上口径。

    共 4 条评论
    15
  • 有铭
    2018-09-03
    通道看上去像线程安全队列。那么这玩意在低层基于什么原理实现?cas?自旋?内核锁?性能如何
    13
  • My dream
    2018-09-21
    老师,请教一下,通道的传值可以直接传指针不,你讲的拷贝,那么内存开销是很大的,如果通道传指针的话,会不会好很多

    作者回复: 原则上可以传任何类型的数据。不过,要是传指针的话要自己保证安全啊,原始数据放篡改之类的。

    12
  • 请叫我小岳同学
    2018-09-03
    1. 通道的长度,表示channel 缓冲的长度。当channel处于阻塞状态时,容纳最多的同类型的长度。 2. 深拷贝

    作者回复: 第一个问题,长度代表通道当前包含的元素个数,容量就是初始化时你设置的那个数。 第二个问题你再想想,可以做做试验。

    11
  • Geek_a8be59
    2019-06-27
    ch1 := make(chan int, 2) // 发送方。 go func() { for i := 0; i < 10; i++ { fmt.Printf("Sender: sending element %v...\n", i) ch1 <- i } fmt.Println("Sender: close the channel...") close(ch1) }() // 接收方。 for { elem, ok := <-ch1 if !ok { fmt.Println("Receiver: closed channel") break } fmt.Printf("Receiver: received an element: %v\n", elem) } fmt.Println("End.") 老师,根据您的提供的源码有三个问题需请教。 第一问题:第一次携程调度应该发生主携程中的elem, ok := <-ch1 这个代码处,这时候应该在chan有等待的协成,再第一向chan1<-i传值得时候,根据您的描述"当发送操作在执行的时候发现空的通道中,正好有等待的接收,那么会把元素直接复制给对方"。照这么说应该在这次就跳转到主协成中,并打印出接收到的数据了。但是实际是先发送i=3的时候才做第一次携程调度,请问这是为什么? 第二问题:缓存区的大小不是设置的是2么,为什么length当发送了i=3的时候才会阻塞发生调度呢,正常不是应该i=2的时候么 第三个问题:当for循环结束了以后 就是在chan关闭之前,为什么又能调度到主协成让他接收呢。不应该到这个协成调用结束么?
    展开

    作者回复: 所以说不要用“协程”这个概念,因为“协程(coroutine)”指的是程序在同一个线程内的自行调度,是应用程序本身完全可控的。而 goroutine 的调度是 Go 语言的运行时系统发起的。 你不要揣测 Go 语言的调度器会怎样调度。你首先要知道哪些代码点是调度的时机(注意,到了调度时机也不一定发生调度,只是时机而已)。你还要知道如果想让多个 goroutine 按照你拟定的流程执行就需要用到 Channel 以及各种同步工具。 你说的“跳转到”只能在 coroutine 场景下才能这么说。在 goroutine 的场景下,没有“跳转”这么一说。 其一,你在上面的 for 语句中启用了一个 goroutine,你怎么就能断定后面的代码一定会先于这个 go 函数执行?不要做这种假设。因为连 goroutine 的调度都是并发的。 其二,两个 goroutine 一个 channel,一个 goroutine 发,一个 goroutine 取。这个 ch1 什么时候满、什么时候空,你基本上是确定不了的。因为两个 for 循环 在迭代的过程中都可能因被调用而换下 CPU。 其三,你要知道,几乎任何函数调用都存在调度时机,更何况是像 fmt.Println 这种需要 I/O 的重型操作。所以,为什么你那前一个 for 循环结束之后就不能被调度了呢? 以上是我通过你的文字表达猜测并回答的,并不一定完全匹配你要问的问题。还有问题的话再问我。 我觉得你对“并发”和“调度”这两个概念不清楚。我建议你好好看看专栏里讲 goroutine 的那几篇文章。有必要的话,买我的《Go 并发编程实战》第二版从头学一下。

    共 3 条评论
    10
  • Yayu
    2018-09-05
    老师,我知道 golang 这门语言中所有的变量赋值操作都是 value copy的,不论这个变量是值类型,还是指针类型。关于您这里说的 shallow copy 与 deep copy 的问题我还是不是很清楚, google 了一下,每门语言的支持都不太一样,您是怎么定义这两个概念的?能否详细说一下?

    作者回复: 浅拷贝只是拷贝值以及值中直接包含的东西,深拷贝就是把所有深层次的结构一并拷贝。

    10
  • 苏浅
    2018-09-03
    通道必须要手动关闭吗?go会自动清理吗?

    作者回复: 需要手动关闭,这是个很好的习惯,而且也可以利用关的动作来给接收方传递一个信号。Go的GC只会清理被分配到堆上的、不再有任何引用的对象。

    10
  • kk
    2019-12-26
    技术课却没几行代码,全是大段的文字,读起来晦涩佶屈
    共 2 条评论
    7
  • Xiaolan🇨🇳
    2019-03-12
    通道的使用场景是不是同一个进程的不同线程间通讯使用?如果是不同程序进程还可以使用吗?

    作者回复: 它是面向同一个进程的。多个进程之间内存一般不会共享,所以没法用channel。进程间通讯可以考虑IPC方法,比如有名管道,你可以参看一下os.Pipe函数的文档。其实进程间通讯最强大和灵活的还是socket。

    6
  • Geek_a8be59
    2019-06-28
    对昨天您提的问题做了一下补充: 1. 你的 Wait 方法里的那个 for 语句是干嘛的? 2. total :=<-s.nowtotal 中的 total 不是一个会改变的数啊,那你后边的 total == s.now-1 判断就存在问题。 ------------------------------ // 抽象一个栅栏 type Barrier interface { Wait () } // 创建栅栏对象 func NewBarrier (n int) Barrier { var barrier = &barrier{} barrier.now = n barrier.chanArr = make(chan int) barrier.nowtotal = make(chan int) go barrier.NowTotal() return barrier } func (s *barrier) NowTotal() { //用于判断当前属于第几个运行的goroutine for i:=0;i<s.now;i++ { s.nowtotal<-i } } func (s *barrier) Wait() { total :=<-s.nowtotal if total == s.now-1 { //这里s.now-1等于9就是说明这是第10个goroutine for i:=0;i<total;i++ { //这个是为了去唤醒剩余的9个。注:题目的要求就是前9个goroutine阻塞,第10个goroutine去唤醒他们 s.chanArr<-i } } else { num:=<-s.chanArr fmt.Printf("拿到了数据:%v\n",num) } } // 栅栏的实现类 type barrier struct { chanArr chan int nowtotal chan int now int } // 测试代码 func main () { // 创建栅栏对象 b := NewBarrier(10) // 达到的效果:前9个协程调用Wait()阻塞,第10个调用后10个协程全部唤醒 for i:=0; i<10; i++ { go b.Wait() } select { } } 第一个您的问题:for循环主要去唤醒其他阻塞的goroutine 第二个您的问题:s.nowtotal 一直是在变得呀,通过传i进来,主要说明当前是运行的第几个goroutine 只是整体用的语意不这么明确 这样您看我的能实现要求么?
    展开

    作者回复: 为什么非要知道那些 goroutine 都是第几个呢?再说了,你那个 NowTotal 中的 for i:=0;i<s.now;i++ { s.nowtotal<-i } 相当于向 nowtotal 发送 0、1、2、3、4、5、6、7、8、9。那你的 Wait 方法每次给 total 赋的值就是不一样的。即使你的 if total == s.now-1 可以判断已经够 10 个 goroutine了,可 s.chanArr<-i 不应该是最后一个 Wait 方法做的事。你想想,你这样只能完成一次“阻塞 9 个,满 10 个放行”。 我觉得你实现得不好。职责划分不清晰。你应该将把控的逻辑放在 NowTotal 方法里。Wait 方法每执行一次就通过通道告诉 NowTotal 一下。告诉完了,Wait 方法用另一个通道阻塞自己。一旦够了 10 个,NowTotal 就通过第二个通道放行。

    4
  • 勇.Max
    2018-11-15
    老师,有个问题困惑很久,如果传指针的话,接收方和发送方不在一台机器上,指针还有效吗?(指针不是指向本地内存的吗)
    共 3 条评论
    4
  • My dream
    2018-09-27
    通道传值首先要保证原始数据的安全性是吗?所以一般不建议用传指针的方式来通讯,是不是这样理解的

    作者回复: 如果你的通道要给外人使用,或者通过通道对外提供功能,那就不要传指针值了,容易造成安全漏洞,另外这个时候最好限制下通道的方向。

    5
  • wh
    2018-09-05
    不要从接受端关闭channel算是基本原则了,另外如果有多个并发发送者,1个或多个接收者,有什么普适选择可以分享吗?

    作者回复: 可以用另外的标志位做,比如context。

    4
  • 皮卡丘
    2018-09-03
    很明显现象是浅拷贝,为啥那么多说深拷贝的
    4
  • 🐶
    2019-08-10
    通道的长度代表着整个通道已经存在的元素值len可以查看,类似于切片,而容量则是刚开始设定的值,整个chan可以看成一个已经长度的队列,操作也可以跟队列相类比。 go没有深拷贝,只有浅拷贝,通过值拷贝,而没有所谓的引用拷贝,也是因为这样,整个go语言才能使得效率很高,内存占用少! 建议:老师可以画一些图来理清思路,因为一直看文字会很累...

    作者回复: 好,收到。

    3