解锁并发:Go的Sync.Once
深度研究
1.1 简介
我们都知道 Go 中 init()
函数里面可以做初始化,但是它是在所在包首次被加载时执行,若未实际使用,既浪费了内存,又延缓了程序启动时间。
而 sync.Once
可以在任何位置调用,我们可以在实际依赖某个变量时才去初始化,这样就可以实现延迟初始化,并且可以有效地减少不必要的性能浪费。
更重要的,sync.Once
是 Go 语言中的一个同步原语,是并发安全的。无论有多少个 goroutines 尝试同时执行它,它可以保证某些代码在并发环境下只执行一次。这在多线程环境中特别有用,可以防止初始化代码被执行多次。
在如下情况尤为重要:
- 初始化全局资源
- 数据库连接池的创建
- 配置文件的加载
- 等等
1.2 实战用法
1.2.1 单例模式
1 | package main |
运行上述代码时,你会看到如下输出:
1 | Create Singleton |
1.2.2 关闭 channel
在 Go 语言中,如果尝试关闭一个已经关闭的通道,会导致运行时 panic。为了安全地关闭通道,可以使用 sync.Once
来确保通道只被关闭一次。
1 | type SafeChannel struct { |
1.2.3 Std case
1 | func UnescapeString(s string) string { |
html.UnescapeString(s)
函数是线程安全的,在进入函数的时候,首先就会依赖包里内置的 populateMapsOnce
实例(本质是一个 sync.Once) 来执行初始化 entity、entity2
的操作。字典 entity、entity2
包含大量键值对,若使用 init
在包加载时初始化,若不被使用,将会浪费大量内存。
1.3 实现原理
以 go1.23进行分析,代码很精炼,可以打开进行阅读。
1.3.1 内部结构
在 Go 的源码中,sync.Once
的定义如下:
1 | type Once struct { |
1.3.2 基本实现
sync.Once
的核心方法是 Do
,这个方法的实现如下:
1 | func (o *Once) Do(f func()) { |
- 快速路径:
1 | if o.done.Load() == 0 { |
首先通过 atomic.Load
检查 done
的值是否为 0。如果是 1,说明已经有 goroutine 执行过传入的函数,函数结束立刻返回。
- 慢速路径:
如果done
的值为 0,代码进入慢速路径,加锁以确保只有一个 goroutine 进入临界区。
1 | o.m.Lock() |
- 再次检查和执行函数:
在持有锁的情况下,再次检查done
的值,如果仍然是 0,就执行目标函数,并在函数执行完毕后将done
设置为 1。
1 | if o.done.Load() == 0 { |
1.3.3 实现特点
原子操作的作用
在Do
方法的实现中,原子操作atomic.Load
和atomic.Store
被用来检查和设置done
标志。这些原子操作是底层操作系统提供的 CPU 级别的操作,确保操作的原子性,从而避免竞态条件(race condition),在无锁的情况下保证并发安全。为什么会有双重检查(double check)
先说结论:通过双重检查,可以在大多数情况下避免锁竞争,提高性能并保证在并发环境下只执行一次。
- 第一次检查:在获取锁之前,先使用原子加载操作
atomic.Load
检查done
变量的值是否为 1,如果是,则此时函数返回,这一检查可以避免不必要的锁竞争。 - 第二次检查:原子性只保证了获取值为0或为1,没有中间状态,但还是有可能多个goroutine进入到
doSlow
函数中。所以在doSlow
函数中获取锁之后,再次检查done
变量的值,从未确保在并发环境下只执行一次f
函数。
- 注释一:hot path
1 | // The hot path is inlined at every call site. |
hot path表示程序非常频繁执行的一系列指令,sync.Once绝大多数场景都会访问o.done
,访问 done
的机器指令显然是处于hot path上的。
在Go中,结构体第一个字段的地址和结构体的指针是相同的,如果访问第一个字段,直接对结构体的指针解引用即可获得访问地址,如果访问其他的字段,还需要额外计算与第一个值的偏移,才能获取要访问的值的地址。
将done放在第一个字段,是为了访问它的机器代码更紧凑,速度更快。
4. 注释二:CAS
1 | // Note: Here is an incorrect implementation of Do: |
注释说的很清楚,当一次 once.Do
返回时,执行的 f
一定是完成的状态,所以 CAS 并不满足这个条件,那么为什么会有这种要求呢?
举个例子,我们都知道,在分布式系统中,有个必不可少的组件是服务发现,客户端访问服务端资源时,需要通过服务发现来获取服务端的建立链接的信息,如IP、Port。
当多个客户端访问服务端资源时,大致有两个流程:
- 初始化AddrManager(服务发现),用于获取建立链接的服务端信息。
- 多个客户端与服务端建立链接,访问具体的资源
回到CAS 这个问题,显然 AddrManager 应该使用sync.Once
进行唯一的初始化,而如果初始化 AddrManager 的程序未执行完成,后续的流程显然会出错、崩溃。这就是sync.Once
执行后 f
一定是完成状态的一个原因。
- 注释三:panic
1 | // If f panics, Do considers it to have returned; future calls of Do return |
所以,我们应该在 sync.Once.Do
的 goroutine 里进行 recover
,避免错过可能的panic 信息,也能避免注释二中例子里更严重的后果。
1 | func main() { |
- 注释四:not be copied
1 | A Once must not be copied after first use. |
参考文章:noCopy Strategies You Should Know in Golang | by Wesley Wei | Sep, 2024 | Programmer’s Career。
- 如果
sync.Once
被copy 了,显然会执行多次f
函数,这与我们的目标相悖。 - 为什么
sync.Once
中不使用 noCopy ? 因为可能真的有人 copy 后初始化多个单一资源吧😂,尽管这样用并不优雅。
1.4 自定义优化
针对上面的问题可以看出,我们完全可以对 sync.Once
做一些自定义的优化:
1 | package main |
输出
1 | init... |
上述代码实现了一个简单加强的 Once 结构体:
- 只有在没有发生
error
的情况下,才会跳过函数执行,避免初始化失败。 - 使用
noCopy
来提示不要进行拷贝
1.5 注意事项
- 合理评估需求,在非并发情况下,没必要使用
sync.Once
造成性能浪费。 - 不要在
Do
的f
中嵌套调用Do
。
1 | func testDo() { |
原因比较简单,同一个锁,第二次执行 Lock时会一直等待第一次Lock释放,造成死锁。一个有趣的点在于Go 不支持可重入锁,可以进一步阅读:Reentrant Locks Your Should Know in Golang | by Wesley Wei | Programmer’s Career
- . 不要拷贝一个 sync.Once 使用或作为参数传递
参考上文 A Once must not be copied after first use.
1.6 总结
本文详细介绍了 Go
语言中的 sync.Once
,包括它的基本定义、使用场景和应用实例以及源码分析等。在实际开发中,sync.Once
经常被用于实现单例模式和延迟初始化操作。
另外重点介绍了 sync.Once
源代码中的以下内容:
- 原子操作的作用
- 双重检查
- hot path
- CAS
- not be copied
根据以上内容给出了一个简单优化版的 sync.Once
并给出了一些注意事项。
更多该系列文章,参考medium链接:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a
English post: https://programmerscareer.com/golang-syncOnce/
作者:Wesley Wei – LinkedIn, Wesley Wei – Medium, Wesley Wei – Twitter
发表日期:原文在 2024-09-26 22:41 时创作于 https://programmerscareer.com/zh-cn/golang-syncOnce/
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
评论