CategoryResourceRepost/极客时间专栏/Go语言核心36讲/模块三:Go语言实战与应用/31 | sync.WaitGroup和sync.Once.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

175 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="31 | sync.WaitGroup和sync.Once" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/7e/6b88fa520c495b8cbb58b1f263fc6f7e.mp3"></audio>
我们在前几次讲的互斥锁、条件变量和原子操作都是最基本重要的同步工具。在Go语言中除了通道之外它们也算是最为常用的并发安全工具了。
说到通道,不知道你想过没有,之前在一些场合下里,我们使用通道的方式看起来都似乎有些蹩脚。
比如:**声明一个通道使它的容量与我们手动启用的goroutine的数量相同之后再利用这个通道让主goroutine等待其他goroutine的运行结束。**
这一步更具体地说就是让其他的goroutine在运行结束之前都向这个通道发送一个元素值并且让主goroutine在最后从这个通道中接收元素值接收的次数需要与其他的goroutine的数量相同。
这就是下面的`coordinateWithChan`函数展示的多goroutine协作流程。
```
func coordinateWithChan() {
sign := make(chan struct{}, 2)
num := int32(0)
fmt.Printf(&quot;The number: %d [with chan struct{}]\n&quot;, num)
max := int32(10)
go addNum(&amp;num, 1, max, func() {
sign &lt;- struct{}{}
})
go addNum(&amp;num, 2, max, func() {
sign &lt;- struct{}{}
})
&lt;-sign
&lt;-sign
}
```
其中的`addNum`函数的声明在demo65.go文件中。`addNum`函数会把它接受的最后一个参数值作为其中的`defer`函数。
我手动启用的两个goroutine都会调用`addNum`函数,而它们传给该函数的最后一个参数值(也就是那个既无参数声明,也无结果声明的函数)都只会做一件事情,那就是向通道`sign`发送一个元素值。
看到`coordinateWithChan`函数中最后的那两行代码了吗?重复的两个接收表达式`&lt;-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(&quot;The number: %d [with sync.WaitGroup]\n&quot;, num)
max := int32(10)
go addNum(&amp;num, 3, max, wg.Done)
go addNum(&amp;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`,就可以被视为一个计数周期。在一个此类值的生命周期中,它可以经历任意多个计数周期。但是,只有在它走完当前的计数周期之后,才能够开始下一个计数周期。
<img src="https://static001.geekbang.org/resource/image/fa/8d/fac7dfa184053d2a95e121aa17141d8d.png" alt=""><br>
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语言专栏文章配套详细代码。](https://github.com/hyper0x/Golang_Puzzlers)