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 都尽量不影响等待计数。但如果发送本身永久阻塞,还是会卡住,所以要保证接收流程启动。
小结
WaitGroup 负责等待,不负责收集结果。入门并发代码应该同时设计三件事:任务如何启动,结果如何回收,什么时候关闭 channel。发送方结束后由一个单独 goroutine 关闭结果 channel,是很常见也很稳的模式。
当任务数量变大时,要加并发上限;当错误可能影响后续任务时,要用 context 取消;当输出面向用户或测试时,要排序。把这些细节补齐,WaitGroup 才不只是一个“等一下”的工具,而是并发流程里可靠的一部分。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。