Go语言核心36讲33
我们在前几次讲的互斥锁、条件变量和原子操作都是最基本重要的同步工具。在Go语言中,除了通道之外,它们也算是最为常用的并发安全工具了。
说到通道,不知道你想过没有,之前在一些场合下里,我们使用通道的方式看起来都似乎有些蹩脚。
比如:声明一个通道,使它的容量与我们手动启用的goroutine的数量相同,之后再利用这个通道,让主goroutine等待其他goroutine的运行结束。
这一步更具体地说就是:让其他的goroutine在运行结束之前,都向这个通道发送一个元素值,并且,让主goroutine在最后从这个通道中接收元素值,接收的次数需要与其他的goroutine的数量相同。
这就是下面的coordinateWithChan
函数展示的多goroutine协作流程。
func coordinateWithChan() {
sign := make(chan struct{}, 2)
num := int32(0)
fmt.Printf("The number: %d [with chan struct{}]\n", num)
max := int32(10)
go addNum(&num, 1, max, func() {
sign <- struct{}{}
})
go addNum(&num, 2, max, func() {
sign <- struct{}{}
})
<-sign
<-sign
}
其中的addNum
函数的声明在demo65.go文件中。addNum
函数会把它接受的最后一个参数值作为其中的defer
函数。
我手动启用的两个goroutine都会调用addNum
函数,而它们传给该函数的最后一个参数值(也就是那个既无参数声明,也无结果声明的函数)都只会做一件事情,那就是向通道sign
发送一个元素值。
看到coordinateWithChan
函数中最后的那两行代码了吗?重复的两个接收表达式<-sign
,是不是看起来很丑陋?
前导内容:sync
包的WaitGroup
类型
其实,在这种应用场景下,我们可以选用另外一个同步工具,即:sync
包的WaitGroup
类型。它比通道更加适合实现这种一对多的goroutine协作流程。
sync.WaitGroup
类型(以下简称WaitGroup
类型)是开箱即用的,也是并发安全的。同时,与我们前面讨论的几个同步工具一样,它一旦被真正使用就不能被复制了。
WaitGroup
类型拥有三个指针方法:Add
、Done
和Wait
。你可以想象该类型中有一个计数器,它的默认值是0
。我们可以通过调用该类型值的Add
方法来增加,或者减少这个计数器的值。
一般情况下,我会用这个方法来记录需要等待的goroutine的数量。相对应的,这个类型的Done
方法,用于对其所属值中计数器的值进行减一操作。我们可以在需要等待的goroutine中,通过defer
语句调用它。
而此类型的Wait
方法的功能是,阻塞当前的goroutine,直到其所属值中的计数器归零。如果在该方法被调用的时候,那个计数器的值就是0
,那么它将不会做任何事情。
你可能已经看出来了,WaitGroup
类型的值(以下简称WaitGroup
值)完全可以被用来替换coordinateWithChan
函数中的通道sign
。下面的coordinateWithWaitGroup
函数就是它的改造版本。
func coordinateWithWaitGroup() {
var wg sync.WaitGroup
wg.Add(2)
num := int32(0)
fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
max := int32(10)
go addNum(&num, 3, max, wg.Done)
go addNum(&num, 4, max, wg.Done)
wg.Wait()
}
很明显,整体代码少了好几行,而且看起来也更加简洁了。这里我先声明了一个WaitGroup
类型的变量wg
。然后,我调用了它的Add
方法并传入了2
,因为我会在后面启用两个需要等待的goroutine。
由于wg
变量的Done
方法本身就是一个既无参数声明,也无结果声明的函数,所以我在go
语句中调用addNum
函数的时候,可以直接把该方法作为最后一个参数值传进去。
在coordinateWithWaitGroup
函数的最后,我调用了wg
的Wait
方法。如此一来,该函数就可以等到那两个goroutine都运行结束之后,再结束执行了。
以上就是WaitGroup
类型最典型的应用场景了。不过不能止步于此,对于这个类型,我们还是有必要再深入了解一下的。我们一起看下面的问题。
问题:sync.WaitGroup
类型值中计数器的值可以小于0
吗?
这里的典型回答是:不可以。
问题解析
为什么不可以呢,我们解析一下。之所以说WaitGroup
值中计数器的值不能小于0
,是因为这样会引发一个panic。 不适当地调用这类值的Done
方法和Add
方法都会如此。别忘了,我们在调用Add
方法的时候是可以传入一个负数的。
实际上,导致WaitGroup
值的方法抛出panic的原因不只这一种。
你需要知道,在我们声明了这样一个变量之后,应该首先根据需要等待的goroutine,或者其他事件的数量,调用它的Add
方法,以使计数器的值大于0
。这是确保我们能在后面正常地使用这类值的前提。
如果我们对它的Add
方法的首次调用,与对它的Wait
方法的调用是同时发起的,比如,在同时启用的两个goroutine中,分别调用这两个方法,那么就有可能会让这里的Add
方法抛出一个panic。
这种情况不太容易复现,也正因为如此,我们更应该予以重视。所以,虽然WaitGroup
值本身并不需要初始化,但是尽早地增加其计数器的值,还是非常有必要的。
另外,你可能已经知道,WaitGroup
值是可以被复用的,但需要保证其计数周期的完整性。这里的计数周期指的是这样一个过程:该值中的计数器值由0
变为了某个正整数,而后又经过一系列的变化,最终由某个正整数又变回了0
。
也就是说,只要计数器的值始于0
又归为0
,就可以被视为一个计数周期。在一个此类值的生命周期中,它可以经历任意多个计数周期。但是,只有在它走完当前的计数周期之后,才能够开始下一个计数周期。
(sync.WaitGroup的计数周期)
因此,也可以说,如果一个此类值的Wait
方法在它的某个计数周期中被调用,那么就会立即阻塞当前的goroutine,直至这个计数周期完成。在这种情况下,该值的下一个计数周期,必须要等到这个Wait
方法执行结束之后,才能够开始。
如果在一个此类值的Wait
方法被执行期间,跨越了两个计数周期,那么就会引发一个panic。
例如,在当前的goroutine因调用此类值的Wait
方法,而被阻塞的时候,另一个goroutine调用了该值的Done
方法,并使其计数器的值变为了0
。
这会唤醒当前的goroutine,并使它试图继续执行Wait
方法中其余的代码。但在这时,又有一个goroutine调用了它的Add
方法,并让其计数器的值又从0
变为了某个正整数。此时,这里的Wait
方法就会立即抛出一个panic。
纵观上述会引发panic的后两种情况,我们可以总结出这样一条关于WaitGroup
值的使用禁忌,即:不要把增加其计数器值的操作和调用其Wait
方法的代码,放在不同的goroutine中执行。换句话说,要杜绝对同一个WaitGroup
值的两种操作的并发执行。
除了第一种情况外,我们通常需要反复地实验,才能够让WaitGroup
值的方法抛出panic。再次强调,虽然这不是每次都发生,但是在长期运行的程序中,这种情况发生的概率还是不小的,我们必须要重视它们。
如果你对复现这些异常情况感兴趣,那么可以参看sync
代码包中的waitgroup_test.go文件。其中的名称以TestWaitGroupMisuse
为前缀的测试函数,很好地展示了这些异常情况的发生条件。你可以模仿这些测试函数自己写一些测试代码,执行一下试试看。
知识扩展
问题:sync.Once
类型值的Do
方法是怎么保证只执行参数函数一次的?
与sync.WaitGroup
类型一样,sync.Once
类型(以下简称Once
类型)也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个sync.Mutex
类型的字段,所以,复制该类型的值也会导致功能的失效。
Once
类型的Do
方法只接受一个参数,这个参数的类型必须是func()
,即:无参数声明和结果声明的函数。
该方法的功能并不是对每一种参数函数都只执行一次,而是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。
所以,如果你有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个sync.Once
类型的值(以下简称Once
值)。
Once
类型中还有一个名叫done
的uint32
类型的字段。它的作用是记录其所属值的Do
方法被调用的次数。不过,该字段的值只可能是0
或者1
。一旦Do
方法的首次调用完成,它的值就会从0
变为1
。
你可能会问,既然done
字段的值不是0
就是1
,那为什么还要使用需要四个字节的uint32
类型呢?
原因很简单,因为对它的操作必须是“原子”的。Do
方法在一开始就会通过调用atomic.LoadUint32
函数来获取该字段的值,并且一旦发现该值为1
,就会直接返回。这也初步保证了“Do
方法,只会执行首次被调用时传入的函数”。
不过,单凭这样一个判断的保证是不够的。因为,如果有两个goroutine都调用了同一个新的Once
值的Do
方法,并且几乎同时执行到了其中的这个条件判断代码,那么它们就都会因判断结果为false
,而继续执行Do
方法中剩余的代码。
在这个条件判断之后,Do
方法会立即锁定其所属值中的那个sync.Mutex
类型的字段m
。然后,它会在临界区中再次检查done
字段的值,并且仅在条件满足时,才会去调用参数函数,以及用原子操作把done
的值变为1
。
如果你熟悉GoF设计模式中的单例模式的话,那么肯定能看出来,这个Do
方法的实现方式,与那个单例模式有很多相似之处。它们都会先在临界区之外,判断一次关键条件,若条件不满足则立即返回。这通常被称为“快路径”,或者叫做“快速失败路径”。
如果条件满足,那么到了临界区中还要再对关键条件进行一次判断,这主要是为了更加严谨。这两次条件判断常被统称为(跨临界区的)“双重检查”。
由于进入临界区之前,肯定要锁定保护它的互斥锁m
,显然会降低代码的执行速度,所以其中的第二次条件判断,以及后续的操作就被称为“慢路径”或者“常规路径”。
别看Do
方法中的代码不多,但它却应用了一个很经典的编程范式。我们在Go语言及其标准库中,还能看到不少这个经典范式及它衍生版本的应用案例。
下面我再来说说这个Do
方法在功能方面的两个特点。
第一个特点,由于Do
方法只会在参数函数执行结束之后把done
字段的值变为1
,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关goroutine的同时阻塞。
例如,有多个goroutine并发地调用了同一个Once
值的Do
方法,并且传入的函数都会一直执行而不结束。那么,这些goroutine就都会因调用了这个Do
方法而阻塞。因为,除了那个抢先执行了参数函数的goroutine之外,其他的goroutine都会被阻塞在锁定该Once
值的互斥锁m
的那行代码上。
第二个特点,Do
方法在参数函数执行结束后,对done
字段的赋值用的是原子操作,并且,这一操作是被挂在defer
语句中的。因此,不论参数函数的执行会以怎样的方式结束,done
字段的值都会变为1
。
也就是说,即使这个参数函数没有执行成功(比如引发了一个panic),我们也无法使用同一个Once
值重新执行它了。所以,如果你需要为参数函数的执行设定重试机制,那么就要考虑Once
值的适时替换问题。
在很多时候,我们需要依据Do
方法的这两个特点来设计与之相关的流程,以避免不必要的程序阻塞和功能缺失。
总结
sync
代码包的WaitGroup
类型和Once
类型都是非常易用的同步工具。它们都是开箱即用和并发安全的。
利用WaitGroup
值,我们可以很方便地实现一对多的goroutine协作流程,即:一个分发子任务的goroutine,和多个执行子任务的goroutine,共同来完成一个较大的任务。
在使用WaitGroup
值的时候,我们一定要注意,千万不要让其中的计数器的值小于0
,否则就会引发panic。
另外,我们最好用“先统一Add
,再并发Done
,最后Wait
”这种标准方式,来使用WaitGroup
值。 尤其不要在调用Wait
方法的同时,并发地通过调用Add
方法去增加其计数器的值,因为这也有可能引发panic。
Once
值的使用方式比WaitGroup
值更加简单,它只有一个Do
方法。同一个Once
值的Do
方法,永远只会执行第一次被调用时传入的参数函数,不论这个函数的执行会以怎样的方式结束。
只要传入某个Do
方法的参数函数没有结束执行,任何之后调用该方法的goroutine就都会被阻塞。只有在这个参数函数执行结束以后,那些goroutine才会逐一被唤醒。
Once
类型使用互斥锁和原子操作实现了功能,而WaitGroup
类型中只用到了原子操作。 所以可以说,它们都是更高层次的同步工具。它们都基于基本的通用工具,实现了某一种特定的功能。sync
包中的其他高级同步工具,其实也都是这样的。
思考题
今天的思考题是:在使用WaitGroup
值实现一对多的goroutine协作流程时,怎样才能让分发子任务的goroutine获得各个子任务的具体执行结果?
Go语言核心36讲33的更多相关文章
- Go语言核心36讲(导读)--学习笔记
目录 开篇词 | 跟着学,你也能成为Go语言高手 导读 | 写给0基础入门的Go语言学习者 导读 | 学习专栏的正确姿势 开篇词 | 跟着学,你也能成为Go语言高手 Go 语言是由 Google 出品 ...
- Go语言核心36讲(Go语言进阶技术八)--学习笔记
14 | 接口类型的合理运用 前导内容:正确使用接口的基础知识 在 Go 语言的语境中,当我们在谈论"接口"的时候,一定指的是接口类型.因为接口类型与其他数据类型不同,它是没法被实 ...
- Go语言核心36讲(Go语言进阶技术十六)--学习笔记
22 | panic函数.recover函数以及defer语句(下) 我在前一篇文章提到过这样一个说法,panic 之中可以包含一个值,用于简要解释引发此 panic 的原因. 如果一个 panic ...
- Go语言核心36讲(Go语言实战与应用一)--学习笔记
23 | 测试的基本规则和流程 (上) 在接下来的日子里,我将带你去学习在 Go 语言编程进阶的道路上,必须掌握的附加知识,比如:Go 程序测试.程序监测,以及 Go 语言标准库中各种常用代码包的正确 ...
- Go语言核心36讲(Go语言实战与应用三)--学习笔记
25 | 更多的测试手法 在本篇文章,我会继续为你讲解更多更高级的测试方法.这会涉及testing包中更多的 API.go test命令支持的,更多标记更加复杂的测试结果,以及测试覆盖度分析等等. 前 ...
- Go语言核心36讲(Go语言实战与应用四)--学习笔记
26 | sync.Mutex与sync.RWMutex 从本篇文章开始,我们将一起探讨 Go 语言自带标准库中一些比较核心的代码包.这会涉及这些代码包的标准用法.使用禁忌.背后原理以及周边的知识. ...
- Go语言核心36讲(Go语言实战与应用十一)--学习笔记
33 | 临时对象池sync.Pool 到目前为止,我们已经一起学习了 Go 语言标准库中最重要的那几个同步工具,这包括非常经典的互斥锁.读写锁.条件变量和原子操作,以及 Go 语言特有的几个同步工具 ...
- Go语言核心36讲(Go语言实战与应用十四)--学习笔记
36 | unicode与字符编码 在开始今天的内容之前,我先来做一个简单的总结. Go 语言经典知识总结 在数据类型方面有: 基于底层数组的切片: 用来传递数据的通道: 作为一等类型的函数: 可实现 ...
- Go语言核心36讲(Go语言实战与应用十八)--学习笔记
40 | io包中的接口和工具 (上) 我们在前几篇文章中,主要讨论了strings.Builder.strings.Reader和bytes.Buffer这三个数据类型. 知识回顾 还记得吗?当时我 ...
- Go语言核心36讲(Go语言实战与应用二十二)--学习笔记
44 | 使用os包中的API (上) 我们今天要讲的是os代码包中的 API.这个代码包可以让我们拥有操控计算机操作系统的能力. 前导内容:os 包中的 API 这个代码包提供的都是平台不相关的 A ...
随机推荐
- 第九十五篇:vue-router的导航守卫
好家伙,考完期末考了. 恢复博客更新 1.什么是导航守卫? "导航"表示路由正在发生变化 设置导航,就在切换过程中进行限制 "守卫"就好理解了 盯着你,不然 ...
- maven执行跳过测试
-Dmaven.test.skip=true 例子 mvn clean install -Dmaven.test.skip=true
- 手写tomcat——编写一个提供servlet能力的 http服务器
点击查看代码 package com.grady.diytomcat; import com.grady.diytomcat.handler.RequestHandler; import org.do ...
- Flink SQL 子图复用逻辑分析
子图复用优化是为了找到SQL执行计划中重复的节点,将其复用,避免这部分重复计算的逻辑.先回顾SQL执行的主要流程 parser -> validate -> logical optimiz ...
- 微信小程序-全局配置、组件、页面跳转、用户信息等
全局配置 三个页面 app.json pages字段 "pages":[ "pages/index/index", # 首页 "pages/home/ ...
- 新渲染引擎、自定义设计和高质量用户体验的样例应用 Wonderous 现已开源
探索世界奇观,并体验 Flutter 的强大之处. Flutter 的愿景是让你能够在空白画布上绘制出不受限制的精美应用.最近,通过与 gskinner 团队的通力合作,我们打造了一个全新的移动应用 ...
- Kibana探索数据(Discover)详解
设置时间过滤器 时间过滤器按照指定的时间段展示搜索结果.设置了 index contains time-based events 和 time-field 的索引模式可以使用时间过滤器. 时间过滤器默 ...
- Beats: Filebeat和pipleline processors
简要来说: 使用filebeat读取log日志,在filebeat.yml中先一步处理日志中的个别数据,比如丢弃某些数据项,增加某些数据项. 按照之前的文档,是在filebeat.yml中操作的,具体 ...
- 在K8S中安装jenkins
以 NFS 为存储卷的示例,将在 NFS 存储卷上创建 Jenkins 目录,然后创建 NFS 类型的 PV.PVC. 1.NFS 存储卷创建 Jenkins 目录 进入 NFS Server 服务器 ...
- Notebook交互式完成目标检测任务
摘要:本文将介绍一种在Notebook中进行算法开发的新方式,新手也能够快速训练自己的模型. 目标检测是计算机视觉中非常常用且基础的任务,但是由于目标检测任务的复杂性,往往令新手望而却步.本文将介绍一 ...