第37章:通道用例大全



通道用例大全

在阅读本文之前,请先阅读通道(第21章)一文。 那篇文章详细地解释了通道类型和通道值,以及各种通道操作的规则细节。 一个Go新手程序员可能需要反复多次阅读那篇文章和当前这篇文章来精通Go通道编程。

本文余下的内容将展示很多通道用例。 希望这篇文章能够说服你接收下面的观点:

  • 使用通道进行异步和并发编程是简单和惬意的;
  • 通道同步技术比被很多其它语言采用的其它同步方案(比如角色模型async/await模式)有着更多的应用场景和更多的使用变种。

请注意,本文的目的是展示尽量多的通道用例。但是,我们应该知道通道并不是Go支持的唯一同步技术,并且通道并不是在任何情况下都是最佳的同步技术。 请阅读原子操作(第40章)和其它并发同步技术(第39章)来了解更多的Go支持的同步技术。

将通道用做future/promise

很多其它流行语言支持future/promise来实现异步(并发)编程。 Future/promise常常用在请求/回应场合。

返回单向接收通道做为函数返回结果

在下面这个例子中,sumSquares函数调用的两个实参请求并发进行。 每个通道读取操作将阻塞到请求返回结果为止。 两个实参总共需要大约3秒钟(而不是6秒钟)准备完毕(以较慢的一个为准)。

  1. package main
  2. import (
  3. "time"
  4. "math/rand"
  5. "fmt"
  6. )
  7. func longTimeRequest() <-chan int32 {
  8. r := make(chan int32)
  9. go func() {
  10. time.Sleep(time.Second * 3) // 模拟一个工作负载
  11. r <- rand.Int31n(100)
  12. }()
  13. return r
  14. }
  15. func sumSquares(a, b int32) int32 {
  16. return a*a + b*b
  17. }
  18. func main() {
  19. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  20. a, b := longTimeRequest(), longTimeRequest()
  21. fmt.Println(sumSquares(<-a, <-b))
  22. }

将单向发送通道类型用做函数实参

和上例一样,在下面这个例子中,sumSquares函数调用的两个实参的请求也是并发进行的。 和上例不同的是longTimeRequest函数接收一个单向发送通道类型参数而不是返回一个单向接收通道结果。

  1. package main
  2. import (
  3. "time"
  4. "math/rand"
  5. "fmt"
  6. )
  7. func longTimeRequest(r chan<- int32) {
  8. time.Sleep(time.Second * 3) // 模拟一个工作负载
  9. r <- rand.Int31n(100)
  10. }
  11. func sumSquares(a, b int32) int32 {
  12. return a*a + b*b
  13. }
  14. func main() {
  15. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  16. ra, rb := make(chan int32), make(chan int32)
  17. go longTimeRequest(ra)
  18. go longTimeRequest(rb)
  19. fmt.Println(sumSquares(<-ra, <-rb))
  20. }

对于上面这个特定的例子,我们可以只使用一个通道来接收回应结果,因为两个参数的作用是对等的。

  1. ...
  2. results := make(chan int32, 2) // 缓冲与否不重要
  3. go longTimeRequest(results)
  4. go longTimeRequest(results)
  5. fmt.Println(sumSquares(<-results, <-results))
  6. }

这可以看作是后面将要提到的数据聚合的一个应用。

采用最快回应

本用例可以看作是上例中只使用一个通道变种的增强。

有时候,一份数据可能同时从多个数据源获取。这些数据源将返回相同的数据。 因为各种因素,这些数据源的回应速度参差不一,甚至某个特定数据源的多次回应速度之间也可能相差很大。 同时从多个数据源获取一份相同的数据可以有效保障低延迟。我们只需采用最快的回应并舍弃其它较慢回应。

注意:如果有N个数据源,为了防止被舍弃的回应对应的协程永久阻塞,则传输数据用的通道必须为一个容量至少为N-1的缓冲通道。

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. "math/rand"
  6. )
  7. func source(c chan<- int32) {
  8. ra, rb := rand.Int31(), rand.Intn(3) + 1
  9. // 睡眠1秒/2秒/3秒
  10. time.Sleep(time.Duration(rb) * time.Second)
  11. c <- ra
  12. }
  13. func main() {
  14. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  15. startTime := time.Now()
  16. c := make(chan int32, 5) // 必须用一个缓冲通道
  17. for i := 0; i < cap(c); i++ {
  18. go source(c)
  19. }
  20. rnd := <- c // 只有第一个回应被使用了
  21. fmt.Println(time.Since(startTime))
  22. fmt.Println(rnd)
  23. }

“采用最快回应”用例还有一些其它实现方式,本文后面将会谈及。

更多“请求/回应”用例变种

做为函数参数和返回结果使用的通道可以是缓冲的,从而使得请求协程不需阻塞到它所发送的数据被接收为止。

有时,一个请求可能并不保证返回一份有效的数据。对于这种情形,我们可以使用一个形如struct{v T; err error}的结构体类型或者一个空接口类型做为通道的元素类型以用来区分回应的值是否有效。

有时,一个请求可能需要比预期更长的用时才能回应,甚至永远都得不到回应。 我们可以使用本文后面将要介绍的超时机制来应对这样的情况。

有时,回应方可能会不断地返回一系列值,这也同时属于后面将要介绍的数据流的一个用例。

使用通道实现通知

通知可以被看作是特殊的请求/回应用例。在一个通知用例中,我们并不关心回应的值,我们只关心回应是否已发生。 所以我们常常使用空结构体类型struct{}来做为通道的元素类型,因为空结构体类型的尺寸为零,能够节省一些内存(虽然常常很少量)。

向一个通道发送一个值来实现单对单通知

