十二: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 同时访问共享数据是安全的
相关推荐
nice_lcj52015 分钟前
深入理解 Java 反射:从 Class 对象到方法调用(含性能实测 + 迷你 IOC 实战 + 面试考点)
java·开发语言·后端·面试
骑着蜗牛撵大象32743 分钟前
2026 后端 AI 工程化:Spring AI 管调用、MCP 管工具、Agent 内嵌管自治
java·人工智能·后端·spring·ai·可观测性·mcp
摸爬滚打的小李1 小时前
Spring XML + WAR + Tomcat 项目启动
后端
IT_陈寒2 小时前
SpringBoot自动配置的坑,这次终于爬出来了
前端·人工智能·后端
RainbowFish2 小时前
# RAG 与智能体记忆:从原理到工程实践 > 一篇给学习者的总结。
后端
苏三说技术3 小时前
为什么越来越多人用Arthas 4 ?
后端
ServBay3 小时前
AI 开发平台出事后,API Key 泄露要怎么办
后端·aigc·ai编程
程序员老申3 小时前
解剖 PDF 底层二进制:一行代码如何抓出假薪资流水的 3 处破绽
后端
JuiceFS4 小时前
从 Meta 的工程实践看 EB 级 AI 存储架构
人工智能·后端
数据库小学妹4 小时前
MySQL NULL陷阱:NOT IN、三值逻辑与NOT EXISTS
数据库·后端·mysql