基本用法
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。