sync.WaitGroup 是 Go 并发入门必学工具。它的职责很简单:等一组 goroutine 做完。但真实代码里,等待只是第一步。你还要拿到每个任务的结果、处理错误、控制并发数量,并保证 channel 正确关闭。很多初学者会写出“能等完,但结果偶尔丢失或死锁”的代码。
我们用一个小场景来讲:并发检查多个接口是否可用,最后输出每个接口的状态。
只等待不收结果
最小 WaitGroup 示例:
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
_ = check(url)
}(url)
}
wg.Wait()
这里有两个好习惯。第一,wg.Add(1) 在启动 goroutine 之前调用。第二,把循环变量作为参数传进匿名函数,避免闭包拿错值。新版本 Go 已经改善了部分循环变量问题,但把参数传进去仍然清晰。
问题是这段代码没有结果。实际检查接口,肯定要知道哪个 URL 成功、哪个失败。
用 channel 收结果
定义结果结构:
type CheckResult struct {
URL string
Status int
Duration time.Duration
Err error
}
worker 发送结果:
func checkURL(ctx context.Context, client *http.Client, url string) CheckResult {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return CheckResult{URL: url, Err: err}
}
resp, err := client.Do(req)
if err != nil {
return CheckResult{URL: url, Duration: time.Since(start), Err: err}
}
defer resp.Body.Close()
return CheckResult{
URL: url,
Status: resp.StatusCode,
Duration: time.Since(start),
}
}
并发执行:
func checkAll(ctx context.Context, urls []string) []CheckResult {
client := &http.Client{Timeout: 5 * time.Second}
results := make(chan CheckResult)
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
results <- checkURL(ctx, client, url)
}(url)
}
go func() {
wg.Wait()
close(results)
}()
var out []CheckResult
for r := range results {
out = append(out, r)
}
return out
}
关闭 results 的 goroutine 很关键。发送方都结束后再关闭,接收方才能 range 退出。不要由多个 worker 自己关闭结果 channel,否则必然有机会 panic。
缓冲 channel 可以减少阻塞
上面的无缓冲 channel 也能工作,因为主 goroutine 进入 range 后会持续接收。但如果你希望 worker 发送结果时不被接收速度影响,可以把缓冲设为任务数:
results := make(chan CheckResult, len(urls))
这不是必须。缓冲太大可能隐藏背压,缓冲太小可能让 worker 等待。对一次性批量检查来说,任务数缓冲简单直观;对长期运行的服务,应该更谨慎地设计队列大小。
控制并发上限
如果 URL 有几千个,不能给每个 URL 都起 goroutine。可以用信号量 channel 限制并发:
func checkAllLimited(ctx context.Context, urls []string, limit int) []CheckResult {
client := &http.Client{Timeout: 5 * time.Second}
results := make(chan CheckResult, len(urls))
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results <- CheckResult{URL: url, Err: ctx.Err()}
return
}
results <- checkURL(ctx, client, url)
}(url)
}
wg.Wait()
close(results)
out := make([]CheckResult, 0, len(urls))
for r := range results {
out = append(out, r)
}
return out
}
并发上限要结合目标服务和本机资源。检查内网健康接口可能 20 个并发就够;打第三方 API 可能要更低,还要配合限速。
错误策略要明确
并发任务遇到错误时,有两种常见策略。第一,全部跑完,返回每个任务的错误。第二,一旦出现严重错误,取消剩余任务。健康检查通常适合第一种,因为你想知道所有接口状态。批量写入数据库可能适合第二种,因为继续执行会造成更多脏数据。
使用 context 可以实现取消:
ctx, cancel := context.WithCancel(parent)
defer cancel()
if fatal(err) {
cancel()
}
但取消不是魔法。每个 worker 都要把 context 传给 HTTP 请求、数据库查询或 select 分支,取消才能及时生效。
输出结果排序
并发结果返回顺序不稳定。如果展示给用户,最好按输入顺序或 URL 排序。可以在结果里记录索引:
type CheckResult struct {
Index int
URL string
Err error
}
收集后排序:
sort.Slice(out, func(i, j int) bool {
return out[i].Index < out[j].Index
})
稳定输出能让测试和人工阅读都舒服。否则每次运行顺序不同,差异比较会很吵。
常见坑
WaitGroup 最常见的问题有三个。第一,忘记 Done,导致永远等待。用 defer wg.Done() 能减少这种错误。第二,Add 在 goroutine 里调用,可能主 goroutine 先执行到 Wait,造成竞态。第三,结果 channel 没有关,接收方一直等。
还有一个隐蔽问题:worker 发送结果时,如果没有接收方,可能阻塞,进而 wg.Done() 执行不到。把 Done defer 在函数开头,可以确保发送前后的 panic 或 return 都尽量不影响等待计数。但如果发送本身永久阻塞,还是会卡住,所以要保证接收流程启动。
使用 errgroup 简化并发代码
对于更复杂的并发场景,标准库之外可以使用 golang.org/x/sync/errgroup。它把 WaitGroup 和错误传播封装在一起:
import "golang.org/x/sync/errgroup"
func checkAllWithErrgroup(ctx context.Context, urls []string) ([]CheckResult, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]CheckResult, len(urls))
for i, url := range urls {
i, url := i, url
g.Go(func() error {
res := checkURL(ctx, &http.Client{Timeout: 5 * time.Second}, url)
if res.Err != nil {
return res.Err
}
results[i] = res
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
errgroup 的优势是:一旦某个 goroutine 返回错误,context 会被取消,其他 goroutine 会收到取消信号。这比手写 WaitGroup + channel 错误传播更简洁。缺点是引入了外部依赖,且结果切片需要预分配并正确索引。
Worker Pool 模式的封装
对于长期运行的服务,Worker Pool 是比每次都新建 goroutine 更稳定的模式:
type Task struct {
URL string
}
type Pool struct {
workers int
tasks chan Task
results chan CheckResult
}
func NewPool(workers int) *Pool {
return &Pool{
workers: workers,
tasks: make(chan Task, workers*2),
results: make(chan CheckResult, workers*2),
}
}
func (p *Pool) Start(ctx context.Context) {
var wg sync.WaitGroup
for i := 0; i < p.workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
client := &http.Client{Timeout: 5 * time.Second}
for task := range p.tasks {
select {
case <-ctx.Done():
return
default:
}
p.results <- checkURL(ctx, client, task.URL)
}
}()
}
go func() {
wg.Wait()
close(p.results)
}()
}
func (p *Pool) Submit(task Task) {
p.tasks <- task
}
func (p *Pool) Close() {
close(p.tasks)
}
Worker Pool 模式的好处是 goroutine 数量可控,内存分配可预测,适合持续处理任务队列的场景。启动后固定数量的 worker 等待任务,不会因为瞬间大量请求而耗尽系统资源。
Context 超时与 WaitGroup 的协同
当整体任务有 deadline 时,context 超时和 WaitGroup 需要配合:
func checkWithDeadline(urls []string, deadline time.Duration) ([]CheckResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), deadline)
defer cancel()
results := make(chan CheckResult, len(urls))
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
res := checkURL(ctx, &http.Client{Timeout: 2 * time.Second}, url)
select {
case results <- res:
case <-ctx.Done():
}
}(url)
}
go func() {
wg.Wait()
close(results)
}()
var out []CheckResult
for r := range results {
out = append(out, r)
}
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("部分任务未完成: %w", ctx.Err())
}
return out, nil
}
注意 select 在发送结果时也要监听 ctx.Done(),否则超时后 worker 可能卡在发送结果到已无人接收的 channel 上。
内存管理与 goroutine 泄漏
使用 WaitGroup 时常见的内存问题是 goroutine 泄漏:
// 有泄漏风险的代码
func leakyCheck(urls []string) {
var wg sync.WaitGroup
results := make(chan CheckResult)
for _, url := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
// 如果这里发生 panic,Done() 仍然会被执行
// 但如果 results 接收方提前退出,这里会永久阻塞
results <- checkSlow(url)
}(url)
}
// 只读前3个结果就退出
for i := 0; i < 3; i++ {
fmt.Println(<-results)
}
// wg.Wait() 永远不会返回,因为没有关闭 results
wg.Wait()
}
正确的做法是给 channel 操作加上 context 控制,或者使用有缓冲 channel 确保发送不会永久阻塞。
Worker 中捕获 panic
生产代码里,worker 不应该因为 panic 导致整个程序崩溃。可以在每个 worker 里加 recover():
func safeWorker(wg *sync.WaitGroup, results chan<- CheckResult, task Task) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
results <- CheckResult{
URL: task.URL,
Err: fmt.Errorf("worker panic: %v", r),
}
}
}()
// 实际任务逻辑
}
这样即使某个下游库 panic,也不会影响到其他任务,WaitGroup 的计数仍然正确。
性能对比:同步 vs 并发
并发不是免费的。对于非常轻量的任务,串行执行可能比并发更快:
func BenchmarkSequential(b *testing.B) {
urls := []string{"http://localhost:8080/a", "http://localhost:8080/b"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, url := range urls {
checkURL(context.Background(), &http.Client{Timeout: time.Second}, url)
}
}
}
func BenchmarkConcurrent(b *testing.B) {
urls := []string{"http://localhost:8080/a", "http://localhost:8080/b"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
checkAll(context.Background(), urls)
}
}
对于网络 IO 密集型任务(如 HTTP 请求),并发通常能显著提升吞吐量。但对于 CPU 密集型短任务,goroutine 切换开销可能抵消收益。建议用 go test -bench 实际测量。
FAQ
Q: WaitGroup 可以复用吗?
A: 可以,但必须等上一批 Wait() 返回后才能开始新一批 Add()。通常更简单的做法是为每批任务新建一个 WaitGroup。
Q: 可以在 select 里接收 results 同时做其他事吗?
A: 可以,但要确保 select 的分支最终都能让 wg.Wait() 的 goroutine 继续执行并关闭 channel。
Q: 如果某个 worker panic 了怎么办?
A: defer wg.Done() 仍会被执行,但 panic 会终止 goroutine。生产代码应该用 recover() 捕获 panic,把 panic 信息作为错误发送到 results channel。
Q: WaitGroup 的 Add 和 Done 计数不匹配会怎样?
A: 如果 Add 超过 Done,Wait() 永远不会返回。如果 Done 超过 Add,程序会在运行时 panic。
最佳实践
- 总是 defer Done:在 goroutine 函数开头就
defer wg.Done(),避免各种提前返回路径遗漏。 - Add 在启动前调用:不要在 goroutine 内部调用
wg.Add(1),这会引入竞态条件。 - 一个 goroutine 负责 close channel:不要多个 worker 尝试关闭 channel,也不要在 Wait 完成前关闭。
- 用 context 传播取消:让每个 worker 都能感知上级取消信号,优雅退出。
- 限制并发数:通过信号量或 worker pool 控制并发上限,保护下游服务。
- 保持结果有序:如果输出顺序有业务意义,在结果结构里保留索引用于排序。
小结
WaitGroup 负责等待,不负责收集结果。入门并发代码应该同时设计三件事:任务如何启动,结果如何回收,什么时候关闭 channel。发送方结束后由一个单独 goroutine 关闭结果 channel,是很常见也很稳的模式。
当任务数量变大时,要加并发上限;当错误可能影响后续任务时,要用 context 取消;当输出面向用户或测试时,要排序。把这些细节补齐,WaitGroup 才不只是一个“等一下”的工具,而是并发流程里可靠的一部分。对于更复杂的场景,可以考虑 errgroup 或 Worker Pool 等更高级的并发模式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。