最稳妥方式是用 sync.once 实现懒汉式单例,它通过原子操作确保初始化函数只执行一次且具备 happens-before 语义,避免重排和竞态;init 函数不适用于需延迟初始化、传参或错误处理的场景。

Go 里用 sync.Once 实现线程安全单例最稳妥
直接上结论:别手写锁判断,用 sync.Once 是 Go 官方推荐且最省心的方式。它天然解决多 goroutine 并发初始化时的竞态问题,比自己加 sync.Mutex 更轻量、更可靠。
常见错误是写成“先判空再加锁再判空”(double-check),但 Go 的内存模型下,没用 sync.Once 或没正确使用 atomic,可能因指令重排导致其他 goroutine 看到未完全初始化的对象。
-
sync.Once内部用原子操作保证只执行一次,且自带 happens-before 语义,初始化函数里的所有写操作对后续读取者可见 - 初始化函数不能带参数,所以通常封装成闭包或提前捕获依赖
- 如果初始化可能失败(比如加载配置出错),
sync.Once不会重试,得靠外部兜底(比如返回 error + nil 实例)
var (
instance *DB
once sync.Once
)
func GetDB() *DB {
once.Do(func() {
instance = &DB{conn: connectToDB()}
})
return instance
}
为什么不用全局变量 + init 函数?
init 函数确实能保证只执行一次,但它在包加载时就运行,没法延迟初始化,也不支持按需创建、传参或错误处理。
典型踩坑场景:
- DB 连接依赖环境变量,而
init执行时环境还没准备好,导致 panic - 想在测试中替换 mock 实例,但
init已固化,无法重置 - 多个单例之间有依赖顺序,
init的执行顺序由导入路径决定,难控制
所以除非是纯常量或无副作用的预计算(比如正则编译),否则别用 init 做单例初始化。
懒汉式 vs 饿汉式:性能和启动时间怎么权衡?
饿汉式(包级变量直接初始化)启动快、无并发开销,但浪费资源——比如单例对象占内存大,却整个生命周期只用一次;懒汉式(sync.Once)按需创建,内存友好,但首次调用有微小延迟。
实际选型看场景:
- Web 服务中 DB、Redis 客户端这类核心依赖,建议懒汉式——避免启动失败,也方便注入 mock
- 日志实例、配置解析器这类轻量且必用的,饿汉式更简单:
var logger = zap.NewExample() - 别为了“绝对线程安全”把所有变量都套
sync.Once,没并发访问风险的,普通变量就行
测试时怎么替换或重置单例?
单例最难测的点在于状态残留。Go 没有类、没有构造器重载,靠包级变量共享状态,测试间容易互相污染。
可行办法只有两个:
- 导出初始化函数(如
SetDBForTest(*DB)),让测试能手动注入新实例,原GetDB()内部用指针变量存值 - 用接口+依赖注入替代单例:把单例当参数传给需要它的结构体,测试时直接传 mock,彻底绕过全局状态
硬编码的 sync.Once 单例无法重置——sync.Once 的状态不可逆,这是设计使然,不是 bug。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











