直接用全局变量加init函数不安全,因为init只保证包级初始化一次,无法保障懒加载时多goroutine并发调用的初始化安全;sync.Once才是官方推荐的轻量、线程安全方案。

为什么直接用全局变量加 init 函数不安全
很多人写 init() 里初始化一个全局指针,以为这就叫单例。但问题在于:如果多个 goroutine 同时首次调用该单例的获取函数(比如 GetInstance()),而这个函数又没做同步控制,就可能触发多次初始化——init() 确实只执行一次,但它只管包级初始化,不管“懒加载”逻辑的并发安全。
sync.Once 是最轻量且推荐的方案
sync.Once 内部用原子操作 + 互斥锁组合实现,保证 Do() 中的函数有且仅执行一次,且所有 goroutine 都会阻塞等待它完成。这是 Go 官方明确推荐的单例初始化方式。
常见错误是把 sync.Once 放在函数内部(每次调用都新建一个),正确做法是作为包级变量:
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{...}
})
return instance
}
- 不要在
once.Do()外面加额外锁,sync.Once已包含必要同步 - 不要在
Do()回调里做耗时操作(如网络请求、大文件读取),否则所有 goroutine 都会卡住等它结束 - 如果初始化可能失败,需额外封装返回 error;
sync.Once本身不支持失败重试
如果需要带参数或可配置的单例,别硬塞进 Do
一旦初始化逻辑依赖运行时参数(比如从 config 加载地址、TLS 配置),就不能把 GetInstance() 设计成无参函数再靠 sync.Once 搞定。这时候应拆成两步:
- 先调用
NewSingleton(cfg Config)构造实例(可多次调用,但通常只调一次) - 再由使用者显式赋值给包级变量,或用
sync.Once包裹构造过程,但把配置传入闭包
例如:
var (
instance *Singleton
once sync.Once
)
func InitSingleton(cfg Config) {
once.Do(func() {
instance = NewSingleton(cfg)
})
}
注意:此时 InitSingleton() 必须在所有并发调用 GetInstance() 前完成,否则仍可能触发竞态。
不要用 double-check lock(DCL)模拟 Java 风格单例
有人试图用 atomic.LoadPointer + sync.Mutex 手写双重检查,这在 Go 里既没必要也不安全——Go 的内存模型不保证编译器和 CPU 的重排序行为能被 DCL 正确约束,而且 sync.Once 已足够高效(首次之后几乎零开销)。自己实现容易漏掉 unsafe.Pointer 转换、内存屏障或锁粒度问题。
更隐蔽的坑是:哪怕你写对了 DCL,也比 sync.Once 多出至少 2–3 倍的指令路径和 cache miss,实际性能反而更差。
真正需要极致性能的场景(比如每微秒调用百次),说明单例本身设计可能有问题——考虑是否真需要全局唯一,还是该用对象池(sync.Pool)或依赖注入。










