go 是常驻内存吗_Go 标准库源码学习(一)详解短小精悍的 Once

点击上方蓝色“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) == 0o.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语言中文网”:

efb0627001d55f1deebc392ae4291ba2.png

Go语言中文网启用微信学习交流群,欢迎加微信:274768166

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值