GO语言基础篇(二十二)- Channel初探

819 阅读2分钟

这是我参与8月更文挑战的第 22 天,活动详情查看: 8月更文挑战

本文主要是分享channel的一些语法及概念性的东西。当然,也会通过示例协助理解。后边会准备一篇文章主要分享channel的实际使用,深入的了解channel

channel初识

如果说goroutine是Go程序并发的执行体,通道就是它们之间的连接。通道是可以让 一个goroutine发送特定值到另一个goroutine的通信机制。每一个通道是一个具体类型的导管,叫作通道的元素类型。一个有int类型元素的通道写为chan int

使用内置的make函数来创建一个通道:

ch := make(chan int) // ch 的类型是,chan int

像map一样,通道是一个使用make创建的数据结构的引用。当复制或者作为参数传递 到一个函数时,复制的是引用,这样调用者和被调用者都引用同一份数据结构。和其他引用 类型一样,通道的零值是nil

var ch chan int//此时ch就是nil

同种类型的通道可以使用==符号进行比较。当二者都是同一通道数据的引用时,比较值为true。通道也可以和nil进行比较

通道有两个主要操作:发送(send)和接收(receive),两者统称为通信。send语句从一 个goroutine传输一个值到另一个在执行接收表达式的goroutine。两个操作都使用 <- 操作符 书写。发送语句中,通道和值分别在 <- 的左右两边。在接收表达式中,<- 放在通道操作数前面。在接收表达式中,其结果未被使用也是合法的

ch <- x //发送语句
x = <-ch //赋值语句中的接收表达式
<-ch //接收语句,丢弃结果

通道支持第三个操作:关闭(close),它设置一个标志位来指示值当前已经发送完毕,,这个通道后面没有值了;关闭后的发送操作将导致宕机在一个已经关闭的通道上进行接收操作,将获取所有已经发送的值,直到通道为空;这时任何接收操作会立即完成,同时获取到一个通道元素类型对应的零值

调用内置的close函数来关闭通道

close(ch)

使用简单的make创建的通道叫无缓冲(unbuffered)通道,但是make还可以接收第二个可选参数,它表示通道容量,如果容量是0,就创建了一个无缓冲通道

ch = make(chan int)//无缓冲通道
ch = make(chan int, 0)//无缓冲通道
ch = make(chan int, 3) //容量为3的缓冲通道

无缓冲channel

无缓冲通道上的发送操作将会阻塞,直到另一个goroutine在对应的通道上执行接收操作,这时值传送完成,两个goroutine都可以继续执行。相反,如果接收操作先执行,接收方goroutine将阻塞,直到另一个goroutine在同一个通道上发送一个值

使用无缓冲通道进行的通信导致发送和接收goroutine同步化。因此,无缓冲通道也称为同步通道。当一个值在无缓冲通道上传递时,接收值后发送方goroutine才被再次唤醒

在讨论并发的时候,当我们说x早于y发生时,不仅仅是说x发生的时间早于y,而是说保证它是这样,并且是可预期的,比如更新变量,我们可以依赖这个机制

当x既不比y早也不比y晚时,我们说x和y并发。这不意味着,x和y一定同时发生,只说明我们不能假设它们的顺序。后边的文章会分享,在两个goroutine并发地访问同一 个变量的时候,有必要对这样的事件进行排序,避免程序的执行发生问题

通过下边的示例来理解上边提到的概念

package main

import "fmt"

func chanDemo() {
    c := make(chan int)
    c <- 1
    c <- 2
    n := <-c
    fmt.Println(n)
}

func main() {
    chanDemo()
}

如果运行上边这段程序,你会发现报错,报错内容是:

fatal error: all goroutines are asleep - deadlock!

原因是:因为c是一个无缓冲通道,所以,当往c这个通道中发送1之后,需要有一个goroutinue进行接收之后,才可以继续往c中发送数据(如果你将c <- 2这一行删掉,也是会报这个错误的,需要另一个goroutine进行接收)

对上边的chanDemo函数进行如下修改