我们已知道,如果一个通道中无值可接收,则此通道上的下一个接收操作将阻塞到另一个协程发送一个值到此通道为止。 所以一个协程可以向此通道发送一个值来通知另一个等待着从此通道接收数据的协程。

在下面这个例子中,通道done被用来做为一个信号通道来实现单对单通知。

  1. package main
  2. import (
  3. "crypto/rand"
  4. "fmt"
  5. "os"
  6. "sort"
  7. )
  8. func main() {
  9. values := make([]byte, 32 * 1024 * 1024)
  10. if _, err := rand.Read(values); err != nil {
  11. fmt.Println(err)
  12. os.Exit(1)
  13. }
  14. done := make(chan struct{}) // 也可以是缓冲的
  15. // 排序协程
  16. go func() {
  17. sort.Slice(values, func(i, j int) bool {
  18. return values[i] < values[j]
  19. })
  20. done <- struct{}{} // 通知排序已完成
  21. }()
  22. // 并发地做一些其它事情...
  23. <- done // 等待通知
  24. fmt.Println(values[0], values[len(values)-1])
  25. }

从一个通道接收一个值来实现单对单通知

如果一个通道的数据缓冲队列已满(非缓冲的通道的数据缓冲队列总是满的)但它的发送协程队列为空,则向此通道发送一个值将阻塞,直到另外一个协程从此通道接收一个值为止。 所以我们可以通过从一个通道接收数据来实现单对单通知。一般我们使用非缓冲通道来实现这样的通知。

这种通知方式不如上例中介绍的方式使用得广泛。

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. done := make(chan struct{})
  8. // 此信号通道也可以缓冲为1。如果这样,则在下面
  9. // 这个协程创建之前,我们必须向其中写入一个值。
  10. go func() {
  11. fmt.Print("Hello")
  12. // 模拟一个工作负载。
  13. time.Sleep(time.Second * 2)
  14. // 使用一个接收操作来通知主协程。
  15. <- done
  16. }()
  17. done <- struct{}{} // 阻塞在此,等待通知
  18. fmt.Println(" world!")
  19. }

另一个事实是,上面的两种单对单通知方式其实并没有本质的区别。 它们都可以被概括为较快者等待较慢者发出通知。

多对单和单对多通知

略微扩展一下上面两个用例,我们可以很轻松地实现多对单和单对多通知。

  1. package main
  2. import "log"
  3. import "time"
  4. type T = struct{}
  5. func worker(id int, ready <-chan T, done chan<- T) {
  6. <-ready // 阻塞在此,等待通知
  7. log.Print("Worker#", id, "开始工作")
  8. // 模拟一个工作负载。
  9. time.Sleep(time.Second * time.Duration(id+1))
  10. log.Print("Worker#", id, "工作完成")
  11. done <- T{} // 通知主协程(N-to-1)
  12. }
  13. func main() {
  14. log.SetFlags(0)
  15. ready, done := make(chan T), make(chan T)
  16. go worker(0, ready, done)
  17. go worker(1, ready, done)
  18. go worker(2, ready, done)
  19. // 模拟一个初始化过程
  20. time.Sleep(time.Second * 3 / 2)
  21. // 单对多通知
  22. ready <- T{}; ready <- T{}; ready <- T{}
  23. // 等待被多对单通知
  24. <-done; <-done; <-done
  25. }

事实上,上例中展示的多对单和单对多通知实现方式在实践中用的并不多。 在实践中,我们多使用sync.WaitGroup来实现多对单通知,使用关闭一个通道的方式来实现单对多通知(详见下一个用例)。

通过关闭一个通道来实现群发通知

上一个用例中的单对多通知实现在实践中很少用,因为通过关闭一个通道的方式在来实现单对多通知的方式更简单。 我们已经知道,从一个已关闭的通道可以接收到无穷个值,我们可以利用这一特性来实现群发通知。

我们可以把上一个例子中的三个数据发送操作ready <- struct{}{}替换为一个通道关闭操作close(ready)来达到同样的单对多通知效果。

  1. ...
  2. close(ready) // 群发通知
  3. ...

当然,我们也可以通过关闭一个通道来实现单对单通知。事实上,关闭通道是实践中用得最多通知实现方式。

从一个已关闭的通道可以接收到无穷个值这一特性也将被用在很多其它在后面将要介绍的用例中。 实际上,这一特性被广泛地使用于标准库包中。比如,context标准库包使用了此特性来传达操作取消消息。

定时通知(timer)

用通道实现一个一次性的定时通知器是很简单的。 下面是一个自定义实现:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func AfterDuration(d time.Duration) <- chan struct{} {
  7. c := make(chan struct{}, 1)
  8. go func() {
  9. time.Sleep(d)
  10. c <- struct{}{}
  11. }()
  12. return c
  13. }
  14. func main() {
  15. fmt.Println("Hi!")
  16. <- AfterDuration(time.Second)
  17. fmt.Println("Hello!")
  18. <- AfterDuration(time.Second)
  19. fmt.Println("Bye!")
  20. }

事实上,time标准库包中的After函数提供了和上例中AfterDuration同样的功能。 在实践中,我们应该尽量使用time.After函数以使代码看上去更干净。

注意,操作<-time.After(aDuration)将使当前协程进入阻塞状态,而一个time.Sleep(aDuration)函数调用不会如此。

<-time.After(aDuration)经常被使用在后面将要介绍的超时机制实现中。

将通道用做互斥锁(mutex)

上面的某个例子提到了容量为1的缓冲通道可以用做一次性二元信号量)。 事实上,容量为1的缓冲通道也可以用做多次性二元信号量(即互斥锁)尽管这样的互斥锁效率不如sync标准库包中提供的互斥锁高效。

有两种方式将一个容量为1的缓冲通道用做互斥锁:

  1. 通过发送操作来加锁,通过接收操作来解锁;
  2. 通过接收操作来加锁,通过发送操作来解锁。

