在 Go 的并发编程中,sync.Once 是一个非常经典的同步原语。它的作用很简单:保证某个函数在并发环境中只执行一次。无论有多少个 goroutine 同时调用,目标函数都只会被执行一次。
例如:
var once sync.Once
func initConfig() {
fmt.Println("init config")
}
func main() {
for i := 0; i < 100; i++ {
go func() {
once.Do(initConfig)
}()
}
}
即使有 100 个 goroutine 同时调用 once.Do(),initConfig() 也只会执行一次。
但一个非常有意思的问题是:为什么 sync.Once 在高并发场景下依然非常高效?
答案在于它的内部设计:atomic + mutex + fast path 优化。
其实
sync.Once和sync.Map都是精心设计的并发原语,它们的核心思想都是尽量减少加锁的开销。在sync.Once中,大多数时候都只做原子读取,没有锁,所以“读多写少”就让它非常快。同样的,sync.Map也是通过“读多写少”的优化思路,把读操作拆到无锁的局部缓存,而只有写的时候才会加锁。所以这两者都是借助极致优化的读路径和偶尔加锁的写路径,让并发性能保持稳定高效。
一、sync.Once 的内部结构
sync.Once 的结构非常简单:
type Once struct {
done uint32
m Mutex
}
其中:
-
done:表示函数是否已经执行过 -
m:互斥锁,用于保证并发安全
这两个字段配合使用,实现了“只执行一次”的语义。
二、fast path:绝大多数情况不需要加锁
sync.Once 最核心的优化在于 fast path(快速路径)。
Do() 方法的简化实现如下:
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
这里先通过 atomic.Load 读取 done。
如果 done == 1,说明函数已经执行过:
直接返回
整个过程:
- 不需要锁
- 不需要阻塞
- 只有一次原子读取
这使得 sync.Once 在函数已经执行过之后,性能几乎和普通函数调用一样。
在实际系统中,Once 通常用于:
- 单例初始化
- 配置加载
- 连接池初始化
这些操作 只会发生一次,之后大量调用都会走 fast path。
因此性能非常高。
三、slow path:只在第一次执行时加锁
如果 done == 0,说明函数还没有执行,需要进入慢路径:
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
执行流程:
加锁
↓
再次检查 done
↓
执行函数 f()
↓
done = 1
这里有一个经典的并发设计:double-check(双重检查)。
原因是可能存在这样的并发情况:
goroutine A 进入 Do
goroutine B 进入 Do
两者同时看到:
done == 0
于是都进入 doSlow()。
但由于有互斥锁:
A 先获得锁
B 等待锁
A 执行完函数后:
done = 1
当 B 获得锁时再次检查:
done == 1
于是 B 不会再次执行函数。
这样就保证了函数只会执行一次。
四、atomic 的关键作用
如果 sync.Once 每次调用都加锁,那么在高并发环境下会出现严重的锁竞争。
Go 的设计是:
执行前:可能需要加锁
执行后:永远不加锁
实现方式就是:
atomic.Load + fast path
当 done == 1 时:
所有 goroutine 都只做一次原子读取
原子读取在 CPU 层面非常快,通常只需要几纳秒。
因此在高并发情况下,sync.Once 几乎没有性能损耗。
五、为什么 done 要在函数执行后才设置
源码中有一个细节:
defer atomic.StoreUint32(&o.done, 1)
也就是说:
先执行 f()
再设置 done
而不是:
先设置 done
再执行 f()
原因是 内存可见性(memory ordering)。
如果先设置 done = 1:
goroutine A
done = 1
f() 还没执行完
这时 goroutine B 可能看到:
done == 1
于是直接返回。
但实际上:
f() 还没有完成
这样就破坏了 Once 的语义。
因此必须保证:
f() 执行完成
↓
done = 1
这样其他 goroutine 才能安全地继续执行。
六、sync.Once 为什么适合高并发
总结 sync.Once 高性能的原因:
1. fast path 优化
大多数调用只需要:
atomic.Load
不需要锁。
2. 锁只发生一次
真正加锁的情况:
只有第一次执行
之后永远不会再加锁。
3. 原子操作代替锁
使用:
atomic.Load
atomic.Store
避免锁竞争。
4. 双重检查减少锁开销
即使在高并发同时初始化的情况下:
只有一个 goroutine 会执行函数
七、典型应用场景
sync.Once 常用于:
单例模式
var once sync.Once
var instance *Config
func GetConfig() *Config {
once.Do(func() {
instance = loadConfig()
})
return instance
}
懒加载
例如:
- 数据库连接
- Redis 客户端
- 全局配置
- 日志系统
总结
sync.Once 的设计非常经典,本质上是:
atomic + mutex + fast path
它的核心思想是:
第一次执行时保证正确性
之后的执行保证性能
正是这种设计,使得 sync.Once 在高并发环境下依然保持极高的效率。
这也是 Go 并发原语设计中非常值得学习的一个例子。