文章目录
- [18 - Go 等待协程:WaitGroup 使用与坑(深度解析 + 原理)](#18 - Go 等待协程:WaitGroup 使用与坑(深度解析 + 原理))
- [什么是 WaitGroup](#什么是 WaitGroup)
- 使用示例(逐步深入)
- 常见坑(重点)
-
- [坑一:Add 写在 goroutine 里(致命问题)](#坑一:Add 写在 goroutine 里(致命问题))
- [坑二:多调用 Done 导致负数 panic](#坑二:多调用 Done 导致负数 panic)
- [坑三:WaitGroup 被复制(隐蔽但致命)](#坑三:WaitGroup 被复制(隐蔽但致命))
- [坑四:WaitGroup 重用不当](#坑四:WaitGroup 重用不当)
- 底层原理解析(重点)
- [WaitGroup vs channel vs context](#WaitGroup vs channel vs context)
- 最佳实践(非常重要)
-
- [Add 和 goroutine 启动要"绑定"](#Add 和 goroutine 启动要“绑定”)
- [永远使用 defer Done](#永远使用 defer Done)
- [不要跨函数滥用 WaitGroup](#不要跨函数滥用 WaitGroup)
- [与 channel 组合使用](#与 channel 组合使用)
- [不要用 WaitGroup 做这些事](#不要用 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)问题。