WaitGroup.Add()必须在goroutine启动前调用,否则易致panic或死锁;Done()需防panic导致未执行;传参必须用指针,禁止复制。

WaitGroup.Add() 必须在 go 语句之前调用
这是最常导致 panic: sync: negative WaitGroup counter 或死锁的地方。WaitGroup 不是“动态注册器”,它的计数器必须在任何 go 启动前就确定好总数。
-
Add(1)放在 goroutine 内部?极大概率 panic——因为Done()可能已执行,而计数器还没加 - 循环中漏掉一次
Add()(比如某个分支跳过)?Wait()永远不返回 - 想“边跑边加”?不行。WaitGroup 不支持运行时扩容;真要动态任务数,得用
sync/atomic.Int64+ channel 或改用errgroup.Group
正确姿势:先数清要启多少个 goroutine,一次性 wg.Add(n),再启动所有 go:
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
// ...
}(url)
}
defer wg.Done() 看似安全,但有隐藏失效风险
defer wg.Done() 在函数正常返回时没问题,但一旦 goroutine 内部 panic,defer 就不会触发,Wait() 会永远卡住——而且不报错、不崩溃,只“静默挂起”。
- panic 后没 recover?
Done()就丢了,计数器永远不归零 - 别写
defer wg.Add(-1)——语义错、线程不安全、还容易和Add(1)混淆 - 更稳妥的做法:把
wg.Done()放在函数末尾显式调用,并用defer func(){...}()包一层 recover(仅当业务允许忽略 panic 时)
简单场景下,用 recover 补一手即可:
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
go func() {
defer func() {
if r := recover(); r != nil {
// 记日志
}
wg.Done()
}()
// 可能 panic 的逻辑
}()
WaitGroup 不可复制,传参必须用指针
WaitGroup 是 struct,内部含 mutex 和计数器。值传递(比如函数参数写 wg sync.WaitGroup)等于拷贝一份独立计数器,主 goroutine 调 Wait() 等的其实是空壳,子 goroutine 的 Done() 对它毫无影响。
- 函数参数写成
wg *sync.WaitGroup,调用时传&wg - 别写
w2 := wg,这是隐式复制,后续w2.Add()和wg.Wait()完全脱节 - 结构体字段里存 WaitGroup?不行。必须存
*sync.WaitGroup,初始化时 new 出来或取地址
闭包里也一样:循环变量捕获 + 值传递 = 两重坑。务必显式传参:
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int, w *sync.WaitGroup) {
defer w.Done()
fmt.Println("task", id)
}(i, &wg)
}
WaitGroup 只管数量,不管数据安全
它不解决竞态。多个 goroutine 并发读写同一个 map、slice 或普通变量,哪怕 Wait() 返回了,结果也可能错乱甚至 panic。
-
WaitGroup和sync.Mutex/sync.Map/channel是互补关系,不是替代关系 - 想收集结果?别往共享 slice 里 append——用
chan接收每个 goroutine 的输出更自然 - 需要错误传播或上下文取消?
errgroup.Group才是正解,WaitGroup压根没这能力
它只是个计数器:你告诉它“我要等 5 个”,它就数到 5 个 Done(),然后放行。至于那 5 个干了啥、有没有写坏内存、有没有超时——它一概不管。
最难绷的是:sync.WaitGroup{} 的零值可以直接用,所以你可能写了半年都没意识到自己一直用着未显式初始化的实例,直到某次高并发下计数器原子操作竞争暴露问题。

















