sync.WaitGroup 与 Once
本教程共 80 篇 · 第 63 篇 · 更新于 2026-07-27 · 约 8 分钟阅读
63. sync.WaitGroup 与 Once
本节目标:学会用
WaitGroup等待一组 goroutine 完成,用Once保证初始化只执行一次,了解Cond条件变量。
sync.WaitGroup
前面我们用 time.Sleep 等子 goroutine,那是碰运气。正式做法是用 sync.WaitGroup。它像一个计数器:Add 加数,Done 减数,Wait 等到归零。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 计数 +1
go func(n int) {
defer wg.Done() // 计数 -1
fmt.Println("任务", n)
}(i)
}
wg.Wait() // 阻塞直到计数归零
fmt.Println("全部完成")
}
三个方法:
Add(n int):计数器加 n,通常传 1Done():等价于Add(-1)Wait():阻塞直到计数归零
Add 的位置很重要
Add 一定要在启动 goroutine 之前调用,不能放 goroutine 里面:
// 错误:Add 放 goroutine 里
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // 可能主 goroutine 已经 Wait 了
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
主 goroutine 可能先跑到 Wait,这时计数还是 0,直接放行,子 goroutine 后面才 Add,就漏了。
// 正确:Add 在外面
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
Warning计数器不能变负数,否则 panic。确保
Add和Done的数量配对。
WaitGroup 不能复制
WaitGroup 内部有状态,复制后是另一个独立的计数器。传给函数必须用指针:
// 错误:传值
func work(wg sync.WaitGroup) {
defer wg.Done() // 减的是副本的计数,原 wg 永远不归零
}
// 正确:传指针
func work(wg *sync.WaitGroup) {
defer wg.Done()
}
Tip如果
WaitGroup是结构体字段,也要确保结构体本身用指针传递,或者把WaitGroup声明为指针字段。
复用 WaitGroup
WaitGroup 可以复用,但必须等上一轮 Wait 返回后才能再次 Add。并发 Add 和 Wait 会有数据竞争。
var wg sync.WaitGroup
// 第一轮
wg.Add(2)
go func() { defer wg.Done(); work() }()
go func() { defer wg.Done(); work() }()
wg.Wait()
// 第二轮,复用
wg.Add(1)
go func() { defer wg.Done(); moreWork() }()
wg.Wait()
如果不想操心复用,直接每次新建一个也行。
sync.Once
sync.Once 保证某个操作在并发下只执行一次,常用于懒加载初始化:
var (
once sync.Once
instance *Database
)
func getDB() *Database {
once.Do(func() {
fmt.Println("初始化数据库")
instance = &Database{host: "localhost"}
})
return instance
}
无论多少个 goroutine 同时调 getDB,初始化只执行一次,所有调用拿到同一个 instance。
Once 只有一个方法 Do(f func()):
- 第一次调用:执行
f - 后续调用:直接跳过
func main() {
var once sync.Once
var wg sync.WaitGroup
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
defer wg.Done()
once.Do(func() {
fmt.Println("只执行一次")
})
}()
}
wg.Wait()
}
输出只有一行「只执行一次」。
Note
sync.Once内部用了原子操作和锁实现(Go 1.21 起优化为快速路径原子检查)。它比「自己加锁 + bool 标志」更安全,因为Once保证:即使Do里的f还没执行完,后续调用的 goroutine 也会阻塞等待,直到第一次执行完成。
Go 1.21 的新工具
如果你只是想懒加载一个值,Go 1.21 提供了更顺手的函数:
// 返回一个函数,首次调用执行 f,之后返回缓存结果
loadConfig := sync.OnceValue(func() *Config {
return loadConfigFromDisk()
})
cfg := loadConfig() // 第一次:加载
cfg2 := loadConfig() // 后续:返回缓存
sync.OnceValue[T]:返回单个值sync.OnceValues[T1, T2]:返回两个值(比如值和 error)
loadConfig := sync.OnceValues(func() (*Config, error) {
return loadConfigFromDisk()
})
cfg, err := loadConfig()
比手动 Once + 变量更简洁。
sync.Cond 条件变量
sync.Cond 用于「等待某个条件满足」。它配合锁使用,提供三个方法:
Wait():释放锁并阻塞,被唤醒后重新加锁Signal():唤醒一个等待者Broadcast():唤醒所有等待者
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
queue []int
)
// 消费者:等队列有数据
func consume() {
mu.Lock()
for len(queue) == 0 {
cond.Wait() // 释放锁,等通知
}
v := queue[0]
queue = queue[1:]
mu.Unlock()
fmt.Println("消费:", v)
}
// 生产者:加数据后通知
func produce(v int) {
mu.Lock()
queue = append(queue, v)
cond.Signal() // 通知一个等待者
mu.Unlock()
}
Warning
Wait必须在for循环里,不能是if。因为被唤醒时条件不一定真的满足了(可能有别的消费者抢先处理了),要重新检查:
for len(queue) == 0 {
cond.Wait()
}
Tip实际项目中
Cond用得不多,因为 channel 通常能更优雅地解决同样的问题。了解它主要是为了读懂标准库或老代码。能用 channel 就别用Cond。
小结
WaitGroup:Add加数、Done减数、Wait等归零Add必须在 goroutine 外调用,Done放deferWaitGroup不能复制,传指针Once.Do:并发下只执行一次,用于初始化- Go 1.21 的
OnceValue/OnceValues懒加载更方便 Cond条件变量:Wait必须在for里,但通常用 channel 替代
下一节讲 sync.Map 和 atomic 包。