func chanDemo() {
    c := make(chan int)
    go func() {
        for{ //死循环,不断的从通道中取数据,没有数据的时候就阻塞住
            n := <-c
            fmt.Println(n)
        }
    }()
    c <- 1
    c <- 2
}

此时再执行,就可以正常打印出结果了。如果你的go版本是1.13以前的,或者你的计算机是单核的,那只能打印出来一个1。如果你的go版本是1.14及以上,你的计算机是多核,且没设置runtime.GOMAXPROCS(1)(在1.14版本及以上,它的默认值和你电脑的CPU核数相等),那你的执行结果是1、2(我这里以1.13以前版本为例)

解释一下原因:对于go的1.13以前的版本,goroutine是非抢占式的,如果一个goroutine不让出控制权,别的goroutine是不能执行的(如果你不清楚非抢占式调度,点这里)。并且从上篇文章中,知道,当涉及IO操作的时候,goroutine会让出控制权,所以上边执行结果为1,就好理解了。当往通道中传递一个1之后,闭包那个goroutine会获取到1并打印,因为打印操作涉及IO操作,所以它会让出控制权,此时会将2放入c中,因为此时主goroutine,也就是main,执行完毕了,所以主goroutine会关闭,所以,闭包的goroutine还没执行,就被杀了

在前边的文章中,我们知道go语言中的函数是一等公民(如果不清楚,可以点这里),channel也是一等公民,也就是说,它可以作为函数的参数,也可以作为返回值

channel作为参数

可以针对上边的程序进行修改,看一下channel作为函数的参数和返回值

package main

import (
    "fmt"
)

func worker(c chan int)  {
    for{ //死循环,不断的从通道中取数据,没有数据的时候就阻塞住
        n := <-c
        fmt.Println(n)
    }
}

func chanDemo() {
    c := make(chan int)
    go worker(c)
    c <- 1
    c <- 2
}

func main() {
    chanDemo()
}

channel作为返回值

还是上边的示例,对它进行改进。现在开很多个worker,然后不断的往每个worker中发数据

package main

import (
    "fmt"
    "time"
)

func createWorker() chan int {
    c := make(chan int)
    go func() { //需要开不同的goroutine去收
        for{ //死循环,不断的从通道中取数据,没有数据的时候就阻塞住
            fmt.Printf("worker received %c\n", <-c)
        }
    }()

    return c
}

func chanDemo() {
    var channels [10]chan int
    for i := 0; i < 10; i++ {
        channels[i] = createWorker()
    }

    //向10个channel发送数据
    for i := 0; i < 10; i++ {
        channels[i] <- 'a' + i
    }
    time.Sleep(time.Millisecond)//为了避免创建完所有通道,并发送完所有数据之后,main函数执行结束,导致其它goroutine还没执行就被杀。所以睡眠1ms,让其它goroutine执行
}

func main() {
    chanDemo()
}

上边的程序会比较简单,但是特别的方便理解

单向channel

从上边的程序,我们可以看出来,我们通过createWorker函数创建出来的通道,是用来发送数据的,所以可以将createWorker的返回值写成这样

func createWorker() chan<- int {
    ......
}

这样使用者一眼就可以看出来,这个函数返回的channel是一个发送数据的单向channel

//只能发送的通道(允许发送,但是不能接收)
chan <- int

//只能接收的通道(允许接收,但是不能发送)
<- chan int

还是以上边的代码为例,我们将返回值的channel改成一个只能发送数据的channel类型

package main

import (
    "fmt"
    "time"
)

func createWorker() chan<- int {
    c := make(chan int)
    go func() {
        for{
            fmt.Printf("worker received %c\n", <-c)
        }
    }()

    return c
}

func chanDemo() {
    var channels [10]chan<- int 
    for i := 0; i < 10; i++ {
        channels[i] = createWorker()
    }

    //向10个channel发送数据
    for i := 0; i < 10; i++ {
        channels[i] <- 'a' + i
    }
    time.Sleep(time.Millisecond)//为了避免创建完所有通道,并发送完所有数据之后,main函数执行结束,导致其它goroutine还没执行就被杀。所以睡眠1ms,让其它goroutine执行
}

