sync.waitgroup 是 go 中唯一可靠管理协程生命周期的方式,需严格遵循 add/done 配对、避免负计数、禁止外部重置、慎用 defer done,并结合 context 实现超时控制与 panic 恢复。

用 sync.WaitGroup 管理协程生命周期是唯一可靠方式
Go 没有内置的“等待所有 goroutine 结束”机制,sync.WaitGroup 是标准库中唯一被设计用于此目的的同步原语。它不依赖超时或轮询,而是通过显式计数控制退出时机。误用 time.Sleep 或空 select{} 会导致程序提前退出或卡死。
关键点:必须在启动 goroutine 前调用 WaitGroup.Add(1),且只能在 goroutine 内部调用 WaitGroup.Done() —— 如果在主 goroutine 中提前调用 Done(),会导致 Wait() 立即返回,后台任务还没开始就结束。
-
WaitGroup的计数器是 int 类型,不能为负;多次调用Done()而没配对Add()会 panic - 不要把
WaitGroup作为参数传给 goroutine 后,在外部修改其字段(比如直接赋值wg = sync.WaitGroup{}),这会破坏内部状态 - 若需动态增减任务数(如 worker pool 场景),每次新增任务前都必须调用
Add(1),哪怕在 goroutine 内部
避免在 defer 中调用 WaitGroup.Done() 导致的 panic
常见错误是写成:go func() { defer wg.Done(); ... }()。这看似简洁,但一旦 goroutine 内部 panic,defer 仍会执行 Done(),而此时 WaitGroup 可能已被主 goroutine 调用过 Wait() 并清零,导致 “negative WaitGroup counter” panic。
更安全的做法是确保 Done() 总在正常逻辑路径末尾显式调用,或包裹 recover:
go func() {
defer func() {
if r := recover(); r != nil {
// 日志记录 panic,但依然要 Done
log.Printf("worker panicked: %v", r)
}
wg.Done()
}()
// 实际工作...
}()
结合 context.Context 实现带取消的等待
单纯等所有 goroutine 结束不够 —— 很多场景需要“等它们做完,但最多等 5 秒,超时就强制退出”。这时不能只靠 WaitGroup,必须配合 context.WithTimeout。
典型结构是:主 goroutine 启动所有任务后,启动一个监听 ctx.Done() 的 goroutine,一旦超时或取消,就通知各 worker 停止工作;同时主 goroutine 在 WaitGroup.Wait() 上阻塞,但要用 select 包裹,以响应上下文取消:
- 每个 worker 必须检查
ctx.Done()并及时退出,否则WaitGroup永远不会完成 - 不要在 worker 中调用
ctx.Cancel()—— 应由主 goroutine 统一控制 - 超时后,
WaitGroup.Wait()不会自动返回,必须靠select切换分支跳出
别忽略 panic 恢复和日志输出对退出判断的影响
如果某个后台 goroutine panic 且未 recover,整个程序会崩溃,WaitGroup.Wait() 根本没机会执行。即使加了 recover,若没记录日志或没做标记,你可能以为所有任务都成功结束了,实际某个关键 worker 已静默退出。
建议在每个 worker 入口加统一 recover + 日志,并考虑用额外 channel 或原子变量标记失败状态:
- 用
atomic.Bool记录是否有 worker 异常退出,主 goroutine 在Wait()后检查该标志 - 不要依赖
log.Fatal或os.Exit在 worker 中退出 —— 这会跳过Done()调用,导致Wait()永久阻塞 - 若 worker 需要返回错误,应通过 channel 发送给主 goroutine,而不是 panic
真正麻烦的从来不是等完,而是等完之后怎么确认“完”的质量 —— 有没有 panic?有没有漏掉 Done?有没有卡在 I/O 上?这些细节不处理,退出逻辑就只是个幻觉。










