Go sync.Once的三重门

我在极客时间开设的专栏Go 并发编程实战课中,详细介绍了sync.Once并发原语的实现,对于使用这个原语来说,内容已经足够了,但是还是有些同学愿意深入挖掘更深层的设计,并且提出了一些疑问,所以我再专门写一篇文章, 作为这么专栏的补充吧。

一、为什么不能直接使用一个flag+原子操作简单实现?

虽然文章中我介绍了为什么不能简单的使用一个flag+atomic实现,但是还是有一些同学询问,我就再解答一下。事实上不光国内的一些读者有这个疑问,国外也有一些读者问这个问题,以至于Russ Cox后来在sync.Once的源代码中专门加了一段注释,说明为什么flag+atomic简单实现有问题。

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.

如果你看了这段注释还不理解,那么没关系,我们详细说道说道。

首先,我们看看flag+atomic的简单实现是什么样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package wrong
import "sync/atomic"
type Once struct {
done uint32
}
func (o *Once) Do(f func()) {
if !atomic.CompareAndSwapUint32(&o.done, 0, 1) {
return
}
f()
}

关键在于并发的goroutine在调用Do方法时,当Do方法返回时,我们期望的是初始化函数f要执行完毕,但是这个实现第一个goroutine在使用f初始化时,后续并发的goroutine会立即返回,尽管f还没有执行完。

这带来的一个问题就是:后续的goroutine在使用这些未初始化的资源的时候,会出现意想不到的问题,比如panic,或者资源未初始化,这不是我们期望的。

所以不能这么简单的实现。

所以在使用sync.Once初始化一次资源的时候,请规规矩矩的使用标准库的sync.Once就好了,不要再想着做什么优化。

如果你只是设置一个标志,而没有初始化的资源的操作,也就是你只需要done字段,不需要初始化方法f的话,你倒是可以这样使用。

二、为什么使用Mutex?

最终,标准库的sync.Once实现方式如下,也是比较简单的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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()
}
}

有的同学不太理解为什么要使用Mutex。

使用Mutex并不会影响这个数据结构的性能。因为Mutex的逻辑(也就是doSlow方法)只会在初始化时并发的情况下发生,一旦初始化完成,后续的goroutine在调用Do方法时并不会请求锁。

所以,使用Mutex主要处理并发初始化的问题。

假设Once对象的Do方法还没有被初次调用。这个时候有goroutine g2和goroutine g3同时调用Do方法。碰巧,g2g3可能原子读取done变量会等于0,所以这两个goroutine可能都会同时进入doSlow方法(可能在同一个CPU上,也可能在不同的cpu上)。

这个时候我们就需要Mutex限制只允许一个goroutine并发执行,也就是将并行变成了串行。假设g2运气好先执行,那么它就会进行初始化,并且执行完毕后把o.done设置为1,再释放锁。

锁释放后,g3开始执行,这个时候还还会执行double checking,再一次检查done字段。这一步是必须的,因为不双检查的话,它又会执行f一次。这里正确地使用了双检查,发现done已经被设置成了1,所以不需要初始化了,就直接返回。

如果一个goroutine在双检查的时候如果发现done=0,说明还没有goroutine执行过初始化,这种重担压在了自己的身上,就像g2一样,它就会执行初始化函数f。

所以,这里使用了Mutex,保护并发的初始化。

到了这一步基本上很少有同学有疑问了,但是好学爱钻研的网友还是提出了非常有趣的问题。

三、为什么乱序执行没影响?

我们知道,现代的CPU都是支持乱序执行的。那么最后两行defer atomic.StoreUint32(&o.done, 1)f()如果乱序了怎么办,那不也是还是没有初始化完毕就把done设置为1了吗?

而且有些人,包括Russ Cox review这段代码的时候也提出,defer atomic.StoreUint32(&o.done, 1) (源代码和此有所不同)能不能改成o.done=1

Go的标准库的代码质量是非常高的,而且都经过大神的仔细review,所以这样设计肯定是有它的用处的。

首先,第15行的defer atomic.StoreUint32(&o.done, 1)可以确保执行完第16行的f才将done设置为1。

虽然Go内存模型并没有定义atomic的happen before关系,也没有定义像C++的atomic的六种memory ordering模型。有一个悠久的仍然open的issue讨论atomic的内存模型的问题(issue#5045),但是目前对atomic的内存模型保持模糊的定义。不同的CPU架构可能很难形成一个统一的定义。

对于x86架构,stackoverflow有一段描述 atomic的Load和Store的原子性和会不会重排:

On strongly ordered architectures like x86/amd64, acquire load and release store are just regular loads and stores. To make them atomic you need to ensure the memory is aligned to the operand size (automatic in Go), and that the compiler doesn't re-order them in incompatible ways, or optimize them away (e.g. reuse a value in a register instead of reading it from memory.)

但是对于arm等架构,需要使用内存屏障(Memory barrier)技术保证memory ordering。

Ian Lance Taylor曾经在论坛中说:

In C++ memory model terms I believe that the sync/atomic Load
operations are memory_order_acquire, and I believe that the
sync/atomic Store operations are memory_order_release. It's possible
that if we ever document it we will go for stronger memory ordering,
but I believe that these operations must at least carry those guarantees.

I'm somewhat less certain of the memory order guarantees of the Swap,
CompareAndSwap, and Add functions. I guess that Swap and
CompareAndSwap are probably at least memory_order_acq_rel, but Add may
be memory_order_relaxed.

Russ Cox曾经回答过问题,他把go的atomic 操作定位sequential consistency的,这是一个更严格的memory ordering。它们之前的读写保证再Load/Store,不会重排在Load/Store之后, 它们之后的读写操作也不会重排在Load/Store之前,所以建立了一个内存屏障(Memory barrier)。

rsc
2019年7月16日上午9:12:01

Although there's been no official resolution to the issue, I think the actual path forward is what I posted a while back: "Go's atomics guarantee sequential consistency among the atomic variables (behave like C/C++'s seqconst atomics), and that you shouldn't mix atomic and non-atomic accesses for a given memory word."

至少目前,我们可以按照他们的解答进行理解。

这样的话,Go可以保证第15行defer atomic.StoreUint32(&o.done, 1)肯定会在第16行f()之后执行,这样就不会出现未初始化完成就将done设置为1的问题。

另一个问题,第14行为什么不使用atomic?

因为Mutex的happend before关系, g2设置o.done=1之后才释放锁,这个时候g3才获取到锁,所以当g3获取到锁之后,o.done肯定就已经是1了,所以这个时候访问o.done肯定得到1的结果,不会在g2设置o.done=1 g3看不到o.done=1这个write。

第6行没有Mutex等的保护,所以通过atomic可以保证在o.done设置为1之后能看到这个设置的结果,避免总是落入到doSlow逻辑中。