• 一份尽可能全面的Go channel介绍


    写在前面

    针对目前网络上Go channel知识点较为分散(很难有单独的一份资料把所有知识点都囊括进来)的情况,在下斗胆站在巨人的肩膀上,总结了前辈的工作,并加入了自己的理解,形成了这篇文章。本文类似于“导航页”或者“查询手册”,旨在帮助读者对Go channel有一个系统、全面的了解,对想要快速上手的读者提供必要的知识、系统的总结和常见的避坑指南,同时也为想要深入探索的同学提供一些优秀的第三方资料(起码在下认为是优秀的)。在此,声明以下几点:

    1. 本文不存在抄袭和剽窃。本文没有抄袭任何文章的文字、代码、插图,且所有引用都注明了出处。在此,对本文所引用文章的所有作者,表示由衷的感谢和深深的敬佩,您各位的辛勤付出和无私奉献让我学到了宝贵的知识,谢谢各位前辈!
    2. 本文不是对网络内容的摘抄与堆砌。首先,本文对Go channel相关知识点进行了条理化地总结,力求清晰、全面、易读、好理解;其次,本文有在下自己的见解和贡献,如2.6小节的内容(尽管该内容确实无关紧要)、所有插图和代码,以及其它零零散散的内容。
    3. 如果您想要通过底层实现和原理来自下而上地了解Go channel,那么您可以直接阅读资料[6][7],这些前辈的源码解读让在下收益匪浅。等您充分理解了源码,或许就不需要本文了。

    正文

    一、概览

    顾名思义,管道channel是Go中一种特殊的数据类型,用于在Goroutine间传递数据,示意图如下:

    注意:
      1.上图特地画成了右进左出的形式,这与channel的读写语法在视觉上是一致的(继续阅读您就会感受到这一点)。
      2. 仅在一个单独的Goroutine中使用channel毫无意义,并且容易引发错误。

    二、基础知识

    2.1 类型声明

    channel的类型由关键字"chan"和该channel可以传送的元素的类型组成,如 chan int 表示一个channel,该channel可以传送int类型的数据。注意,Go channel的类型声明是一种独特的存在,它由两个分开的“单词”表示,这种表示方法,就算放眼整个“编程语言界”,也实属罕见(事实上,在下并没有在其它地方见过类似的情形,且在下认为C语言中的 struct custom_type 和这里的 chan int 并不类似)。另外, <-chan elementType 和 chan<- elementType 也是类型声明,分别表示只能从中读取数据的channel和只能向其写入数据的channel,关于channel的方向,2.4小节会详述。

    2.2 变量声明与初始化

    channel变量的声明有var和make两种方式:

     1 var chanX chan int // only declare; nil channel
     2 fmt.Printf("%v\n", chanX) // <nil>
     3 chanY := make(chan int) // declare & initialize; unbuffered channel
     4 fmt.Printf("%v\n", chanY) // 0xc000086060
     5 chanZ := make(chan int, 10) // declare & initialize; buffered channel
     6 fmt.Printf("%v\n", chanZ) // 0xc0000d6000
     7 var chanW = make(chan int) // declare & initialize; unbuffered channel
     8 fmt.Printf("%v\n", chanW) // 0xc0000860c0
     9 chanX = make(chan int)
    10 fmt.Printf("%v\n", chanX) // 0xc000086120

     注意:
      1. 如第1行代码所示,单纯的var方式只是声明,并未初始化,channel的值为其默认零值,即nil。nil channel什么也做不了,因此,var形式的声明只是语法上正确,并没有实际作用,除非它后来又被make形式的声明重新赋值,如第9行所示[1][2]。
      2. make形式能同时声明和初始化channel,又细分为buffered channel(第5行)和unbuffered channel(第3、7、9行),后文会详述。
      3. 显然,var和make可以一起使用,如第7行所示。
      4. 从输出可以看出,make创建的channel本质上是一个指针。

    2.3 收发操作

      1. channel的读写使用术语“接收”(receive)和“发送”(send)表示。如果您跟我一样,搞不清“发送”到底指“发送到channel”还是“由channel发送”,那么,请忘记“接收”和“发送”,转而记住“从channel接收”(receive from channel)和“发送到channel”(send to channel),或者直接记住“读出”和“写入”。
      2. 接收和发送的操作符都是向左指的箭头("<-")(注意没有向右指的箭头)。箭头由channel指出( data := <-chanX )表示接收(receive from)/读出;箭头指向channel( chanX <- data )表示发送(send to)/写入。
      3. 读出操作可以是 data := <-chanX 、 data = <-chanX 、 _ = <-chanX 、 <-chanX、 、 data, ok := <-chanX 几种,其中 data = <-chanX 中data必须已事先声明。 data <-chanX 是不可以的,因为 data <-chanX 会被编译器认为是将变量chanX的值写入到管道data(请见第2条),而不是从管道chanX中读出内容到变量data。

    2.4 方向(读写限定)

    2.4.1 单向channel的作用

    2.1节所示,可以声明单向channel(只能从该channel接收数据或只能发送数据到该channel)。显然,单向channel是不能用于Goroutine之间通信的,那么,单向channel的作用是什么呢?单向channel主要用于函数形参或函数返回值,用来限制某channel在函数体中是只读/只写的,或者限制某函数返回的channel是只读/只写的。这一点类似于C++中使用const修饰函数形参或返回值。参考资料[2]和[3]对这一点有详细的介绍。

    2.4.2 转换限制

    双向channel可以转换为单向channel,但反之不行[2]。

    2.5 本质类型

    2.1节可以看出,channel是一个指针[4]。

    2.6 为什么不是chan[elementType]

    这是一个无聊的问题,也是一个没有什么探究价值的问题。这里仅给出在下毫无根据的猜测。看到 chan[int] ,您会想到什么?在下想到了Go的 map[string]int 以及C++的 vector<int> 。后者是什么?数据结构!但channel不是数据结构,也不该被视为数据结构,它是Goroutine间通信的载体、媒介。在我们由经验而来的潜意识里,<>或[]前面的“单词”往往表示数据结构的类型,而<>或[]中的“单词”往往表示该数据结构存储的数据的类型。但channel不是数据结构,它是用来传递数据而非存储数据的,尽管channel中实际上有一个用来缓存数据的循环队列,但这只是暂时的缓存,目的依然是为了传递数据(想想快递柜)。因此,为了在形式上提醒程序员channel不是数据结构,Go为channel采用了 chan elementType 这样一种“蹩脚”的类型声明,而不是容易让人误会的 chan[elementType] 。再次强调,这仅是在下的猜测,且毫无根据。

    2.7 FIFO性质

    channel具有先进先出(FIFO)的性质(早期的Go channel并不严格遵循FIFO原则[5]),而且其内部也确实使用了循环队列,然而,正如2.6节所说,channel不是数据结构,也不应被视为队列。

    2.8 len和cap

    可以对channel使用 len() 和 cap() 函数, len() 返回当前channel缓冲区中已有元素的个数(快递柜中快递的件数), cap() 返回缓冲区的总容量(快递柜格子的总数)。对于nil channel ( var chanX chan int )和unbuffered channel ( chanY := make(chan int) ), len() 和 cap() 的结果都是0,读者可自行编码验证。

    2.9 锁

    使用channel不用显式加锁了吧?是的,除非你的代码中还有其它必须加锁的逻辑。但请注意,channel的内部实现使用了互斥锁

    三、nil channel

    关于nil channel,请了解:

      1. nil channel毫无用处。
      2. nil channel是channel的默认零值。
      3. 向nil channel写数据,或从nil channel中读数据,会永久阻塞(block),但不会panic。
      4. 关闭(close)一个nil channel会触发pannic。

    四、buffered channel & unbuffered channel

    4.1 概述

    buffered channel是指有内部缓冲区(buffer,由循环队列实现)的channel,buffer大小由make的第二个参数指定,如 chanX := make(chan int, 3) 的buffer大小为3,最多能暂存3个数据。当发送者向channel发送数据而接收者还没有就绪时,如果buffer未满,就会将数据放入buffer;当接收者从channel读取数据时,如果buffer中有数据,会将buffer中的第一个数据(队首)取出,给到接收者。利用buffered channel,可以实现Goroutine间的异步通信。

    unbuffered channel就是内部缓冲区大小为0的channel。由于没有暂存数据的地方,unbuffered channel的数据传输只能是同步的,即只有读写双方都就绪时,通信才能成功[8],此时,数据直接从发送者拷贝给接收者(请看源码注释)。只要读写双方中的一方没有就绪,通信就一直block。

    资料[2]中送快递的比方很是形象。buffered channel就像是有快递柜的快递系统,快递员不必等到取件人到达,他只要把快递放到快递就可以了,不必关心收件人何时来取快递。当然,如果快递柜已满,快递员就必须等到收件人到达,然后直接将快递交到收件人手上,不必经过快递柜。同样,收件人也不必眼巴巴等着快递员,他只要到快递柜取快递就行了。这种情况下快递收发是异步的。unbuffered channel就像是没有快递柜的快递系统,只能是收发双方当面交接。需要注意的是,快递系统没有严格的先来后到限制,而channel是严格FIFO的。第一个接收者必然会得到buffer中的第一个数据,以此类推。

    下面是两种channel的图示,特别地,unbuffered channel更像是“厚度”为0的“传送门”。相比于在下的简明版图示,资料[2]和[6]中的示意图更加形象,但没有突出两种channel在结构上的差别。

                                                    ▲buffered channel

                                 ▲unbuffered channel

     此外,unbuffered channel可以用于Goroutine间的同步,资料[2]和[9]已经提供了很好的示例代码,在下就不献丑了。

    4.2 示例

    请看下面的代码:

     1 package main
     2 
     3 import (
     4 	"fmt"
     5 	"time"
     6 )
     7 
     8 func main() {
     9 	mychnl := make(chan int)
    10 
    11 	go func() {
    12 		fmt.Println("send 100")
    13 		mychnl <- 100
    14 		fmt.Println("has sent")
    15 	}()
    16 }
    
    View Code

    运行上面代码,不会得到任何输出。因为 go 关键字在启动Goroutine后会立即返回,程序继续往下走,当主协程(main函数所在的Goroutine)结束后,整个程序结束,不会等待其它Goroutine(参考资料[10]和[11])。因此,还没等到输出语句执行,整个程序就结束了。

    说句题外话,如果您对主协程中的变量如何“传递”到其它协程感到疑惑,可以学习关于“闭包 变量捕获”的内容,比如参考资料[12]。

    我们可以在main函数的最后添加 time.Sleep(1 * time.Second) 以便给输出语句足够的时间。

    再运行代码,可以看到第一句输出,但看不到第二句输出,即使增大主协程sleep的时间也不行。原因是:如前所述,unbuffered channel必须在读写双方都就绪时才能传送数据,否则block,因此, mychnl <- 100 一句导致其所在的Goroutine阻塞了(因为没有接收者),直到sleep结束,整个程序随着主协程的退出而结束。

    下面,我们使用 sync.WaitGroup 代替sleep,看看会发生什么(请读者自行学习 sync.WaitGroup ):

     1 package main
     2 
     3 import (
     4 	"fmt"
     5 	"sync"
     6 )
     7 
     8 func main() {
     9 	mychnl := make(chan int)
    10 
    11 	var wg sync.WaitGroup
    12 	wg.Add(1)
    13 	go func() {
    14 		defer wg.Done()
    15 		fmt.Println("send 100")
    16 		mychnl <- 100
    17 		fmt.Println("has sent")
    18 	}()
    19 	wg.Wait()
    20 }
    
    View Code

    这次,我们同样只得到了第一句输出,并且紧接着就得到了一个 fatal error: fatal error: all goroutines are asleep - deadlock! 。原因是,这里 wg.Wait() 会阻塞等待第13行启动的Goroutine结束,而后者中 mychnl <- 100 阻塞等待一个接收者(快递员等待收件人),显然,接收者永远不会出现,于是,死锁(deadlock)了。

    我们可以添加另一个作为接收者的Goroutine来解决这一问题:

     1 package main
     2 
     3 import (
     4 	"fmt"
     5 	"sync"
     6 )
     7 
     8 func main() {
     9 	mychnl := make(chan int)
    10 
    11 	var wg sync.WaitGroup
    12 
    13 	wg.Add(1)
    14 	go func() {
    15 		defer wg.Done()
    16 		fmt.Println("send 100")
    17 		mychnl <- 100
    18 		fmt.Println("has sent")
    19 	}()
    20 
    21 	wg.Add(1)
    22 	go func() {
    23 		defer wg.Done()
    24 		fmt.Println("begin receive")
    25 		x := <-mychnl
    26 		fmt.Printf("received %v\n", x)
    27 	}()
    28 
    29 	wg.Wait()
    30 }
    
    View Code

    结果是:

    begin receive
    send 100
    has sent
    received 100
    View Code

     如果您对输出的顺序感到疑惑(第一个输出的总是 begin receive 而不是 has sent ),那就请学习一下Goroutine的相关知识吧。

    4.3 都可能阻塞

    需要强调的是,unbuffered channel、buffered channel都有可能block:

      1. 对unbuffered channel,当读者/写者未就绪时,写操作/读操作会一直block;
      2. 对buffered channel,当buffer已满且读者未就绪时,写操作会一直block;同理,当buffer已空且写者未就绪时,读操作会一直block。

    基于以上两点,您需要调动起逆向思维来明确以下三点:

      1. 如果已经有读者在阻塞了,那么,buffer一定是空的且没有写者就绪;
      2. 如果已经有写者在阻塞了,那么,buffer一定是满的且没有读者就绪;
      3. 读者和写者不可能同时阻塞。

    注意,以上结论对unbuffered channel同样适用,其缓冲区容量为0,既可以视为恒空,也可以视为恒满。弄清了以上内容,才能更好地理解下文中channel的发送/接收逻辑。

    4.4 unbuffered channel和nil channel的区别

    虽然两者都是buffer容量为0,但是:

      1. nil channel完全不可用,对它的读写操作将无条件block,即便读写双方都就绪也不行。证据:4.2节最后一段代码中第9行改为 var mychnl chan int ,再次运行,会得到 fatal error: all goroutines are asleep - deadlock! 错误。
      2. 当读写双方都就绪时,unbuffered channel可以用来通信。可以利用这一点同步多个Goroutine。

    五、发送/接收步骤

    这里只梳理基本步骤,异常检查及更多细节,请参考[7]和[6]的源码解读。

    发送步骤:
    1. 如果存在阻塞等待的接收者(即Goroutine),那么直接将待发送的数据交给“等待接收队列”中的第一个Goroutine。(- 什么?直接交付?如果此时buffer中还有数据,不就跳过去了吗?还怎么满足FIFO?- 不存在!既然都有接收者在等待了,说明buffer必然早就空了!见4.3节)
    2. 如果没有在阻塞等待的接收者:
      2.1 若buffer还有剩余空间,则将待发送的数据送到buffer的队尾;
      2.2 若buffer已经没有剩余空间了,那么,将发送者(Goroutine)和要发送的数据打包成一个struct,加入到“等待发送队列”的末尾,同时将该发送者block。

    接收步骤:
    1. 如果存在阻塞等待的发送者(此时要么buffer已满,要么压根就没有buffer):
      1.1 若buffer已满,从buffer中取出队首元素交给接收者,同时从“等待发送队列”中取出队首元素(Goroutine和其待发送数据的打包),将其要发送的数据放入buffer的队尾,同时将对应的Goroutine唤醒;
      1.2 若没有buffer,从“等待发送队列”中取出队首元素,将其要发送的数据直接拷贝给接收者,同时将对应的Goroutine唤醒。
    2. 如果没有在阻塞等待的发送者:
      2.1 若buffer中还有数据,则取出队首元素发给接收者;
      2.2 若buffer已空,那么,将接收者(Goroutine)和它为要接收的数据准备的地址( data := <-chanX , data 的地址)打包成一个struct,加入到“等待接收队列”的末尾,同时将该接收者block。

    六、for range读取和select

    关于这两点,资料[9]已经有详尽的描述了,读者可前往阅读。这里拾人牙慧,强调两个要点,因为在下认为这两点确实非常重要:

      1. 如果发送端不是一直发数据,且没有关闭channel,那么,for range读取会陷入block,道理很简单,没有数据可读了。所以,要么您能把控全局,确保您的for range读取不会block;要么,别用for range读channel。
      2. select不是loop,当它select了一个case执行后,整个select就结束了。所以,如果想要一直select,那就在select外层加上for吧。

    七、channel的关闭

    您需要知道以下几点:

    1. 关闭nil channel,或者关闭一个已经关闭的channel,会panic。
    2. channel的关闭是不可逆的,一旦关闭就不能再“打开”了,它没有open函数。
    3. 向一个已经关闭的channel写数据,会panic。
    4. 从一个已经关闭的channel读数据,会先将buffer中的数据(如果有的话)读出来,然后读到的就是buffer可缓存的数据类型对应的零值。特别注意,即使是unbuffered channel,关闭后也能读出零值,见下面的代码。
      4.1 为什么不关闭可能阻塞,关闭了反而不阻塞了呢?因为,理论上,不关闭,还是“有念想”的,如果出现写者,还是可以往channel写数据的,这样就有数据可读了;但一旦关闭,就彻底“没念想”了(参考第3条),阻塞一万年也没用,所以就直接返回零值了。
      4.2 为什么写一个已关闭的channel会panic,而读一个已关闭的channel却不会panic呢?在下也不知道,这里仅给出猜测:写操作要比读操作“危险”(想想POST请求和GET请求),因此,对写操作的处理往往要比对读操作的处理严格。对channel而言,读closed channel只会影响自己(当前Goroutine),而写操作就不同了(试想,如果可以向closed channel写入默认零值,接着这些值又被其它Goroutine读取……)。如果让写closed channel的Goroutine阻塞呢?要明白,这种阻塞是不可能被唤醒的,所以,试想一下,有许多个写channel的Goroutine,然后,某个Goroutine把channel关闭了……那么,为什么不让读 closed channel的Goroutine也panic呢?哎,得饶人处且饶人,能不panic就不panic吧。另一个重要原因是,读操作本身是可以判断读出的数据是来自未关闭的channel还是已关闭的channel的,见第6条。
      4.3 所谓“读出默认零值”,其实是将对应数据直接置零了。如 data := <-chanX ,若 chanX 已关闭,则 data 直接被置为0值。可以通过阅读源码了解这一点。
    5. 利用for range读channel,如果channel关闭,for loop会退出,不会读出默认零值。
    6. 可以通过 data, ok := <-chanX 的方式判断channel是否关闭,若关闭, ok 为false,否则, ok 为true。
    7. 除非业务需要(如channel被for range读取),否则channel无需显式关闭(参考资料[13])。资料[14]总结了关闭channel的原则,而资料[15]和资料[7]介绍了优雅关闭channel的方法。

     1 package main
     2 
     3 import "fmt"
     4 
     5 func main() {
     6 	mychnl := make(chan int)
     7 	close(mychnl)
     8 	x := <-mychnl
     9 	fmt.Printf("%v\n", x)
    10 }
    
    Code: 读出默认零值

    八、源码解读

    资料[6]和[7]已经做了非常精彩的解读,在下已无需班门弄斧。这里仅再次强调channel所维护的主要数据结构,以帮助读者更好地理解源码和channel本身。

      1. channel维护一个循环队列,即缓存区;
      2. channel维护两个双向链表,分别存储等待向channel写入的Goroutine和等待从channel读数据的Goroutine。

    九、引发panic/block的情形

    何时会触发panic:
    1. 关闭nil channel;
    2. 关闭已经关闭的channel;
    3. 向已经关闭的channel写数据。

    何时会引起block:
    1. nil channel的读写会恒阻塞;
    2. unbuffered channel,在读写双方未同时就绪时,阻塞;
    3. buffered channel,buffer已空且没有等待的写者时,读channel会阻塞;
    4. buffered channel,buffer已满且没有等待的读者时,写channel会阻塞;
    5. for range读channel,且该channel既没被关闭又没有持续的写者时,阻塞。

    参考

    [  1] Initializing channels in Go - Ukiah Smith
    [  2] Channel · Go语言中文文档
    [  3] Go语言的单向通道到底有什么用? - 知乎
    [  4] Getting Started With Golang Channels! Here’s Everything You Need to Know
    [  5] Go 语言 Channel 实现原理精要 | Go 语言设计与实现
    [  6] 深入理解Golang之channel - 掘金
    [  7] 深入 Go 并发原语 — Channel 底层实现
    [  8] go - The differences between channel buffer capacity of zero and one in golang - Stack Overflow
    [  9] Go Channel 详解
    [
    10] Go 系列教程 —— 21. Go 协程 - Go语言中文网 - Golang中文社区
    [11] go - No output from goroutine - Stack Overflow
    [12] Go中被闭包捕获的变量何时会被回收 | Tony Bai
    [13] go - Is it OK to leave a channel open? - Stack Overflow
    [
    14] channel关闭的注意事项 - Go语言中文网 - Golang中文社区
    [
    15] <译>如何优雅的关闭channel - SegmentFault 思否

    写在后面
    资料[9]还给出了与channel有关的定时、超时等操作,读者可自行前往学习。

    再次感谢本文所有链接对应的作者。在下才疏学浅,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。

  • 相关阅读:
    TO DO List
    springboot 热部署
    <dependencyManagement>的作用
    人体工程学座椅
    temp
    temp
    声明式编程和命令式编程的本质区别
    weak first question
    Spring依赖注入方式和依赖来源
    SpringBoot 整合 H2 数据库
  • 原文地址:https://www.cnblogs.com/zpcdbky/p/15819498.html
Copyright © 2020-2023  润新知