点击上方蓝色“Go语言中文网”关注我们,领全套Go资料,每天学习 Go 语言
概述
Once 在标准库中的功能被描述如下:
Once is an object that will perform exactly one action.
即保证某个动作只执行一次。这很好理解,延迟初始化、单例(懒汉式)就是这种场景。下面用 Once 实现一个懒汉式的单例:
type Model struct {}
var instance *Model
var once sync.Once
func GetModel() *Model {
once.Do(func() {
instance = &Model{}
})
return instance
}
值得注意的是,Once.Do 被设计成严格保证传入的函数只执行一次,且当 Once.Do 返回时,保证抢占 Once 执行权成功的 goroutine 传入 Do 的函数已经执行完成。
这意味着如果两个 goroutine 同时首次执行 Once.Do,肯定只有一个能执行,那么另一个必须等待直到它执行完成之后方能返回。是不是有点双重检测锁(DCL)的味道了?
源码
Once 的源码异常简单,纯代码只有不到 20 行,作为 go 语言极为常用和基础的同步工具,当真对得起"短小精悍"这个形容。这里将代码全部贴出
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()
}
}
可以看到它内部确实实现了双重检测锁。
这里有几个值得注意的点:
- Do 方法中的第一层检测
atomic.LoadUint32(&o.done) == 0
是否应该用 CAS 来实现? - 如果不是,为什么不只用
o.done == 0
(就像 java 单例的双重检测锁那样)? - doSlow 方法的临界区内,为什么第二层检测时直接读取 done,而写值却用了 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.
前文提到了,Once 需要保证当 Once.Do 返回时,抢占 Once 执行权成功的那个 goroutine 传入 Do 的函数已经执行完成。因此这里并不能使用 CAS,因为在并发执行时,CAS 抢夺失败的那个 goroutine 会直接返回,而此时有可能抢夺成功的那个还没执行完成,所以不能达到设计目标。因此源代码中使用 atomic.LoadUint32(&o.done) == 0
和o.done == 0
结合互斥锁实现了双重检测锁,将同时执行的 goroutine 阻塞在 Mutex 处直到抢夺成功的函数执行完成。
再来看第二个问题。
熟悉 Java 的应该还记得,java 版本的双重检测锁中,我们需要在检测变量前加个 volatile 保证可见性:
public static volatile Object instance;
同样的问题 go 也会有,但 go 并没有可见性
的概念。事实上根据go 内存模型官方文档[1],虽然 go 没有 JMM 的工作内存和主内存之类的区分,但它确实有自己的一套观测(observe)规则和 Happens before 规则。关于"可观测"和"可见",我认为是两个不同的维度:可见性是针对变量来描述的 —— 你可以说变量 a 具有可见性;而可观测是针对读写操作来描述的——你只能说对变量 a 的某个写操作能保证被某个它的读操作观测到。
内存模型文档中提到:
When multiple goroutines access a shared variable v, they must use synchronization events to establish happens-before conditions that ensure reads observe the desired writes.
因此如果不做同步而直接用o.done == 0
的话肯定无法观测到 doSlow 方法中设置的 done 值。
但是 go 并没有给我们提供volatile
这个关键字,那我们该如何保证可观测呢?我以前写过一片文章提到过这个问题为什么 golang 没有 volatile? 文中提到过,go 鼓励开发者"通过通信去共享内存,而不是通过共享内存去通信"。但是目前看来,这个观念的转换过程并不是那么顺利——至少在这里,1.13.4 的标准库源码中 Once.go 的源码中还在使用共享变量进行通信……
言归正传,Once.done 是一个共享变量,那么怎么保证其并发可观测呢?办法有很多,加锁可以,但是基于原子操作的 atomic.Load*/Store*是一个更轻量且高效的选择。至于为什么 atomic 操作能保证可观测将会在下一小节说明。
第三个问题就比较值得玩味了。
临界区(Critical Section)
指的是并发场景下只能有一个线程/协程执行的代码区域。在 go 中,临界区是 Mutex 上锁和解锁的中间执行区:
fun xxx(){
mutex.lock()
defer mutex.unlock()
x=10
}
这里x=10
就处于临界区内。那么,它能否保证可观测呢?——或者说,能否保证另一个 goroutine 的 atomic.Load*读取到这里设置的值呢?
答案是不能保证,至少在官方的内存模型文档中没有给出保证。准确来说,go 的内存模型只列举了 6 种同步机制(atomic 操作不在其中,下一小节会解释原因):
- Initialization
- Goroutine creation
- Goroutine destruction
- Channel communication
- Locks
- Once
其中对 Locks 的 Happens before 规则描述为:
For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() happens before call m of l.Lock() returns.
这里 m,n 指的是调用函数的实际时间点。注意这里只规定了 unlock 先于 lock 发生,因此只能保证这两个操作之间的可观测。而如果在临界区外部对变量进行读写则,无法保证能被观测到,正如 Once 中的情形一样。这也就解释了为什么 doSlow 中写 done 时即使是在临界区内也要使用 atomic.StoreUint32 进行写操作。
至于 doSlow 中第二重检测时为什么是直接读取 done 而没有用 atomic 操作,因为这个读操作和临界区内的写操作均处于临界区内,已经保证了对写操作的可观测性,自然不需要 atomic 了。
atomic 操作真能保证可观测吗?
既然官方的内存模型中并没有提到 atomic 操作的可观测性,那它真能保证可观测吗?
我们试着打开 atomic 的文档,第二段话就解释了为什么内存模型文档中没有提到它:
These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package.
意思很明显——"atomic 函数是给底层、特殊应用使用的,同步应该使用 channel 或者 sync 包下的工具"——就是让你最好别用呗。
说了这么多,它到底能保证可观测性吗?上源码,以 Store 为例,函数声明在这里 runtime/internal/atomic/atomic_amd64x.go
func Store(ptr *uint32, val uint32)
实现在 runtime/internal/atomic/asm_amd64.s
TEXT runtime∕internal∕atomic·Store(SB), NOSPLIT, $0-12
MOVQ ptr+0(FP), BX
MOVL val+8(FP), AX
XCHGL AX, 0(BX)
RET
注意到,Store 的实现中使用了一个交换指令 XCHGL,在 x86 架构上它实际上包含了一个 LOCK 前缀。是不是比较眼熟?没错,他就是 java 的 volatile 变量读写操作 经过 JIT 编译后携带的指令前缀,volatile 就是通过它保证了可见性。因此到这里我们可以确定 atomic 操作的可观测性。
意外收获
阅读 Once 源码还有个意外收获。注意到 Once 的定义处有这样一段注释
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
}
其中提到了为什么要把 done 这个成员变量放在结构体的第一位,以及一个神秘的词——hot path
。什么是hot path
呢?参考 stackoverflow 上的 回答[2],它指泛指编译后的 go 程序中极度频繁运行的指令集合,其特点是所有的hot path
中的调用都会被内联编译以保证运行速度。
什么是内联编译?就是每次调用都会生成一次完整的操作指令直接执行,而不是通过跳转指令跳转至定义处执行。这样做的好处是提高运行速度,但缺点也很明显:某个调用很频繁的话就会大量浪费空间,造成编译后的程序体积膨胀。
Once 作为 go 语言官方提供的 6 种同步机制之一,被广泛应用于各个场景,其 Do 方法的调用是极度频繁的,而每次 Do 的调用都会访问 done,因此 done 也被列为 hot path
之一。而为了保证其内联编译的体积,这里做的一个优化就是将 done 放置于结构体的第一位,好处是能直接通过结构体地址读取其值 (原理类似 c 语言中数组的地址等于首位元素的地址),节省了用于计算偏移量的指令,缓解了内联编译后的体积膨胀问题。
总结
Once 的核心内容其实就是一个 go 版的双重检测锁,需要注意的是其使用了 atomic.Load 和 atomic.Store 操作来保证操作的可观测性,但是第二重检测时由于临界区内保证了其能观测到更新操作,因此使用了直接读取。
另外,我们通过注释发现了 go 的hot path
机制,并且了解到结构体的首位在访问时能具有一定优势,能节省一定指令数量,从而可以在诸如hot path
等内联编译场景下优化体积膨胀的问题。
文中链接
[1]go内存模型官方文档: https://golang.org/ref/mem
[2]回答: https://stackoverflow.com/questions/59174176/what-does-hot-path-mean-in-the-context-of-sync-once
推荐阅读
为什么 Go 标准库中有些函数只有签名,没有函数体?
50 条争论的文章发生了啥:Go 标准库 encoding/json 真的慢吗?
喜欢本文的朋友,欢迎关注“Go语言中文网”:
Go语言中文网启用微信学习交流群,欢迎加微信:274768166