18 - Go 等待协程:WaitGroup 使用与坑

文章目录


18 - Go 等待协程:WaitGroup 使用与坑(深度解析 + 原理)

在 Go 并发编程中,goroutine 非常轻量,但如何优雅地等待多个协程执行完成,才是工程实践中的关键问题。

很多人第一反应是用 channel,但在"只关心完成,不关心结果"的场景中,sync.WaitGroup 才是更合适的工具。

这篇文章不仅讲用法,还会带你深入理解 WaitGroup 的本质、实现机制,以及那些非常容易踩的坑


什么是 WaitGroup

WaitGroup 本质上是一个协程计数器 + 阻塞等待机制

它解决的问题是:

主协程如何等待一组子协程执行完成?

来看一个最简单的模型:

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(3) // 设置需要等待的 goroutine 数量

	go func() {
		defer wg.Done() // 执行完任务后,计数器减1
		fmt.Println("任务1完成")
	}()

	go func() {
		defer wg.Done() // 执行完任务后,计数器减1
		fmt.Println("任务2完成")
	}()

	go func() {
		defer wg.Done() // 执行完任务后,计数器减1
		fmt.Println("任务3完成")
	}()

	wg.Wait() // 阻塞,直到计数器归零
	fmt.Println("所有任务完成")
}

输出:

bath 复制代码
任务3完成
任务1完成
任务2完成
所有任务完成

小结

  • Add(n):设置任务数量
  • Done():任务完成(等价于 Add(-1)
  • Wait():阻塞直到计数器为 0

👉 核心思想:计数器归零 → 主协程继续执行


使用示例(逐步深入)

基础示例:等待多个任务

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1) // 增加计数器
		// 启动goroutine处理任务
		go func(i int) {
			// 任务完成后,计数器减1
			defer wg.Done()
			fmt.Println("处理任务:", i)
		}(i) // 注意这里的i是值传递,而不是引用传递
	}
	wg.Wait()
	fmt.Println("全部完成")
}

输出:

bath 复制代码
处理任务: 2
处理任务: 0
处理任务: 1
全部完成

示例进阶:结合业务处理

go 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

// 并发执行
func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // 告诉主协程,子协程已经执行完毕

	fmt.Println("worker", id, "开始")
	time.Sleep(time.Second)
	fmt.Println("worker", id, "结束")
}
func main() {
	// 并发执行多个 worker
	var wg sync.WaitGroup
	// 等待5个协程执行完毕
	for i := 0; i < 5; i++ {
		// 告诉主协程,子协程还没执行完毕
		wg.Add(1)
		// 并发执行子协程
		go worker(i, &wg)
	}
	// 等待所有协程执行完毕
	wg.Wait()
	fmt.Println("所有 worker 执行完毕")
}

输出:

bath 复制代码
worker 4 开始
worker 0 开始
worker 1 开始
worker 2 开始
worker 3 开始
worker 3 结束
worker 4 结束
worker 0 结束
worker 1 结束
worker 2 结束
所有 worker 执行完毕

示例进阶:错误处理(常见误区前奏)

WaitGroup 不能直接获取返回值 ,如果你需要结果,必须结合 channel

go 复制代码
package main

import (
	"fmt"
	"sync"
)

// 定义一个 worker,将计算结果发送到 channel 中
func worker(id int, wg *sync.WaitGroup, ch chan<- int) {
	// 告诉 WaitGroup 我们已经完成了
	fmt.Println("worker", id, "starting")
	defer wg.Done()
	ch <- id * 2
	fmt.Println("worker", id, "done")
}
func main() {
	var wg sync.WaitGroup
	// 创建一个 channel,大小为5
	ch := make(chan int, 5)
	for i := 1; i < 5; i++ {
		wg.Add(1)
		go worker(i, &wg, ch)
	}
	// 等待所有 worker 都完成
	wg.Wait()
	// 关闭 channel,防止阻塞
	close(ch)
	// 从 channel 中读取数据
	for v := range ch {
		// v 就是从 channel 中接收到的值
		fmt.Println("结果:", v)
	}
}

输出:

bath 复制代码
worker 4 starting
worker 4 done
worker 1 starting
worker 1 done
worker 2 starting
worker 2 done
worker 3 starting
worker 3 done
结果: 8
结果: 2
结果: 4
结果: 6

小结:

  • WaitGroup 解决"同步问题"(等完成)

  • channel 解决"通信问题"(传结果)

  • 两者通常组合使用,而不是互相替代


思考点

为什么 WaitGroup 不设计成可以直接返回结果?

👉 因为它的职责非常单一:只做"等待"这件事,避免职责膨胀。


常见坑(重点)

这里是实际开发中最容易翻车的地方。


坑一:Add 写在 goroutine 里(致命问题)

❌ 错误写法:

go 复制代码
for i := 0; i < 3; i++ {
	go func() {
		wg.Add(1) // ❌ 错误
		defer wg.Done()
		fmt.Println("任务")
	}()
}
wg.Wait()

👉 问题:

  • Wait() 可能先执行
  • Add() 还没来得及执行
  • 直接 panic:sync: WaitGroup misuse

✔ 正确写法:

go 复制代码
wg.Add(1)
go func() {
	defer wg.Done()
}()

小结

👉 Add 必须在启动 goroutine 之前执行


坑二:多调用 Done 导致负数 panic

go 复制代码
wg.Add(1)

go func() {
	defer wg.Done()
	wg.Done() // ❌ 多调用
}()

