十二:goroutine(4.1 WaitGroup)

一、先建立 WaitGroup 的核心认知

sync.WaitGroup 本质上解决的是一个非常简单的问题:

一个 goroutine 如何等待一组 goroutine 全部执行完?

最经典的例子:

go 复制代码
func main() {
    var wg sync.WaitGroup

    wg.Add(3)

    go func() {
        defer wg.Done()
        fmt.Println("任务1完成")
    }()

    go func() {
        defer wg.Done()
        fmt.Println("任务2完成")
    }()

    go func() {
        defer wg.Done()
        fmt.Println("任务3完成")
    }()

    wg.Wait()

    fmt.Println("所有任务完成")
}

这里可以把 WaitGroup 想象成一个计数器:

scss 复制代码
Add(3)

计数器:
3
│
├── goroutine 1 Done() → 2
│
├── goroutine 2 Done() → 1
│
└── goroutine 3 Done() → 0
                    │
                    ▼
                 Wait() 返回

所以:

scss 复制代码
wg.Add(3)

意思是:

我要等待 3 个任务。

scss 复制代码
wg.Done()

意思是:

我完成了一个任务。

scss 复制代码
wg.Wait()

意思是:

如果还有任务没完成,我就阻塞;全部完成后我再继续。

二、WaitGroup 到底是什么?

定义在:

bash 复制代码
package sync

类型:

go 复制代码
type WaitGroup struct {
    // 内部状态
}

它是一种同步原语(Synchronization Primitive) 。

和你已经学习过的 Channel 不一样:

Channel

核心是:

goroutine 之间传递数据 / 同步

例如:

go 复制代码
ch := make(chan int)

go func() {
    ch <- 100
}()

x := <-ch

这里 Channel 既负责:

diff 复制代码
数据传递
+
同步

WaitGroup

核心是:

等待一组 goroutine 完成

它本身不负责传递业务数据。

三、WaitGroup 的三个核心 API

WaitGroup 真正需要掌握的 API 其实只有三个:

scss 复制代码
Add(delta int)
Done()
Wait()
scss 复制代码
wg.Add(1)

增加等待计数。

例如:

scss 复制代码
wg.Add(5)

表示:

复制代码
当前有 5 个任务需要等待

它实际上可以传负数:

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

所以:

scss 复制代码
wg.Done()

本质上就是:

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

为什么强烈推荐:

go 复制代码
defer wg.Done()

而不是:

scss 复制代码
go func() {
    doSomething()

    wg.Done()
}()

因为任务中间可能发生多个 return:无论从哪里退出,都能够执行:

否则就可能出现:

scss 复制代码
任务已经结束
      ↓
忘记 Done()
      ↓
WaitGroup counter 永远 > 0
      ↓
Wait() 永久阻塞

这是一类非常典型的死锁问题。

scss 复制代码
wg.Wait()

作用:

等待 WaitGroup 的计数器变成 0。

执行过程:

scss 复制代码
main goroutine

wg.Add(2)
    │
    │
    ├──────────────→ goroutine A
    │                  sleep 1s
    │                  Done()
    │
    └──────────────→ goroutine B
                       sleep 2s
                       Done()

main
  │
  Wait()
  │
  │ 阻塞
  │
  ▼
counter = 0
  │
  ▼
Wait() 返回
  │
  ▼
全部完成

四、Add 应该放在哪里?

初学者最容易写成:

scss 复制代码
for i := 0; i < 10; i++ {
    go func() {
        wg.Add(1)
        defer wg.Done()

        doSomething()
    }()
}

wg.Wait()

这是错误的。

为什么?

因为:

go 复制代码
go func()

只是创建了一个 goroutine。

这个 goroutine 什么时候真正执行,由 Go scheduler 决定。

所以可能出现:

csharp 复制代码
main goroutine

启动 goroutine
启动 goroutine
启动 goroutine

Wait()
│
▼
发现 counter = 0
直接返回

而那些 goroutine 甚至还没来得及执行:wg.Add(1)

应该:

scss 复制代码
for i := 0; i < 10; i++ {
    wg.Add(1)

    go func() {
        defer wg.Done()

        doSomething()
    }()
}

wg.Wait()

正确方式也就是说:

Add 必须在启动 goroutine 之前完成。

这是 WaitGroup 最重要的使用规则之一:

Add(1) 必须在 go 前面

五、WaitGroup 和 Channel 的一个重要区别

假设:

启动 10 个 goroutine,等它们全部完成。

方法一:WaitGroup

scss 复制代码
var wg sync.WaitGroup

for i := 0; i < 10; i++ {
    wg.Add(1)

    go func() {
        defer wg.Done()
        work()
    }()
}

wg.Wait()

语义非常直接:

markdown 复制代码
启动任务
    ↓
等待所有任务完成

方法二:Channel

也可以:

css 复制代码
done := make(chan struct{}, 10)

for i := 0; i < 10; i++ {
    go func() {
        work()
        done <- struct{}{}
    }()
}

for i := 0; i < 10; i++ {
    <-done
}

也能实现。

但是语义变复杂了:

markdown 复制代码
Channel
    ↓
用消息通知任务完成

而 WaitGroup:

markdown 复制代码
WaitGroup
    ↓
专门表达"等待一组任务完成"

所以:

如果只是等待一批 goroutine 完成,WaitGroup 通常比 Channel 更合适。

六、WaitGroup 不会处理错误

这是一个非常重要的限制。

例如:

scss 复制代码
var wg sync.WaitGroup

wg.Add(2)

go func() {
    defer wg.Done()

    if err := task1(); err != nil {
        fmt.Println(err)
    }
}()

go func() {
    defer wg.Done()

    task2()
}()

wg.Wait()

WaitGroup 只知道:

复制代码
task1 Done
task2 Done

它不知道:

复制代码
task1 成功
task1 失败

所以如果你需要:

diff 复制代码
并发执行
+
错误收集
+
错误取消

通常应该考虑:

vbnet 复制代码
errgroup.Group

这也是后面非常值得学习的内容。

可以形成这样的知识体系:

markdown 复制代码
WaitGroup
    ↓
等待一组任务完成

errgroup
    ↓
等待 + 错误处理 + 取消

七、waitGroup 不会限制并发数量

例如:

scss 复制代码
var wg sync.WaitGroup

for i := 0; i < 100000; i++ {
    wg.Add(1)

    go func() {
        defer wg.Done()
        work()
    }()
}

wg.Wait()

WaitGroup 不会说:

复制代码
最多只能运行 100 个 goroutine

它只是:

markdown 复制代码
100000 个任务
        ↓
全部完成以后
        ↓
Wait 返回

如果想限制并发数量,需要:

复制代码
Channel semaphore

或者其他并发控制方式。

例如:

go 复制代码
sem := make(chan struct{}, 100)

于是:

diff 复制代码
WaitGroup
+
Channel semaphore

可以组合出:

diff 复制代码
等待所有任务
+
限制最大并发数

八、WaitGroup 最重要的几个坑

坑 1:忘记 Done

scss 复制代码
wg.Add(1)

go func() {
    doSomething()
}()

wg.Wait()

结果:

ini 复制代码
counter = 1

永远不会变成:

复制代码
0

所以:

go 复制代码
defer wg.Done()

是最常见的安全写法。

坑 2:Add 放到 goroutine 内部

错误:

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

正确:

scss 复制代码
wg.Add(1)

go func() {
    defer wg.Done()
}()

坑 3:Done 次数超过 Add 次数

例如:

scss 复制代码
wg.Add(1)

go func() {
    defer wg.Done()
    wg.Done()
}()

相当于:

scss 复制代码
counter = 1

Done()
→ 0

Done()
→ -1

会触发:

go 复制代码
panic

典型错误:

bash 复制代码
sync: negative WaitGroup counter

所以:

scss 复制代码
Add(3)

必须对应:

scss 复制代码
Done()
Done()
Done()

坑 4:复制 WaitGroup

这是非常重要的。

例如:

