sync.once是go官方为“只执行一次+懒加载”设计的唯一推荐方案,它用atomic.loaduint32+sync.mutex自动保证内存可见性与happens-before,比手写dcl或mutex更安全高效。

直接说结论:别手写双重检查锁(DCL),也别用普通 mutex 包一层,就用 sync.Once —— 它是 Go 官方为“只执行一次 + 懒加载”设计的正解,不是备选,是唯一推荐方案。
为什么 sync.Once.Do 一定比手写 if + lock 更安全
手动实现双重检查锁(比如先判 instance == nil,再加锁、再判)在 Go 中极易出错:编译器重排、CPU 缓存不一致、内存模型未保证 happens-before,都可能导致某个 goroutine 读到部分初始化的对象——指针非 nil,但字段仍是零值,运行时 panic。
sync.Once 内部用 atomic.LoadUint32 + sync.Mutex 组合,自动插入内存屏障,确保初始化函数里所有写入对其他 goroutine 全局可见。你不用懂原子操作,它已经包圆了。
- 零值安全:
var once sync.Once声明即用,不用new或&sync.Once{} - 首次调用有轻量锁开销,之后全是原子读,比每次进
sync.Mutex快得多 - 一旦
Do中 panic,once永久标记为 done,不再重试——所以初始化前建议打日志,失败后做基础校验(如if dbPool == nil { panic("DB init failed") })
sync.Once.Do 里怎么处理 error 和 panic
sync.Once.Do 函数签名是 func(f func()),不接收参数、不返回值、不暴露 error。错误会被静默吞掉,后续调用仍返回未初始化变量,这是最常踩的坑。
必须把错误显式存到包级或结构体字段中,且读取时主动检查:
- 声明包级变量:
var initErr error,在Do闭包内赋值:initErr = err - 或结构体字段:
err error,赋值s.err = err(error是接口,直接赋值线程安全) - 获取函数必须检查:
if initErr != nil { return nil, initErr },不能只判instance == nil - 若初始化函数可能 panic,建议在闭包内
defer func() { if r := recover(); r != nil { log.Printf("init panic: %v", r) } }()
sync.Once 声明位置错了,等于没用
sync.Once 的作用域决定“只一次”的范围。放错地方,每个调用都新建一个 once,完全失效。
- 全局单例(如数据库连接池):
var dbPool *sql.DB和var dbOnce sync.Once都必须是包级变量 - 结构体内嵌(如 Service 的 HTTP 客户端):
once sync.Once可作为字段,但该结构体实例本身必须全局唯一(比如也是包级变量),否则每个Service{}都有自己的once - 绝对不要在函数内声明:
func GetX() { var once sync.Once; once.Do(...) }—— 每次调用都新建,毫无意义 - 也不要放进闭包或循环里,同理
Go 1.21+ 推荐用 sync.OnceValue 替代手写模式
如果你只是想“算一次、复用结果”,sync.OnceValue 比 sync.Once 更简洁、更安全,且天然支持泛型和错误处理变体 sync.OnceValues。
例如读配置文件:
var loadConfig = sync.OnceValue(func() (*Config, error) {
cfg, err := parseConfigFile("config.yaml")
if err != nil {
return nil, err
}
return cfg, nil
})
// 调用
cfg, err := loadConfig()
它把结果缓存、并发控制、类型安全全封装好了。但注意:sync.OnceValue 不适用于需要在初始化后修改状态的场景(比如启动后台 goroutine 后还要调用 Close()),这种仍得用传统 sync.Once + 显式字段管理。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











