必须用sync.Once,因包级变量+if判断存在数据竞争和部分初始化风险;init()无法传参或容错;手写Mutex易出错;而sync.Once通过原子检查+双重锁定确保仅执行一次且线程安全。

Go 里延迟加载单例必须用 sync.Once,其他方式不是线程安全就是语义错误——哪怕只跑一个 goroutine,不加 once.Do() 也迟早出问题。
为什么不能只靠包级变量 + if 判断
常见写法:if instance == nil { instance = &MyService{} } 看似简单,但并发下会创建多个实例。Go 不保证包级变量赋值的原子性,go run -race 一跑就报 data race。更隐蔽的是:即使没 panic,instance 可能被部分初始化(比如字段还没来得及赋值),另一个 goroutine 就读到了中间状态。
- 包级变量本身不带同步语义,
nil检查和赋值是两步操作 - init() 函数也不行——它在
main()前执行,无法传参、无法处理初始化失败 - 手写
sync.Mutex太重,且容易漏掉二次检查或 defer 解锁
sync.Once.Do() 是唯一推荐的初始化入口
sync.Once 内部用原子操作判断 done 标志位,仅当未执行过才加锁并运行函数,执行完自动标记,后续调用直接返回。它轻量、无副作用、官方背书。
- 初始化函数只能执行一次,无论多少 goroutine 同时调用
GetInstance() - 函数内可做任意耗时操作:读配置、连 DB、解析 JSON,都 OK
- 不要把初始化逻辑拆到多个地方——
once.Do()必须包裹全部初始化步骤
示例:
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
RedisURL: os.Getenv("REDIS_URL"),
}
})
return instance
}
带错误返回的单例怎么写
真实场景中,初始化常会失败(如 DB 连不上、配置缺失)。错误不能吞掉,也不能让调用方拿到 nil 却不知原因。标准做法是把 error 和实例一起返回,并在 once.Do() 内捕获。
- 错误变量必须是包级导出或闭包捕获,否则
Do()外拿不到 - 首次失败后,后续调用仍返回同一个 error,不会重试
- 别在
GetInstance()外再判err != nil—— 那会绕过 once 保护
示例:
var (
instance *DB
err error
once sync.Once
)
func GetDB() (*DB, error) {
once.Do(func() {
instance, err = sql.Open("mysql", dsn)
if err != nil {
return
}
err = instance.Ping()
})
return instance, err
}
传参和重置的边界在哪
sync.Once 天然不支持“重新初始化”。一旦执行过,Do() 永远跳过。所以:GetInstance(name string) 这种签名是误导——首次调用传的 name 生效,之后再传别的值完全无效。
- 需要运行时变更配置?说明这不是单例,是对象池或依赖注入场景
- 测试时想重置?应改用构造函数 + 依赖注入,而不是全局变量 + once
- 真要强行重置(极少见),得额外加
sync.RWMutex控制once和instance,但破坏了单例语义,慎用
最易被忽略的一点:单例内部状态若可变(比如缓存 map),必须自己保证线程安全——sync.Once 只管初始化那一次,不管后续读写。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











