并发中的 Channel (上)
<p>本讲我们继续深入 Go 语言的并发。</p>
<p>在前面的示例中,对待协程任务的态度是“放任自流”的。也就是说,一个协程被开启后,我们便不再管它,让它自生自灭,最多是为其它任务让行或终止运行。但在实际开发中,协程任务之间常常会发生通信。</p>
<p>举例来说,现有协程 A 和协程 B,二者都处于运行状态。协程 B 中的某些逻辑需要协程 A 的执行结果作为输入条件,此时就急需将这些结果数据从协程 A 传递给协程 B 了。由此便引出一个问题:如何在并发任务之间进行<strong>数据共享</strong>。</p>
<h2>CSP并发模型</h2>
<p>纵观编程领域,在多任务之间共享数据的方式主要分为两种。</p>
<p>一种是<strong>多线程任务之间的内存共享</strong>,这种方式的代表是 Java、C++、Python 等语言中的多线程开发,这种方式普遍要通过“锁”来确保数据安全。</p>
<p>另一种便是 Go 语言提倡的 <strong>CSP 模型</strong>方式,这种方式的核心思想在于<strong>以通信的方式共享内存数据</strong>。</p>
<p>这两种数据共享的区别主要在于前者是共享内存实现通信,后者是通过通信共享内存。在 Effective Go 中,谈及并发时有这样一句原文:</p>
<p>> Do not communicate by sharing memory; instead, share memory by communicating.</p>
<p>说的就是这个意思。</p>
<p><code>💡 提示:Go语言并非只允许CSP方式并发,它同样支持传统的多线程任务调度方式。</code></p>
<p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=45077617737bd6e4b76a34f39220384c&amp;file=file.png" alt="" /></p>
<p>普通的线程并发模型(例如Java、C++等),他们线程间通信都是通过共享内存的方式来进行的;非常典型的方式就是,构建一个全局共享数据(变量),通过加锁机制来保证共享数据在并发环境下的线程安全,从而实现并发线程间的通信。</p>
<p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=d21daddcdee309580f7972aa80003298&amp;file=file.png" alt="" /></p>
<p>Go语言既提供了传统的线程并发模型编程方式,也提供了CSP并发模型的实现,在CSP并发模型中,每一个并发实体(Process)只需要关注消息发送的对象(Channel),而不用关注另一个并发实体,这使得并发实体间实现了完全解耦,这两个并发原语之间没有从属关系, Process 可以订阅任意个 Channel,Channel 也并不关心是哪个 Process 在利用它进行通信;Process 围绕 Channel 进行读写,形成一套有序阻塞和可预测的并发模型。</p>
<p>随着对 Go 并发领会的逐渐深入,使用得越来越频繁,便会遇到使用 Goroutine 的三个“陷阱”:</p>
<ol>
<li><strong>Goroutine Leaks(协程任务泄露)</strong></li>
<li><strong>Data Race(数据竞争)</strong></li>
<li><strong>Incomplete Work(未完成的任务)</strong></li>
</ol>
<p>针对上述 1 和 3,规避的方式就是<strong>确保每一个协程任务可以正常结束</strong>。如果一个协程运行失控,便会长期驻留在内存中,导致系统资源的浪费,出现陷阱 1。或者该执行的任务没有完全完成,导致陷阱 3。</p>
<p><code>💡 提示:想想如何终止协程任务,想想协程中的 defer 的使用。</code></p>
<p>针对上述 2,规避的方式便是<strong>通过传递数据的方式共享数据,而非直接操作某个公共变量</strong>,从而规避数据竞争。</p>
<p>协程任务之间传递数据需要借助通道(Channel)来完成。</p>
<h2>通道(Channel)类型</h2>
<p>从本质上说,<strong>Go 语言中的通道(Channel)也是一种类型</strong>,只不过在使用时有些特殊,稍后会详述。从分类上看,可将其分为两种。<strong>一种是同步通道,另一种是缓冲通道</strong>。</p>
<p>同步通道有点类似于送外卖的过程。若外卖小哥和点餐顾客分别为协程 A 和协程 B,只有当协程 A 把数据(即外卖)送给协程 B(即顾客),协程 B 才能开始执行后续的操作(即吃外卖)。否则,协程 B 只能一直等待数据(即外卖)的到来。</p>
<p>缓冲通道则有点类似于送快递的过程。若快递员和收件人分别为协程 A 和协程 B,协程 A 可以把数据(即快递)放到缓冲区(即菜鸟驿站)。当协程 B 需要时,只要去缓冲区(即菜鸟驿站)中取数据(即快递)即可。</p>
<p>值得一提的是,缓冲区和菜鸟驿站真的很像,它们都有最大容量限制。一旦协程 A 发现缓冲区(即菜鸟驿站)满了,就不得不等待数据(即快递)被取走,才能将数据(即快递)放到空余的位置中。</p>
<p>同步和缓冲,这两种方式孰优孰劣呢?其实并没有特别明确的定论,我们只要根据实际情况,选择合适的方式就是最优的。</p>
<p>篇幅所限,本讲先介绍较为简单的同步通道,下一讲再介绍缓冲通道以及更多内容。</p>
<h2>同步通道</h2>
<p>理论部分到此为止,接下来实际演练。我们一起实现如下编程需求:</p>
<p>假设我们正在饲养一只母鸡,等待其下蛋。每下一个蛋,我们就拿来做荷包蛋吃。</p>
<p>为了使用同步通道,我们使用两个协程来实现上述需求。协程 A 代表母鸡,它的作用是产蛋,并将产蛋的数量传给协程 B,我们将协程 A 的代码逻辑封装成名为 layEggs() 函数。协程 B 表示吃荷包蛋,等待传入可用的鸡蛋数量,然后输出文字:“吃 x 个荷包蛋”(x 表示鸡蛋数量)。我们将协程B的代码逻辑封装成名为 eatEggs() 函数。</p>
<p>如前文所述,通道(Channel)也是一种数据类型。因此,为了让 layEggs() 和 eatEggs() 都能使用通道类型变量,将通道声明为全局公共变量。该通道将传送鸡蛋的数量,其传送的数据类型是 int,所以我们把它命名为 intChan。具体代码实现如下:</p>
<pre><code>var intChan = make(chan int)</code></pre>
<p>这句代码中,<strong>chan 即表明通道类型,紧接着的 int 表示通道上传送的数据的类型。make() 的目的则是创建通道</strong>。最终的 intChan 变量就是通道类型的变量了。如果使用下面的代码输出 intChan 及其类型:</p>
<pre><code>func main() {
fmt.Println(intChan)
fmt.Println(reflect.TypeOf(intChan))
}</code></pre>
<p>可以得到如下结果:</p>
<p>> 0xc00001a0c0
>
> chan int</p>
<p>接下来实现 layEggs()函数,该函数需要向通道中传出数据,方法是<strong>使用箭头操作符</strong>。在传送结束后,<strong>别忘了调用 close()函数关闭通道,关闭通道时需要指定通道</strong>。具体实现代码如下:</p>
<pre><code>func layEggs() {
intChan &lt;- 1
close(intChan)
}</code></pre>
<p>如此,便可将 1 通过 intChan 传出。</p>
<p>接着,再来实现 eatEggs() 函数。该函数需要从通道中取数据,方法依然是<strong>使用箭头操作符</strong>,只不过方向上刚好和传出数据相反。具体实现代码如下:</p>
<pre><code>func eatEggs(intChan chan int) {
eggCounts := &lt;-intChan
fmt.Printf(&quot;吃%d个荷包蛋&quot;, eggCounts)
}</code></pre>
<p>这里的 eggCounts 便是 int 型数据了。请注意这里的箭头操作符,虽然看上去和传出数据的方向相同,但由于主体角色发生了转变,实际上是相反的。但不能将 “<-” 改为 “->” 。</p>
<p>最后,整合这两个函数,完善 main() 函数,并使用 sync.WaitGroup 类型变量确保协程任务能够完全执行。整体代码如下:</p>
<pre><code>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 &lt;- 1
// 关闭通道
close(intChan)
}
func eatEggs(intChan chan int) {
// 使用断言确保协程任务正常结束
defer syncWait.Done()
// 从通道获取int类型值
eggCounts := &lt;-intChan
// 输出结果
fmt.Printf(&quot;吃%d个荷包蛋&quot;, eggCounts)
}</code></pre>
<p>运行这段代码,程序输出:</p>
<p>> 吃1个荷包蛋</p>
<p><code>❗️ 注意:使用同步通道时,要确保传出数据和获取数据必须成对出现。另外,一旦通道被关闭,便不能再向其中传出数据了。</code></p>
<h2>小结</h2>
<p>🎉 恭喜,您完成了本次课程的学习!</p>
<p>📌 以下是本次课程的重点内容总结:</p>
<ol>
<li>Go 并发的 CSP 并发模型;</li>
<li>通道(Channel)类型;</li>
<li>同步通道的使用。</li>
</ol>
<p>本讲首先快速回顾了多任务调度的两种类型,并详细阐述了 Go 语言推荐的 CSP 并发模型的机制。它们最大的区别在于:使用多线程本质上是通过共享内存实现通信,CSP 模型则是通过通信共享内存。此外还介绍了在 Go 中使用并发的“正确姿势”,规避三种编码“陷阱”。从而构建强壮的代码。</p>
<p>接着介绍了 Go 语言中的通道类型,它就像“传送带”一样,将数据从一个协程带到另一个协程中,实现数据共享。</p>
<p>通道分为两种:同步通道和缓冲通道。使用同步通道要求数据的发送者和接收者必须成对出现,传送时使用箭头操作符(“<-”)。传送结束后,别忘了调用 Go SDK 内置的 close 函数关闭通道,结束整个数据传送流程。</p>
<p>➡️ 在下次课程中,我们会继续介绍 Go 语言中的并发,涉及并发任务间的通信方式,内容如下:</p>
<ul>
<li>并发中的 Channel 下部</li>
</ul>