下面是一个通过发送操作来加锁的例子。

  1. package main
  2. import "fmt"
  3. func main() {
  4. mutex := make(chan struct{}, 1) // 容量必须为1
  5. counter := 0
  6. increase := func() {
  7. mutex <- struct{}{} // 加锁
  8. counter++
  9. <-mutex // 解锁
  10. }
  11. increase1000 := func(done chan<- struct{}) {
  12. for i := 0; i < 1000; i++ {
  13. increase()
  14. }
  15. done <- struct{}{}
  16. }
  17. done := make(chan struct{})
  18. go increase1000(done)
  19. go increase1000(done)
  20. <-done; <-done
  21. fmt.Println(counter) // 2000
  22. }

下面是一个通过接收操作来加锁的例子,其中只显示了相对于上例而修改了的部分。

  1. ...
  2. func main() {
  3. mutex := make(chan struct{}, 1)
  4. mutex <- struct{}{} // 此行是必需的
  5. counter := 0
  6. increase := func() {
  7. <-mutex // 加锁
  8. counter++
  9. mutex <- struct{}{} // 解锁
  10. }
  11. ...

将通道用做计数信号量(counting semaphore)

缓冲通道可以被用做计数信号量)。 计数信号量可以被视为多主锁。如果一个缓冲通道的容量为N,那么它可以被看作是一个在任何时刻最多可有N个主人的锁。 上面提到的二元信号量是特殊的计数信号量,每个二元信号量在任一时刻最多只能有一个主人。

计数信号量经常被使用于限制最大并发数。

和将通道用做互斥锁一样,也有两种方式用来获取一个用做计数信号量的通道的一份所有权。

  1. 通过发送操作来获取所有权,通过接收操作来释放所有权;
  2. 通过接收操作来获取所有权,通过发送操作来释放所有权。

下面是一个通过接收操作来获取所有权的例子:

  1. package main
  2. import (
  3. "log"
  4. "time"
  5. "math/rand"
  6. )
  7. type Seat int
  8. type Bar chan Seat
  9. func (bar Bar) ServeCustomer(c int) {
  10. log.Print("顾客#", c, "进入酒吧")
  11. seat := <- bar // 需要一个位子来喝酒
  12. log.Print("++ customer#", c, " drinks at seat#", seat)
  13. log.Print("++ 顾客#", c, "在第", seat, "个座位开始饮酒")
  14. time.Sleep(time.Second * time.Duration(2 + rand.Intn(6)))
  15. log.Print("-- 顾客#", c, "离开了第", seat, "个座位")
  16. bar <- seat // 释放座位,离开酒吧
  17. }
  18. func main() {
  19. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  20. bar24x7 := make(Bar, 10) // 此酒吧有10个座位
  21. // 摆放10个座位。
  22. for seatId := 0; seatId < cap(bar24x7); seatId++ {
  23. bar24x7 <- Seat(seatId) // 均不会阻塞
  24. }
  25. for customerId := 0; ; customerId++ {
  26. time.Sleep(time.Second)
  27. go bar24x7.ServeCustomer(customerId)
  28. }
  29. for {time.Sleep(time.Second)} // 睡眠不属于阻塞状态
  30. }

在上例中,只有获得一个座位的顾客才能开始饮酒。 所以在任一时刻同时在喝酒的顾客数不会超过座位数10。

上例main函数中的最后一行for循环是为了防止程序退出。 后面将介绍一种更好的实现此目的的方法。

在上例中,尽管在任一时刻同时在喝酒的顾客数不会超过座位数10,但是在某一时刻可能有多于10个顾客进入了酒吧,因为某些顾客在排队等位子。 在上例中,每个顾客对应着一个协程。虽然协程的开销比系统线程小得多,但是如果协程的数量很多,则它们的总体开销还是不能忽略不计的。 所以,最好当有空位的时候才创建顾客协程。

  1. ... // 省略了和上例相同的代码
  2. func (bar Bar) ServeCustomerAtSeat(c int, seat Seat) {
  3. log.Print("++ 顾客#", c, "在第", seat, "个座位开始饮酒")
  4. time.Sleep(time.Second * time.Duration(2 + rand.Intn(6)))
  5. log.Print("-- 顾客#", c, "离开了第", seat, "个座位")
  6. bar <- seat // 释放座位,离开酒吧
  7. }
  8. func main() {
  9. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  10. bar24x7 := make(Bar, 10)
  11. for seatId := 0; seatId < cap(bar24x7); seatId++ {
  12. bar24x7 <- Seat(seatId)
  13. }
  14. // 这个for循环和上例不一样。
  15. for customerId := 0; ; customerId++ {
  16. time.Sleep(time.Second)
  17. seat := <- bar24x7 // 需要一个空位招待顾客
  18. go bar24x7.ServeCustomerAtSeat(customerId, seat)
  19. }
  20. for {time.Sleep(time.Second)}
  21. }

在上面这个修改后的例子中,在任一时刻最多只有10个顾客协程在运行(但是在程序的生命期内,仍旧会有大量的顾客协程不断被创建和销毁)。

在下面这个更加高效的实现中,在程序的生命期内最多只会有10个顾客协程被创建出来。

  1. ... // 省略了和上例相同的代码
  2. func (bar Bar) ServeCustomerAtSeat(consumers chan int) {
  3. for c := range consumers {
  4. seatId := <- bar
  5. log.Print("++ 顾客#", c, "在第", seatId, "个座位开始饮酒")
  6. time.Sleep(time.Second * time.Duration(2 + rand.Intn(6)))
  7. log.Print("-- 顾客#", c, "离开了第", seatId, "个座位")
  8. bar <- seatId // 释放座位,离开酒吧
  9. }
  10. }
  11. func main() {
  12. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  13. bar24x7 := make(Bar, 10)
  14. for seatId := 0; seatId < cap(bar24x7); seatId++ {
  15. bar24x7 <- Seat(seatId)
  16. }
  17. consumers := make(chan int)
  18. for i := 0; i < cap(bar24x7); i++ {
  19. go bar24x7.ServeCustomerAtSeat(consumers)
  20. }
  21. for customerId := 0; ; customerId++ {
  22. time.Sleep(time.Second)
  23. consumers <- customerId
  24. }
  25. }

