Go系列教程(二十二) | Go信道(channels)

在上一教程里,我们探讨了如何使用 Go 协程(Goroutine)来实现并发。我们接着在本教程里学习信道(Channel),学习如何通过信道来实现 Go 协程间的通信。

什么是信道?

信道(通道)可以想像成 Go 协程之间通信的管道。如同管道中的水会从一端流到另一端,通过使用信道,数据也可以从一端发送,在另一端接收。

信道的声明

所有信道都关联了一个类型。信道只能运输这种类型的数据,而运输其他类型的数据都是非法的。

chan T 表示 T 类型的信道。

信道的零值为 nil。信道的零值没有什么用,应该使用 make 内置函数,来像对 Map 和 Slice 初始化那样,来定义信道。

定义:指对标志符声明并初始化,声明可以通俗的理解为指定标志符所表示的具体类型,初始化可以理解为开辟内存空间。只声明并没有对其初始化的标志符在Go语言中其默认值为类型零值。

下面编写代码,定义一个信道。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import "fmt"

func main() {
    // 变量声明
    var a chan int
    if a == nil {
        fmt.Println("channel a is nil, going to initialize it")
        // 变量初始化
        a = make(chan int)
        fmt.Printf("Type of a is %T", a)
    }
}

在第 7 行,因为只是对变量 a 的一个声明,并没有初始化,由于信道是引用类型,所以变量 a 的值为类型零值,也就是为 nil。于是,程序执行了 if 语句块内的语句,在第 11 行,初始化了信道 a。程序中 a 是一个 int 类型的信道。该程序会输出:

1
2
channel a is nil, going to initialize it  
Type of a is chan int

简短声明通常也是一种定义信道的简洁有效的方法。

1
a := make(chan int)

这一行代码同样定义了一个 int 类型的信道 a

通过信道进行发送和接收

如下所示,该语法通过信道发送和接收数据。

1
2
3
4
5
// 读取信道 a
data := <- a

// 写入信道 a
a <- data 

信道旁的箭头方向指定了是发送数据还是接收数据。

在第一行,箭头对于 a 来说是向外指的,因此我们读取了信道 a 的值,并把该值存储到变量 data

在第二行,箭头指向了 a,因此我们在把数据写入信道 a

发送与接收默认是阻塞的

发送与接收默认是阻塞的。这是什么意思?当把数据发送到信道时,程序控制会在发送数据的语句处发生阻塞,直到有其它 Go 协程从信道中读取到数据,才会解除阻塞。与此类似,当读取信道中的数据时,如果没有其它的 Go 协程把数据写入到这个信道中,那么读取信道的过程就会一直阻塞着。

信道的这种特性能够帮助 Go 协程之间进行高效的通信,不需要用到其他编程语言中常见的显式锁或条件变量。

信道的代码示例

理论已经够了🤣。接下来写点代码,看看协程之间通过信道是怎么通信的吧。

我们其实可以重写上章学习 Go 协程 时写的程序,现在我们在这里用上信道。

首先引用前面教程里的程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

import (  
    "fmt"
    "time"
)

func hello() {  
    fmt.Println("Hello world goroutine")
}

