12.3 通道(channel)
如果说 goroutine 是 Go语言程序的并发体的话,那么通道(channel)就是它们之间的通信机制。一个 channels 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。每个 channel 都有一个特殊的类型,也就是 channels 可发送数据的类型,例如一个可以发送 int 类型数据的 channel 一般写为 chan int。
12.3.1 通道介绍
Go语言使用通信的方法代替共享内存,当一个资源需要在 goroutine 之间共享时,通道在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。在声明通道时,需要指定将要被共享的数据的类型。可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。上面介绍的通信方法就是使用通道(channel),如图12-1所示。
图12-1 通道(channel)
举个例子,在地铁站、食堂、洗手间等公共场所人很多的情况下,大家养成了排队的习惯,目的也是避免拥挤、插队导致的低效的资源使用和交换过程。代码与数据也是如此,多个 goroutine 为了争抢数据,势必造成执行的低效率,使用队列的方式是最高效的,channel 就是一种队列一样的结构。
通道的内部实现依赖于Go语言的调度器和运行时系统。当一个goroutine执行发送或接收操作时,如果通道没有准备好,那么该goroutine会被阻塞,直到通道准备好为止。此时,调度器会自动切换到其他goroutine,使得程序可以充分利用计算机的多核处理能力。同时,通道还具有同步机制,即发送和接收操作都是"原子性"的,任何时刻只有一个goroutine可以进行操作。这避免了竞态条件和死锁等问题,并保证了程序的正确性和可靠性。
12.3.2 创建通道
在Go语言中,和通道相关的函数如下:
- 函数make():用于创建一个通道对象,可以指定通道的类型和缓冲容量。
- 函数close():用于关闭一个通道对象,表示该通道不再进行数据传输。对于已经关闭的通道,发送操作会报错,接收操作则会返回通道的零值。
- 函数len():用于获取一个通道中当前存储的元素数量。
- 函数cap():用于获取一个通道的缓冲容量,如果通道没有缓冲,则返回0。
在Go语言中,使用函数make()创建一个通道实例,具体语法格式如下:
通道实例 := make(chan 数据类型)
- 数据类型:通道内传输的元素类型。
- 通道实例:通过make创建的通道句柄。
例如下面的例子:
ch1 := make(chan int) // 创建一个整型类型的通道
ch2 := make(chan interface{}) // 创建一个空接口类型的通道, 可以存放任意格式
type Equip struct{ /* 一些字段 */ }
ch2 := make(chan *Equip) // 创建Equip指针类型的通道, 可以存放*Equip
12.3.3 使用通道发送数据
在通道创建后,接下来就可以使用通道实现数据的发送和接收操作。其中使用通道发送数据的语法格式如下:
通道变量 <- 值
- 通道变量:通过make创建好的通道实例。
- 值:可以是变量、常量、表达式或者函数返回值等。值的类型必须与ch通道的元素类型一致。
在使用函数make()创建一个通道后,就可以使用<-向通道发送数据,例如下面的演示代码:
// 创建一个空接口通道
ch := make(chan interface{})
// 将0放入通道中
ch <- 0
// 将hello字符串放入通道中
ch <- "hello"
当把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go 程序运行时能智能地发现一些永远无法发送成功的语句并做出提示。请看下面的代码:
package main
func main() {
// 创建一个整型通道
ch := make(chan int)
// 尝试将0通过通道发送
ch <- 0
}
运行上述代码时会报错:
fatal error: all goroutines are asleep - deadlock!
上述报错的意思是:运行时发现所有的 goroutine(包括main)都处于等待 goroutine。也就是说所有goroutine中的channel并没有形成发送和接收对应的代码。
实例12-3:使用通道在不同的goroutine之间传递数据(源码路径:Go-codes\12\fa.go)
实例文件fa.go的具体实现代码如下所示。
import "fmt"
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
func main() {
ch := make(chan int)
go producer(ch)
for num := range ch {
fmt.Printf("Received %d\n", num)
}
}
对上述代码的具体说明如下:
- 首先定义了一个名为producer()的函数,用于生成一定数量的数字,并将它们写入通道ch中。注意,这里使用了单向通道(chan<- int),表示该通道仅用于发送数据。
- 在函数main()中创建了通道ch,然后使用关键字"go"和函数producer()调用来创建并启动一个goroutine,从而实现了并发执行任务。
- 接着,使用range循环遍历通道ch,以便接收所有产生的数字。
执行后会输出:
Received 0
Received 1
Received 2
Received 3
Received 4
注意:在上述例子中,我们使用了通道机制来进行goroutine之间的数据传递,但没有使用通道的同步机制。由于通道是阻塞的,当通道中没有可读取的数据时,接收操作会被自动阻塞,直到有新的数据可以读取为止。因此,在这个例子中,我们可以通过遍历通道来获取所有产生的数字,而无需进行额外的同步操作。
12.3.4 使用通道接收数据
在Go语言中,通道接收功能同样使用操作符“<-”实现,通道接收有如下3个特性:
(1)通道的收发操作在不同的两个 goroutine 间进行:由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行。
(2)接收将持续阻塞直到发送方发送数据:如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。
(3)每次接收一个元素:通道一次只能接收一个数据元素。
在Go语言中,使用通道接收数据的方法一共有以下 4 种写法。
1. 阻塞接收数据
当使用阻塞模式接收数据时,将接收变量作为操作符“<-”的左值,具体格式如下:
data := <-ch
执行上述语句时将会阻塞,直到接收到数据并赋值给 data 变量。
2. 非阻塞接收数据
当使用非阻塞方式从通道接收数据时,语句不会发生阻塞,具体格式如下:
data, ok := <-ch
- data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。
- ok:表示是否接收到数据。
非阻塞的通道接收方法可能造成高的 CPU 占用情况,因此使用非常少。如果需要实现接收超时检测,可以配合 select 语句和计时器 channel 进行。
3. 接收任意数据,忽略接收的数据
阻塞接收数据后,忽略从通道返回的数据,具体格式如下:
<-ch
执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步。
4. 循环接收
在使用通道接收数据时,还可以借用 for range 语句接收多个元素,具体格式如下:
for data := range ch {
}
通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据,数据类型就是通道的数据类型。通过 for 遍历获得的变量只有一个,即上面演示代码中的 data。
请看下面的实例,模拟实现了一个简单的游戏程序。在这个游戏中,每个玩家都会进行一定的操作,并根据随机生成的分数来计算得分。通道用于记录每个玩家的得分,并将它们传递给主程序,最后通过计算得出总得分。
实例12-4:文件的打开与关闭(源码路径:Go-codes\12\game.go)
实例文件game.go的具体实现代码如下所示。
import (
"fmt"
"math/rand"
"time"
)
func playGame(player string, ch chan int) {
fmt.Printf("%s is ready to play!\n", player)
time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
score := rand.Intn(100)
fmt.Printf("%s got a score of %d!\n", player, score)
ch <- score
}
func main() {
players := []string{"Alice", "Bob", "Charlie", "David", "Eve"}
scores := make(chan int, len(players))
for _, player := range players {
go playGame(player, scores)
}
totalScore := 0
for i := 0; i < len(players); i++ {
score := <-scores
totalScore += score
}
fmt.Printf("Total score: %d\n", totalScore)
}
对上述代码的具体说明如下:
- 首先定义了一个名为playGame()的函数来模拟游戏过程。
- 然后,创建了一个通道scores,并使用关键字"go"和playGame()函数调用来创建并启动多个goroutine,每个goroutine代表一个玩家,从而实现了并发执行游戏任务。
- 在每次游戏结束后,函数playGame()会将该玩家的得分写入通道scores中。
- 在main()函数中,通过遍历scores通道来收集所有玩家的得分,并计算出总得分。
- 最后,打印输出总得分。
由于每个玩家的得分都是随机生成的,所以每次运行程序时,输出结果都可能会略有不同。执行后会输出:
Eve is ready to play!
Alice is ready to play!
Bob is ready to play!
Charlie is ready to play!
Charlie got a score of 64!
David is ready to play!
David got a score of 69!
Bob got a score of 31!
Eve got a score of 44!
Alice got a score of 96!
Total score: 304
注意:在上述例子中,使用了通道机制来进行goroutine之间的数据同步和数据传递。在函数main()中,当所有玩家的得分都写入到scores通道中后,可以通过遍历scores通道的方式来收集所有得分,并保证它们的顺序与原始玩家列表的顺序一致。