题外话:当然,如果我们并不关心座位号(这种情况在编程实践中很常见),则实际上bar24x7计数信号量是完全不需要的:

  1. ... // 省略了和上例相同的代码
  2. func ServeCustomer(consumers chan int) {
  3. for c := range consumers {
  4. log.Print("++ 顾客#", c, "开始在酒吧饮酒")
  5. time.Sleep(time.Second * time.Duration(2 + rand.Intn(6)))
  6. log.Print("-- 顾客#", c, "离开了酒吧")
  7. }
  8. }
  9. func main() {
  10. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  11. const BarSeatCount = 10
  12. consumers := make(chan int)
  13. for i := 0; i < BarSeatCount; i++ {
  14. go ServeCustomer(consumers)
  15. }
  16. for customerId := 0; ; customerId++ {
  17. time.Sleep(time.Second)
  18. consumers <- customerId
  19. }
  20. }

通过发送操作来获取所有权的实现相对简单一些,省去了摆放座位的步骤。

  1. package main
  2. import (
  3. "log"
  4. "time"
  5. "math/rand"
  6. )
  7. type Customer struct{id int}
  8. type Bar chan Customer
  9. func (bar Bar) ServeCustomer(c Customer) {
  10. log.Print("++ 顾客#", c.id, "开始饮酒")
  11. time.Sleep(time.Second * time.Duration(3 + rand.Intn(16)))
  12. log.Print("-- 顾客#", c.id, "离开酒吧")
  13. <- bar // 离开酒吧,腾出位子
  14. }
  15. func main() {
  16. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  17. bar24x7 := make(Bar, 10) // 最对同时服务10位顾客
  18. for customerId := 0; ; customerId++ {
  19. time.Sleep(time.Second * 2)
  20. customer := Customer{customerId}
  21. bar24x7 <- customer // 等待进入酒吧
  22. go bar24x7.ServeCustomer(customer)
  23. }
  24. for {time.Sleep(time.Second)}
  25. }

对话(或称乒乓)

两个协程可以通过一个通道进行对话,整个过程宛如打乒乓球一样。 下面是一个这样的例子,它将打印出一系列斐波那契(Fibonacci)数。

  1. package main
  2. import "fmt"
  3. import "time"
  4. import "os"
  5. type Ball uint64
  6. func Play(playerName string, table chan Ball) {
  7. var lastValue Ball = 1
  8. for {
  9. ball := <- table // 接球
  10. fmt.Println(playerName, ball)
  11. ball += lastValue
  12. if ball < lastValue { // 溢出结束
  13. os.Exit(0)
  14. }
  15. lastValue = ball
  16. table <- ball // 回球
  17. time.Sleep(time.Second)
  18. }
  19. }
  20. func main() {
  21. table := make(chan Ball)
  22. go func() {
  23. table <- 1 // (裁判)发球
  24. }()
  25. go Play("A:", table)
  26. Play("B:", table)
  27. }

使用通道传送传输通道

一个通道类型的元素类型可以是另一个通道类型。 在下面这个例子中, 单向发送通道类型chan<- int是另一个通道类型chan chan<- int的元素类型。

  1. package main
  2. import "fmt"
  3. var counter = func (n int) chan<- chan<- int {
  4. requests := make(chan chan<- int)
  5. go func() {
  6. for request := range requests {
  7. if request == nil {
  8. n++ // 递增计数
  9. } else {
  10. request <- n // 返回当前计数
  11. }
  12. }
  13. }()
  14. return requests // 隐式转换到类型chan<- (chan<- int)
  15. }(0)
  16. func main() {
  17. increase1000 := func(done chan<- struct{}) {
  18. for i := 0; i < 1000; i++ {
  19. counter <- nil
  20. }
  21. done <- struct{}{}
  22. }
  23. done := make(chan struct{})
  24. go increase1000(done)
  25. go increase1000(done)
  26. <-done; <-done
  27. request := make(chan int, 1)
  28. counter <- request
  29. fmt.Println(<-request) // 2000
  30. }

尽管对于上面这个用例来说,使用通道传送传输通道这种方式并非是最有效的实现方式,但是这种方式肯定有最适合它的用武之地。

检查通道的长度和容量

我们可以使用内置函数caplen来查看一个通道的容量和当前长度。 但是在实践中我们很少这样做。我们很少使用内置函数cap的原因是一个通道的容量常常是已知的或者不重要的。 我们很少使用内置函数len的原因是一个len调用的结果并不能总能准确地反映出的一个通道的当前长度。

但有时确实有一些场景需要调用这两个函数。比如,有时一个协程欲将一个未关闭的并且不会再向其中发送数据的缓冲通道中的所有数据接收出来,在确保只有此一个协程从此通道接收数据的情况下,我们可以用下面的代码来实现之:

  1. for len(c) > 0 {
  2. value := <-c
  3. // 使用value ...
  4. }

我们也可以用本文后面将要介绍的尝试接收机制来实现这一需求。两者的运行效率差不多,但尝试接收机制的优点是多个协程可以并发地进行读取操作。

有时一个协程欲将一个缓冲通道写满而又不阻塞,在确保只有此一个协程向此通道发送数据的情况下,我们可以用下面的代码实现这一目的:

  1. for len(c) < cap(c) {
  2. c <- aValue
  3. }

