1. 引言
在 Go 并发编程中,sync.WaitGroup 和 sync.Once 是两个最基础也最常用的同步原语。WaitGroup 用于等待一组 goroutine 完成任务,而 Once 则确保某个函数只执行一次。理解它们的原理与易错点,是写出健壮并发代码的前提。
2. 核心知识点
2.1 sync.WaitGroup 三方法
WaitGroup 提供三个方法:
Add(delta int):增加或减少等待的计数器Done():等价于Add(-1),表示一个任务完成Wait():阻塞直到计数器归零
go
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// 执行任务
}()
wg.Wait()
2.2 Add 必须在 Wait 前调用
Add 必须在 Wait 之前调用,否则可能导致 panic 或 Wait 提前返回。这是因为 Wait 在计数器为 0 时会立即返回,如果在 goroutine 内部才调用 Add,Wait 可能已经返回了。
2.3 WaitGroup 不可复制
WaitGroup 内部包含计数器状态,复制后行为异常。因此,WaitGroup 应当通过指针传递,绝不能复制。
go
// 错误:复制 WaitGroup
func process(wg sync.WaitGroup) { // 危险!
defer wg.Done()
}
// 正确:传递指针
func process(wg *sync.WaitGroup) {
defer wg.Done()
}
2.4 sync.Once:Do(f) 确保 f 只执行一次
sync.Once 的 Do(f) 方法确保函数 f 只执行一次,即使被多个 goroutine 并发调用。
go
var once sync.Once
var config *Config
func getConfig() *Config {
once.Do(func() {
config = loadConfig() // 只执行一次
})
return config
}
2.5 Once 的实现:atomic + Mutex 双重检查
sync.Once 内部使用 atomic 和 Mutex 实现双重检查,确保高效且线程安全:
go
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
2.6 sync.OnceFunc / sync.OnceValue(Go 1.21+)
Go 1.21 引入了更便捷的封装:
sync.OnceFunc(f):返回一个只执行一次的包装函数sync.OnceValue(f):返回一个只执行一次并缓存返回值的函数
go
getConfig := sync.OnceValue(loadConfig)
cfg := getConfig() // 首次调用执行 loadConfig
cfg2 := getConfig() // 返回缓存
3. WaitGroup 使用模式
3.1 主 goroutine Add,子 goroutine Done
最标准的模式:主 goroutine 负责 Add,子 goroutine 负责 Done。
go
// 正确:主 goroutine Add
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
process(n)
}(i)
}
wg.Wait()
3.2 子 goroutine 内 Add 需谨慎
在 goroutine 内部调用 Add 是危险的,可能导致 Wait 提前返回:
go
// 错误:goroutine 内 Add
for i := 0; i < 10; i++ {
go func(n int) {
wg.Add(1) // 危险!Wait 可能已返回
defer wg.Done()
process(n)
}(i)
}
wg.Wait()
3.3 配合 channel 实现优雅关闭
WaitGroup 常与 channel 配合,实现优雅关闭:
go
var wg sync.WaitGroup
jobs := make(chan int, 10)
// 消费者
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
process(job)
}
}()
}
// 生产者
for i := 0; i < 10; i++ {
jobs <- i
}
close(jobs)
wg.Wait() // 等待所有消费者完成
4. 易错点与常见误解
4.1 在 goroutine 内调用 Add
如上所述,这可能导致 Wait 提前返回,因为 Wait 可能在 Add 执行前就检查到计数器为 0。
4.2 复制 WaitGroup
复制 WaitGroup 会复制其内部状态,导致计数器不一致,行为异常。务必使用指针传递。
4.3 Done 调用次数多于 Add → panic
如果 Done 的调用次数超过 Add 的总数,计数器会变为负数,触发 panic:
go
var wg sync.WaitGroup
wg.Add(1)
go func() {
wg.Done()
wg.Done() // panic: negative WaitGroup counter
}()
wg.Wait()
4.4 Once.Do 中的函数 panic 后,Once 仍视为已执行
如果 Once.Do 中的函数 panic,Once 仍会标记为已执行,不会重试:
go
var once sync.Once
once.Do(func() {
panic("boom") // panic 后 Once 仍标记为已执行
})
once.Do(func() {
fmt.Println("不会执行")
})
5. 完整示例
5.1 WaitGroup 批量任务
go
package main
import (
"fmt"
"sync"
"time"
)
func process(n int) {
time.Sleep(time.Millisecond * 100)
fmt.Printf("处理任务 %d\n", n)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
process(n)
}(i)
}
wg.Wait()
fmt.Println("所有任务完成")
}
5.2 Once 幂等初始化
go
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config map[string]string
)
func loadConfig() map[string]string {
fmt.Println("加载配置...")
return map[string]string{"host": "localhost", "port": "8080"}
}
func getConfig() map[string]string {
once.Do(func() {
config = loadConfig()
})
return config
}
func main() {
// 并发调用,但 loadConfig 只执行一次
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(getConfig())
}()
}
wg.Wait()
}
5.3 OnceValue 简化写法(Go 1.21+)
go
package main
import (
"fmt"
"sync"
)
func loadConfig() map[string]string {
fmt.Println("加载配置...")
return map[string]string{"host": "localhost", "port": "8080"}
}
func main() {
getConfig := sync.OnceValue(loadConfig)
cfg1 := getConfig() // 首次调用,执行 loadConfig
cfg2 := getConfig() // 返回缓存
fmt.Println(cfg1)
fmt.Println(cfg2)
}
6. 总结
WaitGroup用于等待一组 goroutine 完成,核心是Add、Done、Wait三方法Add必须在Wait前调用,且应在主 goroutine 中调用WaitGroup不可复制,应通过指针传递Once确保函数只执行一次,内部使用 atomic + Mutex 双重检查Once.Do中的函数 panic 后不会重试- Go 1.21+ 提供了
OnceFunc和OnceValue便捷封装
掌握这些同步原语,能帮助你写出更健壮、更高效的并发代码。