一文解析 Golang sync.Once 用法及原理
作者:ag9920
前言
在此前一篇文章中我们了解了 Golang Mutex 原理解析,今天来看一个官方给出的 Mutex 应用场景:sync.Once。
1. 定位
Once is an object that will perform exactly one action.
sync.Once
是 Go 标准库提供的使函数只执行一次的实现,常应用于单例模式,例如初始化配置、保持数据库连接等。它可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的。
2. 对外接口
Once 对外仅暴露了唯一的方法 Do(f func())
,f 为需要执行的函数。
// 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())
结合注释,我们来看看 Do 方法有哪些需要注意的:
- 只有在当前的 Once 实例第一次调用
Do
方法时,才会真正执行f
。哪怕在多次调用Do
中间f
的值有所变化,也只会被实际调用一次; Do
针对的是只希望执行一次的初始化操作,由于f
是没有参数的,如果需要传参,可以采用包装一层 func 的形式来实现:config.once.Do(func() { config.init(filename) })
- 在对
f
的调用返回之前,不会返回对Do
的调用,所以如果f方法中又调用来Do方法,将会死锁。所以不要做这样的操作:
func main() { var once sync.Once once.Do(func() { once.Do(func() { fmt.Println("hello kenmawr.") }) }) }
- 如果
f
抛出了 panic,此时Do
会认为f
已经返回,后续多次调用Do
也不会再触发对f
的调用。
3. 实战用法
sync.Once 的场景很多,但万变不离其宗的落脚点在于:任何只希望执行一次的操作。
基于此,我们可以发现很多具体的应用场景落地,比如某个资源的清理,全局变量的初始化,单例模式等,它们本质都是一样的。这里简单列几个,大家可以直接参考代码熟悉。
3.1 初始化
很多同学可能会有疑问,我直接在 init()
函数里面做初始化不就可以了吗?效果上是一样的,为什么还要用 sync.Once,这样还需要多声明一个 once 对象。
原因在于:init()
函数是在所在包首次被加载时执行,若未实际使用,既浪费了内存,又延缓了程序启动时间。而 sync.Once
可以在任何位置调用,而且是并发安全的,我们可以在实际依赖某个变量时才去初始化,这样「延迟初始化」从功能上讲并无差异,但可以有效地减少不必要的性能浪费。
我们来看 Golang 官方的 html 库中的一个例子,我们经常使用的转义字符串函数
func UnescapeString(s string) string
在进入函数的时候,首先就会依赖包里内置的 populateMapsOnce
实例(本质是一个 sync.Once) 来执行初始化 entity
的操作。这里的entity
是一个包含上千键值对的 map
,如果init()
时就去初始化,会浪费内存。
var populateMapsOnce sync.Once var entity map[string]rune func populateMaps() { entity = map[string]rune{ "AElig;": '\U000000C6', "AMP;": '\U00000026', "Aacute;": '\U000000C1', "Abreve;": '\U00000102', "Acirc;": '\U000000C2', // 省略后续键值对 } } func UnescapeString(s string) string { populateMapsOnce.Do(populateMaps) i := strings.IndexByte(s, '&') if i < 0 { return s } // 省略后续的实现 ... }
3.2 单例模式
开发中我们经常会实现 Getter
来暴露某个非导出的变量,这个时候就可以把 once.Do
放到 Getter
里面,完成单例的创建。
package main import ( "fmt" "sync" ) type Singleton struct{} var singleton *Singleton var once sync.Once func GetSingletonObj() *Singleton { once.Do(func() { fmt.Println("Create Obj") singleton = new(Singleton) }) return singleton } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() obj := GetSingletonObj() fmt.Printf("%p\n", obj) }() } wg.Wait() } /*--------- 输出 ----------- Create Obj 0x119f428 0x119f428 0x119f428 0x119f428 0x119f428 **/
3.3 关闭channel
一个channel如果已经被关闭,再去关闭的话会 panic,此时就可以应用 sync.Once 来帮忙。
type T int type MyChannel struct { c chan T once sync.Once } func (m *MyChannel) SafeClose() { // 保证只关闭一次channel m.once.Do(func() { close(m.c) }) }
4. 原理
在 sync 的源码包中,Once 的定义是一个 struct,所有定义和实现去掉注释后不过 30行,我们直接上源码来分析:
package sync import ( "sync/atomic" ) // 一个 Once 实例在使用之后不能被拷贝继续使用 type Once struct { done uint32 // done 表明了动作是否已经执行 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 结构定义
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/386), // and fewer instructions (to calculate offset) on other architectures. done uint32 m Mutex }
sync.Once绝大多数场景都会访问o.done
,访问 done
的机器指令是处于hot path上,hot path表示程序非常频繁执行的一系列指令。由于结构体第一个字段的地址和结构体的指针是相同的,如果是第一个字段,直接对结构体的指针解引用即可,如果是其他的字段,除了结构体指针外,还需要计算与第一个值的偏移,所以将done放在第一个字段,则CPU减少了一次偏移量的计算,访问速度更快。
- 为何不使用 CAS 来达到执行一次的效果
其实使用 atomic.CompareAndSwapUint32
是一个非常直观的方案,这样的话 Do
的实现就变成了
func (o *OnceA) Do(f func()) { if !atomic.CompareAndSwapUint32(&o.done, 0, 1) { return } f() }
这样的问题在于,一旦出现 CAS 失败的情况,成功协程会继续执行 f
,但其他失败协程不会等待 f
执行结束。而Do
的API定位对此有着强要求,当一次 once.Do
返回时,执行的 f
一定是完成的状态。
对此,sync.Once 官方给出的解法是:
Slow path falls back to a mutex, and the atomic.StoreUint32 must be delayed until after f returns.
我们再来结合 doSlow()
看一看这里是怎么解决这个并发问题的:
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() } }
atomic.LoadUint32
用于原子加载地址(也就是 &o.done),返回加载到的值;- o.done 为 0 是代表尚未执行。若同时有两个 goroutine 进来,发现 o.done 为 0(此时
f
尚未执行),就会进入o.doSlow(f)
的慢路径中(slow path); doSlow
使用sync.Mutex
来加锁,一个协程进去,其他的被阻塞在锁的地方(注意,此时是阻塞,不是直接返回,这是和 CAS 方案最大的差别);- 经过
o.m.Lock()
获取到锁以后,如果此时 o.done 还是 0,意味着依然没有被执行,此时就可以放心的调用f
来执行了。否则,说明当前协程在被阻塞的过程中,已经失去了调用f
的机会,直接返回。 defer atomic.StoreUint32(&o.done, 1)
是这里的精华,必须等到f()
返回,在 defer 里才能够去更新 o.done 的值为 1。
5. 避坑
- 不要拷贝一个 sync.Once 使用或作为参数传递,然后去执行
Do
,值传递时done
会归0,无法起到限制一次的效果。 - 不要在
Do
的f
中嵌套调用Do
。
到此这篇关于一文解析 Golang sync.Once 用法及原理的文章就介绍到这了,更多相关 Golang sync.Once 内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!