sync.Once

基于Go 1.13

sync.Once 是一个只执行一次动作的对象

1
2
3
4
5
6
7
8
9
type Once struct{
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/x86),
// and fewer instructions (to calculate offset) on other architectures.
done uint32
m Mutex
}

What is hot path?

hot path 是一系列被非常频繁执行的指令

  • 当需要访问struct的第一个字段时,我们可以直接对指针解引用来访问第一个字段。
  • 要访问其他字段时,除了结构指针之外, 还需要提供与第一个字段的偏移量

在机器码中,这个偏移量是传递指令的附加值,这会使指令变得更长。对性能的影响是,CPU必须对结构指针添加偏移量以获取想要访问的字段的地址。

因此访问struct的第一个字段的机器码更快,更加紧凑。

这里假设字段在内存中的布局与结构定义中的布局相同,因为编译器可以决定改变内存中结构的字段顺序来优化存储空间,目前go编译器未做这样的优化。

这是一个小优化,只有性能非常重要才值得付出这样的努力。

func (*Once) Do

Go1.13的源码如下,非常的简洁

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Do calls the function f if and only if Do is being called for the
// first time for this instance of Once. In other words, given
// var once Once
// if once.Do(f) is called multiple times, only the first call will invoke f,
// even if f has a different value in each invocation. A new instance of
// Once is required for each function to execute.
//
// Do is intended for initialization that must be run exactly once. Since f
// is niladic, it may be necessary to use a function literal to capture the
// arguments to a function to be invoked by Do:
// config.once.Do(func() { config.init(filename) })
//
// Because no call to Do returns until the one call to f returns, if f causes
// Do to be called, it will deadlock.
//
// If f panics, Do considers it to have returned; future calls of Do return
// without calling f.
//
func (o *Once) Do(f func()) {
// Note: Here is an incorrect implementation of Do:
//
// if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the atomic.StoreUint32 must be delayed until after f returns.

if atomic.LoadUint32(&o.done) == 0 {
// Outlined slow-path to allow inlining of the fast-path.
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.Do 中的函数只会执行一次,并保证 once.Do 返回时,传入Do的函数已经执行完成。(多个 goroutine 同时执行 once.Do 的时候,可以保证抢占到 once.Do 执行权的 goroutine 执行完 once.Do 后,其他 goroutine 才能得到返回 )

从代码流程中,我们可以很清楚的看到,Once.Do()的逻辑:

  1. 首先考虑到为了并发安全加mutex,但是Once.Do()对性能有一定要求,所以选用原子操作,LoadUint32获取锁更快
  2. 判断执行标志,若已执行过就直接返回
  3. 因为是判断执行标志而不修改,就会有多个goroutine同时判断为true,之后用mutex调用函数f()
  4. 获得mutex后,先检查执行标志,以免重复执行
  5. 接着调用f()
  6. 然后将执行标志done置为1
  7. 最后解开mutex,当其他落入doSlow的goroutine在重复上述过程时就可以保证f()只被调用一次。

一种错误实现

注意在源码中,提到了一种错误实现

1
2
3
4
5
6
type Once uint32
func (o *Once) Do(f func()) {
if atomic.CompareAndSwapUint32((*uint32)(o), 0, 1) {
f()
}
}

刚才我们提到了Once.Do()可以保证了只有当f()执行完毕时,Once.Do()才会返回。这意味着如果多个goroutines同时调用Once.Do(),那么f()当然会执行一次,但是所有的调用都会等到f()完成(它们会被阻塞)。

当我们使用sync.Once时,依赖于此行为,依赖于在调用Once.Do()完成f()之后的情况。这样我们才可以安全地使用f()初始化变量等操作,而不会出现竞态条件。

但这种实现,并没有这样的保证,抢占到执行权的goroutine会执行f(),而其他goroutine会直接返回,这时f()并没有执行完。

init()和sync.Once

  • Go语言规范保证了包的init()函数只能被调用一次,并且只能从单个线程调用所有函数(并不是说init()不能启动goroutine,但是除非使init()成为多线程,否则它们是线程安全的)
  • 使用sync.Once.Do()的原因是,控制是否以及何时执行某些代码。 init()函数将在应用程序启动时调用。 sync.Once.Do()允许执行延迟初始化之类的操作,例如在第一次请求资源时创建资源,而不是在应用程序启动时创建资源; 或仅在实际需要时才初始化资。

简而言之,init()在包导入时执行,而sync.Once.Do()在你的控制之下; 你可以决定是否调用,何时调用以及它的作用。

程序启动时初始化

1
2
3
4
5
var singleton Cache = New(10000, WithTTL(10000*100))

func Singleton() Cache {
return singleton
}

Sync.Once实现单例延迟初始化

1
2
3
4
5
6
7
8
9
10
11
var (
singleton Cache
once sync.Once
)

func Singleton() Cache(){
once.Do(func(){
singleton = New(10000, WithTTL(10000*100))
})
return singleton
}

Reference