在 Go 中,sync 包下的 WaitGroup 能有助于我们控制协程之间的同步。当需要等待一组协程都执行完各自任务后,才能继续后续逻辑。这种场景,就非常适合使用它。但是,在使用 WaitGroup 的过程中,你可能会犯错误,下文我们将通过示例逐步探讨。
任务示例
初始任务
假设我们有以下任务 woker,它执行的任务是将参数 msg 打印出来。
func worker(msg string) {
fmt.Printf("worker do %s\n", msg)
}
func main() {
worker("task 1")
fmt.Println("main exit")
}
执行结果如下
worker do task 1
main exit
更多任务
如果有更多的任务需要处理
func worker(msg string) {
fmt.Printf("worker do %s\n", msg)
}
func main() {
worker("task 1")
worker("task 2")
worker("task 3")
fmt.Println("main exit")
}
它们依次执行的结果
worker do task 1
worker do task 2
worker do task 3
main exit
并发执行
依次执行可以完成所有任务,但由于任务间没有依赖性,并发执行是更好的选择。
func worker(msg string) {
fmt.Printf("worker do %s\n", msg)
}
func main() {
go worker("task 1")
go worker("task 2")
go worker("task 3")
fmt.Println("main exit")
}
但这样,我们大概率得到这样的结果
使用 WaitGroup
WaitGroup 提供三个 API。
- Add(delta int) 函数提供了 WaitGroup 的任务计数,delta 的值可以为正也可以为负,通常在添加任务时使用。
- Done() 函数其实就是 Add(-1),在任务完成时调用。
- Wait() 函数用于阻塞等待 WaitGroup 的任务们均完成,即阻塞等待至任务数为 0。
我们将代码改写如下
var wg sync.WaitGroup
func worker(msg string) {
wg.Add(1)
defer wg.Done()
fmt.Printf("worker do %s\n", msg)
}
func main() {
go worker("task 1")
go worker("task 2")
go worker("task 3")
fmt.Println("waiting")
wg.Wait()
fmt.Println("main exit")
}
执行结果可能
waiting
worker do task 1
worker do task 3
worker do task 2
main exit
同样也可能
waiting
worker do task 2
worker do task 1
main exit
还有可能
虽然main exit总会在最后打印输出,但并发任务未均如愿得到执行。
全局变量改为传参
也许是我们不应该将 wg 设为全局变量?那改为函数传参试试。
func worker(msg string, wg sync.WaitGroup) {
wg.Add(1)
defer wg.Done()
fmt.Printf("worker do %s\n", msg)
}
func main() {
var wg sync.WaitGroup
go worker("task 1", wg)
go worker("task 2", wg)
go worker("task 3", wg)
fmt.Println("waiting")
wg.Wait()
fmt.Println("main exit")
}
但执行结果显然更不对了
值传递改为指针传递
如果去查看 WaitGroup 的这三个 API 函数,你会发现它们的方法接收者都是指针。
我们使用值传递 WaitGroup,那就意味着在函数中使用的 wg 是一个复制对象。而 WaitGroup 的定义描述中有提及:使用过程中它不能被复制(详细原因可以查看菜刀历史文章no copy 机制)。
因此,我们需要将 WaitGroup 的参数类型改为指针。
func worker(msg string, wg *sync.WaitGroup) {
wg.Add(1)
defer wg.Done()
fmt.Printf("worker do %s\n", msg)
}
func main() {
var wg sync.WaitGroup
go worker("task 1", &wg)
go worker("task 2", &wg)
go worker("task 3", &wg)
fmt.Println("waiting")
wg.Wait()
fmt.Println("main exit")
}
那这样是不是就可以了呢?
waiting
worker do task 3
worker do task 2
worker do task 1
main exit
看着好像符合预期了,但是如果多次执行,你发现可能会得到这样的结果。
worker do task 2
waiting
worker do task 1
worker do task 3
main exit
或者这样
竟然还有问题?!
执行顺序
其实问题出在了执行顺序。
注意,wg.Add(1)我们是在 worker 函数中执行,而不是在调用方(main函数)。通过 Go 关键字让一个 gotoutine 执行起来存在一小段的滞后时间。而这就会存在问题:当程序执行到了wg.Wait()时,前面的 3 个goroutine 并不一定都启动起来了,即它们不一定来得及调用wg.Add(1)。(这个 goroutine 滞后的问题其实也是上文并发执行未能得到预期结果的原因所在。)
例如最后一个结果,每个 worker 都还来不及执行wg.Add(1),main 函数就已经执行到wg.Wait(),此时它发现任务计数是0,所以就直接非阻塞执行后续 main 函数逻辑了。
对于这个问题,我们的解决方案是:
- 在 main 函数调用worker前就应该执行wg.Add(1)来给任务准确计数;
- 避免潜在复制风险,不再传递 WaitGroup 参数;
- 将wg.Done()从worker中移出,与wg.Add()调用形成对应。
func worker(msg string) {
fmt.Printf("worker do %s\n", msg)
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
worker("task 1")
}()
wg.Add(1)
go func() {
defer wg.Done()
worker("task 2")
}()
wg.Add(1)
go func() {
defer wg.Done()
worker("task 3")
}()
fmt.Println("waiting")
wg.Wait()
fmt.Println("main exit")
}
这样,无论执行多少次,结果都能符合预期要求。
waiting
worker do task 3
worker do task 2
worker do task 1
main exit
事实上,上述写法不够简洁。当大量相同子任务通过 goroutine 执行时,我们应该采用 for 语句来编写代码。
func worker(msg string) {
fmt.Printf("worker do %s\n", msg)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
worker(fmt.Sprintf("task %d", i+1))
}(i)
}
fmt.Println("waiting")
wg.Wait()
fmt.Println("main exit")
}
总结
我们可以将 WaitGroup 的核心使用姿势总结为如下模版
wg.Add(1)
go func() {
defer wg.Done()
YourFunction()
}()
在进入 goroutine 之前执行wg.Add(1),goroutine 中的第一行代码为defer wg.Done()。
这样,我们能让调用方(例子中的main函数)有效地控制任务数,同时既避免了传递 WaitGroup 的风险,又能让子任务YourFunction()只关心自身逻辑。
从本文的例子可以看出,在并发编程时,一定要采用正确的使用姿势,否则很容易产生让人困惑的问题。