Go 入门:WaitGroup 不只等待,还要收好结果

从并发检查多个接口出发,讲 sync.WaitGroup、结果 channel、错误收集、并发上限和常见坑。

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 才不只是一个“等一下”的工具,而是并发流程里可靠的一部分。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页