sync.Once 原理解析
约 933 字大约 3 分钟
2024-12-08
使用sync.Once可以实现安全单例
Go 官方的 sync.Once 用来保证某段初始化代码 只会执行一次。它用了双检锁模式,并结合 原子操作 来读写状态变量 done,这样在并发场景下既安全,又能保证读取到最新状态。
- 先检查变量是否已经被修改
- 如果没有修改,就尝试获取锁
- 获取锁的 goroutine 执行业务逻辑,修改变量并释放锁
- 其他冲突的 goroutine 唤醒后直接返回,不会重复执行
package sync
import (
"sync/atomic"
)
// 在 [Go 内存模型] 的术语中,
// f 的返回会在内存顺序上先于(synchronizes before)任何 once.Do(f) 调用的返回。
type Once struct {
_ noCopy
// done 表示该操作是否已经执行过,它被放在结构体的第一个字段,
// 因为它位于热点路径(hot path)中且该路径会在每一个调用点被内联(inline),
// 将 done 放在第一个字段可以在某些架构(amd64/386)上生成更紧凑的指令,
// 并且在其他架构上减少指令数量(因为计算字段偏移量所需的指令更少)。
done atomic.Bool
m Mutex
}
// 在对 f 的调用返回之前,任何对 Do 的调用都不会返回,所以如果 f 内部再次调用 Do,就会发生死锁。
//
// 如果 f 发生 panic,Do 会认为它已经返回,之后对 Do 的调用会直接返回而不会再次调用 f。
func (o *Once) Do(f func()) {
// 注意:下面是一个错误的 Do 实现示例:
//
// if o.done.CompareAndSwap(0, 1) {
// f()
// }
//
// Do 保证当它返回时 f 一定已经执行完成,而上面的实现无法满足这个保证,
// 因为在两个并发调用的情况下,赢得 CAS(Compare-And-Swap)的调用会执行 f,
// 而第二个调用会立即返回而不会等待第一个调用的 f 执行完成,
// 这就是为什么慢路径(slow path)需要回退到使用互斥锁(mutex),
// 以及为什么必须将 o.done.Store 的执行延迟到 f 返回之后。
if !o.done.Load() {
// 将慢路径拆分成单独的函数,从而使快路径(fast path)能够被内联(inline)。
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if !o.done.Load() {
defer o.done.Store(true)
f()
}
}为什么要使用原子读/写
不安全的双检锁
存在并发问题的代码
type Once struct {
m sync.Mutex
done uint32
}
func (o *Once) Do(f func()) {
if o.done == 1 {
return
}
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
o.done = 1
f()
}
}问题原因
在Golang中,对超过机器字(64bit、32bit)大小的值进行读写,可以看作是对拆成 word 大小的几个读写无序进行。
因为Golang中对变量的读和写都没有原子性的保证,所以很可能出现这种情况:锁里边变量赋值只处理了一半,锁外边的另一个goroutine就读到了未完全赋值的变量。所以这个双检锁的实现是不安全的。
Golang中将这种问题称为data race,说的是对某个数据产生了并发读写,读到的数据不可预测,可能产生问题,甚至导致程序崩溃。可以在构建或者运行时使用 -race 参数来检测这种情况。
检测 Data Race的方法
- -race 用于在程序运行时检测真实的数据竞争,能够发现多个 goroutine 并发读写同一个变量的情况
$ go test -race mypkg # 测试包
$ go run -race mysrc.go # 运行源文件
$ go build -race mycmd # 编译命令
$ go install -race mypkg # 安装包