当然,我们也可以使用后面将要介绍的尝试发送机制来实现这一需求。

使当前协程永久阻塞

Go中的选择机制(select)(第21章)是一个非常独特的特性。它给并发编程带来了很多新的模式和技巧。

我们可以用一个无分支的select流程控制代码块使当前协程永久处于阻塞状态。 这是select流程控制的最简单的应用。 事实上,上面很多例子中的for {time.Sleep(time.Second)}都可以换为select{}

一般,select{}用在主协程中以防止程序退出。

一个例子:

  1. package main
  2. import "runtime"
  3. func DoSomething() {
  4. for {
  5. // 做点什么...
  6. runtime.Gosched() // 防止本协程霸占CPU不放
  7. }
  8. }
  9. func main() {
  10. go DoSomething()
  11. go DoSomething()
  12. select{}
  13. }

顺便说一句,另外还有一些使当前协程永久阻塞的方法(第46章),但是select{}是最简单的方法。

尝试发送和尝试接收

含有一个default分支和一个case分支的select代码块可以被用做一个尝试发送或者尝试接收操作,取决于case关键字后跟随的是一个发送操作还是一个接收操作。

  • 如果case关键字后跟随的是一个发送操作,则此select代码块为一个尝试发送操作。 如果case分支的发送操作是阻塞的,则default分支将被执行,发送失败;否则发送成功,case分支得到执行。
  • 如果case关键字后跟随的是一个接收操作,则此select代码块为一个尝试接收操作。 如果case分支的接收操作是阻塞的,则default分支将被执行,接收失败;否则接收成功,case分支得到执行。

尝试发送和尝试接收代码块永不阻塞。

标准编译器对尝试发送和尝试接收代码块做了特别的优化,使得它们的执行效率比多case分支的普通select代码块执行效率高得多。

下例演示了尝试发送和尝试接收代码块的工作原理。

  1. package main
  2. import "fmt"
  3. func main() {
  4. type Book struct{id int}
  5. bookshelf := make(chan Book, 3)
  6. for i := 0; i < cap(bookshelf) * 2; i++ {
  7. select {
  8. case bookshelf <- Book{id: i}:
  9. fmt.Println("成功将书放在书架上", i)
  10. default:
  11. fmt.Println("书架已经被占满了")
  12. }
  13. }
  14. for i := 0; i < cap(bookshelf) * 2; i++ {
  15. select {
  16. case book := <-bookshelf:
  17. fmt.Println("成功从书架上取下一本书", book.id)
  18. default:
  19. fmt.Println("书架上已经没有书了")
  20. }
  21. }
  22. }

输出结果:

  1. 成功将书放在书架上 0
  2. 成功将书放在书架上 1
  3. 成功将书放在书架上 2
  4. 书架已经被占满了
  5. 书架已经被占满了
  6. 书架已经被占满了
  7. 成功从书架上取下一本书 0
  8. 成功从书架上取下一本书 1
  9. 成功从书架上取下一本书 2
  10. 书架上已经没有书了
  11. 书架上已经没有书了
  12. 书架上已经没有书了

后面的很多用例还要用到尝试发送和尝试接收代码块。

无阻塞地检查一个通道是否已经关闭

假设我们可以保证没有任何协程会向一个通道发送数据,则我们可以使用下面的代码来(并发安全地)检查此通道是否已经关闭,此检查不会阻塞当前协程。

  1. func IsClosed(c chan T) bool {
  2. select {
  3. case <-c:
  4. return true
  5. default:
  6. }
  7. return false
  8. }

此方法常用来查看某个期待中的通知是否已经来临。此通知将由另一个协程通过关闭一个通道来发送。

峰值限制(peak/burst limiting)

通道用做计数信号量用例和通道尝试(发送或者接收)操作结合起来可用实现峰值限制。 峰值限制的目的是防止过大的并发请求数。

下面是对将通道用做计数信号量一节中的最后一个例子的简单修改,从而使得顾客不再等待而是离去或者寻找其它酒吧。

  1. ...
  2. bar24x7 := make(Bar, 10) // 此酒吧只能同时招待10个顾客
  3. for customerId := 0; ; customerId++ {
  4. time.Sleep(time.Second)
  5. consumer := Consumer{customerId}
  6. select {
  7. case bar24x7 <- consumer: // 试图进入此酒吧
  8. go bar24x7.ServeConsumer(consumer)
  9. default:
  10. log.Print("顾客#", customerId, "不愿等待而离去")
  11. }
  12. }
  13. ...

另一种“采用最快回应”的实现方式

在上面的“采用最快回应”用例一节已经提到,我们也可以使用选择机制来实现“采用最快回应”用例。 每个数据源协程只需使用一个缓冲为1的通道并向其尝试发送回应数据即可。示例代码如下:

  1. package main
  2. import (
  3. "fmt"
  4. "math/rand"
  5. "time"
  6. )
  7. func source(c chan<- int32) {
  8. ra, rb := rand.Int31(), rand.Intn(3)+1
  9. // 休眠1秒/2秒/3秒
  10. time.Sleep(time.Duration(rb) * time.Second)
  11. select {
  12. case c <- ra:
  13. default:
  14. }
  15. }
  16. func main() {
  17. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  18. c := make(chan int32, 1) // 此通道容量必须至少为1
  19. for i := 0; i < 5; i++ {
  20. go source(c)
  21. }
  22. rnd := <-c // 只采用第一个成功发送的回应数据
  23. fmt.Println(rnd)
  24. }

注意,使用选择机制来实现“采用最快回应”的代码中使用的通道的容量必须至少为1,以保证最快回应总能够发送成功。 否则,如果数据请求者因为种种原因未及时准备好接收,则所有回应者的尝试发送都将失败,从而所有回应的数据都将被错过。

