← 返回文章

单例模式

这篇文章介绍了一下单例模式中的饥汉式和懒汉式,并且进一步的分析了一下 sync.Once 的源码,最后根据实际情形拓展了一下 Once 的源码。

单例模式算是设计模式中最简单的一个模式了,但是如果突然让写,会发现无从下手的感觉,这其实就是对 单例模式 中的感念不熟的原因。

介绍

单例模式,在百科中,可以搜索到它的含义:单例模式是一种类型创建的常用软件设计模式,通过单例模式的方法,在当前进程中对某个类创建一个实例,且只创建一个实例。

单例模式共有两种:懒汉式 和 饿汉式

饿汉式

饿汉式,指全局的单例实例在类装载时构建。 顾名思义,就是 比较饥饿,上来就直接将类初始化为实例,当有需要时,直接调取使用。

type Singleton struct {
}

var instance = &Singleton{}

func GetSingleton() *Singleton {
   return instance
}

懒汉式

懒汉式顾名思义就是偷懒,在获取实例的时候在进行初始化,但是懒汉式会有并发问题。并发问题主要发生在 instance == nil 这个判断条件上,有可能多个 goruntine 同时获取 instance 对象都是 nil ,然后都开始创建了 Singleton 实例,就不满足单例模式了。

type Singleton struct {
}

var instance *Singleton

func GetSingleton() *Singleton {
  if instance == nil {
    instance = &Singleton{}
    return instance  
  }
  return instance
}

但是这段程序是 非线程安全的,当多线程时,则可能出现不满足 单例模式 的现象。

加锁

当有并发问题出现时,可以使用 加锁 解决。在 golang 中可以使用 sync.Metux 来对整个方法进行加锁,就例如下面这样,这种方式是解决了并发的问题。

var mutex sync.Mutex
type Singleton struct {}

var instance *Singleton

func GetSingleton() *Singleton {
  mutex.Lock()
  defer mutex.UnLock()

  if instance == nil {
    instance = &Singleton{}
    return instance
  }
  return instance
}

但是 sync.Metux 的锁力度太高,每次调用 GetSingleton 方法都需要一个锁才能获得 instance 实例,当调用频率比较高的场景时,性能会很低。

Double Check

为了降低锁的力度,可以使用 Double Check 来实现。例如,下面的代码,第一次判断 if instance == nil 之后需要加锁处理,然后第二次判断 if instance == nil 之后才能创建实例。这种方式相较于上述方案来说,锁的粒度更低,毕竟 instance != nil 的时候是不需要加锁的。

type Singleton struct {}

var mutex Sync.Mutex
var instance *Singleton

func GetSingleton() *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 的源码,代码并不多。

type Once struct {
  done int32
  m Mutex
}

func (o *Once)Do(f func()){
  if aomic.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 的数据结构 主要由 done 和 m 组成。done 用于存储 f 函数是否已经执行。 m 是一个锁实例。

Do 方法:传入一个 f 函数,通过 sync.Once 来保证只执行一次。在 Do 方法中使用 atmoic 来读取 done 的变量,如果是 0,则说明该函数没有被执行过,调用 doSlow 方法,传入 f 函数,来执行 f 函数。

doSlow 方法:doSlow 方法的第一步就是加锁,保证同一时间只能由一个 goroutine 来执行 doSlow 方法。在接下来,判断 done 是否等于 0,这就相当于上面说的 DoubleCheck,这可以避免并发带来的问题。当 done == 0 时,执行 f 函数,然后使用 atomic 将 done 的值置为 1。

使用了 DoubleCheck 保证了 f 只执行一遍。

思考:如果使用 CAS 这种自旋锁 代替上述的 锁机制 可以么。

例如

if atomic.CompareAndSwapInt32(&o.done, 0, 1) {
  f()
}

使用这个方法尽管看上去可以解决并发问题,但是在现实情况中,Do 方法也是存在并发的,当第一个调用者将 done 的值由 0 变成 1,然后执行 f 函数。如果 执行 f 函数是一个非常耗时的方法,还没有执行完,第二个调用者也开始调用,获取到 f 的实例 执行其他操作,而这时 f 还没有执行完毕,就会报错。 所以官方采用的是 Lock + DoubleCheck 的方式。

拓展

  1. 执行异常后,可以加继续执行 Once

在看了源码后,我们可以按照自己的需求来扩展 sync.Once 包。例如,当执行 f 函数时,报错了,怎么办。 我们可以实现一个高版本的 Once 包,如下所示:

func doSlow(f func() error) error {
  o.m.Lock()
  defer o.m.UnLock()
  
  var err error
  if o.done == 0 {
    err = f()
    if err == nil {
      atomic.StoreUint32(&o.done, 1)
    }
  }
  
  return err
}
  1. 带执行结果的 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 some redis connection
    
    atomic.StoreUint32(&this.init, 1)
  })
}

func (this *RedisConn) IsConnect() bool {  // 另一个 goroutine
  return atomic.LoadUint32(&this.init) != 0
}

参考链接

https://juejin.cn/post/7093394883875962894