0x00 前言

在 Golang 中要实现一次语义,一般有两种方式:

  • init() 函数
  • sync.Once

一般会将程序初始化需要的一些逻辑放到 init() 函数中,但是如果这个逻辑需要在程序动态运行的过程中再执行的,就可以用 sync.Once 来实现。

本文来探究下 sync.Once 的源码实现,基于 go1.15.3

0x01 源码之下

源码

不到三十行的源码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// go1.15.3

import (
	"sync/atomic"
)

// Once is an object that will perform exactly one action.
type Once struct {
	done uint32
	m    Mutex
}

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

其中:

  • Once 中包含标记是否执行过的 done 和 锁 m
  • Oncefunc Do() 为导出函数,参数 f func() 为需要实现一次语义的函数参数;
  • doSlow() 为实现了一次语义的具体逻辑。

一个优秀的函数实现总是经过极致性能优化的。同理,一次语义的实现如果每次都需要加锁,那必将性能低下。因此,这里就有了 hot path 和 slow path 之分。这里指的是,在未执行一次语义之前,都需要加锁,走的 slow path;一旦执行了一次语义之后,所有调用都直接走 hot path,无需加锁。

func Do()

我们先来看 Do 的逻辑:done 代表是否执行过一次语义了,如果没有,则执行 doSlow();否则直接返回。

1
2
3
4
5
func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

有人看到这个实现可能会好奇,这里是不是可以直接用 CAS?其实,你能想到的,写源码的大佬们肯定也早就想到了,而且还 comment 上了。

那么,用 CAS 不行吗?我们先来看下改成 CAS的实现:

1
2
3
4
5
func (o *Once) Do(f func()) {
	if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
		o.doSlow(f)
	}
}

咋一看好像也没什么问题?CAS 成功后就执行 doSlow(),不成功说明一次语义已经执行成功了。

然而如果你仔细考虑下后半句,会发现这是不成立的。因为,如果在未执行一次语义的情况下,在多并发场景时,这里是先 CAS 完,然后再去执行语义。这样可能会出现中间态,即 done 已经被赋上值了,但是一次语义却未执行成功,甚至未开始执行。

这里提到的避免中间态在下面的实现中也有所体现。这里先简单提一下,sync.Once 作为精确的一次语义的实现,需要保证的一点就是:Once.Do的调用返回之后,无论走的 slow path 还是 hot path,都必须保证一次语义已经被执行了。

func doSlow()

看完外层实现,我们再来看看 doSlow() 的内部实现。doSlow() 先加锁,然后判断是否执行过一次语义了,没执行过就执行,最后给 done 赋值表示执行过,再释放锁。

1
2
3
4
5
6
7
8
func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

这里需要注意的一个点是,doSlow() 也可能是多并发的,所以需要加锁且判断是否已经执行过语义。这里加锁的另外一个目的就是,如果已经有一个执行流拿到锁了在执行语义了,那么其他执行流就必须等待在这里,确保返回之前语义已经执行完成了。

另外一个重要的点是,这里为什么在为 done 赋值的时候使用了 defer 呢?在 Go 中,放在 defer 逻辑中的,一般都是需要保证在 panic 情况也被执行的。这里考虑的一个点就是:毕竟 f() 是用户传入的函数参数,Once 也不能保证是否能够顺利执行成功。但是,这里能保证的一点就是:如果进入了 f() 的逻辑,那么就不会再进入第二次,这也是一次语义逻辑必须保证的。

0x02 小结

本文简单从源码之下学习了 sync.Once 如何做到一次语义的逻辑。看似简简单单的二十多行的代码,其实还是隐含着不少值得思考的细节在里面。但是,话说回来,其实也谈不上隐含。因为这些思考,都已经被 comment 在代码上了。因此,我们在源码之下畅游的时候,别忘了 comment 也是极具价值的,这里凝练了作者在实现代码时候的思考和智慧。

0x03 参考

这里有篇大佬更详细的实现和讲解:Go并发编程 — sync.Once 单实例模式的思考