4.4 学习 Go 协程:详解信道/通道

http://image.iswbm.com/20200607145423.png

Go 语言之所以开始流行起来,很大一部分原因是因为它自带的并发机制。

如果说 goroutine 是 Go语言程序的并发体的话,那么 channel(信道) 就是 它们之间的通信机制。channel,是一个可以让一个 goroutine 与另一个 goroutine 传输信息的通道,我把他叫做信道,也有人将其翻译成通道,二者都是一个概念。

信道,就是一个管道,连接多个goroutine程序 ,它是一种队列式的数据结构,遵循先入先出的规则。

1. 信道的定义与使用

每个信道都只能传递一种数据类型的数据,所以在你声明的时候,你得指定数据类型(string int 等等)

var 信道实例 chan 信道类型

声明后的信道,其零值是nil,无法直接使用,必须配合make函进行初始化。

信道实例 = make(chan 信道类型)

亦或者,上面两行可以合并成一句,以下我都使用这样的方式进行信道的声明

信道实例 := make(chan 信道类型)

假如我要创建一个可以传输int类型的信道,可以这样子写。

// 定义信道
pipline := make(chan int)

信道的数据操作,无非就两种:发送数据与读取数据

// 往信道中发送数据
pipline<- 200

// 从信道中取出数据,并赋值给mydata
mydata := <-pipline

信道用完了,可以对其进行关闭,避免有人一直在等待。但是你关闭信道后,接收方仍然可以从信道中取到数据,只是接收到的会永远是 0。

close(pipline)

对一个已关闭的信道再关闭,是会报错的。所以我们还要学会,如何判断一个信道是否被关闭?

当从信道中读取数据时,可以有多个返回值,其中第二个可以表示 信道是否被关闭,如果已经被关闭,ok 为 false,若还没被关闭,ok 为true。

x, ok := <-pipline

2. 信道的容量与长度

一般创建信道都是使用 make 函数,make 函数接收两个参数

  • 第一个参数:必填,指定信道类型

  • 第二个参数:选填,不填默认为0,指定信道的容量(可缓存多少数据)

对于信道的容量,很重要,这里要多说几点:

  • 当容量为0时,说明信道中不能存放数据,在发送数据时,必须要求立马有人接收,否则会报错。此时的信道称之为无缓冲信道

  • 当容量为1时,说明信道只能缓存一个数据,若信道中已有一个数据,此时再往里发送数据,会造成程序阻塞。 利用这点可以利用信道来做锁。

  • 当容量大于1时,信道中可以存放多个数据,可以用于多个协程之间的通信管道,共享资源。

至此我们知道,信道就是一个容器。

若将它比做一个纸箱子

  • 它可以装10本书,代表其容量为10

  • 当前只装了1本书,代表其当前长度为1

信道的容量,可以使用 cap 函数获取 ,而信道的长度,可以使用 len 长度获取。

package main

import "fmt"

func main() {
    pipline := make(chan int, 10)
    fmt.Printf("信道可缓冲 %d 个数据\n", cap(pipline))
    pipline<- 1
    fmt.Printf("信道中当前有 %d 个数据", len(pipline))
}

输出如下

信道可缓冲 10 个数据
信道中当前有 1 个数据

3. 缓冲信道与无缓冲信道

按照是否可缓冲数据可分为:缓冲信道无缓冲信道

缓冲信道

允许信道里存储一个或多个数据,这意味着,设置了缓冲区后,发送端和接收端可以处于异步的状态。

pipline := make(chan int, 10)

无缓冲信道

在信道里无法存储数据,这意味着,接收端必须先于发送端准备好,以确保你发送完数据后,有人立马接收数据,否则发送端就会造成阻塞,原因很简单,信道中无法存储数据。也就是说发送端和接收端是同步运行的。

pipline := make(chan int)

// 或者
pipline := make(chan int, 0)

4. 双向信道与单向信道

通常情况下,我们定义的信道都是双向通道,可发送数据,也可以接收数据。

但有时候,我们希望对信道的数据流向做一些控制,比如这个信道只能接收数据或者这个信道只能发送数据。

因此,就有了 双向信道单向信道 两种分类。

双向信道

默认情况下你定义的信道都是双向的,比如下面代码

import (
    "fmt"
    "time"
)

