sync.Once确保初始化函数在并发下仅执行一次并阻塞其他goroutine,不支持重试、取消或限流,误用会导致panic、死锁或失效。

Go 里的 sync.Once 不是“执行一次”,而是“保证初始化动作仅执行且仅执行一次”
很多人误以为 sync.Once 是用来做“限流执行”或“防重入调用”的,其实它只服务于一个目的:**确保某个初始化函数在并发场景下最多被调用一次,并阻塞其他 goroutine 直到该函数返回**。它不记录状态、不提供重试、不支持取消,也不是通用的互斥工具。
典型误用是把它套在业务逻辑里反复调用——比如每次 HTTP 请求都去 once.Do(func(){...}),结果发现初始化逻辑没跑、或 panic 了却没报错、或卡死在某次调用上。
-
sync.Once的Do方法接收一个func(),且该函数不能带参数、不能返回值 - 一旦
Do内部函数 panic,once会把 panic 向上传播,但后续再调用Do仍会 panic(不会重试,也不会静默跳过) - 同一个
sync.Once实例不能复用;想控制多个独立初始化动作,就得定义多个变量
单例模式中,sync.Once 只负责“懒加载时的线程安全”,不是单例本身
单例对象的生命周期、存储位置、是否可替换,和 sync.Once 没关系。它只是帮你把“实例化并赋值”这个动作变成线程安全的。
常见写法是配合包级变量 + sync.Once:
立即学习“go语言免费学习笔记(深入)”;
var (
instance *DB
once sync.Once
)
func GetDB() *DB {
once.Do(func() {
instance = &DB{...} // 或调用 NewDB()
})
return instance
}
注意:这里 instance 是包级变量,once 是它的“守门人”。如果把 instance 放进函数作用域,或者每次调用都 new 一个 sync.Once,那就完全失效了。
- 不要在方法内声明
once sync.Once—— 每次调用都是新实例,起不到同步作用 - 不要把
sync.Once嵌入结构体当作“每个对象的单例控制器”——它不管理对象生命周期,只管一次函数执行 - 若需测试替换单例(如 mock),应导出变量或使用接口+依赖注入,而不是硬编码
once.Do
比 sync.Once 更隐蔽的坑:初始化函数里调用了自身 GetXXX()
这是死锁高发区。例如在 GetDB() 的初始化函数里又调用了 GetCache(),而后者内部也用了 sync.Once,且其初始化函数反过来依赖 GetDB() —— 就会卡在 once.Do 的内部 mutex 上,goroutine 永久等待。
sync.Once 的实现本质是带原子状态的互斥锁,所有等待者都会阻塞在同一条 futex 上,没有超时、不可中断。
- 初始化函数必须是纯构造逻辑,不能有跨单例的循环依赖调用
- 若必须组合多个资源,建议拆成原子初始化步骤,或改用显式初始化函数(如
Init()),由上层统一协调顺序 - 调试时可加日志打点,但注意:日志语句要放在
once.Do外围,否则看不到“谁卡住了”
替代方案:什么时候不该用 sync.Once?
它只适合“无参、无副作用、幂等、快速完成”的初始化。遇到这些情况,就得换思路:
- 需要传参初始化(比如从配置读 host/port)→ 改用工厂函数 + 缓存 map,或用
sync.Map存 key→instance 映射 - 初始化可能失败且需重试 →
sync.Once不支持,得自己实现带错误返回和重试逻辑的 once-like 结构 - 服务启停周期内要重建单例(如数据库连接池热更新)→ 它不支持 reset,应设计为可关闭/重建的对象,而非全局单例
- 想控制并发数或限频 → 用
semaphore或rate.Limiter,别硬套sync.Once
真正精妙的地方不在语法糖,而在你能否一眼看出:这段初始化到底有没有隐式依赖、会不会 panic、要不要支持重入——sync.Once 从不替你回答这些问题,它只忠实地执行一次。


















