应使用sync.once而非手写双重检查锁定,因其内置内存屏障、原子操作和互斥锁,确保初始化一次且安全,避免nil解引用、竞态和cpu缓存不一致;手写dcl易因指令重排和可见性问题导致未定义行为。

别手写双重检查锁定(DCL)——用 sync.Once 就够了,安全、简洁、无坑。 手动实现 DCL 在 Go 里极易出错,不是性能问题,而是正确性问题:nil pointer dereference、竞态、CPU 缓存不一致都可能在高并发初始化时突然爆发。
为什么手写双重检查锁定在 Go 里危险
Go 的内存模型对指令重排和写入可见性有特殊要求,而手写 DCL 往往只关注“加锁”和“判空”,忽略底层细节:
- 普通布尔标志位(如
initialized bool)无法保证其他 goroutine 立即看到写入,可能导致重复初始化或返回未完全构造的对象 - 即使用了
sync.Mutex,第一次判空不加锁,第二次加锁后赋值,仍可能因编译器/CPU 重排导致 instance 指针被提前写入,但字段尚未初始化(典型 crash 场景) - 没用
atomic.StorePointer或unsafe.Pointer配合屏障,instance = &T{}这一行本身就不是原子的 - 错误示例中常见
defer mutex.Unlock()放在第二次判空之后,导致锁未释放,直接死锁
sync.Once 是标准解法,不是“推荐”,是“必须”
sync.Once 内部已封装所有内存屏障、原子操作和 once-execution 语义,使用者只需两件事:
- 声明一个包级
sync.Once变量(不能是局部变量,也不能被复制) - 在获取函数中调用其
Do方法,传入一个无参无返回的闭包来完成初始化 - 闭包内必须通过指针或包级变量捕获并赋值目标实例,例如
instance = &Config{...} -
Do中的函数最多执行一次,且执行期间阻塞所有后续调用;执行完后,后续调用直接返回,零开销
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{Port: 8080, Timeout: 30}
})
return instance
}
什么情况下才考虑手动 DCL
极少数场景下你确实需要绕过 sync.Once,比如:
- 必须提前暴露未初始化的指针(如供 DI 容器注入钩子)
- 初始化逻辑需分阶段,且要精确控制“指针可见”和“对象就绪”的时间点
- 兼容非常旧的 Go 版本(
atomic.Value 或 atomic.Pointer[T],不能用 bool + sync.Mutex 组合。例如:
var instance atomic.Pointer[Config]
func GetConfig() *Config {
if v := instance.Load(); v != nil {
return v
}
// 构造新实例
cfg := &Config{Port: 8080}
if !instance.CompareAndSwap(nil, cfg) {
return instance.Load()
}
return cfg
}
注意:CompareAndSwap 失败说明别人已设值,必须立刻用 Load() 获取那个值,而不是继续构造。
最容易被忽略的三个细节
哪怕你决定用 sync.Once,以下三点不注意,照样线上 panic:
-
once字段若嵌入结构体,必须以指针形式使用(如&s.once.Do(...)),否则每次方法调用都在拷贝新sync.Once实例,失去“once”语义 -
Do里的闭包若引用了尚未声明或为 nil 的变量(如v := configMap["db"]但 map 为空),会导致初始化静默失败,instance保持 nil,后续直接 panic - 初始化函数里做网络请求、读文件等耗时操作,会阻塞所有等待 goroutine —— 这不是 bug,是设计使然,得提前预热或改用异步加载+原子切换
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











