在 Go 语言里,并发模型依靠轻量的 goroutine 来实现,但当我们需要等待一批协程全部完成再继续后续逻辑时,如果只靠 time.Sleep 或者通道手动计数,代码既脆弱又难维护。sync.WaitGroup 是标准库提供的结构体,它通过内部计数器帮主协程精准阻塞,直到所有子协程报告结束。理解它的方法语义和并发安全性,是写出正确并发程序的基础。

WaitGroup 的基本机制与正确调用顺序
sync.WaitGroup 暴露三个方法:Add、Done 和 Wait。Add 用于增加内部计数器,通常在启动协程前调用,传入即将创建的协程数量;Done 将计数器减一,一般放在协程函数末尾;Wait 会阻塞调用它的协程,直到计数器变为零。底层实现依赖原子操作和信号量,因此多个协程并发调用 Done 是安全的,但 Add 和 Wait 的调用时机有严格要求。
最常见的错误是在启动 goroutine 之后才调用 Add,这样主协程的 Wait 可能先于 Add 执行,导致计数器为零而直接返回,子协程变成孤儿协程。正确写法是在 go 关键字之前完成 Add,或者使用循环统一 Add 后再启动。下面的示例展示了安全模式:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
tasks := []string{"a", "b", "c"}
for _, t := range tasks {
wg.Add(1)
go func(task string) {
defer wg.Done()
time.Sleep(100 * time.Millisecond)
fmt.Println("finished", task)
}(t)
}
wg.Wait()
fmt.Println("all done")
}
上述代码在每次循环中都先 Add(1),然后立即启动携带参数的匿名协程,并在协程中用 defer 调用 Done。这样即使任务 panic(未被 recover 时除外),Done 也会执行,避免主协程永久阻塞。注意我们显式把循环变量 t 以参数形式传入,而不是在闭包里直接引用,这能防止变量被后续迭代覆盖。
典型误用场景与对应的排查思路
第一种典型误用是忘记调用 Done,或者在某些提前 return 的分支中遗漏。由于 WaitGroup 计数不为零,Wait 永远不返回,程序表现为假死。借助 defer wg.Done() 可以把递减动作绑定到函数退出,不论正常结束还是出错都生效,比在末尾手写更可靠。若协程内存在多重退出点,defer 是唯一省心的做法。
第二种误用是 Add 传入负值或计数器变成负数。WaitGroup 不允许计数器小于零,否则会触发 runtime panic。这通常发生在 Done 调用次数多于 Add,例如重复 defer 或者逻辑错误多减了一次。在复杂调用链中,建议把 Add 集中在父作用域,子函数只负责 Done,并且通过代码评审确认配对关系。
第三种误用是复制 WaitGroup 实例。WaitGroup 内部含不可复制的状态,若将其值传给函数或存入切片,会造成计数器不同步。应始终传递指针,如 *sync.WaitGroup。下面的错误示例演示了值传递带来的隐患:
package main
import (
"sync"
)
func bad(wg sync.WaitGroup) {
// 这里操作的是副本,原 wg 计数器不变
defer wg.Done()
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
bad(wg)
wg.Wait() // 永久阻塞
}
将参数改为 *sync.WaitGroup 并传 &wg 即可修复。Go 的 vet 工具能静态检查出部分值复制问题,日常开发应把 go vet 纳入流水线。
在真实业务中的组合实践与替代方案
在并行抓取或批量处理数据库记录时,WaitGroup 常与带缓冲通道组合,既控制并发上限又等待全部完成。例如启动 N 个 worker,从任务通道读取,处理完调用 Done,主协程 Wait 后关闭资源。这种结构比无限制开启 goroutine 更平稳,不会因为任务量暴涨而耗尽内存。
当任务需要返回结果或错误时,可以配合 errgroup.Group 使用,它在 WaitGroup 基础上增加了错误传播和上下文取消能力。如果场景只是简单的“等所有都做完”,原生 WaitGroup 更轻量;若需要任意一个失败就取消其余协程,errgroup 更合适。下面展示与通道限流结合的写法:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
sem := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
wg.Add(1)
sem <- struct{}{}
go func(id int) {
defer wg.Done()
defer func() { <-sem }()
time.Sleep(time.Second)
fmt.Println("task", id)
}(i)
}
wg.Wait()
close(sem)
}
这段程序最多同时运行三个协程,既利用了多核又保护了对端服务。WaitGroup 只负责等待,限流交给 sem 通道,职责清晰。实践中还应考虑加上 context 以便超时退出,避免外部信号无法中断 Wait。掌握这些组合方式后,sync.WaitGroup 就能在绝大多数协程同步需求中提供简洁可靠的解决方案。
sync.WaitGroupgoroutineGo并发修改时间:2026-08-15 13:09:32