第三种“采用最快回应”的实现方式

如果一个“采用最快回应”用例中的数据源的数量很少,比如两个或三个,我们可以让每个数据源使用一个单独的缓冲通道来回应数据,然后使用一个select代码块来同时接收这三个通道。 示例代码如下:

  1. package main
  2. import (
  3. "fmt"
  4. "math/rand"
  5. "time"
  6. )
  7. func source() <-chan int32 {
  8. c := make(chan int32, 1) // 必须为一个缓冲通道
  9. go func() {
  10. ra, rb := rand.Int31(), rand.Intn(3)+1
  11. time.Sleep(time.Duration(rb) * time.Second)
  12. c <- ra
  13. }()
  14. return c
  15. }
  16. func main() {
  17. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  18. var rnd int32
  19. // 阻塞在此直到某个数据源率先回应。
  20. select{
  21. case rnd = <-source():
  22. case rnd = <-source():
  23. case rnd = <-source():
  24. }
  25. fmt.Println(rnd)
  26. }

注意:如果上例中使用的通道是非缓冲的,未被选中的case分支对应的两个source函数调用中开辟的协程将处于永久阻塞状态,从而造成内存泄露(第45章)。

本小节和上一小节中展示的两种方法也可以用来实现多对单通知。

超时机制(timeout)

在一些请求/回应用例中,一个请求可能因为种种原因导致需要超出预期的时长才能得到回应,有时甚至永远得不到回应。 对于这样的情形,我们可以使用一个超时方案给请求者返回一个错误信息。 使用选择机制可以很轻松地实现这样的一个超时方案。

下面这个例子展示了如何实现一个支持超时设置的请求:

  1. func requestWithTimeout(timeout time.Duration) (int, error) {
  2. c := make(chan int)
  3. go doRequest(c) // 可能需要超出预期的时长回应
  4. select {
  5. case data := <-c:
  6. return data, nil
  7. case <-time.After(timeout):
  8. return 0, errors.New("超时了!")
  9. }
  10. }

脉搏器(ticker)

我们可以使用尝试发送操作来实现一个每隔一定时间发送一个信号的脉搏器。

  1. package main
  2. import "fmt"
  3. import "time"
  4. func Tick(d time.Duration) <-chan struct{} {
  5. c := make(chan struct{}, 1) // 容量最好为1
  6. go func() {
  7. for {
  8. time.Sleep(d)
  9. select {
  10. case c <- struct{}{}:
  11. default:
  12. }
  13. }
  14. }()
  15. return c
  16. }
  17. func main() {
  18. t := time.Now()
  19. for range Tick(time.Second) {
  20. fmt.Println(time.Since(t))
  21. }
  22. }

事实上,time标准库包中的Tick函数提供了同样的功能,但效率更高。 我们应该尽量使用标准库包中的实现。

速率限制(rate limiting)

上面已经展示了如何使用尝试发送实现峰值限制。 同样地,我们也可以使用使用尝试机制来实现速率限制,但需要前面刚提到的定时器实现的配合。 速率限制常用来限制吞吐和确保在一段时间内的资源使用不会超标。

下面的例子借鉴了官方Go维基中的例子。 在此例中,任何一分钟时段内处理的请求数不会超过200。

  1. package main
  2. import "fmt"
  3. import "time"
  4. type Request interface{}
  5. func handle(r Request) {fmt.Println(r.(int))}
  6. const RateLimitPeriod = time.Minute
  7. const RateLimit = 200 // 任何一分钟内最多处理200个请求
  8. func handleRequests(requests <-chan Request) {
  9. quotas := make(chan time.Time, RateLimit)
  10. go func() {
  11. tick := time.NewTicker(RateLimitPeriod / RateLimit)
  12. defer tick.Stop()
  13. for t := range tick.C {
  14. select {
  15. case quotas <- t:
  16. default:
  17. }
  18. }
  19. }()
  20. for r := range requests {
  21. <-quotas
  22. go handle(r)
  23. }
  24. }
  25. func main() {
  26. requests := make(chan Request)
  27. go handleRequests(requests)
  28. // time.Sleep(time.Minute)
  29. for i := 0; ; i++ {requests <- i}
  30. }

上例的代码虽然可以保证任何一分钟时段内处理的请求数不会超过200,但是如果在开始的一分钟内没有任何请求,则接下来的某个瞬时时间点可能会同时处理最多200个请求(试着将time.Sleep行的注释去掉看看)。 这可能会造成卡顿情况。我们可以将速率限制和峰值限制一并使用来避免出现这样的情况。

开关

通道(第21章)一文提到了向一个nil通道发送数据或者从中接收数据都属于阻塞操作。 利用这一事实,我们可以将一个select流程控制中的case操作中涉及的通道设置为不同的值,以使此select流程控制选择执行不同的分支。

下面是另一个乒乓模拟游戏的实现。此实现使用了选择机制。在此例子中,两个case操作中的通道有且只有一个为nil,所以只能是不为nil的通道对应的分支被选中。 每个循环步将对调这两个case操作中的通道,从而改变两个分支的可被选中状态。

  1. package main
  2. import "fmt"
  3. import "time"
  4. import "os"
  5. type Ball uint8
  6. func Play(playerName string, table chan Ball, serve bool) {
  7. var receive, send chan Ball
  8. if serve {
  9. receive, send = nil, table
  10. } else {
  11. receive, send = table, nil
  12. }
  13. var lastValue Ball = 1
  14. for {
  15. select {
  16. case send <- lastValue:
  17. case value := <- receive:
  18. fmt.Println(playerName, value)
  19. value += lastValue
  20. if value < lastValue { // 溢出了
  21. os.Exit(0)
  22. }
  23. lastValue = value
  24. }
  25. receive, send = send, receive // 开关切换
  26. time.Sleep(time.Second)
  27. }
  28. }
  29. func main() {
  30. table := make(chan Ball)
  31. go Play("A:", table, false)
  32. Play("B:", table, true)
  33. }

