8.5 WaitGroup

我们在第6章多次提到过sync.WaitGroup类型和它的方法。sync.WaitGroup类型的值也是开箱即用的。例如,在声明

  1. var wg sync.WaitGroup

之后,我们就可以直接正常使用wg变量了。该类型有3个指针方法,即AddDoneWait

类型sync.WaitGroup是一个结构体类型。在它之中有一个代表计数的字段。当一个sync.WaitGroup类型的变量被声明之后,其值中的那个计数值将会是0。我们可以通过该值的Add方法增大或减少其中的计数值。例如:

  1. wg.Add(3)

  1. wg.Add(-3)

虽然Add方法接受一个int类型的值,并且我们也可以通过该方法减少计数值,但是我们一定不要让计数值变为负数。因为这样会立即引发一个运行恐慌。这也代表着我们对sync.WaitGroup类型值的错误使用。

除了调用sync.WaitGroup类型值的Add方法并传入一个负数之外,我们还可以通过调用该值的Done来使其中的计数值减一。也就是说,下面这3条语句与wg.Add(-3)的执行效果是一致的:

  1. wg.Done()
  2. wg.Done()
  3. wg.Done()

使用该方法的禁忌与Add方法的一样——不要让值中的计数值变为负数。例如,这段代码中的第5条语句会引发一个运行时恐慌:

  1. var wg sync.WaitGroup
  2. wg.Add(2)
  3. wg.Done()
  4. wg.Done()
  5. wg.Done()

我们现在知道,使用sync.WaitGroup类型值的Add方法和Done方法可以变更其中的计数值。那么变更这个计数值有什么用呢?

当我们调用sync.WaitGroup类型值的Wait方法的时候,它会去检查该值中的计数值。如果这个计数值为0,那么该方法会立即返回,且不会对程序的运行产生任何影响。 但是,如果这个计数值大于0,那么该方法的调用方所属的那个Goroutine就会被阻塞。直到该计数值重新变为0之时,为此而被阻塞的所有Goroutine才会被唤醒。

这个类型的值一般被用来协调多个Goroutine的运行。假设,在我们的程序中启用了4个Goroutine,分别是G1、G2、G3和G4。其中,G2、G3和G4是由G1中的代码启用并被用于执行某些特定任务的。G1在启用这3个Goroutine之后要等待这些特定任务的完成。在这种情况下,我们有两个方案。

第一个方案是使用前文讲到的通道来传递任务完成信号。例如,我们在启用G2、G3和G4之前声明这样一个通道:

  1. sign := make(chan byte, 3)

然后,在G2、G3和G4执行的任务完成之后,立即向该通道发送代表了某个任务已被执行完成的元素值:

  1. go func() { // G2
  2. // 省略若干条语句
  3. sign <- 2
  4. }()
  5. go func() { // G3
  6. // 省略若干条语句
  7. sign <- 3
  8. }()
  9. go func() { // G4
  10. // 省略若干条语句
  11. sign <- 4
  12. }()

最后,在启用这几个Goroutine之后,我们还要在G1执行的函数中添加类似以下的代码,以等待相关的任务完成信号:

  1. for i := 0; i < 3; i++ {
  2. fmt.Printf("G%d is ended.\n", <-sign)
  3. }
  4. // 省略若干条语句

这样的方法固然是有效的。上面的这条for语句会等到G2、G3和G4都被运行结束之后才会被执行结束,继而其后面的语句才会得以执行。sign通道起到了协调这4个Goroutine的运行的作用。

不过,对于这样一个简单的协调工作来说,使用通道是否过重了?或者说,通道sign是否被大材小用了?通道的实现中包含了很多专为并发安全的传递数据而建立的数据结构和算法。原则上说,我们不应该把通道当作互斥锁或信号灯来说用。在这里使用它并没有体现出它的优势,反而会在代码易读性和程序性能方面打一些折扣。

