sync.Once 是 Go 单例最稳的选择,因其用 atomic 和互斥锁组合规避指令重排与可见性问题,保证初始化函数仅执行一次,且首次后为无锁原子读。

为什么 sync.Once 是 Go 单例最稳的选择
Go 语言里手动写双重检查锁定(Double-Checked Locking)不仅没必要,还容易出错。根本原因在于:Go 的内存模型不保证普通变量的读写重排序行为在多 goroutine 下安全,而 sync.Once 内部用 atomic 和互斥锁组合,天然规避了指令重排和可见性问题。
常见错误现象:if instance == nil 判断后,多个 goroutine 同时进入初始化分支,导致多次构造;或构造未完成就被其他 goroutine 读到部分初始化的指针(尤其含指针字段的结构体)。
-
sync.Once是原子性的“只执行一次”,无论多少 goroutine 并发调用,Do中的函数仅执行且仅执行一次 - 不需要自己管理
sync.Mutex或sync.RWMutex,避免忘记加锁、重复加锁、死锁 - 性能上比手写双重检查更好——
Once在首次之后是纯原子读,无锁路径
怎么用 sync.Once 写线程安全单例
核心就三步:声明私有全局变量、声明 sync.Once、封装获取函数。所有初始化逻辑必须塞进 once.Do() 的闭包里,不能拆开。
典型场景:数据库连接池、配置加载器、日志实例、HTTP 客户端复用。
立即学习“go语言免费学习笔记(深入)”;
- 变量必须是包级私有(小写开头),否则破坏封装性
-
once.Do()传入的是函数值,不是函数调用,别写成once.Do(initFunc()) - 初始化函数内若 panic,
once.Do会记录失败状态,后续调用仍 panic——这点和双重检查不同,需主动处理异常
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{Path: "/etc/app.conf"}
// 这里可加载文件、校验字段、初始化子资源
})
return instance
}
手写双重检查锁定在 Go 里为什么危险
即使你加了 sync.Mutex,裸指针赋值仍可能被编译器或 CPU 重排。比如 instance = &T{} 可能被拆成“分配内存 → 写字段 → 写 instance 指针”,其他 goroutine 看到非 nil 的 instance 但字段还是零值。
错误示例中常出现:if instance == nil { mu.Lock(); if instance == nil { instance = new(T) } mu.Unlock() } —— 缺少内存屏障,Go 不保证这中间的写操作对其他 goroutine “立即可见”。
- Go 标准库不提供
volatile或std::atomic_thread_fence类接口,无法手动插入内存屏障 - 用
unsafe.Pointer+atomic.StorePointer能实现,但代码复杂、易错、可读性差,得不偿失 - 某些老教程用
atomic.Value包装指针,可行但绕远路,sync.Once更直接
sync.Once 的边界和注意点
sync.Once 解决的是“初始化一次”,不是“全局唯一对象生命周期管理”。它不负责销毁、重置或热更新。
容易被忽略的地方:
- 如果单例依赖外部状态(如环境变量变更),
sync.Once无法自动响应,得另做 reload 机制 - 测试时若需重置单例状态,不能靠重新赋值
instance,必须导出once或加 reset 函数(通常不推荐) - 多个单例之间有依赖顺序时,
sync.Once不提供执行顺序保证,需手动协调或合并初始化逻辑
真要动态重建,不如把单例封装成带 Init() 和 Reset() 方法的结构体,由上层控制生命周期。