func main() {
    pipline := make(chan int)

    go func() {
        fmt.Println("准备发送数据: 100")
        pipline <- 100
    }()

    go func() {
        num := <-pipline
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(time.Second)
}

单向信道

单向信道,可以细分为 只读信道只写信道

定义只读信道

var pipline = make(chan int)
type Receiver = <-chan int // 关键代码:定义别名类型
var receiver Receiver = pipline

定义只写信道

var pipline = make(chan int)
type Sender = chan<- int  // 关键代码:定义别名类型
var sender Sender = pipline

仔细观察,区别在于 <- 符号在关键字 chan 的左边还是右边。

  • <-chan 表示这个信道,只能从里发出数据,对于程序来说就是只读

  • chan<- 表示这个信道,只能从外面接收数据,对于程序来说就是只写

有同学可能会问:为什么还要先声明一个双向信道,再定义单向通道呢?比如这样写

type Sender = chan<- int
sender := make(Sender)

代码是没问题,但是你要明白信道的意义是什么?(以下是我个人见解

信道本身就是为了传输数据而存在的,如果只有接收者或者只有发送者,那信道就变成了只入不出或者只出不入了吗,没什么用。所以只读信道和只写信道,唇亡齿寒,缺一不可。

当然了,若你往一个只读信道中写入数据 ,或者从一个只写信道中读取数据 ,都会出错。

完整的示例代码如下,供你参考:

import (
    "fmt"
    "time"
)
 //定义只写信道类型
type Sender = chan<- int

//定义只读信道类型
type Receiver = <-chan int

func main() {
    var pipline = make(chan int)

    go func() {
        var sender Sender = pipline
        fmt.Println("准备发送数据: 100")
        sender <- 100
    }()

    go func() {
        var receiver Receiver = pipline
        num := <-receiver
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(time.Second)
}

5. 遍历信道

遍历信道,可以使用 for 搭配 range关键字,在range时,要确保信道是处于关闭状态,否则循环会阻塞。

import "fmt"

func fibonacci(mychan chan int) {
    n := cap(mychan)
    x, y := 1, 1
    for i := 0; i < n; i++ {
        mychan <- x
        x, y = y, x+y
    }
    // 记得 close 信道
    // 不然主函数中遍历完并不会结束,而是会阻塞。
    close(mychan)
}

func main() {
    pipline := make(chan int, 10)

    go fibonacci(pipline)

    for k := range pipline {
        fmt.Println(k)
    }
}

6. 用信道来做锁

当信道里的数据量已经达到设定的容量时,此时再往里发送数据会阻塞整个程序。

利用这个特性,可以用当他来当程序的锁。

示例如下,详情可以看注释

package main

import (
    "fmt"
    "time"
)

// 由于 x=x+1 不是原子操作
// 所以应避免多个协程对x进行操作
// 使用容量为1的信道可以达到锁的效果
func increment(ch chan bool, x *int) {
    ch <- true
    *x = *x + 1
    <- ch
}

func main() {
    // 注意要设置容量为 1 的缓冲信道
    pipline := make(chan bool, 1)

    var x int
    for i:=0;i<1000;i++{
        go increment(pipline, &x)
    }

    // 确保所有的协程都已完成
    // 以后会介绍一种更合适的方法(Mutex),这里暂时使用sleep
    time.Sleep(time.Second)
    fmt.Println("x 的值:", x)
}

输出如下

x 的值:1000

如果不加锁,输出会小于1000。

7. 信道传递是深拷贝吗?

数据结构可以分为两种:

  • 值类型 :String,Array,Int,Struct,Float,Bool

  • 引用类型:Slice,Map

这两种不同的类型在拷贝的时候,在拷贝的时候效果是完全不一样的,这对于很多新手可能是一个坑。

对于值类型来说,你的每一次拷贝,Go 都会新申请一块内存空间,来存储它的值,改变其中一个变量,并不会影响另一个变量。

func main() {
    aArr := [3]int{0,1,2}
    fmt.Printf("打印 aArr: %v \n", aArr)
    bArr := aArr
    aArr[0] = 88
    fmt.Println("将 aArr 拷贝给 bArr 后,并修改 aArr[0] = 88")
    fmt.Printf("打印 aArr: %v \n", aArr)
    fmt.Printf("打印 bArr: %v \n", bArr)
}

从输出结果来看,aArr 和 bArr 相互独立,互不干扰

打印 aArr: [0 1 2]
将 aArr 拷贝给 bArr 后,并修改 aArr[0] = 88
打印 aArr: [88 1 2]
打印 bArr: [0 1 2]

对于引用类型来说,你的每一次拷贝,Go 不会申请新的内存空间,而是使用它的指针,两个变量名其实都指向同一块内存空间,改变其中一个变量,会直接影响另一个变量。

func main() {
    aslice := []int{0,1,2}
    fmt.Printf("打印 aslice: %v \n", aslice)
    bslice := aslice
    aslice[0] = 88
    fmt.Println("将 aslice 拷贝给 bslice 后,并修改 aslice[0] = 88")
    fmt.Printf("打印 aslice: %v \n", aslice)
    fmt.Printf("打印 bslice: %v \n", bslice)
}

从输出结果来看,aslice 的更新直接反映到了 bslice 的值。

打印 aslice: [0 1 2]
将 aslice 拷贝给 bslice 后,并修改 aslice[0] = 88
打印 aslice: [88 1 2]
打印 bslice: [88 1 2]

介绍完深拷贝和浅拷贝后,来回来最开始的问题:信道传递是深拷贝吗?

答案是:是否是深拷贝,取决于你传入的值是值类型,还是引用类型?

7. 几个注意事项

  1. 关闭一个未初始化的 channel 会产生 panic

  2. 重复关闭同一个 channel 会产生 panic

  3. 向一个已关闭的 channel 发送消息会产生 panic

  4. 从已关闭的 channel 读取消息不会产生 panic,且能读出 channel 中还未被读取的消息,若消息均已被读取,则会读取到该类型的零值。

  5. 从已关闭的 channel 读取消息永远不会阻塞,并且会返回一个为 false 的值,用以判断该 channel 是否已关闭(x,ok := <- ch)

  6. 关闭 channel 会产生一个广播机制,所有向 channel 读取消息的 goroutine 都会收到消息

  7. channel 在 Golang 中是一等公民,它是线程安全的,面对并发问题,应首先想到 channel。