Golang 设计模式:单例模式

Golang 设计模式:单例模式

tk_sky 40 2024-02-23

Golang 设计模式:单例模式

背景

单例模式可以说是一种最经典最简单的设计模式。顾名思义,这种设计模式下我们声明一个类并保证这个类在全局只存在唯一实例供外部反复调用。

单例模式需要解决的对应场景:

  • 原理上只应该存在一个的类,比如全局的监控模块
  • 实例化时很耗费资源的类,比如连接池、线程池,或者中间件的 client 、consumer等等
  • 一些不容易在逻辑上 make 的入参比较繁杂的组件

在实现的模式上,一般有两种单例模式的实现类型:饿汉式和懒汉式。

  • 饿汉式:在一开始就完成单例的初始化工作,不管之后什么时候用
  • 懒汉式:到需要被使用时才做初始化工作

如何使用这两种模式,主要还是看具体的场景。饿汉式不够懒,如果单例对象长期用不到甚至永远用不到,就白白做这个初始化了;懒汉式可能不够及时,并且如果初始化工作出现 panic 等,相比初始化时就发现影响会更大。

饿汉式单例模式

其实就是单例初始化的时机是程序启动之初,而不考虑之后是不是会被用到。

在实现上,可以拆解成下面的步骤:

  • 单例的类和构造方法要声明为不可到处类型,避免外界直接初始化
  • 在代码启动之初,就初始化好一个全局单一的实例
  • 暴露一个可导出的单例获取方法 GetX,返回这个单例对象

在 golang 中,饿汉式单例可以实现如下:

package singleton

var s *singleton

func init() {
    s = newSingleton()
}

type singleton struct {
}

func newSingleton() *singleton {
    return &singleton{}
}

func (s *singleton) Work() {
}

func GetInstance() *singleton {
    return s
}

注意:

  • singleton 首字母小写,作为不可导出类型
  • 在初始化函数 init 里做单例的初始化工作
  • 对外暴露可导出的方法 GetInstance,返回建好的对象 s

但是,这里可以发现一个规范性的问题:我们的 GetInstance 函数返回的是一个不可导出类型 singleton 的实例。虽然代码流程上没问题,但是包外获取到以后,也没法把它作为参数进行传递,这样设计就很怪;singleton 这个不可导出类作为对外暴露的对象,就显得有些混淆和不必要。

所以,最规范的处理方法是再定义一层 interface,将这个接口作为 GetInstance 的返回值类型:

type Instance interface {
    Work()
}

func GetInstance() Instance {
    return s
}

懒汉式单例模式

懒汉模式是初始化不在程序初始时,而是只在需要的时候进行。但是,这种模式的代码实现细节上有很多要注意的地方。例如:

package singleton

var s *singleton

type singleton struct {
}

func newSingleton() *singleton {
    return &singleton{}
}

func (s *singleton) Work() {
}

type Instance interface {
    Work()
}

func GetInstance() Instance {
    if s == nil {
        s = newSingleton()
    }
    return s
}

这个代码看上去似乎没什么问题,在 GetInstance 处判断是否初始化,没有的话进行一次初始化。

然而,这种实现方法默认了我们的使用方只会单线程地调用 GetInstance。在很多场景下,对你方法的调用是并发的,作为设计者就需要提前考虑到这一点。如果使用方并发地调用这个方法,作为临界资源的 s 就可能出现 race 的情况,s 就可能被初始化两次,违背了单例的定义。

于是我们做出修改,加上一把全局锁:

package singleton

import "sync"

var (
    s   *singleton
    mux sync.Mutex
)

type singleton struct {
}

func newSingleton() *singleton {
    return &singleton{}
}

func (s *singleton) Work() {
}

type Instance interface {
    Work()
}

func GetInstance() Instance {
    mux.Lock()
    defer mux.Unlock()
    if s == nil {
        s = newSingleton()
    }
    return s
}

这样用户都需要先获得锁才能完成对应的初始化工作,流程上就不会有问题了。但是这个代码也不完美,因为初始化完成以后用户在获取单例的时候还是需要加锁,这里就存在无意义的性能损耗。

所以,我们继续修改,把锁放到初始化外边:

package singleton

import "sync"

var (
    s   *singleton
    mux sync.Mutex
)

type singleton struct {
}

func newSingleton() *singleton {
    return &singleton{}
}

func (s *singleton) Work() {
}

type Instance interface {
    Work()
}

func GetInstance() Instance {
    if s != nil {
        return s
    }
    mux.Lock()
    defer mux.Unlock()

    s = newSingleton()

    return s
}

这样实现,只要 s 被成功初始化,用户调 GeInstance 就不用加锁了。

然而,仔细看这个代码,还是可以发现一些并发导致的第二次 init 问题:如果还未初始化时两个线程并发调用 Get,同时走到抢锁这一步,虽然串行化了,但是仍然初始化了两次。

怎么办呢?我们不如在加锁后来一次 double check,阻止串行时的第二次初始化操作:

package singleton

import "sync"

var (
    s   *singleton
    mux sync.Mutex
)

type singleton struct {
}

func newSingleton() *singleton {
    return &singleton{}
}

func (s *singleton) Work() {
}

type Instance interface {
    Work()
}

func GetInstance() Instance {
    if s != nil {
        return s
    }
    mux.Lock()
    defer mux.Unlock()
    if s != nil {
        return s
    }
    s = newSingleton()

    return s
}

这样,既可以使得后续在使用 GetInstance 时不需要经过锁,又避免了并发产生的多次初始化问题。

sync.Once

但在 go 语言中,其实不需要编写上面这么复杂的代码。我们可以利用 sync 包下的单例工具 sync.Once,将代码重新优雅的写成如下:

package singleton

import "sync"

var (
    s    *singleton
    once sync.Once
)

type singleton struct {
}

func newSingleton() *singleton {
    return &singleton{}
}

func (s *singleton) Work() {
}

type Instance interface {
    Work()
}

func GetInstance() Instance {
    once.Do(func() {
        s = newSingleton()
    })
    return s
}

接下来就看看 sync.Once 是如何实现的。

Once 对应的数据结构如下:

package sync

import (
    "sync/atomic"
)

type Once struct {
    // 通过一个整型变量标识,once 保护的函数是否已经被执行过
    done uint32
    // 一把锁,在并发场景下保护临界资源 done 字段只能串行访问
    m    Mutex
}

就是两个核心字段:一个uint32,表示这个函数是否已被执行过;一把锁,保护这个uint32。至于为什么是 uint32 而不是 bool,因为只有 uint32 才能原子操作。

sync.Once 本质也是一个加锁 double check 机制,实现了任务的全局单次执行:

func (o *Once) Do(f func()) {
    // 锁外的第一次 check,读取 Once.done 的值
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    // 加锁
    o.m.Lock()
    defer o.m.Unlock()
    // double check
    if o.done == 0 {
        // 任务执行完成后,将 Once.done 标识为 1
        defer atomic.StoreUint32(&o.done, 1)
        // 保证全局唯一一次执行用户注入的任务
        f()
    }
}

这样把第一次 check 放锁外,下次调用的时候就不需要获取锁;其次,获取锁之后做 double check,这样就保证了全局只会做一次用户注入的任务。

总结

单例模式保证某个类型的实例全局只会被创建一次,之后共用同一个实例。根据初始化实例的时机,分为饿汉式和懒汉式两种。对于懒汉式单例模式,我们需要使用锁外 check 后在锁内再 check 的 double check 方法避免并发条件下出现多次初始化的问题。这种方法和 golang 标准库的 sync.Once 实现方法相同。