likes
comments
collection
share

Go并发编程 — sync.Once

作者站长头像
站长
· 阅读数 13

简介

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 数据结构主要由 donem 组成,其中 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 方法,然后执行使用 atomicdone 保存成 1。使用 DoubleCheck 保证了 f 方法只会被执行一次。

接着看,那可以这样实现 sync.Once 嘛?

这样不是更简单一点嘛,使用原子的 CAS 操作就可以解决并发问题呀,并发只执行一次 f 方法的问题是可以解决,但是 Do 方法可能并发,第一个调用者将 done 设置成了 1 然后调用 f 方法,如果 f 方法特别耗时间,那么第二个调用者获取到 done1 就直接返回了,此时 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
}