scss 复制代码
func doSomething(wg sync.WaitGroup) {
    wg.Add(1)

    go func() {
        defer wg.Done()
    }()
}

这里发生了:

css 复制代码
main wg
   │
   ├── copy
   │
   ▼
doSomething 的 wg

你操作的是:

复制代码
副本

而不是原来的 WaitGroup。

因此:

WaitGroup 不应该被复制。

通常应该传指针:

scss 复制代码
func doSomething(wg *sync.WaitGroup) {
    wg.Add(1)

    go func() {
        defer wg.Done()
    }()
}

因为 WaitGroup 内部包含:

diff 复制代码
状态
+
semaphore / waiter 信息

它是一个具有内部同步状态的对象。

复制之后:

css 复制代码
wg A
wg B

它们的状态就不再共享。

因此:

css 复制代码
你 Add 的是 A
你 Wait 的可能是 B

最终当然会出现问题。

这也是为什么官方文档有非常重要的一句话:

A WaitGroup must not be copied after first use.

也就是说:

WaitGroup 第一次使用之后不能复制。

九、wg 为什么通常声明成变量而不是指针?

你经常会看到:

dart 复制代码
var wg sync.WaitGroup

而不是:

css 复制代码
wg := &sync.WaitGroup{}

因为:

dart 复制代码
var wg sync.WaitGroup

已经足够。

调用:

scss 复制代码
wg.Add(1)
wg.Done()
wg.Wait()

Go 会自动处理方法调用中的取地址。

所以:

dart 复制代码
var wg sync.WaitGroup

是最推荐、最自然的写法。

十、waitgroup 的正确生命周期

例如:

go 复制代码
func main() {
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)

        go func(i int) {
            defer wg.Done()

            fmt.Println("task", i)
        }(i)
    }

    wg.Wait()

    fmt.Println("all done")
}

这就是最标准的 WaitGroup 模式。

十一、WaitGroup 和 Go Memory Model

并发编程里非常重要的:

happens-before(先行发生)关系

所以 WaitGroup 不只是:

复制代码
等一等

它还承担了:

复制代码
同步内存访问

的作用。

假设:

go 复制代码
var result int

var wg sync.WaitGroup

wg.Add(1)

go func() {
    defer wg.Done()

    result = 100
}()

wg.Wait()

fmt.Println(result)

为什么这里读取:result。是安全的?因为:Done 与 Wait 建立了必要的同步关系,因此完成 goroutine 中的写入,对等待方可见。

但是注意,即使你最后:

scss 复制代码
wg.Wait()

也不能解决两个 goroutine 同时修改 x 的问题。

WaitGroup 只能保证:

复制代码
任务完成之后
主 goroutine 再继续

不能保证:

复制代码
多个 goroutine 同时访问共享数据是安全的
相关推荐
云和数据.ChenGuang42 分钟前
用 Claude Code 重构遗留系统:从评估到落地的完整指南
java·人工智能·spring boot·后端·重构·springai
豆角焖肉1 小时前
SpringBoot 集成腾讯 AI 人脸识别
人工智能·spring boot·后端·人脸识别·腾讯 ai·base64 图片
付威20231 小时前
如果你想拥有自己的 Agent,为什么值得从 rpi 开始
后端
可乐ea2 小时前
2000 个智能体把 Agent 重写成 Rust:编排流水线拆解
开发语言·后端·rust·多智能体协作·ai编程助手·智能体编排·rust重写
用户72757483196502 小时前
02-变量、常量和字符串:len("Hello, 世界") 为什么不是 9?
后端
SamDeepThinking2 小时前
CyclicBarrier的底层实现
java·后端·面试
god_cvz2 小时前
第二章:6、对话记忆与引用标注
后端·ai编程
拖孩2 小时前
全程 AI 写的小程序,刚接上会员虚拟支付,上线第二天就有人真金白银买单了|双签名、定价、iOS 的坑我全踩了一遍
前端·后端·微信小程序
我是人✓2 小时前
SpringMVC异常处理与拦截器实战揭秘
java·开发语言·后端
仟里码2 小时前
九:goroutine(1.什么是协程)
后端