func main() {
    chanDemo()
}

我们将createWorker函数的返回值类型,改成了chan<- int的单向通道,它返回的通道,只能进行发送数据,因此,在创建channels的时候,也需要将它创建为一个只能发送数据的单向通道

如果我们试图在发动数据的循环中去发送数据,就会编译错误

image.png

缓冲channel

理论

缓冲通道有一个元素队列,队列的最大长度在创建的时候,通过make的第二个参数来设置

ch := make(chan string, 3)

缓冲通道上的发送操作在队列的尾部插入一个元素,接收操作从队列的头部移除一个元素。如果通道满了,发送操作会阻塞所在的goroutine,直到另一个goroutine对它进行接收操作来留出可用空间。如果通道是空的,执行接收操作的goroutine会被阻塞,直到另一个goroutine向通道中发送数据

不太常见的一个情况是,程序需要知道通道缓冲区的容量,可以通过调用内置的cap函数获取它:

fmt.Println(cap(ch)) //3

当使用内置的len函数时,可以获取当前通道内的元素个数。因为在并发程序中这个信息会随着检索操作很快过时,所以它的价值很低,但是它在错误诊断和性能优化的时候很有用

fmt.Println(len(ch)) // "2"

如果,发送和接收操作都由同一个goroutine执行,但在真实的程序中通常由不同的goroutine执行。因为语法简单,新手有时候粗暴地将缓冲通道作为队列在单个goroutine中 使用,但是这是个错误。通道和goroutine的调度深度关联,如果没有另一个goroutine从通道进行接收,发送者(也许是整个程序)有被永久阻塞的风险。如果仅仅需要一个简单的队列,用slice创建一个就可以了

如果使用一个无缓冲通道,有3个goroutine向通道中发送数据,两个比较慢的goroutine将被卡住,因为在它们发送响应结果到通道的时候没有goroutine来接收。这个情况叫作goroutine泄漏,它属于一个bug。不像回收变量,泄露的goroutine不会自动回收,所以确保goroutine在不再需要的时候可以自动结束

无缓冲和缓冲通道的选择、缓冲通道容量大小的选择,都会对程序的正确性产生影响。 无缓冲通道提供强同步保障,因为每一次发送都需要和一次对应的接收同步;对于缓冲通道,这些操作则是解耦的。如果我们知道要发送的值数量的上限,通常会创建一个容量是使用上限的缓冲通道,在接收第一个值前就完成所有的发送。在内存无法提供缓冲容量的情况 下,可能导致程序死锁

通道的缓冲也可能影响程序的性能。想象蛋糕店里的三个厨师,在生产线上,在把每一 个蛋糕传递给下一个厨师之前,一个烤,一个加糖衣,一个雕刻。在空间比较小的厨房,每 一个厨师完成一个蛋糕流程,必须等待下一个厨师准备好接受它;这个场景类似于使用无缓冲通道来通信

如果在厨师之间有可以放一个蛋糕的位置,一个厨师可以将制作好的蛋糕放到这里,然 后立即开始制作下一个,这类似于使用一个容量为1的缓冲通道。只要厨师们以相同的速度工作,大多数工作就可以快速处理,消除他们各自之间的速率差异。如果在厨师之间有更多的空间——更长的缓冲区——就可以消除更大的暂态速率波动而不影响组装流水线,比如当 一个厨师稍作休息时,后面再抓紧跟上进度

另一方面,如果生产线的上游持续比下游快,缓冲区满的时间占大多数。如果后续的流程更快,缓冲区通常是空的。这时缓冲区的存在是没有价值的

组装流水线是对于通道和goroutine合适的比喻。例如,如果第二段更加复杂,一个厨师可能跟不上第一个厨师的供应,或者跟不上第三个厨师的需求。为了解决这个问题,我们可以雇用另一个厨师来帮助第二段流程,独立地执行同样的任务。这个类似于创建另外一个 goroutine使用同一个通道来通信

