sync.once是go官方为“只执行一次+懒加载”设计的正解,它通过原子操作done uint32与互斥锁组合确保happens-before关系,避免手动双重检查因内存模型导致的未初始化读取问题,零值安全、声明即用,且首次do后全为原子读,高效可靠。

直接用 sync.Once 就行,它就是 Go 官方为“只执行一次 + 懒加载”这个场景专设的类型,不是辅助工具,是正解。
为什么不能手写 double-check 或自定义锁
Go 的内存模型和编译器优化会让手动实现的双重检查(如先判 nil 再加锁再判 nil)失效:某个 goroutine 可能读到未完全初始化的指针,引发 nil pointer dereference 或字段值为空但对象已非 nil 的诡异状态。而 sync.Once 内部用原子读写 done uint32 + 互斥锁组合,自动建立 happens-before 关系,保证初始化完成前所有字段写入对其他 goroutine 可见。
-
sync.Once是零值安全的,声明即用:var once sync.Once,无需new或额外初始化 - 首次
Do()有轻量锁开销,之后全是原子读,比每次进Mutex快得多 - 手写方案容易漏掉 volatile 语义、指令重排防护或缓存一致性处理——
sync.Once全包了
sync.Once.Do() 里怎么处理错误
sync.Once.Do() 函数签名是 func(f func()),不接收返回值,也不暴露 error。一旦初始化失败,错误会被静默吞掉,后续调用仍返回未初始化的变量,极易导致运行时 panic。
- 必须在闭包外显式声明错误变量,比如结构体字段
err error或包级var initErr error - 赋值要原子:Go 中
error是接口,直接赋值db.err = err是安全的(不用指针) - 获取函数必须主动检查错误:
if db.err != nil { return nil, db.err },不能只判db.conn == nil - 如果
Do()中 panic,once会标记为 done,不再重试——建议初始化前打日志,失败后做基础校验(如if db.conn == nil { panic("init failed") })
sync.Once 声明位置的坑:别放错地方
sync.Once 的作用域决定了“只一次”的范围。放错位置就彻底失效。
- 如果是全局单例(如数据库连接池),
once和实例变量都必须是包级变量:var dbPool *sql.DB; var dbOnce sync.Once - 如果属于某个结构体(如
Service的内部 HTTP 客户端),once可以是结构体字段,但该结构体本身必须是全局唯一的(比如也是包级变量),否则每个Service{}实例都会触发一次初始化 - 绝对不要在函数内声明
once sync.Once,也不要把它放进闭包或循环里——每次调用都新建一个,完全失去意义
Go 1.21+ 推荐用 sync.OnceValue 替代手写模式
如果你只是想“计算一次、多次复用结果”,sync.OnceValue 比 sync.Once 更简洁、更安全,且天然支持并发。
- 函数签名是
func OnceValue[T any](f func() T) func() T,直接返回一个可多次调用的函数 - 无需额外变量存储结果,也无需手动处理错误传播(错误可作为返回值的一部分)
- 示例:
getConfig := sync.OnceValue(func() map[string]string { return loadConfigFromEnv() }),之后直接config := getConfig() - 注意:它不适用于需要延迟副作用(如启动后台 goroutine、注册信号 handler)的场景,因为副作用只在首次调用时发生,且无法捕获其错误——这种场景仍得用
sync.Once+ 显式错误字段
真正难的不是写对第一次初始化,而是确保所有调用路径都走同一份 once 实例、所有错误都被显式检查、所有 panic 都有兜底日志。这些细节不写进代码注释里,半年后你自己都看不懂为什么连不上数据库。