下面是另一个也展示了开关效果的但简单得多的(非并发的)小例子。 此程序将不断打印出1212...。 它在实践中没有太多实用价值,这里只是为了学习的目的才展示之。

  1. package main
  2. import "fmt"
  3. import "time"
  4. func main() {
  5. for c := make(chan struct{}, 1); true; {
  6. select {
  7. case c <- struct{}{}:
  8. fmt.Print("1")
  9. case <-c:
  10. fmt.Print("2")
  11. }
  12. time.Sleep(time.Second)
  13. }
  14. }

控制代码被执行的几率

我们可以通过在一个select流程控制中使用重复的case操作来增加对应分支中的代码的执行几率。

一个例子:

  1. package main
  2. import "fmt"
  3. func main() {
  4. foo, bar := make(chan struct{}), make(chan struct{})
  5. close(foo); close(bar) // 仅为演示目的
  6. x, y := 0.0, 0.0
  7. f := func(){x++}
  8. g := func(){y++}
  9. for i := 0; i < 100000; i++ {
  10. select {
  11. case <-foo: f()
  12. case <-foo: f()
  13. case <-bar: g()
  14. }
  15. }
  16. fmt.Println(x/y) // 大致为2
  17. }

在上面这个例子中,函数f的调用执行几率大致为函数g的两倍。

从动态数量的分支中选择

每个select控制流程中的分支数量在运行中是固定的,但是我们可以使用reflect标准库包中提供的功能在运行时刻来构建动态分支数量的select控制流程。 但是请注意:一个select控制流程中的分支越多,此select控制流程的执行效率就越低(这是我们常常只使用不多于三个分支的select控制流程的原因)。

reflect标准库包中也提供了模拟尝试发送和尝试接收代码块的TrySendTryRecv函数。

数据流操纵

本节将介绍一些使用通道进行数据流处理的用例。

一般来说,一个数据流处理程序由多个模块组成。不同的模块执行分配给它们的不同的任务。 每个模块由一个或者数个并行工作的协程组成。实践中常见的工作任务包括:

  • 数据生成/搜集/加载;
  • 数据服务/存盘;
  • 数据计算/处理;
  • 数据验证/过滤;
  • 数据聚合/分流;
  • 数据组合/拆分;
  • 数据复制/增殖;
  • 等等。

一个模块中的工作协程从一些其它模块接收数据做为输入,并向另一些模块发送输出数据。 换句话数,一个模块可能同时兼任数据消费者和数据产生者的角色。

多个模块一起组成了一个数据流处理系统。

下面将展示一些模块工作协程的实现。这些实现仅仅是为了解释目的,所以它们都很简单,并且它们可能并不高效。

数据生成/搜集/加载

一个数据产生者可能通过以下途径生成数据:

  • 加载一个文件、或者读取一个数据库、或者用爬虫抓取网页数据;
  • 从一个软件或者硬件系统搜集各种数据;
  • 产生一系列随机数;
  • 等等。

这里,我们使用一个随机数产生器做为一个数据产生者的例子。 此数据产生者函数没有输入,只有输出。

  1. import (
  2. "crypto/rand"
  3. "encoding/binary"
  4. )
  5. func RandomGenerator() <-chan uint64 {
  6. c := make(chan uint64)
  7. go func() {
  8. rnds := make([]byte, 8)
  9. for {
  10. _, err := rand.Read(rnds)
  11. if err != nil {
  12. close(c)
  13. break
  14. }
  15. c <- binary.BigEndian.Uint64(rnds)
  16. }
  17. }()
  18. return c
  19. }

事实上,此随机数产生器是一个多返回值的future/promise。

一个数据产生者可以在任何时刻关闭返回的通道以结束数据生成。

数据聚合

一个数据聚合模块的工作协程将多个数据流合为一个数据流。 假设数据类型为int64,下面这个函数将任意数量的数据流合为一个。

  1. func Aggregator(inputs ...<-chan uint64) <-chan uint64 {
  2. out := make(chan uint64)
  3. for _, in := range inputs {
  4. go func(in <-chan uint64) {
  5. for {
  6. out <- <-in // <=> out <- (<-in)
  7. }
  8. }(in)
  9. }
  10. return out
  11. }

一个更完美的实现需要考虑一个输入数据流是否已经关闭。(下面要介绍的其它工作协程同理。)

  1. import "sync"
  2. func Aggregator(inputs ...<-chan uint64) <-chan uint64 {
  3. output := make(chan uint64)
  4. var wg sync.WaitGroup
  5. for _, in := range inputs {
  6. wg.Add(1)
  7. go func(int <-chan uint64) {
  8. defer wg.Done()
  9. // 如果通道in被关闭,此循环将最终结束。
  10. for x := range in {
  11. output <- x
  12. }
  13. }(in)
  14. }
  15. go func() {
  16. wg.Wait()
  17. close(output)
  18. }()
  19. return output
  20. }

如果被聚合的数据流的数量很小,我们也可以使用一个select控制流程代码块来聚合这些数据流。

  1. // 假设数据流的数量为2。
  2. ...
  3. output := make(chan uint64)
  4. go func() {
  5. inA, inB := inputs[0], inputs[1]
  6. for {
  7. select {
  8. case v := <- inA: output <- v
  9. case v := <- inB: output <- v
  10. }
  11. }
  12. }
  13. ...

数据分流

数据分流是数据聚合的逆过程。数据分流的实现很简单,但在实践中用的并不多。

  1. func Divisor(input <-chan uint64, outputs ...chan<- uint64) {
  2. for _, out := range outputs {
  3. go func(o chan<- uint64) {
  4. for {
  5. o <- <-input // <=> o <- (<-input)
  6. }
  7. }(out)
  8. }
  9. }

