sync.WaitGroup是一种等待n个操作完成的机制,通常,我们使用它来等待n个goroutine完成。下面将学习它的使用方法,然后将看到一个高频错误使用问题,以及这个问题导致的不确定性行为。
下面的代码创建了一个sync.WaitGroup对象,并且为默认的零值。
wg := sync.WaitGroup{}
在内部实现上,sync.WaitGroup拥有一个默认初始化零的内部计数器。我们可以使用Add(int)方法增加这个计数器,使用Done()或者Add一个负数来减小计数器。最后需要知道的一点是,如果想等待计数器为零,必须使用Wait()方法,该方法在计数器不为零时会阻塞。
「NOTE:sync.WaitGroup计数器的值不能负数,否则会产生panic.」
下面的示例程序中,初始化了一个WaitGroup对象,启动3个goroutine并发的将v的值增加1,通过WaitGroup等待这3个goroutine完成。最后,当3个goroutine都执行完成后,打印计数器v的值(本应该打印3)。你能猜测这段代码是否存在问题?
wg := sync.WaitGroup{}
var v uint64
for i := 0; i < 3; i++ {
go func() {
wg.Add(1)
atomic.AddUint64(&v, 1)
wg.Done()
}()
}
wg.Wait()
fmt.Println(v)
如果我们运行上面的代码,得到的是一个不确定的值,它可能打印0到3中的任何值。此外,如果加入-race启用数据竞争检查,在运行时甚至会捕获到存在数据竞争。我们使用的是sync/atomic原子包操作v自增,怎么可能存在数据竞争问题呢?到底是哪里有问题呢?
下面是上面的程序加入-race执行的结果:
─ go run -race example1.go
2
==================
WARNING: DATA RACE
Write at 0x00c00001c108 by goroutine 9:
sync/atomic.AddInt64()
...
Previous read at 0x00c00001c108 by main goroutine:
main.main()
...
上面代码存在的问题是wg.Add(1)操作在新的goroutine内部执行,而不是在父goroutine执行的。因此,这不能保证我们希望在调用wg.Wait()之前等待三个goroutine的本意。
下面是程序打印输出2的一个可能的执行流程。主goroutine启动了3个子goroutine。然而最后一个子goroutine是在前两个子goroutine已经调用wg.Done()之后执行的。此时,主goroutine调用wg.Done()不会被阻塞,当它读取v时,此时v的值为2.竞争检测器会检查到存在竞争问题,因为此时主goroutine对v有访问操作,而第三个子goroutine对v有修改操作。
在处理goroutine时,需要记住的一点是如果没有同步机制,goroutine之间的执行顺序是不确定的,像下面的程序可能打印ab也可能打印ba.
go func() {
fmt.Print("a")
}()
go func() {
fmt.Print("b")
}()
事实上,上面的两个goroutine可以分配给不同的线程,并且不能保证哪个线程会先执行。CPU必须使用所谓的内存栅栏(也称为内存屏障)来保证顺序。Go语言提供了不同的同步技术实现内存栅栏,例如sync.WaitGroup
, 它保证了wg.Add和wg.Wait之间的happens-before关系。
现在回到本文最开始的例子,主要有两种方法修复它存在的问题。一种处理方法如下,在循环之前调用wg.Add操作。
wg := sync.WaitGroup{}
var v uint64
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
// ...
}()
}
// ...
另一种方法是在每个循环的内部,但在启动子goroutine之前调用wg.Add操作,代码如下。
wg := sync.WaitGroup{}
var v uint64
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
// ...
}()
}
// ...
上面的两种处理方法都是正确的。如果我们提前知道最终要设置的计数器的值是多少,那使用第一种处理方法可以只调用一次wg.Add,不像第二种处理方法要多次调用wg.Add操作,但有一点需要注意,后续操作等待的操作次数要与wg.Add添加的相同,这样可以避免一些细微的错误,例如wg.Add的值为3,但是后续等待的操作次数为2,这会导致永久阻塞。
总结,我们在编程时要小心别犯本文讨论的这个常见错误。在使用sync.WaitGroup时,Add操作必须在启动子goroutine之前,在父goroutine中执行完成,而Done操作必须在子goroutine内部执行完成。