Menu

Go WaitGroup:Add、Done、Wait 与工作池

sync.WaitGroup 如何等待一组 goroutine 结束:Add、Done 和 Wait 的规则,为什么必须传指针,如何收集结果和错误,以及基于它构建的工作池。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

基本用法

sync.WaitGroup 统计正在运行的 goroutine。Add 增加计数,Done 减少计数,Wait 阻塞到计数为零为止。

三个下载任务并发执行,所以程序大约只用 10 毫秒,而不是 30 毫秒。结果按输入顺序打印,因为每个 goroutine 只写入 sizes 中属于自己的那个下标,而 main 只在 Wait 之后才读取它们。

WaitGroup 的零值可以直接使用,不需要构造函数。

三条规则

在 go 之前调用 Add,不要在 goroutine 内部调用。 如果由 goroutine 自己调用 Add,main 可能在任何 goroutine 启动之前就执行到 Wait,看到计数为零,于是在工作还没开始时就返回了。如果事先知道数量,在循环之前调用一次 wg.Add(len(files)) 也是等价的。

在 goroutine 的第一行用 defer 调用 Done。 这样即使 goroutine 因为错误提前返回,或者发生 panic,计数器也照样会减一。漏掉 Done 会让 Wait 永远阻塞。如果它是最后剩下的 goroutine,运行时会报告 fatal error: all goroutines are asleep,堆栈中会出现 sync.WaitGroup.Wait。

首次使用之后,绝不要复制 WaitGroup。 给函数传 *sync.WaitGroup,或者像上面那样在闭包中捕获这个变量。

把 WaitGroup 传给函数

当 goroutine 的主体是一个具名函数时,传指针:

如果把 wg sync.WaitGroup 写成值参数,每个 worker 都会在自己的副本上调用 Done,main 就会在 Wait 里永远阻塞。go vet 在你运行之前就能发现这个问题:

./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy

更清晰的设计是让 worker 完全不涉及并发:把它写成普通函数,Add/Done 的记账工作放在调用方的闭包里完成。这样 worker 既容易测试,也能同步调用。

计数器为负

Done 就是 Add(-1)。如果计数降到零以下,程序会 panic:

输出是 recovered: sync: negative WaitGroup counter。常见的原因是某个 goroutine 写了 defer wg.Done(),又在某条路径上显式调用了 wg.Done()。

收集错误

WaitGroup 只负责计数。要处理错误,就给每个 goroutine 分配一个专属位置,在 Wait 之后再检查:

errors.Join(Go 1.20)会跳过 nil 值,如果全部都是 nil 就返回 nil,所以它能直接合并“每个 goroutine 一个错误”,不需要额外的记账代码。

如果你希望某个 goroutine 一失败就停止剩下的工作,改用 golang.org/x/sync/errgroup。它相当于 WaitGroup 加上第一个错误,再加上一个失败时会被取消的 context,g.SetLimit(n) 还能限制并发数。它不在标准库里,所以无法在本页的编辑器中运行:

g, ctx := errgroup.WithContext(ctx)
for _, h := range hosts {
	g.Go(func() error { return checkCtx(ctx, h) })
}
if err := g.Wait(); err != nil {
	return err // the first error; ctx was cancelled for the others
}

工作池

固定数量的 goroutine 从一个通道读取任务,不管有多少任务,并发数都有上限。WaitGroup 告诉你所有 worker 何时结束,那正是可以关闭结果通道的时候。

这三部分的顺序很重要:

  • worker 运行期间,main 必须一直在接收结果。如果 main 在读取之前直接调用 wg.Wait(),worker 会在向 results 发送时阻塞,永远执行不到 Done,整个程序就会死锁。所以 Wait 要放在单独的 goroutine 里运行。
  • close(results) 只在 Wait 之后执行,所以不会有 worker 向已关闭的通道发送数据。
  • 投放任务的代码也在一个 goroutine 里运行,这样投放和收集可以同时进行。

哪个 worker 处理哪个任务每次运行都不一样,所以程序在打印前先按任务排序。它打印的所有内容都是确定的。

WaitGroup、通道还是 errgroup

需求用法
等待 N 个 goroutine,结果写入按下标划分的位置sync.WaitGroup
等待一个 goroutine一个 done 通道,或者结果通道本身
结果在完成时陆续传出一个通道,在 wg.Wait() 之后关闭
出现第一个错误时停止所有任务errgroup.WithContext
超时或调用方取消时停止所有任务context.Context 加上 WaitGroup 或 errgroup

Go 1.25 新增了 wg.Go(func() { ... }),它会替你完成 Add(1) 和延迟调用的 Done。针对 Go 1.24 及更早版本的代码,包括本页的编辑器,使用上面展示的显式写法。

常见错误

  • 在 goroutine 内部调用 wg.Add(1)。 Wait 可能在它执行之前就返回。
  • 提前返回时忘记调用 Done。 始终使用 defer wg.Done()。
  • 按值传递 WaitGroup。 使用指针;go vet 会标记出这个副本。
  • 在必须读空某个通道的同一个 goroutine 里等待。 把 wg.Wait() 和 close 移到一个单独的 goroutine 里。
  • 上一次 Wait 还没返回就复用 WaitGroup。 只有在 Wait 结束之后,才开始新一轮的 Add 调用。

常见问题

Go 中的 sync.WaitGroup 是如何工作的?

WaitGroup 就是一个计数器。wg.Add(n) 让它增加,wg.Done() 让它减一,wg.Wait() 会阻塞,直到它变为零。在启动每个 goroutine 之前调用 Add,在 goroutine 里写 defer wg.Done(),在需要所有任务都完成的地方调用 Wait。

WaitGroup 应该按值传递还是按指针传递?

按指针传递(*sync.WaitGroup),或者让 goroutine 通过闭包捕获它。副本有自己的计数器,所以在副本上调用 Done 永远影响不到原来的那个,Wait 会一直阻塞。go vet 会把这个错误报告为“passes lock by value”。

“sync: negative WaitGroup counter”是什么原因造成的?

Done 的调用次数比 Add 多。通常是某个 goroutine 调用了两次 Done(一次用 defer,一次显式调用),或者某条路径上漏掉了 Add(1)。程序会 panic,因为这个计数器已经无法反映真实情况。

如何获取用 WaitGroup 启动的 goroutine 返回的错误?

WaitGroup 不携带任何结果或错误。给每个 goroutine 在错误切片里分配一个专属位置,在 Wait 之后再合并它们(例如用 errors.Join),或者使用 golang.org/x/sync/errgroup,它的 Wait 会返回第一个错误,还能通过 context 取消其余的 goroutine。

Coddy 编程语言插图

和 Coddy 一起学编程

开始