本讲我们继续深入 Go 语言的并发。

在前面的示例中,对待协程任务的态度是“放任自流”的。也就是说,一个协程被开启后,我们便不再管它,让它自生自灭,最多是为其它任务让行或终止运行。但在实际开发中,协程任务之间常常会发生通信。

举例来说,现有协程 A 和协程 B,二者都处于运行状态。协程 B 中的某些逻辑需要协程 A 的执行结果作为输入条件,此时就急需将这些结果数据从协程 A 传递给协程 B 了。由此便引出一个问题:如何在并发任务之间进行数据共享

# CSP并发模型

纵观编程领域,在多任务之间共享数据的方式主要分为两种。

一种是多线程任务之间的内存共享,这种方式的代表是 Java、C++、Python 等语言中的多线程开发,这种方式普遍要通过“锁”来确保数据安全。

另一种便是 Go 语言提倡的 CSP 模型方式,这种方式的核心思想在于以通信的方式共享内存数据

这两种数据共享的区别主要在于前者是共享内存实现通信,后者是通过通信共享内存。在 Effective Go 中,谈及并发时有这样一句原文:

Do not communicate by sharing memory; instead, share memory by communicating.

说的就是这个意思。

💡 提示:Go语言并非只允许CSP方式并发,它同样支持传统的多线程任务调度方式。

随着对 Go 并发领会的逐渐深入,使用得越来越频繁,便会遇到使用 Goroutine 的三个“陷阱”:

  1. Goroutine Leaks(协程任务泄露)
  2. Data Race(数据竞争)
  3. Incomplete Work(未完成的任务)

针对上述 1 和 3,规避的方式就是确保每一个协程任务可以正常结束。如果一个协程运行失控,便会长期驻留在内存中,导致系统资源的浪费,出现陷阱 1。或者该执行的任务没有完全完成,导致陷阱 3。

💡 提示:想想如何终止协程任务,想想协程中的 defer 的使用。

针对上述 2,规避的方式便是通过传递数据的方式共享数据,而非直接操作某个公共变量,从而规避数据竞争。

协程任务之间传递数据需要借助通道(Channel)来完成。

# 通道(Channel)类型

从本质上说,Go 语言中的通道(Channel)也是一种类型,只不过在使用时有些特殊,稍后会详述。从分类上看,可将其分为两种。一种是同步通道,另一种是缓冲通道

同步通道有点类似于送外卖的过程。若外卖小哥和点餐顾客分别为协程 A 和协程 B,只有当协程 A 把数据(即外卖)送给协程 B(即顾客),协程 B 才能开始执行后续的操作(即吃外卖)。否则,协程 B 只能一直等待数据(即外卖)的到来。

缓冲通道则有点类似于送快递的过程。若快递员和收件人分别为协程 A 和协程 B,协程 A 可以把数据(即快递)放到缓冲区(即菜鸟驿站)。当协程 B 需要时,只要去缓冲区(即菜鸟驿站)中取数据(即快递)即可。

值得一提的是,缓冲区和菜鸟驿站真的很像,它们都有最大容量限制。一旦协程 A 发现缓冲区(即菜鸟驿站)满了,就不得不等待数据(即快递)被取走,才能将数据(即快递)放到空余的位置中。

同步和缓冲,这两种方式孰优孰劣呢?其实并没有特别明确的定论,我们只要根据实际情况,选择合适的方式就是最优的。

篇幅所限,本讲先介绍较为简单的同步通道,下一讲再介绍缓冲通道以及更多内容。

# 同步通道

理论部分到此为止,接下来实际演练。我们一起实现如下编程需求:

假设我们正在饲养一只母鸡,等待其下蛋。每下一个蛋,我们就拿来做荷包蛋吃。

为了使用同步通道,我们使用两个协程来实现上述需求。协程 A 代表母鸡,它的作用是产蛋,并将产蛋的数量传给协程 B,我们将协程 A 的代码逻辑封装成名为 layEggs() 函数。协程 B 表示吃荷包蛋,等待传入可用的鸡蛋数量,然后输出文字:“吃 x 个荷包蛋”(x 表示鸡蛋数量)。我们将协程B的代码逻辑封装成名为 eatEggs() 函数。

