sync.once 是唯一靠谱的单例实现方式,因其通过原子操作与互斥锁双重保障确保初始化逻辑仅执行一次且 goroutine 安全等待,而手动用 mutex 或 if 判断极易出错。

为什么 sync.Once 是唯一靠谱的选择
Go 里没有“类”或“静态变量”,所谓单例本质是全局变量 + 初始化控制。直接用 if instance == nil 判断再赋值,必然在高并发下产生多个实例——这是最常踩的坑。Go 标准库的 sync.Once 内部用原子操作+互斥锁双重保障,确保 Do 只执行一次且完全同步,其他 goroutine 会阻塞等待而非重复初始化。
别自己手写 sync.Mutex 包裹判断逻辑:既容易漏掉锁释放,又无法像 sync.Once 那样天然支持“等待已完成初始化”的语义。
sync.Once 的典型写法和必须注意的参数陷阱
关键不是“怎么写”,而是“在哪写”和“传什么”。常见错误是把 sync.Once 实例定义在函数内部(每次调用都新建),或者传入一个已求值的表达式(导致初始化逻辑提前执行)。
-
sync.Once必须是包级变量,不能是局部变量 - 传给
once.Do()的必须是函数字面量(func() { ... }),不能是已调用的结果(比如once.Do(newService())错!) - 初始化函数里不要依赖外部可变状态(如未加锁的全局 map),否则单例本身线程安全,但它的内容不安全
正确示例:
var (
instance *Service
once sync.Once
)
func GetService() *Service {
once.Do(func() {
instance = &Service{...} // 这里才真正构造
})
return instance
}
如果单例需要传参或依赖注入怎么办
sync.Once 本身不支持参数,硬要在 GetService() 里传参会导致每次调用都新建实例,破坏单例语义。真有配置差异需求,分两种情况处理:
- 配置在启动时确定:用包级变量先存好配置,再在
once.Do里读取,比如config := globalConfig - 需要运行时多态(如不同环境不同实现):这不是单例问题,是对象工厂问题,应该用
func NewService(cfg Config) *Service显式构造,然后由上层缓存实例
强行把参数塞进 GetService(url string) 并试图“按 url 缓存多个单例”,就不再是单例,而是缓存池,该用 sync.Map 或第三方池化库。
测试时如何验证单例只初始化一次
光测返回值是否相同不够,得确认初始化逻辑没被多次执行。最直接的办法是在初始化函数里加计数器或日志:
var initCount int
once.Do(func() {
initCount++
log.Printf("init count: %d", initCount)
instance = &Service{}
})
然后用 go test -race 跑并发测试:
func TestGetServiceConcurrent(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i
<p>注意:<code>initCount</code> 本身要加 <code>sync.Mutex</code> 或用 <code>atomic.Int32</code>,否则测试本身就会触发竞态报警。</p>
<p>真正难的是初始化失败后的重试逻辑——<code>sync.Once</code> 不支持重试,一旦 <code>Do</code> 执行过(无论成功失败),就不会再执行。需要失败重试的话,得自己封装带错误返回和重试机制的初始化逻辑,这时候单例就不再是“简单封装”而是“带状态的初始化控制器”了。</p>











