直接用 sync.once 更安全,因其封装了线程安全的单次执行逻辑,底层结合原子操作与轻量锁,且 go 内存模型明确保证其正确性;手写双重检查锁定易因编译器重排或缺失内存屏障导致部分初始化对象被读取。

为什么直接用 sync.Once 更安全
双重检查锁定(Double-Checked Locking)在 Go 里不是推荐做法——sync.Once 已经封装了线程安全的、只执行一次的逻辑,且底层用的是更轻量的原子操作 + 简单锁,比手写 sync.Mutex + if 判断 + defer unlock 更可靠。Go 的内存模型对 sync.Once 有明确保证,而手写 DCL 容易因编译器重排或缺少内存屏障导致初始化未完成就被其他 goroutine 读到部分构造的对象。
手写 DCL 的典型错误写法
常见翻车点是把检查和赋值拆成两步,又没加 runtime.Gosched() 或 atomic 控制,导致竞态:
var instance *Service
var mu sync.Mutex
func GetInstance() *Service {
if instance == nil { // 第一次检查(非原子)
mu.Lock()
defer mu.Unlock()
if instance == nil { // 第二次检查(仍非原子,且 instance 是普通指针)
instance = &Service{} // 可能被部分写入后就被其他 goroutine 读取
}
}
return instance
}
问题在于:instance = &Service{} 不是原子操作,结构体字段可能未完全初始化就暴露给其他 goroutine;Go 编译器也可能重排写入顺序。
用匿名函数配合 sync.Once 实现真正安全的单例
把初始化逻辑包进匿名函数,传给 sync.Once.Do,既简洁又杜绝竞态:
var (
instance *Service
once sync.Once
)
func GetInstance() *Service {
once.Do(func() {
instance = &Service{
// 初始化耗时操作,比如连接池、配置加载等
client: http.Client{},
}
})
return instance
}
-
sync.Once.Do内部已保证:最多执行一次,且执行完成后所有 goroutine 都能看到完整的写入结果 - 匿名函数里可以做任意复杂初始化,包括调用其他函数、panic 处理、延迟关闭资源等
- 不需要手动管理
sync.Mutex,也不用担心锁粒度或死锁 - 如果初始化函数 panic,
once.Do会重试——这点要注意:它不认为 panic 是“已完成”,后续调用仍会再执行该匿名函数
需要 panic 后仍保持单例?得自己兜底
如果初始化可能失败(比如配置加载出错),又不想每次调用都重试,就得在外层加状态标记:
var (
instance *Service
once sync.Once
err error
)
func GetInstance() (*Service, error) {
once.Do(func() {
instance, err = newService() // 返回 (*Service, error)
})
return instance, err
}
这里的关键是:把 err 和 instance 绑定在一起赋值,靠 sync.Once 保证二者状态一致;不能分开判断或分两次写入,否则可能返回非 nil instance + nil err,或反之。
真正难的不是写出来,是想清楚「失败是否可重试」「失败后要不要暴露错误」「并发下怎么避免重复初始化副作用」——这些决定了你该用 sync.Once 还是得退回去手写带错误缓存的 DCL。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











