Go并发编程 — sync.Once
简介
Once
可以用来执行某个函数,但是这个函数仅仅只会执行一次,常常用于单例对象的初始化场景。说到这,就不得不说一下单例模式了。
单例模式
单例模式有懒汉式和饿汉式两种,上代码。
饿汉式
饿汉式顾名思义就是比较饥饿,所以就是上来就初始化。
var instance = &Singleton{}
type Singleton struct {
}
func GetInstance() *Singleton {
return instance
}
懒汉式
懒汉式顾名思义就是偷懒,在获取实例的时候在进行初始化,但是懒汉式会有并发问题。并发问题主要发生在 instance == nil
这个判断条件上,有可能多个 goruntine
同时获取 instance
对象都是 nil
,然后都开始创建了 Singleton
实例,就不满足单例模式了。
var instance *Singleton
type Singleton struct {
}
func GetInstance() *Singleton {
if instance == nil {
instance = &Singleton{}
}
return instance
}
加锁
我们都知道并发问题出现后,可以通过加锁来进行解决,可以使用 sync.Metux
来对整个方法进行加锁,就例如下面这样。这种方式是解决了并发的问题,但是锁的粒度比较高,每次调用 GetInstance
方法的时候都需要获得锁才能获得 instance
实例,如果在调用频率比较高的场景下性能就不会很好。那有什么方式可以解决嘛?让我们接着往下看吧
var mutex sync.Mutex
var instance *Singleton
type Singleton struct {
}
func GetInstance() *Singleton {
mutex.Lock()
defer mutex.Unlock()
if instance == nil {
instance = &Singleton{}
}
return instance
}
Double Check
为了解决锁的粒度问题,我们可以使用 Double Check
的方式来进行解决,例如下面的代码,第一次判断 instance == nil
之后需要进行加锁操作,然后再第二次判断 instance == nil
之后才能创建实例。这种方式对比上面的案例来说,锁的粒度更低,因为如果 instance != nil
的情况下是不需要加锁的。但是这种方式实现起来是不是比较麻烦,有没有什么方式可以解决呢?
var mutex sync.Mutex
var instance *Singleton
type Singleton struct {
}
func GetInstance() *Singleton {
if instance == nil {
mutex.Lock()
defer mutex.Unlock()
if instance == nil {
instance = &Singleton{}
}
}
return instance
}
使用 sync.Once
可以使用 sync.Once
来实现单例的初始化逻辑,因为这个逻辑至多只会跑一次。推荐使用这种方式来进行单例的初始化,当然也可以使用饿汉式。
var once sync.Once
var instance *Singleton
type Singleton struct {
}
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
源码分析
下面就是 sync.Once
包的源码,我删除了注释,代码不多,Once
数据结构主要由 done
和 m
组成,其中 done
是存储 f
函数是否已执行,m
是一个锁实例。
type Once struct {
done uint32 // f函数是否已执行
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 方法
传入一个 function
,然后 sync.Once
来保证只执行一次,在 Do 方法中使用 atomic 来读取 done 变量,如果是 0 ,就代码 f 函数没有被执行过,然后就调用 doSlow方法,传入 f 函数
- doShow 方法
doShow
的第一个步骤就是先加锁,这里加锁的目的是保证同一时刻是能由一个 goruntine
来执行 doSlow
方法,然后再次判断 done
是否是 0
,这个判断就相当于我们上面说的 DoubleCheck
,因为 doSlow
可能存在并发问题。然后执行 f
方法,然后执行使用 atomic
将 done
保存成 1
。使用 DoubleCheck
保证了 f
方法只会被执行一次。
接着看,那可以这样实现 sync.Once
嘛?
这样不是更简单一点嘛,使用原子的 CAS
操作就可以解决并发问题呀,并发只执行一次 f
方法的问题是可以解决,但是 Do
方法可能并发,第一个调用者将 done
设置成了 1
然后调用 f
方法,如果 f
方法特别耗时间,那么第二个调用者获取到 done
为 1
就直接返回了,此时 f
方法是没有执行过第二次,但是此时第二个调用者可以继续执行后面的代码,如果后面的代码中有用到 f
方法创建的实例,但是由于 f
方法还在执行中,所以可能会出现报错问题。所以官方采用的是 Lock + DoubleCheck
的方式。
if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
f()
}
拓展
- 执行异常后可继续执行的
Once
看懂了源码之后,我们就可以扩展 sync.Once
包了。例如 f
方法在执行的时候报错了,例如连接初始化失败,怎么办?我们可以实现一个高级版本的 Once
包,具体的 slowDo
代码可以参考下面的实现
func (o *Once) slowDo(f func() error) error {
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done == 0 { // Double Check
err = f()
if err == nil { // 没有异常的时候记录done值
atomic.StoreUint32(&o.done, 1)
}
}
return err
}
- 带执行结果的
Once
由于 Once
是不带执行结果的,我们不知道 Once
什么时候会执行结束,如果存在并发,需要知道是否执行成功的话,可以看下下面的案例,我这里是以 redis
连接的问题来进行说明的。Do
方法执行完毕后将 init
值设置成 1
,然后其他 goruntine
可以通过 IsConnetion
来获取连接是否建立,然后做后续的操作。
type RedisConn struct {
once sync.Once
init uint32
}
func (this *RedisConn) Init() {
this.once.Do(func() {
// do redis connection
atomic.StoreUint32(&this.init, 1)
})
}
func (this *RedisConn) IsConnect() bool { // 另外一个goroutine
return atomic.LoadUint32(&this.init) != 0
}
转载自:https://juejin.cn/post/7093394883875962894