func main() {  
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

这是上一篇的代码。我们使用到了休眠,使 Go 主协程等待 hello 协程结束。如果你看不懂,建议你阅读上一教程 Go 协程

我们接下来使用信道来重写上面代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (  
    "fmt"
)

func hello(done chan bool) {  
    fmt.Println("Hello world goroutine")
    done <- true
}

func main() {  
    done := make(chan bool)
    go hello(done)
    <-done
    fmt.Println("main function")
}

在上述程序里,我们在第 13 行创建了一个 bool 类型的信道 done,并把 done 作为参数传递给了 hello 协程。在第 15 行,我们通过信道 done 读取(接收)数据。这一行代码发生了阻塞,除非有协程向 done 写入(发送)数据,否则程序不会跳到下一行代码。于是,这就不需要用以前的 time.Sleep 方式来阻止 Go 主协程退出了。

第 15 行代码 <-done 通过信道 done 读取(接收)数据,但并没有使用数据或者把数据存储到变量中。这完全是合法的。

现在我们的 Go 主协程发生了阻塞,等待信道 done 写入(发送)的数据。该信道作为参数传递给了协程 hellohello 打印出 Hello world goroutine,接下来向 done 写入(发送)数据。当完成写入(发送)时,Go 主协程会通过信道 done 读取(接收)数据,于是它解除阻塞状态,打印出文本 main function

该程序输出如下:

1
2
Hello world goroutine  
main function

我们稍微修改一下程序,在 hello 协程里加入休眠函数,以便更好地理解阻塞的概念。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (  
    "fmt"
    "time"
)

func hello(done chan bool) {  
    fmt.Println("hello go routine is going to sleep")
    time.Sleep(4 * time.Second)
    fmt.Println("hello go routine awake and going to write to done")
    done <- true
}

func main() {  
    done := make(chan bool)
    fmt.Println("Main going to call hello go goroutine")
    go hello(done)
    <-done
    fmt.Println("Main received data")
}

在上面程序里,我们向 hello 函数里添加了 4 秒的休眠(第 10 行)。

程序首先会打印 Main going to call hello go goroutine。接着会开启 hello 协程,打印 hello go routine is going to sleep。打印完之后,hello 协程会休眠 4 秒钟,而在这期间,Go 主协程会在 <-done 这一行发生阻塞,等待来自信道 done 的数据。4 秒钟之后,打印 hello go routine awake and going to write to done,接着再打印 Main received data

信道的另一个示例

我们再编写一个程序来更好地理解信道。该程序会计算一个数中的每一位数字的平方和与立方和,然后把平方和与立方和相加并打印出来。

例如,如果输入是 123,该程序会如下计算输出:

1
2
3
4
5
6
7
8
// 平方和 14
squares = (1 * 1) + (2 * 2) + (3 * 3)

// 立方和 36
cubes = (1 * 1 * 1) + (2 * 2 * 2) + (3 * 3 * 3)

// 平方和与立方和之和 50
output = squares + cubes

我们会这样去构建程序:在一个单独的 Go 协程计算平方和,而在另一个单独的 Go 协程计算立方和,最后在 Go 主协程把平方和与立方和相加。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
	"fmt"
)

func calcSquares(number int, squareop chan int) {
	sum := 0
	for number != 0 {
		digit := number % 10
		sum += digit * digit
		number /= 10
	}
	squareop <- sum
}

func calcCubes(number int, cubeop chan int) {
	sum := 0
	for number != 0 {
		digit := number % 10
		sum += digit * digit * digit
		number /= 10
	}
	cubeop <- sum
}

func main() {
	number := 799
	sqrch := make(chan int)
	cubech := make(chan int)
	go calcSquares(number, sqrch)
	go calcCubes(number, cubech)
	squares, cubes := <-sqrch, <-cubech
	fmt.Println("Final output", squares + cubes)
}

在第 7 行,函数 calcSquares 计算一个数每位的平方和,并把计算结果发送给信道 squareop。与此类似,在第 17 行,函数 calcCubes 计算一个数每位的立方和,并把计算结果发送给信道 cubop

这两个函数分别在单独的 Go 协程里运行(第 31 行和第 32 行),每个函数都有传递信道的参数,以便写入数据。Go 主协程会在第 33 行等待两个信道传来的数据。一旦从两个信道接收完数据,数据就会存储在变量 squarescubes 里,然后计算这两个变量的和并打印出最终的结果。该程序会输出:

1
Final output 2012

死锁

使用信道需要考虑的一个重点是死锁。当 Go 协程给一个信道发送数据时,照理说会有其他 Go 协程来接收数据。如果没有的话,程序就会在运行时触发 panic,形成死锁。

同理,当有 Go 协程等着从一个信道接收数据时,我们期望其他的 Go 协程会向该信道发送数据,要不然程序就会触发 panic。

1
2
3
4
5
6
package main

func main() {  
    ch := make(chan int)
    ch <- 5
}

在上述程序中,我们创建了一个信道 ch,接着在下一行 ch <- 5,我们把 5 发送到这个信道。对于本程序而言,没有其他的 Go 协程从信道 ch 接收数据。于是程序触发 panic,出现如下运行时错误。

1
2
3
4
5
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:  
main.main()  
    /tmp/sandbox249677995/main.go:6 +0x80

单向信道

我们目前讨论的信道都是双向信道,即通过信道既能发送数据,又能接收数据。其实也可以创建单向信道,这种信道只能发送或者接收数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func sendData(sendch chan<- int) {  
    sendch <- 10
}

func main() {  
    sendch := make(chan<- int)
    go sendData(sendch)
    fmt.Println(<-sendch)
}

上面程序的第 10 行,我们创建了唯送(Send Only)信道 sendchchan<- int 定义了唯送信道,因为箭头指向了 chan。在第 12 行,我们试图通过唯送信道接收数据,于是编译器报错:

1
main.go:11: invalid operation: <-sendch (receive from send-only type chan<- int)

一切都很顺利,只不过一个不能读取数据的唯送信道究竟有什么意义呢?