数据合成

数据合成将多个数据流中读取的数据合成一个。

下面是一个数据合成工作函数的实现中,从两个不同数据流读取的两个uint64值组成了一个新的uint64值。 当然,在实践中,数据的组合比这复杂得多。

  1. func Composor(inA, inB <-chan uint64) <-chan uint64 {
  2. output := make(chan uint64)
  3. go func() {
  4. for {
  5. a1, b, a2 := <-inA, <-inB, <-inA
  6. output <- a1 ^ b & a2
  7. }
  8. }()
  9. return output
  10. }

数据分解

数据分解是数据合成的逆过程。一个数据分解者从一个通道读取一份数据,并将此数据分解为多份数据。 这里就不举例了。

数据复制/增殖

数据复制(增殖)可以看作是特殊的数据分解。一份输入数据将被复制多份并输出给多个数据流。

一个例子:

  1. func Duplicator(in <-chan uint64) (<-chan uint64, <-chan uint64) {
  2. outA, outB := make(chan uint64), make(chan uint64)
  3. go func() {
  4. for x := range in {
  5. outA <- x
  6. outB <- x
  7. }
  8. }()
  9. return outA, outB
  10. }

数据计算/分析

数据计算和数据分析模块的功能因具体程序不同而有很大的差异。 一般来说,数据分析者接收一份数据并对之加工处理后转换为另一份数据。

下面的简单示例中,每个输入的uint64值将被进行位反转后输出。

  1. func Calculator(in <-chan uint64, out chan uint64) (<-chan uint64) {
  2. if out == nil {
  3. out = make(chan uint64)
  4. }
  5. go func() {
  6. for x := range in {
  7. out <- ^x
  8. }
  9. }()
  10. return out
  11. }

数据验证/过滤

一个数据验证或过滤者的任务是检查输入数据的合理性并抛弃不合理的数据。 比如,下面的工作者协程将抛弃所有的非素数。

  1. import "math/big"
  2. func Filter0(input <-chan uint64, output chan uint64) <-chan uint64 {
  3. if output == nil {
  4. output = make(chan uint64)
  5. }
  6. go func() {
  7. bigInt := big.NewInt(0)
  8. for x := range input {
  9. bigInt.SetUint64(x)
  10. if bigInt.ProbablyPrime(1) {
  11. output <- x
  12. }
  13. }
  14. }()
  15. return output
  16. }
  17. func Filter(input <-chan uint64) <-chan uint64 {
  18. return Filter0(input, nil)
  19. }

请注意这两个函数版本分别被本文下面最后展示的两个例子所使用。

数据服务/存盘

一般,一个数据服务或者存盘模块为一个数据流系统中的最后一个模块。 这里的实现值是简单地将数据输出到终端。

  1. import "fmt"
  2. func Printer(input <-chan uint64) {
  3. for x := range input {
  4. fmt.Println(x)
  5. }
  6. }

组装数据流系统

现在,让我们使用上面的模块工作者函数实现来组装一些数据流系统。 组装数据流仅仅是创建一些工作者协程函数调用,并为这些调用指定输入数据流和输出数据流。

数据流系统例子1(一个流线型系统):

  1. package main
  2. ... // 上面的模块工作者函数实现
  3. func main() {
  4. Printer(
  5. Filter(
  6. Calculator(
  7. RandomGenerator(), nil,
  8. ),
  9. ),
  10. )
  11. }

上面这个流线型系统描绘在下图中:

线性数据流

数据流系统例子2(一个单向无环图系统):

  1. package main
  2. ... // 上面的模块工作者函数实现
  3. func main() {
  4. filterA := Filter(RandomGenerator())
  5. filterB := Filter(RandomGenerator())
  6. filterC := Filter(RandomGenerator())
  7. filter := Aggregator(filterA, filterB, filterC)
  8. calculatorA := Calculator(filter, nil)
  9. calculatorB := Calculator(filter, nil)
  10. calculator := Aggregator(calculatorA, calculatorB)
  11. Printer(calculator)
  12. }

上面这个单向无环图系统描绘在下图中:

有向无环数据流

更复杂的数据流系统可以表示为任何拓扑结构的图。比如一个复杂的数据流系统可能有多个输出模块。 但是有环拓扑结构的数据流系统在实践中很少用。

从上面两个例子可以看出,使用通道来构建数据流系统是很简单和直观的。

从上例可以看出,通过使用数据聚合模块,我们可以很轻松地实现各个模块的工作协程数量的扇入(fan-in)和扇出(fan-out)。

事实上,我们也可以使用一个简单的通道来代替数据聚合模块的角色。比如,下面的代码使用两个通道代替了上例中的两个数据聚合器。

  1. package main
  2. ... // 上面的模块工作者函数实现
  3. func main() {
  4. c1 := make(chan uint64, 100)
  5. Filter0(RandomGenerator(), c1) // filterA
  6. Filter0(RandomGenerator(), c1) // filterB
  7. Filter0(RandomGenerator(), c1) // filterC
  8. c2 := make(chan uint64, 100)
  9. Calculator(c1, c2) // calculatorA
  10. Calculator(c1, c2) // calculatorB
  11. Printer(c2)
  12. }

修改后的数据流的拓扑结构如下图所示:

有向无环数据流

上面的代码示例并没有太多考虑如何关闭一个数据流。请阅读此篇文章(第38章)来了解如何优雅地关闭通道。


本书由老貘历时三年写成。目前本书仍在不断改进和增容中。你的赞赏是本书和Go101.org网站不断增容和维护的动力。

(请搜索关注微信公众号“Go 101”或者访问github.com/golang101/golang101获取本书最新版)