该需求的第二个方案就是使用sync.WaitGroup类型值。对应的代码如下:

  1. var wg sync.WaitGroup
  2. wg.Add(3)
  3. go func() { // G2
  4. // 省略若干条语句
  5. wg.Done()
  6. }()
  7. go func() { // G3
  8. // 省略若干条语句
  9. wg.Done()
  10. }()
  11. go func() { // G4
  12. // 省略若干条语句
  13. wg.Done()
  14. }()
  15. wg.Wait()
  16. fmt.Println("G2, G3 and G4 are ended.")

可以看到,我们在启用G2、G3和G4之前先声明了一个sync.WaitGroup类型的变量wg,并调用其值的Add方法以使其中的计数值等于将要额外启用的Goroutine的个数。然后,在G2、G3和G4的运行即将结束之前,我们分别通过调用wg.Done方法将其中的计数值减去1。最后,我们在G1中调用wg.Wait方法以等待G2、G3和G4中的那3个对wg.Done方法的调用的完成。待这3个调用完成之时,在wg.Wait()处被阻塞的G1会被唤醒,它后面的那条语句也会被立即执行。

不论是Add方法还是Done方法,它们在被执行的时候都会在增大或减小其所属值中的那个计数值之后对它进行判断。如果该计数值为0,那么该方法就会唤醒所有已为此而被阻塞的Goroutine(如果有的话)。这些Goroutine即是在从该计数值最近一次变为正整数到此时(即重新变为0)的时间段内执行该sync.WaitGroup类型值的Wait方法的Goroutine。

显然,我们的第二个方案更加适合这里的应用场景。它在代码的清晰度和性能损耗方面都会更胜一筹。

在这里,我们可以总结出一些使用一个sync.WaitGroup类型值的方法和规则。

  • 对一个sync.WaitGroup类型值的Add方法的第一次调用,应该发生在对该值的Done方法进行调用之前。因为如果先调用了Done方法,那么就会使该值中的计数值小于0,继而引发运行时恐慌。由于这两个方法通常不会在同一个Goroutine中被调用,所以调用Add方法的时机还应该提前到将会调用该值的Done方法的那个或那些Goroutine被启用之前。

  • 对一个sync.WaitGroup类型值的Add方法的第一次调用,同样应该发生在对该值的Wait方法进行调用之前。如果在我们调用Wait方法的时候该值的计数值等于0,那么该方法将会直接返回而不会阻塞调用方所属的Goroutine。这往往是与我们的期望相悖的。

  • 在一个sync.WaitGroup类型值的生命周期内,其中的计数值总是由起初的0变为某个正整数(或先后变为某几个正整数),然后再回归为0。我们把完成这样一个变化曲线所用的时间称为一个计数周期,如图8-1所示。

如图8-1所示,计数值的每次变化都是由对其所属值的Add方法或Done方法的调用引起的。一个计数周期总是从对其所属值的Add方法的调用开始的,并且也总是以对其所属值的Add方法或Done方法的调用为结束标志的。我们若在一个计数周期之内(不包含计数值等于0的两端)调用其所属值的Wait方法,则会使调用方所在的Goroutine被阻塞,直至该计数周期结束的那一刻。

8.5 WaitGroup - 图1

图 8-1 sync.WaitGroup类型值的计数值的变化曲线示意

  • sync.WaitGroup类型值是可以被复用的。也就是说,此类值的生命周期可以包含任意个计数周期。一旦一个计数周期结束,我们在前面对该值的那些方法的调用所产生的作用就会消失。也就是说,它们不会影响到后续计数周期中的该值的计数值以及参与改变该计数值的各方。换句话讲,一个sync.WaitGroup类型值在其每个计数周期中的状态和作用都是独立的。

最后,值得说明的是,在sync.WaitGroup类型及其方法中也用到了在前面章节中提到的互斥锁、原子操作和信号灯机制。这使得我们总是可以在任意个Goroutine中并发地调用同一个sync.WaitGroup类型值的那些方法。也就是说,它们都是并发安全的。

本节所讲的sync.WaitGroup类型提供了一种方式,使我们可以对多个Goroutine的运行进行简单的协调。这得益于它提供的那几个以计数值为基础的方法,以及它的并发安全特性。只要理解了每个方法对计数值的操纵方式以及意义,我们就可以用好该类型的值了。我们刚刚说明的那些使用方法和规则,对理解该类型及其方法应该是非常有帮助的。