sync.once是go中实现单例唯一靠谱的方式,其底层通过原子操作+mutex确保do内函数仅执行一次,所有goroutine阻塞等待完成并共享同一实例;需配合私有包级变量、错误兜底和依赖注入隔离使用。

sync.Once 是 Go 单例唯一靠谱的初始化方式
不用 sync.Once 的单例,基本等于在并发场景下裸奔。Go 没有类构造器、没有静态块,靠 if instance == nil 手动判空 + 赋值,会在高并发时触发多次初始化,导致多个实例、资源重复创建甚至 panic。
-
sync.Once底层用原子操作 + mutex 实现,保证Do内函数仅执行一次,且所有 goroutine 都会阻塞等待它完成,返回同一实例 - 包级变量必须是私有的(小写开头),否则外部可直接赋值破坏单例语义,比如
singleton.instance = nil - 初始化函数里如果发生 panic,
sync.Once会认为已“执行过”,后续调用Do不再尝试——所以务必在工厂函数内兜住错误,别让 panic 泄露出去
工厂函数不是接口,而是带参数的 NewXXX 函数
Go 里没“工厂类”概念,所谓工厂模式,本质就是一组按需返回不同实现的函数,通常以 NewXXX 命名。它和单例不冲突,反而常配合使用:单例负责“全局唯一”,工厂负责“按配置选实现”。
- 不要把工厂逻辑塞进单例的
GetInstance()里——那会让单例承担太多职责,比如GetInstance("dev")这种写法就违背了单例本意 - 推荐分层:先用工厂函数生成具体对象(如
NewLogger("prod")),再由单例封装其首次创建过程(getLoggerOnce.Do(...)) - 工厂函数返回接口类型(如
Logger),而非具体结构体指针,这样调用方完全不感知底层是zap.Logger还是logrus.Entry
单例 + 工厂组合时最容易漏掉的两件事
很多初学者写了 sync.Once 和工厂函数,但一上线就出问题,往往栽在这两个点上:
- 没处理初始化失败:比如数据库连接池工厂里
sql.Open失败,只log.Fatal或忽略错误,导致后续调用GetInstance()返回 nil 指针,panic 在业务代码里爆发,而不是在初始化阶段暴露 - 没做依赖注入隔离:单例内部如果硬编码调用另一个单例(如
logger := GetLogger()),会导致初始化顺序死锁。正确做法是工厂函数接收依赖作为参数,或用选项模式(Option)传入
为什么不用 init() 做单例初始化
init() 看似简单,但它在包导入时就执行,不可控、不可重试、无法传参、无法返回 error,还容易引发隐式依赖循环。
- 比如配置还没加载完,
init()就去连数据库,必然失败;而sync.Once可以等配置就绪后再调用 -
init()无法测试:你没法在单元测试里重置或替换它初始化的对象 - 多个包 import 同一个包时,
init()只执行一次,看似像单例,但一旦涉及环境判断(如根据os.Getenv("ENV")选日志后端),就会因执行时机不确定而出错
真正难的不是写出 sync.Once 或 NewXXX,而是想清楚哪个对象该全局唯一、哪个该按需新建、哪个该由调用方传入——边界模糊时,单例和工厂就变成耦合的温床。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











