12.3.5 单向channel(通道)
顾名思义,单向 channel 就是只能用于写入或者只能用于读取数据。Go语言的类型系统提供了单方向的 channel 类型,当然 channel 本身必然是同时支持读写的,否则根本没法用。假如一个 channel 真的只能读取数据,那么它肯定只会是空的,因为你没机会往里面写数据。同理,如果一个 channel 只允许写入数据,即使写进去了,也没有丝毫意义,因为没有办法读取到里面的数据。所谓的单向 channel 概念,其实只是对 channel 的一种使用限制。
当将一个 channel 变量传递到一个函数时,可以通过将其指定为单向 channel 变量,从而限制该函数中可以对此 channel 的操作,比如只能往这个 channel 中写入数据,或者只能从这个 channel 读取数据。
在Go语言中,声明单向 channel 变量的方法非常简单,只能写入数据的通道类型为chan<-,只能读取数据的通道类型为<-chan。具体语法格式如下:
var 通道实例 chan<- 元素类型 // 只能写入数据的通道
var 通道实例 <-chan 元素类型 // 只能读取数据的通道
- 元素类型:通道包含的元素类型。
- 通道实例:声明的通道变量。
实例12-5:模拟“石头剪刀布”(源码路径:Go-codes\12\shitou.go)
实例文件shitou.go的具体实现代码如下所示。
import (
"fmt"
"math/rand"
"time"
)
type gesture int
const (
Rock gesture = iota
Paper
Scissors
)
func (g gesture) String() string {
switch g {
case Rock:
return "Rock"
case Paper:
return "Paper"
case Scissors:
return "Scissors"
default:
return "Unknown"
}
}
func player(name string, in <-chan gesture, out chan<- gesture) {
for {
myGesture := gesture(rand.Intn(3))
fmt.Printf("%s: I choose %s\n", name, myGesture)
out <- myGesture
theirGesture := <-in
fmt.Printf("%s: They choose %s\n", name, theirGesture)
switch {
case myGesture == theirGesture:
fmt.Printf("%s: It's a tie!\n", name)
case myGesture == Rock && theirGesture == Scissors ||
myGesture == Paper && theirGesture == Rock ||
myGesture == Scissors && theirGesture == Paper:
fmt.Printf("%s: I win!\n", name)
default:
fmt.Printf("%s: They win!\n", name)
}
}
}
func main() {
aliceIn := make(chan gesture)
bobIn := make(chan gesture)
aliceOut := bobIn
bobOut := aliceIn
go player("Alice", aliceIn, aliceOut)
go player("Bob", bobIn, bobOut)
time.Sleep(5 * time.Second)
}
对上述代码的具体说明如下:
- 首先定义了一个名为player()的函数,用于模拟游戏中的一个玩家。每个玩家都会不断地选择手势,并将它们写入输出通道out中。同时,该函数还从输入通道in中读取对手的手势,并根据规则判断比赛结果。
- 在函数main()中创建了两个单向通道aliceIn和bobIn,分别用于表示Alice和Bob的输入通道。然后,创建了两个单向通道aliceOut和bobOut,并将它们交叉连接,用于表示Alice和Bob的输出通道。
- 最后,使用关键字"go"和函数player()调用来创建并启动两个goroutine,分别代表Alice和Bob。它们会不断地选择手势,并与对手进行比赛,直到程序运行5秒后退出。
执行后会输出:
Alice: I choose Rock
Bob: They choose Scissors
Alice: I win!
Bob: I choose Paper
Alice: They choose Paper
Bob: It's a tie!
Alice: I choose Rock
Bob: They choose Scissors
Alice: I win!
Bob: I choose Paper
Alice: They choose Rock
Bob: They win!
Alice: I choose Scissors
Bob: They choose Rock
Alice: They win!
Bob: I choose Scissors
Alice: They choose Paper
Bob: I win!
Alice: I choose Rock
Bob: They choose Paper
Bob: They win!
...
12.3.6 无缓冲的通道
在Go语言中,无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。这种类型的通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。如果两个 goroutine 没有同时准备好,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。
阻塞指的是由于某种原因数据没有到达,当前协程(线程)持续处于等待状态,直到条件满足才解除阻塞。同步指的是在两个或多个协程(线程)之间,保持数据内容一致性的机制。
下图12-2展示了两个 goroutine 利用无缓冲通道来共享一个值的过程。
图12-2 使用无缓冲的通道在 goroutine 之间同步
对图12-2的具体说明如下:
- 在第 1 步,两个 goroutine 都到达通道,但哪个都没有开始执行发送或者接收。在第 2 步,左侧的 goroutine 将它的手伸进了通道,这模拟了向通道发送数据的行为。这时,这个 goroutine 会在通道中被锁住,直到交换完成。
- 在第 3 步,右侧的 goroutine 将它的手放入通道,这模拟了从通道里接收数据。这个 goroutine 一样也会在通道中被锁住,直到交换完成。在第 4 步和第 5 步,进行交换,并最终在第 6 步,两个 goroutine 都将它们的手从通道里拿出来,这模拟了被锁住的 goroutine 得到释放。两个 goroutine 现在都可以去做别的事情了。
请看下面的例子,使用无缓冲的通道在两个 goroutine 之间同步交换数据。
实例12-6:模拟网球比赛(源码路径:Go-codes\12\wang.go)
在网球比赛中,两位选手会把球在两个人之间来回传递。选手总是处在以下两种状态之一,要么在等待接球,要么将球打向对方。可以使用两个 goroutine 来模拟网球比赛,并使用无缓冲的通道来模拟球的来回。实例文件wang.go的具体实现代码如下所示。
// 这个程序展示如何用无缓冲的通道来模拟
// 2 个goroutine 间的网球比赛
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// wg 用来等待程序结束
var wg sync.WaitGroup
func init() {
rand.Seed(time.Now().UnixNano())
}
// main 是所有Go 程序的入口
func main() {
// 创建一个无缓冲的通道
court := make(chan int)
// 计数加 2,表示要等待两个goroutine
wg.Add(2)
// 启动两个选手
go player("Nadal", court)
go player("Djokovic", court)
// 发球
court <- 1
// 等待游戏结束
wg.Wait()
}
// player 模拟一个选手在打网球
func player(name string, court chan int) {
// 在函数退出时调用Done 来通知main 函数工作已经完成
defer wg.Done()
for {
// 等待球被击打过来
ball, ok := <-court
if !ok {
// 如果通道被关闭,我们就赢了
fmt.Printf("Player %s Won\n", name)
return
}
// 选随机数,然后用这个数来判断我们是否丢球
n := rand.Intn(100)
if n%13 == 0 {
fmt.Printf("Player %s Missed\n", name)
// 关闭通道,表示我们输了
close(court)
return
}
// 显示击球数,并将击球数加1
fmt.Printf("Player %s Hit %d\n", name, ball)
ball++
// 将球打向对手
court <- ball
}
}
对上述代码的具体说明如下:
- 首先创建了一个 int 类型的无缓冲的通道,让两个 goroutine 在击球时能够互相同步。然后创建了参与比赛的两个 goroutine,在这个时候,两个 goroutine 都阻塞住等待击球。
- 将球发到通道里,程序开始执行这个比赛,直到某个 goroutine 输掉比赛。
- 实现了一个无限循环的 for 语句,在这个循环里,是玩游戏的过程。
- goroutine 从通道接收数据,用来表示等待接球。这个接收动作会锁住 goroutine,直到有数据发送到通道里。通道的接收动作返回时。
- 检测 ok 标志是否为 false。如果这个值是 false,表示通道已经被关闭,游戏结束。
- 产生一个随机数,用来决定 goroutine 是否击中了球。
- 如果某个 goroutine 没有打中球,关闭通道。之后两个 goroutine 都会返回,通过 defer 声明的 Done 会被执行,程序终止。
- 如果击中了球 ball 的值会递增 1,并在第 67 行,将 ball 作为球重新放入通道,发送给另一位选手。在这个时刻,两个 goroutine 都会被锁住,直到交换完成。
执行后会输出:
Player Djokovic Hit 1
Player Nadal Hit 2
Player Djokovic Missed
Player Nadal Won
12.3.7 带缓冲的通道
在Go语言中,有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。
在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送过程,并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时将不会发生阻塞,直到通道中没有数据可读时,通道将会再度阻塞。
无缓冲通道保证收发过程同步。无缓冲收发过程类似于快递员给你电话让你下楼取快递,整个递交快递的过程是同步发生的,你和快递员不见不散。但这样做快递员就必须等待所有人下楼完成操作后才能完成所有投递工作。如果快递员将快递放入快递柜中,并通知用户来取,快递员和用户就成了异步收发过程,效率可以有明显的提升。带缓冲的通道就是这样的一个“快递柜”。
在Go程序中,创建带缓冲的通道的格式如下
通道实例 := make(chan 通道类型, 缓冲大小)
- 通道类型:和无缓冲通道用法一致,影响通道发送和接收的数据类型。
- 缓冲大小:决定通道最多可以保存的元素数量。
- 通道实例:被创建出的通道实例。
实例12-7:生产者、消费者模型(源码路径:Go-codes\12\sheng.go)
实例文件sheng.go的具体实现代码如下所示。
import (
"fmt"
"sync"
)
func producer(ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 5; i++ {
ch <- i
fmt.Printf("Produced %d\n", i)
}
close(ch)
}
func consumer(ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range ch {
fmt.Printf("Consumed %d\n", num)
}
}
func main() {
ch := make(chan int, 3)
var wg sync.WaitGroup
wg.Add(2)
go producer(ch, &wg)
go consumer(ch, &wg)
wg.Wait()
}
对上述代码的具体说明如下:
- 首先定义了两个函数producer()和consumer(),用于充当生产者和消费者。函数producer()会生成一定数量的数字,并将它们写入缓冲区为3的通道ch中,同时输出相应的提示信息。函数consumer()则从通道ch中读取数据,并输出相应的提示信息。
- 在函数main()中创建了一个缓冲区为3的通道ch,并使用关键字"go"和producer()、consumer()函数调用来创建并启动两个goroutine。
- 最后,使用sync.WaitGroup来等待goroutine的完成,从而确保所有数字都被正确地生成和消费。
- 需要注意的是,在例子中使用了带缓冲的通道来增加程序的灵活性和吞吐量。通过设置缓冲区大小,我们可以预先为通道分配一定数量的存储空间,从而减少通道阻塞的可能性,并增加程序对生产-消费模式的支持。
执行后会输出:
Produced 0
Produced 1
Produced 2
Produced 3
Consumed 0
Consumed 1
Consumed 2
Consumed 3
Consumed 4
Produced 4