go中应使用sync.once而非double-checked locking,因其通过原子操作和互斥锁确保初始化一次且安全;手写dcl易因内存模型导致未定义行为。

为什么不用 double-check lock 而用 sync.Once
Go 语言里手写双重检查锁(double-checked locking)不仅没必要,还容易出错。因为 Go 的内存模型不保证未同步的读写顺序,if instance == nil 后直接赋值会导致其他 goroutine 看到部分初始化的对象——这不是竞态,而是未定义行为。而 sync.Once 底层用原子操作 + 互斥锁封装了“只执行一次”的语义,安全、简洁、无歧义。
sync.Once 实现单例的标准写法
核心就三步:声明全局变量、声明 sync.Once、用 Do 包裹初始化逻辑。注意初始化函数不能带参数,也不能返回错误——如果有依赖或可能失败,得提前处理好。
常见错误现象:instance 声明为指针但没初始化,或在 Do 外部提前读取导致空指针 panic。
- 必须把实例变量声明为包级变量(或至少作用域覆盖所有调用点)
-
sync.Once本身也必须是包级变量,不能每次调用都 new 一个 - 初始化函数内不要做耗时操作(比如网络请求),否则会阻塞所有后续调用直到完成
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{Port: 8080}
})
return instance
}
什么时候该考虑替代方案
如果单例需要延迟初始化 + 参数传入 + 错误处理,sync.Once 就不够用了。比如从配置文件加载、连接数据库、校验环境变量等场景。
立即学习“go语言免费学习笔记(深入)”;
这时更稳妥的做法是:用一个初始化函数返回 (*T, error),配合 sync.Once + 包级变量缓存结果和错误,避免重复失败尝试。
- 不要在
Do回调里 panic,否则once.Do之后永远无法重试 - 如果初始化失败,建议缓存错误并返回,而不是让后续调用继续尝试(除非明确要重试)
- 某些框架(如 uber-go/zap)用的是“懒加载 + 首次调用时初始化”,本质仍是
sync.Once变体,但把错误暴露给了调用方
并发安全但不是万能的
sync.Once 只保证初始化函数执行一次,不保护实例本身的并发访问。如果单例对象内部有可变状态(比如缓存 map、计数器),仍需额外加锁或使用线程安全类型(如 sync.Map)。
典型踩坑:以为用了 sync.Once 就高枕无忧,结果在 GetConfig() 返回后对 instance.CacheMap 直接读写,引发 data race。
- 初始化安全 ≠ 使用安全
- 检查是否需要
sync.RWMutex或原子操作来保护字段 - 用
go run -race跑测试,别靠猜










