waitgroup 本身线程安全,但不保证业务数据安全;典型错误包括并发修改非原子变量导致丢值,以及闭包捕获循环变量引发 done() 错配。

sync.WaitGroup 本身不会成为高并发瓶颈,但用法错误会让它暴露底层竞态,进而引发不可预测的逻辑失败——这不是 WaitGroup 慢,而是你没保护好共享数据。
WaitGroup 的 Add/Wait/Done 不是原子操作的保护伞
很多人以为 wg.Wait() 返回时,所有 goroutine 对变量的修改就“一定完成且安全”。错。WaitGroup 只管计数器,不管内存访问。比如下面这个典型错误:
多个 goroutine 并发执行 x++,而 x 是普通 int 变量——x++ 在底层是读→改→写三步,没有锁或原子操作,必然丢值。即使 wg.Wait() 正确返回,x 也可能远小于预期值。
- 根本问题不在
WaitGroup,而在你把“等待完成”和“数据安全”混为一谈 -
WaitGroup的计数器内部用了atomic,所以它自己线程安全;但你的业务变量不是 - 这种 bug 表现随机:有时输出 100,有时 97、92,本地跑 10 次可能都对,压测时才崩
闭包捕获循环变量导致的 Done() 错配
在 for 循环里启动 goroutine 时,如果直接引用循环变量(如 i),又没传参或拷贝,会导致多个 goroutine 共享同一个变量地址,wg.Done() 可能被少调、多调甚至 panic。
错误写法:
for i := 0; i
-
i是循环外变量,所有匿名函数闭包捕获的是同一地址 - 循环结束时
i == 5,但 goroutine 还没全启动,结果全打 5,更糟的是wg.Done()可能因 panic 没执行 - 正确做法:显式传参
go func(i int)或用局部变量val := i再闭包
WaitGroup 被重复使用或跨生命周期误用
sync.WaitGroup 不可重置,也不支持复用。一旦 wg.Wait() 返回,内部计数器归零,再调 wg.Add() 会触发未定义行为(Go 1.21+ 会 panic)。
- 常见误用:把
var wg sync.WaitGroup定义成包级变量,多个请求共用 - 或在 HTTP handler 里反复
wg.Add(n); wg.Wait(),第二次调用前没重新声明 - 高并发下这种误用会快速暴露:panic 报错
sync: negative WaitGroup counter或死锁 - 替代方案:每次需要就声明新
var wg sync.WaitGroup,或用sync.Pool复用结构体(但需保证无残留状态)
真正该担心的不是 WaitGroup,而是没开 race detector
上面所有问题,在开发阶段几乎无法靠肉眼发现。Go 提供的 go run -race 或 go build -race 是唯一可靠手段。
- 不加
-race编译运行,程序可能“看起来正常”,上线后在高并发或特定调度路径下才出错 - 竞态检测会明确指出哪一行读、哪一行写、哪个 goroutine 冲突,比日志排查快十倍
- CI 流程中必须加入
-race构建验证,尤其涉及批量任务、定时聚合、缓存刷新等场景
最常被忽略的一点:WaitGroup 的“正确”只体现在计数逻辑上;真正的并发安全,永远要落在你对共享变量的保护上——别让它替你背锅。











