Go 专家(02):sync.Once —— 单例初始化、双重检查锁、底层实现
更新时间:2026-09-01。本文是
languages/go/expert/专家层第 02 篇,接 pprof 性能剖析。sync.Once是 Go 并发编程里最常用的同步原语之一,它能保证一个函数只执行一次,完美适合单例模式和延迟初始化。理解它的底层实现,能帮你理解内存屏障和双重检查锁的陷阱。
本文要回答的问题
sync.Once怎么用?为什么适合单例模式?sync.Once的底层结构是什么?为什么需要done+ 互斥锁?- 双重检查锁(DCLP)在 C++ 里有内存可见性问题,在 Go 里为什么安全?
Do多次调用会发生什么?Once能不能重复使用?
一、基本用法:单例模式
package config
var (
once sync.Once
cfg *Config
)
func LoadConfig() *Config {
once.Do(func() {
cfg = loadFromFile("config.json")
})
return cfg
}多个 goroutine 同时调用 LoadConfig(),once.Do 保证 loadFromFile 只执行一次,后续调用直接返回。
二、底层结构
type Once struct {
done uint32 // 标记是否已经执行
mu Mutex // 互斥锁
}func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.mu.Lock()
defer o.mu.Unlock()
if o.done == 0 {
f()
atomic.StoreUint32(&o.done, 1)
}
}这就是经典的双重检查锁:
- 第一次检查
done == 0(无锁,原子读) - 如果不是 0,直接返回
- 如果是 0,拿锁
- 拿到锁后,第二次检查
done == 0 - 如果还是 0,执行函数,设置
done = 1 - 释放锁
为什么两次检查?
- 第一次检查是无锁的,已经执行过就不会拿锁,性能快
- 第二次检查是在锁里,保证只有一个 goroutine 能执行函数
三、为什么 Go 里双重检查锁安全
在 C++ 里,双重检查锁(DCLP)在没有内存屏障的时候是不安全的:
- 编译器可能重排指令,先设置
done = 1,再初始化对象 - 另一个 goroutine 读到
done = 1,拿到未初始化的对象
在 Go 里,sync/atomic.StoreUint32 提供了顺序一致(SC)的内存语义:
atomic.StoreUint32保证所有在它之前的写操作(比如cfg = loadFromFile())都对其他 goroutine 可见,才会写done = 1- 不会出现
done = 1先写,对象后初始化的重排
所以 Go 里的 sync.Once 双重检查锁是安全的。
四、sync.Once 的常见问题
Q:Once 执行 panic,下次还会执行吗?
once.Do(func() { panic("oops") })
once.Do(func() { fmt.Println("second") })
// 第二次不会执行,因为 done 已经变成 1 了done 在 f() 返回后才设置为 1,如果 f() panic,done 不会被设置,下次 Do 会继续执行。看源码:
if o.done == 0 {
f() // 如果 panic,不会到下一行
atomic.StoreUint32(&o.done, 1)
}Q:Once 能不能重置后重复使用? 官方设计就是"只执行一次",不支持重置。要重复执行,用自己的计数器。
Q:Do 里递归调用 Do 会死锁吗?
var once sync.Once
once.Do(func() {
once.Do(func() {
fmt.Println("inner")
})
})会死锁,因为外层已经拿到了 mu,内层拿锁会阻塞。
五、性能
sync.Once 的性能很好:
- 已经执行过后,路径是
atomic.LoadUint32→ 返回,开销极小 - 只有第一次需要拿锁
在单例场景,sync.Once 比 if init { ... } + 互斥锁快很多。
六、常见坑对照
| 坑 | 现象 | 对策 |
|---|---|---|
| panic 后 done 不设置,下次还执行 | 如果函数可能 panic,需要自己处理 | 在函数内 recover |
| Once 里递归调用 Once | 死锁 | 避免递归 |
| 想重复使用 Once | 第一次执行后再也不执行 | 自己实现计数器,不要用 Once |
相关与延伸
下一篇:sync.Map —— 什么时候用,性能特点;sync.Once 依赖 atomic 的内存语义,见 Go 内存模型。
一句话总结
sync.Once:结构是 done (atomic uint32) + mu (Mutex);双重检查锁实现,第一次检查无锁快,第二次检查在锁里保证只执行一次;Go 的 atomic.Store 有 SC 内存语义,所以双重检查锁安全;保证函数只执行一次,适合单例和延迟初始化;panic 发生时 done 不会设置,下次 Do 重试;已经执行后后续调用无锁返回,性能很好。