waitgroup 本身不导致 cpu 飙升,但误用会引发 goroutine 泄漏、死循环或高频调度,最终 cpu 持续接近 100%;典型原因是 wg.done() 未执行、值传递、defer 位置错误、add/done 不配对,以及并发 add 竞态。

WaitGroup 本身不会导致 CPU 飙升;但用错它,会间接引发高频 goroutine 调度、死循环或 goroutine 泄漏,最终表现为 CPU 持续接近 100%。
WaitGroup.Done() 没执行 → goroutine 积压不退出
这是最隐蔽也最典型的诱因:当 wg.Done() 因位置错误或值传递失效而从未被调用,wg.Wait() 就永远阻塞,但主 goroutine 并未退出,子 goroutine 却可能已“假完成”(比如提前 return 后函数结束,但计数器没减)。更糟的是,如果这些子 goroutine 内部还带了重试逻辑或轮询循环,它们就会持续运行、抢占调度器——CPU 就上去了。
-
defer wg.Done()必须放在函数第一行,否则任何return都会跳过它 - 传参必须是
*sync.WaitGroup,值传递会导致Done()修改副本,原计数器不变 - 检查是否在
recover()或 panic 恢复后遗漏了Done()调用
WaitGroup.Add() 和 Done() 不配对 → 计数器卡在正数或负数
计数器为负会直接 panic;但若长期卡在正数(比如 Add(1) 但 Done() 永远不触发),wg.Wait() 一直阻塞,而业务代码可能还在不断 spawn 新 goroutine(例如 HTTP handler 每次请求都起一个带 wg.Add(1) 的任务),导致 goroutine 数量指数级增长。runtime 调度器要管理数万 goroutine,光调度开销就能吃满一个核。
- 避免在循环中反复
wg.Add(1)却没有对应 Done —— 先统一批量 Add,再并发启动 - 不要在
wg.Wait()正在阻塞时,从其他 goroutine 并发调用wg.Add(),这有竞态风险 - 上线前加日志:
log.Printf("wg.Add(%d), current count: %d", n, atomic.LoadInt64(&wg.counter))(需反射或调试符号辅助,生产环境慎用)
用 pprof 确认是不是 WaitGroup 相关问题
别猜。先跑 go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2,看输出里有没有大量状态为 running 或 runnable 的 goroutine 停在你的任务函数入口;再跑 go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30,如果火焰图里热点集中在某个循环体、runtime.gopark 上方无业务栈,或者 sync.runtime_Semacquire 占比异常高,基本可锁定是同步原语卡死引发的调度风暴。
- 确保已启用 pprof:
import _ "net/http/pprof"+ 启动http.ListenAndServe("localhost:6060", nil) -
goroutine?debug=2输出会显示每个 goroutine 的完整调用栈和状态,重点关注 “created by” 行指向你代码的位置 - 如果看到几百上千个 goroutine 都停在同一个
downloadFromURL函数末尾,但没走到defer wg.Done(),就是典型值传递 or defer 位置错误
修复后务必验证 goroutine 数量是否回落
改完代码别急着上线。加一行监控:fmt.Printf("active goroutines: %d\n", runtime.NumGoroutine()),在 wg.Wait() 前后各打一次。正常情况应先升后降,且最终值接近初始水平(比如从 5 → 105 → 7)。如果 Wait 后仍是三位数甚至更高,说明还有 goroutine 没被正确回收——大概率是某处漏了 Done(),或存在闭包捕获了 wg 变量但用了旧地址。
真正麻烦的不是 WaitGroup 本身,而是它暴露出来的控制流断裂:你认为“这个任务结束了”,但运行时说“不,它还在跑,而且跑了 12746 次”。盯住计数器变化和 goroutine 生命周期,比优化算法更能快速止血。