示例

从上边我们知道,一个无缓冲的通道,发送一个数据,如果没有人接收,是会报错的。比如下边这段程序,执行的时候会报错

package main

func bufferedChannel()  {
    ch := make(chan int)
    ch <- 1
}

func main() {
    bufferedChannel()
}

现在将它改成一个长度为3的缓冲通道,然后我们可以往里边发送3个数

package main

func bufferedChannel()  {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
}

func main() {
    bufferedChannel()
}

执行这部分是没有问题的,如果再往里边发送第4个数据,它就会报错,因为缓冲区已经满了,需要有接收者接收之后,才能继续往里发送数据。下边通过创建goroutine来进行接收缓冲通道中的数据

package main

import (
    "fmt"
    "time"
)

func worker(id int, c chan int)  {
    for  {
        fmt.Printf("worker %d, received %c\n", id, <-c)
    }
}

func bufferedChannel()  {
    ch := make(chan int, 3)
    go worker(0, ch)
    ch <- 'a'
    ch <- 'b'
    ch <- 'c'
    ch <- 'd'
    time.Sleep(time.Millisecond)
}

func main() {
    bufferedChannel()
}

通道什么时候知道数据发完了?

在前边我们知道,channel是可以close的。channel创建出来并不是说一定要close,所以上边的代码写法也是对的。但是如果我们要发发送的数据有一个明显的结尾的话,就可以加close。永远是发送方来进行close的

发送方通过close来告诉接收方,我没有新的数据发送了。还是以上边的代码为例

package main

import (
    "fmt"
    "time"
)

func worker(id int, c chan int)  {
    for  {
        fmt.Printf("worker %d, received %d\n", id, <-c)
    }
}

func channelClose()  {
    ch := make(chan int, 3)
    go worker(0, ch)
    ch <- 1
    ch <- 2
    ch <- 3
    ch <- 4
    close(ch)
    time.Sleep(time.Millisecond)
}

func main() {
    channelClose()
}

输出结果:
worker 0, received 1
worker 0, received 2
worker 0, received 3
worker 0, received 4
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
worker 0, received 0
......

执行上边的代码之后,你会看到接收方打印出很多0。这是因为channel一旦close了,接收方还是能从channel中接收到数据,收到的是channel中元素类型的零值,因为我们创建的是一个chan int类型,所以它的零值就是0,因此我们看到打印出来很多的0

所以,我们就需要在接收方从通道中获取数据的时候进行判断,下边对worker函数进行修改,具体如下:

func worker(id int, c chan int)  {
    for  {
        n, ok := <-c //n为获取到的具体的数,ok就是,是否还有值(如果close了,就没值了)
        if !ok {
            break
        }
        fmt.Printf("worker %d, received %d\n", id, n)
    }
}

除了用上边那种判断ok的方式,还可以通过range来遍历通道,等通道中没数据了,就不会再接收了,还是对worker进行修改,具体如下:

func worker(id int, c chan int)  {
    for n := range c {
        fmt.Printf("worker %d, received %d\n", id, n)
    }
}

注意,上边都是建立在channel被close的情况,如果没有close,其它goroutine一直发,他就会一直收,直到main执行结束

总结

本篇对channel的介绍,还是比较抽象。后边的文章会对channel进行应用。如何运用channel?Go语言的创作者说过一句话

Don't commuincate by sharing memory;share memory by communicating 不要通过共享内存进行通信;通过通信来共享内存

我们以前一直是通过共享内存来进行通信,比如一件事情做完了,通过跟其他人共享一个flag,将其赋值为true,别人就知道做完了,这就是通过共享内存来进行通信。下边一篇文章会分享,CSP模型基于goroutine和channel,通过通信来共享内存

参考

Google资深工程师深度讲解Go语言

《Go程序设计语言》—-艾伦 A. A. 多诺万

《Go语言学习笔记》—-雨痕