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

sync.WaitGroup 是 Go 并发入门必学工具。它的职责很简单:等一组 goroutine 做完。但真实代码里,等待只是第一步。你还要拿到每个任务的结果、处理错误、控制并发数量,并保证 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 都尽量不影响等待计数。但如果发送本身永久阻塞,还是会卡住,所以要保证接收流程启动。

使用 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。

最佳实践

  1. 总是 defer Done:在 goroutine 函数开头就 defer wg.Done(),避免各种提前返回路径遗漏。
  2. Add 在启动前调用:不要在 goroutine 内部调用 wg.Add(1),这会引入竞态条件。
  3. 一个 goroutine 负责 close channel:不要多个 worker 尝试关闭 channel,也不要在 Wait 完成前关闭。
  4. 用 context 传播取消:让每个 worker 都能感知上级取消信号,优雅退出。
  5. 限制并发数:通过信号量或 worker pool 控制并发上限,保护下游服务。
  6. 保持结果有序:如果输出顺序有业务意义,在结果结构里保留索引用于排序。

小结

WaitGroup 负责等待,不负责收集结果。入门并发代码应该同时设计三件事:任务如何启动,结果如何回收,什么时候关闭 channel。发送方结束后由一个单独 goroutine 关闭结果 channel,是很常见也很稳的模式。

当任务数量变大时,要加并发上限;当错误可能影响后续任务时,要用 context 取消;当输出面向用户或测试时,要排序。把这些细节补齐,WaitGroup 才不只是一个“等一下”的工具,而是并发流程里可靠的一部分。对于更复杂的场景,可以考虑 errgroup 或 Worker Pool 等更高级的并发模式。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南