「源码之下」sync.Once: Go 中一次语义的实现
Contents
[NOTE] Updated January 30, 2022. This article may have outdated content or subject matter.
0x00 前言
在 Golang 中要实现一次语义,一般有两种方式:
init()函数sync.Once
一般会将程序初始化需要的一些逻辑放到 init() 函数中,但是如果这个逻辑需要在程序动态运行的过程中再执行的,就可以用 sync.Once 来实现。
本文来探究下 sync.Once 的源码实现,基于 go1.15.3。
0x01 源码之下
源码
不到三十行的源码实现:
| |
其中:
Once中包含标记是否执行过的done和 锁m;Once的func Do()为导出函数,参数f func()为需要实现一次语义的函数参数;doSlow()为实现了一次语义的具体逻辑。
一个优秀的函数实现总是经过极致性能优化的。同理,一次语义的实现如果每次都需要加锁,那必将性能低下。因此,这里就有了 hot path 和 slow path 之分。这里指的是,在未执行一次语义之前,都需要加锁,走的 slow path;一旦执行了一次语义之后,所有调用都直接走 hot path,无需加锁。
func Do()
我们先来看 Do 的逻辑:done 代表是否执行过一次语义了,如果没有,则执行 doSlow();否则直接返回。
| |
有人看到这个实现可能会好奇,这里是不是可以直接用 CAS?其实,你能想到的,写源码的大佬们肯定也早就想到了,而且还 comment 上了。
那么,用 CAS 不行吗?我们先来看下改成 CAS的实现:
| |
咋一看好像也没什么问题?CAS 成功后就执行 doSlow(),不成功说明一次语义已经执行成功了。
然而如果你仔细考虑下后半句,会发现这是不成立的。因为,如果在未执行一次语义的情况下,在多并发场景时,这里是先 CAS 完,然后再去执行语义。这样可能会出现中间态,即 done 已经被赋上值了,但是一次语义却未执行成功,甚至未开始执行。
这里提到的避免中间态在下面的实现中也有所体现。这里先简单提一下,sync.Once 作为精确的一次语义的实现,需要保证的一点就是:Once.Do的调用返回之后,无论走的 slow path 还是 hot path,都必须保证一次语义已经被执行了。
func doSlow()
看完外层实现,我们再来看看 doSlow() 的内部实现。doSlow() 先加锁,然后判断是否执行过一次语义了,没执行过就执行,最后给 done 赋值表示执行过,再释放锁。
| |
这里需要注意的一个点是,doSlow() 也可能是多并发的,所以需要加锁且判断是否已经执行过语义。这里加锁的另外一个目的就是,如果已经有一个执行流拿到锁了在执行语义了,那么其他执行流就必须等待在这里,确保返回之前语义已经执行完成了。
另外一个重要的点是,这里为什么在为 done 赋值的时候使用了 defer 呢?在 Go 中,放在 defer 逻辑中的,一般都是需要保证在 panic 情况也被执行的。这里考虑的一个点就是:毕竟 f() 是用户传入的函数参数,Once 也不能保证是否能够顺利执行成功。但是,这里能保证的一点就是:如果进入了 f() 的逻辑,那么就不会再进入第二次,这也是一次语义逻辑必须保证的。
0x02 小结
本文简单从源码之下学习了 sync.Once 如何做到一次语义的逻辑。看似简简单单的二十多行的代码,其实还是隐含着不少值得思考的细节在里面。但是,话说回来,其实也谈不上隐含。因为这些思考,都已经被 comment 在代码上了。因此,我们在源码之下畅游的时候,别忘了 comment 也是极具价值的,这里凝练了作者在实现代码时候的思考和智慧。
0x03 参考
这里有篇大佬更详细的实现和讲解:Go并发编程 — sync.Once 单实例模式的思考
本文作者 zzkcode
上次修改 2022-01-30
技术声明 由于作者水平有限,如果文章有任何错误或疏漏的地方,烦请通过邮件或评论进行批评指正。
版权声明 本文章著作权归作者所有,任何形式的转载都请注明出处。CC BY-NC-ND 4.0