Go并发9 并发原语 - 任务编排 - WaitGroup使用的常见错误

77 阅读3分钟
WaitGroup使用的常见错误

我们在开发的时候,经常会遇见或看到误用 WaitGroup 的场景,究其原因就是没有弄明白这些检查的逻辑,下面我们列举一下几个典型错误使用案例以及WG的错误检查原则。

1. 计数器设置为负值
WaitGroup 的计数器的值必须大于等于 0。我们在更改这个计数值的时候,WaitGroup 会先做检查,如果计数值被设置为负数,就会导致 panic:

func main() {
 var wg sync.WaitGroup
 wg.Add(10)
 wg.Add(-10)//将-10作为参数调用Add,计数值被设置为0
 wg.Add(-1)//将-1作为参数调用Add,如果加上-1计数值就会变为负数。这是不对的,所以会触发
}

2. 调用 Done 方法的次数过多,超过了 WaitGroup 的计数值

func main() {
 var wg sync.WaitGroup
 wg.Add(1)
 wg.Done()
 wg.Done()
}

Tips: 使用 WaitGroup 的正确姿势是,预先确定好 WaitGroup 的计数值,然后调用相同次数 的 Done 完成相应的任务。

3. 不期望的 Add 时机
我们需要等所有的 Add 方法调用之后再调用 Wait,否则就可能导致 panic 或者不期望的结果。

下面这段代码将 WaitGroup.Add 方法的调用放在了子 gorotuine 中,等主 goorutine 调用 Wait 的时候,因为四个任务 goroutine 一开始都休眠,所以可能 WaitGroup 的 Add 方法还没有被调用,WaitGroup 的计数还是 0,所以它并没有等待四个子 goroutine 执行完毕才继续执行,而是立刻执行了下一步。

func main() {
 var wg sync.WaitGroup
 go dosomething(100, &wg) // 启动第一个goroutine
 go dosomething(110, &wg) // 启动第二个goroutine
 go dosomething(120, &wg) // 启动第三个goroutine
 go dosomething(130, &wg) // 启动第四个goroutine
 wg.Wait() // 主goroutine等待完成
 fmt.Println("Done")
}
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
 duration := millisecs * time.Millisecond
 time.Sleep(duration) // 故意sleep一段时间
 wg.Add(1)
 fmt.Println("后台执行, duration:", duration)
 wg.Done()
}

改进方法
预先设置计数值,将Add放在主方法中:

func main() {
 var wg sync.WaitGroup
 wg.Add(4) // 预先设定WaitGroup的计数值
 go dosomething(100, &wg) // 启动第一个goroutine
 go dosomething(110, &wg) // 启动第二个goroutine
 go dosomething(120, &wg) // 启动第三个goroutine
 go dosomething(130, &wg) // 启动第四个goroutine
 wg.Wait() // 主goroutine等待
 fmt.Println("Done")
}
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
 duration := millisecs * time.Millisecond
 time.Sleep(duration)
 fmt.Println("后台执行, duration:", duration)
 wg.Done()
}

或者我们可以将Add放在执行子方法之前:

func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
 wg.Add(1) // 计数值加1,再启动goroutine
 go func() {
 duration := millisecs * time.Millisecond
 time.Sleep(duration)
 fmt.Println("后台执行, duration:", duration)
 wg.Done()
 }()
}

4. 前一个 Wait 还没结束就重用 WaitGroup 当WG的计数为0时,我们是可以重用的,但是有时我们没等计数归0就开始重用WG,这样会导致出错:

func main() {
 var wg sync.WaitGroup
 wg.Add(1)
 go func() {
 time.Sleep(time.Millisecond)
 wg.Done() // 计数器减1
 wg.Add(1) // 计数值加1
 }()
 wg.Wait() // 主goroutine等待,有可能和第7行并发执行
 }

Tips: WaitGroup 虽然可以重用,但是是有一个前提的,那就是必须等到上一轮的 Wait 完成之后,才能重用 WaitGroup 执行下一轮的 Add/Wait,如果你在 Wait 还没执行完的时候就调用下一轮 Add 方法,就有可能出现 panic。

noCopy:辅助 vet 检查
用于指示 vet 工具在做检查的时候,这个数据结构不能做值复制使用。更严谨地说,是不能在第一次使用之后复制使用 ( must not be copied after first use)。

通过给 WaitGroup 添加一个 noCopy 字段,我们就可以为 WaitGroup 实现 Locker 接口,这样 vet 工具就可以做复制检查了。而且因为 noCopy 字段是未输出类型,所以 WaitGroup 不会暴Lock/Unlock 方法。

type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}

使用WaitGroup的5条最佳实践

  1. 不重用 WaitGroup。新建一个 WaitGroup 不会带来多大的资源开销,重用反而更容易出错。
  2. 保证所有的 Add 方法调用都在 Wait 之前。
  3. 不传递负数给 Add 方法,只通过 Done 来给计数值减 1。
  4. 不做多余的 Done 方法调用,保证 Add 的计数值和 Done 方法调用的数量是一样的。
  5. 不遗漏 Done 方法的调用,否则会导致 Wait hang 住无法返回。