你是否因使用姿势不当,而在 WaitGroup 栽了跟头?

开发 前端
我们能让调用方(例子中的main函数)有效地控制任务数,同时既避免了传递 WaitGroup 的风险,又能让子任务YourFunction()只关心自身逻辑。

​在 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")
}

但这样,我们大概率得到这样的结果

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

还有可能

waiting
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")
}

但执行结果显然更不对了

waiting
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

或者这样

waiting
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()只关心自身逻辑。

从本文的例子可以看出,在并发编程时,一定要采用正确的使用姿势,否则很容易产生让人困惑的问题。​

责任编辑:武晓燕 来源: Golang技术分享
相关推荐

2022-02-28 10:12:10

Redis分布式开发

2020-08-20 10:16:56

Golang错误处理数据

2022-01-17 14:25:14

索引数据库搜索

2021-08-10 07:41:24

ContextWaitGroupGoroutine

2011-09-22 13:56:56

2021-08-26 14:26:25

Java代码集合

2012-07-17 16:10:05

BPMWebsphereIBM

2020-09-18 06:39:18

hashMap循环数据

2023-02-27 09:48:30

谷歌模型

2019-10-10 15:40:17

redisbug数据库

2022-06-21 11:24:05

多线程运维

2021-12-06 10:22:47

切片拷贝Python

2023-08-31 07:51:51

Polaris部署配置

2014-05-30 10:51:56

2017-12-19 22:05:26

2013-11-15 10:42:24

2017-02-23 15:37:44

OptionObject容器

2009-07-24 09:31:10

云计算脏水

2020-11-23 08:43:32

机器学习技术人工智能

2023-05-10 07:42:26

Java多线程编程
点赞
收藏

51CTO技术栈公众号