用 sync.Once 做延迟加载是唯一靠谱方式:它通过原子操作+互斥锁保证线程安全、只执行一次且支持延迟加载,避免 data race;包级变量须为指针类型,初始化逻辑必须完整包裹在 once.Do 中。

用 sync.Once 做延迟加载是唯一靠谱方式
Go 里没有“构造函数”,所谓单例不是靠禁止 new 实现的,而是靠 sync.Once 控制初始化时机——它天然支持延迟加载、线程安全、且只执行一次。别写 if instance == nil,那在并发下必然生成多个实例,go run -race 一跑就报 data race。
关键点:
-
sync.Once内部用原子操作 + 互斥锁,比手写sync.Mutex更轻量、语义更清晰 - 包级变量必须是指针类型(如
instance *Singleton),否则无法在once.Do()中赋值 - 初始化逻辑必须完整包裹在
once.Do(func() { ... })里,哪怕只有一行instance = &Singleton{}
带参数的单例怎么延迟初始化
如果单例需要配置(比如数据库连接字符串、日志级别),不能把参数塞进 GetInstance() 签名里反复传——首次之后的参数会被忽略,这不是 bug,是设计使然。
正确做法是显式调用初始化函数,在 main() 或模块初始化阶段完成:
立即学习“go语言免费学习笔记(深入)”;
- 提供一个导出的
Init(config Config)方法,内部用once.Do()封装构造逻辑 -
GetInstance()只做取值,不参与初始化;若未调用Init()就调用GetInstance(),应 panic 或返回明确错误(比如封装instance和err到私有结构体) - 避免在
init()函数里直接 new 实例:它早于main(),无法读取命令行参数或环境变量,也无法处理初始化失败
为什么不能用 init() 或全局变量直接赋值
常见错误现象包括:数据库连接 dial 两次、日志里重复打印“initialized”,甚至 nil pointer dereference——因为看似简单的 var instance = &MyService{} 在并发 import 场景下可能触发多次初始化表达式求值。
根本原因:
-
init()执行时机不可控,无法传参、无法返回 error,失败时静默吞掉 - 包级变量直接赋值不带同步机制,Go 运行时不保证多 goroutine 对该变量写入的可见性顺序
- 导出的全局变量(如
var Instance *Singleton)若没加锁读取,可能返回未完成初始化的指针
错误处理必须显式暴露
真实场景中,单例常要连 DB、读配置、启监听——这些操作都可能失败。但 sync.Once.Do() 不接受返回 error 的函数签名,所以不能把 error 吞掉。
可行方案:
- 在
once.Do()内部捕获 error,并存到包级initErr error变量中 -
GetInstance()先检查initErr != nil,然后 panic 或返回nil+ 错误信息 - 更推荐的方式:把初始化逻辑封装成工厂函数,返回
(*Singleton, error),再由调用方决定是否 panic 或重试
最易被忽略的一点:一旦 once.Do() 执行过(无论成功失败),它就永久标记为 done,后续调用不会再尝试初始化——所以错误必须在第一次就暴露清楚,不能靠“下次重试”。


















