不能直接用 singleton 做全局变量初始化,因为 var instance singleton 的零值为 nil,若靠 if instance == nil { instance = &singleton{} } 手动初始化,会因竞态导致多个 goroutine 同时创建实例;必须用 sync.once 等同步机制保障“只执行一次”,且不可在 do 中 panic,否则后续调用永远失败。

为什么不能直接用 *Singleton 做全局变量初始化
Go 里声明 var instance *Singleton 后不赋值,它的零值是 nil。如果后续靠 if instance == nil { instance = &Singleton{} } 手动检查并初始化,会引发竞态:多个 goroutine 同时判断为 nil,然后各自新建实例,破坏单例语义。
常见错误现象:go run -race main.go 报出 Write at 0x... by goroutine N 和 Previous write at 0x... by goroutine M —— 这就是并发写 instance 变量导致的。
- 必须用同步机制保护首次初始化逻辑
- 不能依赖“变量未初始化”做判断依据,而应明确区分“未创建”和“已创建但为 nil(比如构造失败)”
- 推荐用
sync.Once,它内部用原子操作 + mutex 实现“只执行一次”,比手写锁更轻量、更安全
sync.Once 是怎么保证线程安全的单例构造的
sync.Once 的 Do 方法接受一个无参函数,确保该函数在整个程序生命周期中最多执行一次。它不关心函数返回值,也不暴露内部状态,天然适配单例初始化场景。
典型写法是把指针变量声明为包级私有,配合 sync.Once 和闭包完成惰性构造:
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
-
once.Do内部使用atomic.LoadUint32快速判断是否已执行,避免每次调用都加锁 - 即使
GetInstance()被十万次并发调用,&Singleton{}也只被 new 一次 - 不要在
once.Do里做可能 panic 的操作(比如打开文件失败),否则once会标记为“已完成”,后续调用永远返回nil
带参数或可能失败的单例怎么处理
如果单例构造需要传参(如配置)、或可能返回 error(如连接数据库),就不能把 GetInstance() 设计成无参且必成功。此时需拆分职责:用一个初始化函数显式触发构造,并缓存结果;GetInstance() 则只负责返回已构造好的实例。
常见做法是用两个包级变量:一个存实例指针,一个存初始化错误,都由同一个 sync.Once 保护:
var (
instance *Singleton
initErr error
once sync.Once
)
func Init(cfg Config) error {
once.Do(func() {
instance, initErr = NewSingleton(cfg)
})
return initErr
}
func GetInstance() (*Singleton, error) {
if initErr != nil {
return nil, initErr
}
return instance, nil
}
- 调用方必须先调用
Init(),再调用GetInstance();顺序错误会导致 panic 或返回nil - 不推荐在
GetInstance()内部隐式调用Init(),那会让错误传播路径变模糊,调试困难 - 如果构造耗时很长(比如加载大模型),建议在应用启动阶段就调用
Init(),而不是等到第一次GetInstance()时才触发
为什么不用 init() 函数做单例初始化
init() 确实能保证只执行一次,但它在包导入时立即运行,无法延迟到首次使用时,也无法接收运行时参数。更重要的是:init() 中的 panic 会导致整个程序启动失败,且无法被 recover —— 对于可选依赖或降级场景极不友好。
- 比如单例依赖外部 Redis,
init()里连接失败 → 程序直接 exit,连日志都来不及打 - 而用
sync.Once+ 显式Init(),可以捕获 error、打印告警、启用本地 mock 实例等 -
init()不适合测试:你没法在单元测试中重新初始化单例,因为包只会被导入一次
真正容易被忽略的是:单例的生命周期管理。Go 没有析构概念,*Singleton 一旦创建就常驻内存,如果它持有文件句柄、网络连接或 goroutine,必须提供显式的 Close() 方法,并由上层控制何时调用 —— 这部分和指针本身无关,但却是实际项目中最常出问题的地方。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