运行直接炸:

复制代码
panic: sync: negative WaitGroup counter

小结

  • Done() 本质是 Add(-1)
  • 调用次数必须严格匹配

坑三:WaitGroup 被复制(隐蔽但致命)

go 复制代码
func worker(wg sync.WaitGroup) { // ❌ 传值
	defer wg.Done()
}

👉 问题:

  • WaitGroup 内部有状态
  • 传值会复制一份
  • 主 goroutine 等的是原始 wg,子 goroutine 操作的是副本

👉 结果:永远等不到结束


✔ 正确写法:

go 复制代码
func worker(wg *sync.WaitGroup)

小结

👉 WaitGroup 必须用指针传递


坑四:WaitGroup 重用不当

go 复制代码
wg.Add(1)
go func() {
	defer wg.Done()
}()

wg.Wait()

wg.Add(1) // ❌ 有风险

👉 如果之前的 goroutine 还没完全结束,可能出现竞态问题


建议

👉 一个 WaitGroup 对应一批任务,不要复用


底层原理解析(重点)

WaitGroup 看起来简单,但内部实现非常精妙。

核心结构(简化理解):

go 复制代码
type WaitGroup struct {
	state1 [3]uint32
}

实际包含:

  • counter(计数器)
  • waiter(等待者数量)
  • semaphore(信号量)

Add 的本质

go 复制代码
wg.Add(n)

本质是:

👉 原子操作增加计数器

go 复制代码
atomic.AddInt32(&counter, n)

Done 的本质

go 复制代码
wg.Done()

等价于:

go 复制代码
wg.Add(-1)

Wait 的本质

go 复制代码
wg.Wait()

核心逻辑:

  • 如果 counter == 0 → 直接返回
  • 如果 > 0 → 当前 goroutine 阻塞
  • 通过信号量(semaphore)挂起

唤醒机制

当最后一个 Done() 执行:

  • counter 变为 0
  • 唤醒所有等待的 goroutine

👉 使用的是 runtime 层的信号量机制(runtime_Semrelease


思考点

为什么 WaitGroup 不用 channel 实现?

👉 因为:

  • channel 需要额外 goroutine 管理
  • WaitGroup 使用原子操作 + 信号量,更轻量、更高效

WaitGroup vs channel vs context

这是很多人容易混淆的点。


WaitGroup

  • 用途:等待一组任务完成
  • 不传递数据
  • 不支持取消

channel

  • 用途:通信 + 同步
  • 可以传递数据
  • 更灵活,但更复杂

context

  • 用途:控制生命周期(取消 / 超时)
  • 常用于请求级控制

小结

工具 作用
WaitGroup 等待任务结束
channel 通信 + 同步
context 取消 / 控制生命周期

最佳实践(非常重要)


Add 和 goroutine 启动要"绑定"

go 复制代码
wg.Add(1)
go func() {
	defer wg.Done()
}()

永远使用 defer Done

避免遗漏:

go 复制代码
defer wg.Done()

不要跨函数滥用 WaitGroup

建议:

  • 作为参数传递(指针)
  • 控制作用域清晰

与 channel 组合使用

WaitGroup 等待结束,channel 传递结果:

👉 这是生产环境最常见组合


不要用 WaitGroup 做这些事

  • 控制并发数 ❌(应该用带缓冲 channel 或 semaphore)
  • 做任务取消 ❌(应该用 context)

总结

WaitGroup 看起来只是三个方法,但背后是 Go 并发设计的一个重要思想:

用最简单的机制解决最单一的问题

它的定位非常明确:

  • 不负责通信
  • 不负责控制
  • 只负责等待

也正因为如此,它才能做到:

  • 高性能(原子操作 + 信号量)
  • 低复杂度
  • 易组合(配合 channel / context)

最后的思考

如果让你自己设计一个 WaitGroup,你会怎么做?

  • 用 channel?
  • 用锁?
  • 如何避免竞态?

👉 想明白这个问题,你对 Go 并发的理解会再上一个层次。

如果你真的从零设计一套,你最终会得到一个结论:

WaitGroup 本质 = 计数器 + 阻塞机制 + 唤醒机制

👉 WaitGroup 的本质是什么?

你可以直接答:

text 复制代码
它是一个基于原子计数器的并发同步原语,
通过 runtime 信号量实现 goroutine 的阻塞与唤醒,
用于解决多个并发任务的收敛(join)问题。
相关推荐
feifeigo1232 小时前
基于遗传算法的矩形排样MATLAB实现
开发语言·matlab
他是龙5512 小时前
65:JS安全&浏览器插件&工具箱等
开发语言·javascript·安全
csbysj20202 小时前
Rust 输出到命令行
开发语言
likerhood2 小时前
Java 中的 `clone()` 与 `Cloneable` 接口详解
java·开发语言·python
Adellle2 小时前
Java 异步回调
java·开发语言·多线程
海寻山2 小时前
Java常用API详解(二):集合类API(ArrayList/HashMap/HashSet)实战,一篇吃透
开发语言·python
XMYX-02 小时前
19 - Go 并发限制:限流与控制并发数
开发语言·golang
卵男(章鱼)2 小时前
汽车网络通讯分析与仿真工具的系统工程:Vector CANoe与ZLG ZCANPRO深度剖析
开发语言·汽车·php
摇滚侠2 小时前
Java 零基础全套视频教程,面向对象(进阶),笔记 90-103
java·开发语言·笔记