如前文所述,通道(Channel)也是一种数据类型。因此,为了让 layEggs() 和 eatEggs() 都能使用通道类型变量,将通道声明为全局公共变量。该通道将传送鸡蛋的数量,其传送的数据类型是 int,所以我们把它命名为 intChan。具体代码实现如下:

go
复制代码var intChan = make(chan int)

这句代码中,chan 即表明通道类型,紧接着的 int 表示通道上传送的数据的类型。make() 的目的则是创建通道。最终的 intChan 变量就是通道类型的变量了。如果使用下面的代码输出 intChan 及其类型:

go
复制代码func main() {
   fmt.Println(intChan)
   fmt.Println(reflect.TypeOf(intChan))
}

可以得到如下结果:

0xc00001a0c0

chan int

接下来实现 layEggs()函数,该函数需要向通道中传出数据,方法是使用箭头操作符。在传送结束后,别忘了调用 close()函数关闭通道,关闭通道时需要指定通道。具体实现代码如下:

go
复制代码func layEggs() {
   intChan <- 1
   close(intChan)
}

如此,便可将 1 通过 intChan 传出。

接着,再来实现 eatEggs() 函数。该函数需要从通道中取数据,方法依然是使用箭头操作符,只不过方向上刚好和传出数据相反。具体实现代码如下:

go
复制代码func eatEggs(intChan chan int) {
   eggCounts := <-intChan
   fmt.Printf("吃%d个荷包蛋", eggCounts)
}

这里的 eggCounts 便是 int 型数据了。请注意这里的箭头操作符,虽然看上去和传出数据的方向相同,但由于主体角色发生了转变,实际上是相反的。但不能将 “<-” 改为 “->” 。

最后,整合这两个函数,完善 main() 函数,并使用 sync.WaitGroup 类型变量确保协程任务能够完全执行。整体代码如下:

go
复制代码var syncWait sync.WaitGroup
// 创建通道类型变量,该通道将传送int类型数据
var intChan = make(chan int)
func main() {
   // 执行2个协程任务
   syncWait.Add(2)
   // 开启下蛋任务
   go layEggs()
   // 开启吃荷包蛋任务
   go eatEggs(intChan)
   // 等待协程任务完成
   syncWait.Wait()
}
func layEggs() {
   // 使用断言确保协程任务正常结束
   defer syncWait.Done()
   // 向通道传送int类型值
   intChan <- 1
   // 关闭通道
   close(intChan)
}
func eatEggs(intChan chan int) {
   // 使用断言确保协程任务正常结束
   defer syncWait.Done()
   // 从通道获取int类型值
   eggCounts := <-intChan
   // 输出结果
   fmt.Printf("吃%d个荷包蛋", eggCounts)
}

运行这段代码,程序输出:

吃1个荷包蛋

❗️ 注意:使用同步通道时,要确保传出数据和获取数据必须成对出现。另外,一旦通道被关闭,便不能再向其中传出数据了。

# 小结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

  1. Go 并发的 CSP 并发模型;
  2. 通道(Channel)类型;
  3. 同步通道的使用。

本讲首先快速回顾了多任务调度的两种类型,并详细阐述了 Go 语言推荐的 CSP 并发模型的机制。它们最大的区别在于:使用多线程本质上是通过共享内存实现通信,CSP 模型则是通过通信共享内存。此外还介绍了在 Go 中使用并发的“正确姿势”,规避三种编码“陷阱”。从而构建强壮的代码。

接着介绍了 Go 语言中的通道类型,它就像“传送带”一样,将数据从一个协程带到另一个协程中,实现数据共享。

通道分为两种:同步通道和缓冲通道。使用同步通道要求数据的发送者和接收者必须成对出现,传送时使用箭头操作符(“<-”)。传送结束后,别忘了调用 Go SDK 内置的 close 函数关闭通道,结束整个数据传送流程。

➡️ 在下次课程中,我们会继续介绍 Go 语言中的并发,涉及并发任务间的通信方式,内容如下:

  • 并发中的 Channel 下部