这就需要用到信道转换(Channel Conversion)了。把一个双向信道转换成唯送信道或者唯收(Receive Only)信道都是行得通的,但是反过来就不行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func sendData(sendch chan<- int) {  
    sendch <- 10
}

func main() {  
    cha1 := make(chan int)
    go sendData(cha1)
    fmt.Println(<-cha1)
}

在上述程序的第 10 行,我们创建了一个双向信道 cha1。在第 11 行把 cha1 作为参数传递给了 sendData 协程。在第 5 行,函数 sendData 里的参数 sendch chan<- intcha1 转换为一个唯送信道。于是该信道在 sendData 协程里是一个唯送信道,而在 Go 主协程里是一个双向信道。该程序最终打印输出 10

关闭信道和使用 for range 遍历信道

数据发送方可以关闭信道,通知接收方这个信道不再有数据发送过来。

当从信道接收数据时,接收方可以多用一个变量来检查信道是否已经关闭。

1
v, ok := <- ch

上面的语句里,如果成功接收信道所发送的数据,那么 ok 等于 true。而如果 ok 等于 false,说明我们试图读取一个关闭的信道。从关闭的信道读取到的值会是该信道类型的零值。例如,当信道是一个 int 类型的信道时,那么从关闭的信道读取的值将会是 0

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"fmt"
)

func producer(values chan int) {
	for i := 0; i < 10; i++ {
		values <- i
	}
	close(values)
}

func main() {
	ch := make(chan int)
	go producer(ch)
	for {
		v, ok := <-ch
		if ok == false {
			break
		}
		fmt.Println("Received ", v, ok)
	}
}

在上述的程序中,producer 协程会将数字从 0 到 9 写入到信道 values,然后关闭该信道。主函数有一个无限的 for 循环(第 17 行),使用变量 ok(第 19 行)检查信道是否已经关闭。如果 ok 等于 false,说明信道已经关闭,于是退出 for 循环。如果 ok 等于 true,会打印出接收到的值和 ok 的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Received  0 true  
Received  1 true  
Received  2 true  
Received  3 true  
Received  4 true  
Received  5 true  
Received  6 true  
Received  7 true  
Received  8 true  
Received  9 true

for range 循环用于在一个信道关闭之前,从信道接收数据。

接下来我们使用 for range 循环重写上面的代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
	"fmt"
)

func producer(values chan int) {
	for i := 0; i < 10; i++ {
		values <- i
	}
	close(values)
}

func main() {
	ch := make(chan int)
	go producer(ch)
	for v := range ch {
		fmt.Println("Received ",v)
	}
}

在第 17 行,for range 循环从信道 ch 接收数据,直到该信道关闭。一旦关闭了 ch,循环会自动结束。该程序会输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Received  0  
Received  1  
Received  2  
Received  3  
Received  4  
Received  5  
Received  6  
Received  7  
Received  8  
Received  9

我们可以使用 for range 循环,重写信道的另一个示例这一节里面的代码,提高代码的可重用性。

如果你仔细观察这段代码,会发现获得一个数里的每位数的代码在 calcSquarescalcCubes 两个函数内重复了。我们将把这段代码抽离出来,放在一个单独的函数里,然后并发地调用它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
	"fmt"
)

func digits(number int, dchnl chan int) {
	for number != 0 {
		digit := number % 10
		dchnl <- digit
		number /= 10
	}
	close(dchnl)
}

func calcSquares(number int, squareop chan int) {
	sum := 0
	dch := make(chan int)
	go digits(number, dch)
	for digit := range dch {
		sum += digit * digit
	}
	squareop <- sum
}

func calcCubes(number int, cubeop chan int) {
	sum := 0
	dch := make(chan int)
	go digits(number, dch)
	for digit := range dch {
		sum += digit * digit * digit
	}
	cubeop <- sum
}

func main() {
	number := 799
	sqrch := make(chan int)
	cubech := make(chan int)
	go calcSquares(number, sqrch)
	go calcCubes(number, cubech)
	squares, cubes := <-sqrch, <-cubech
	fmt.Println("Final output", squares+cubes)
}

上述程序里的 digits 函数,包含了获取一个数的每位数的逻辑,并且 calcSquarescalcCubes 两个函数并发地调用了 digits。当计算完一个数的每一位数时,第 13 行就会关闭信道。calcSquarescalcCubes 两个协程使用 for range 循环分别监听了它们的信道,直到该信道关闭。程序的其他地方不变,该程序同样会输出:

1
Final output 2012

本教程的内容到此结束。关于信道还有一些其他的概念,比如缓冲信道(Buffered Channel)、工作池(Worker Pool)和 select。我们会在接下来的教程里专门介绍它们。感谢你的阅读。祝你过得愉快。

comments powered by Disqus