learn-tech/专栏/Go语言核心36讲/31sync.WaitGroup和sync.Once.md
2024-10-16 00:01:16 +08:00

14 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        31 sync.WaitGroup和sync.Once
                        我们在前几次讲的互斥锁、条件变量和原子操作都是最基本重要的同步工具。在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语言专栏文章配套详细代码。