Go自带的包sync中有一个工具WaitGroup,从名字就可以看出,它可以帮助我们控制并发的流程。

Go的并发是通过goroutine来处理,有时候我们希望控制某一组routine的执行,比如全部执行完了,再进行下一步,这时会想到goroutine的搭档channel,我们可以通过channel的阻塞,来完成流程控制。

不过有了sync.WaitGroup,代码写起来会清晰许多,它的用法非常简单,只有三个方法:

  • Add(delta int)
  • Done()
  • Wait()

通过Add,增加需要等待的任务数,Done会将数目减一,而Wait会将程序阻塞,知道Done方法将数目降至为0。

这是一个具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
var wg sync.WaitGroup
n := 10
var counter int32
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
atomic.AddInt32(&counter, 1)
fmt.Println(i)
wg.Done()
}(i)
}
wg.Wait()
fmt.Println("counter:", counter)
}

上面的例子中,for循环启用了10条goroutine,而在主程序中,使用wg.Wait()等待协程的完成。

好了,上面只是铺垫,之所以记录这篇文,主要还是因为看到有文章在介绍WaitGroup时,有隐性的bug存在。

比如说,把上面的代码改成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
var wg sync.WaitGroup
var counter int32
n := 10
for i := 0; i < n; i++ {
go func(i int) {
wg.Add(1)
atomic.AddInt32(&counter, 1)
// counter += 1 // Dangerous!!
wg.Done()
}(i)
}
wg.Wait()
fmt.Println("counter:", counter)
}

这里就会出现问题了,可能n的数值比较小,运行很多次都未必出现问题,counter绝大概率下还是会输出10。
但这正是并发程序的可怕之处,运行几个月才出现问题,往往让人一头雾水。
n调到100、1000,问题就出现了,我们会发现,wg.Wait()失灵了,它没等到所有goroutine执行完毕,就已经接着走了。

问题就在于wg.Add(1),我们知道wg.Wait()只要判断它自身没有在等待的任务,便不锁住程序,那只要goroutine的执行够快,下一条wg.Add(1)还来不及运行的时候,就被wg.Done()抢先一步,自然wg.Wait()就完成了。

不知道上面的描述你能否理解,其实类似这种问题,可能并非所有人都能一时明白过来,更多的是靠理解与经验,所以并发容易出问题也是这个原因。

关于WaitGroup我还看到另外一个记录的问题,就是有些童鞋将它传递到具体的函数里面去执行,然后就发现它不生效了,这个跟Go的引用与拷贝有关,如果直接传入WaitGroup,实际上它不是一个引用,而是一份拷贝,那么如果是在里面进行的Add操作,那必然对原先的参数是不起作用的,这一点需要去看更多关于Go函数传值的内容。