逑识

吾生也有涯,而知也无涯,以无涯奉有涯,其易欤?

0%

Go 语言设计模式之单例模式

导语」单例模式是一种常用的软件设计模式,它属于创建型模式的一种。在程序中应用这种模式时,需要保证单例对象的类有且只有一个实例,并且提供一个单例对象的全局访问点。使用单例模式可以减小程序的复杂度并节约系统资源。

应用场景

在某些场景下,有一些对象其实我们只会需要一个,例如,线程池对象、缓存对象、日志对象以及配置对象等。这类对象只能有一个实例,如果制造出多个实例,就会导致许多问题产生,比如程序行为异常,资源使用过量或者运行结果不一致等。

单例模式的一个典型应用场景就是保存程序的全局配置信息或状态信息。比如有一个服务器程序,其配置信息全部保存在一个文件中,为了使用这些配置,我们需要实现一个配置类来进行加载。由于服务器程序中的各个线程均使用相同的配置,因此我们可以仅使用配置类的单个实例来保存配置信息,其它线程均通过这个单例对象来获取配置,而如果每个线程都持有一个单独的配置对象,就会将全局的配置搞得一团乱。这种设计模式既可以简化复杂环境下的配置管理,同时也可以避免创建多个配置对象造成系统资源的浪费。

Go 语言实现

为了实现单例模式,我们需要定义一个结构体,该结构体有一个获取单例对象的方法,它返回一个指向单例对象的指针。单例对象本身与结构体在同一包下且是不可导出的,它会在获取方法中被初始化,并且无论该方法被调用多少次,单例对象只会被初始化一次。以下为单例模式的几种实现方式。

无锁实现

最简单的实现方式就是在获取单例对象的方法 (NewSingleton) 中直接判断单例对象 (instance) 是否为 nil,如果为是,则进行初始化操作并返回,如果为否,则直接返回该对象即可。如以下代码所示:

// Singleton definition.
type Singleton struct {
Name string
}

var (
instance *Singleton
)

// NewSingleton return singleton instance.
func NewSingleton() *Singleton {
if instance == nil {
instance = &Singleton{Name: "singleton"}
}
return instance
}

上述实现方式在单 goroutine 的环境下可以正常运行,但当多个 goroutine 同时调用该方法时,可能都会检查到单例对象为 nil,从而每次调用都会产生新的对象,这并不符合单例模式的设计思想。另外,上述代码在多 goroutine 的环境运行时可能会产生数据竞态,从而会导致程序崩溃。

加锁实现

为了解决多 goroutine 并发访问的问题,可以在单例对象的获取方法中使用互斥锁来对各个 goroutine 进行同步。如以下代码所示:

import (
"sync"
)

// Singleton definition.
type Singleton struct {
Name string
}

var (
instance *Singleton
mutex sync.Mutex
)

// NewSingleton return singleton instance.
func NewSingleton() *Singleton {
mutex.Lock()
defer mutex.Unlock()
if instance == nil {
instance = &Singleton{Name: "singleton"}
}
return instance
}

上述方式可以保证各个 goroutine 获取到的均为同一个单例对象,但是在单例对象已被初始化的情况下,后续访问的 goroutine 依然会进行不必要的加锁和解锁操作,会给程序的性能带来不好的影响。

双重检查实现

为了解决直接加锁方式在高并发场景下带来的性能损耗问题,这里采用了 Check-Lock-Check (也称作双重检查)的方式来进行优化。不同于直接加锁,这种方式会先检查单例对象是否为 nil,然后再进行加锁,接着再次检查单例对象是否为 nil,最后进行单例对象的初始化操作。如以下代码所示:

import (
"sync"
)

// Singleton definition.
type Singleton struct {
Name string
}

var (
instance *Singleton
mutex sync.Mutex
)

// NewSingleton return singleton instance.
func NewSingleton() *Singleton {
if instance == nil {
mutex.Lock()
defer mutex.Unlock()
if instance == nil {
instance = &Singleton{Name: "singleton"}
}
}
return instance
}

其中,第一次检查可以在单例对象不为 nil 的情况下直接返回单例对象而不必再执行加锁操作;第二次检查可以避免重复创建对象,因为在检查和获取互斥锁期间可能有其它 goroutine 已经获取了锁并创建了单例对象,所以这次检查也是必要的。

原子实现

双重检查的唯一缺点是,两次检查操作是非原子的,可能会被 goroutine 调度机制打断从而产生一些中间状态,造成结果混乱,该缺陷可通过使用原子操作来进行改善。如以下代码所示:

import (
"sync"
"sync/atomic"
)

// Singleton definition.
type Singleton struct {
Name string
}

var (
instance *Singleton
done uint32
mutex sync.Mutex
)

// NewSingleton return singleton instance.
func NewSingleton() *Singleton {
if atomic.LoadUint32(&done) == 0 {
mutex.Lock()
defer mutex.Unlock()
if done == 0 {
instance = &Singleton{Name: "singleton"}
atomic.StoreUint32(&done, 1)
}
}
return instance
}

基于该思想,我们可以直接使用 Go 标准库 sync 包中的 Once 结构体来实现相同的操作。如以下代码所示:

import (
"sync"
)

// Singleton definition.
type Singleton struct {
Name string
}

var (
instance *Singleton
once sync.Once
)

// NewSingleton return singleton instance.
func NewSingleton() *Singleton {
once.Do(func() {
instance = &Singleton{Name: "singleton"}
})
return instance
}

sync.Once 可以保证其调用的函数仅被执行一次,下面是 sync.Once 的具体实现。

// Once is an object that will perform exactly one action.
type Once struct {
done uint32
m Mutex
}

func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
// Outlined slow-path to allow inlining of the fast-path.
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()
}
}

可以看到其实现思想与我们的原子实现基本一致。

初始化实现

上面介绍的各种实现方式只有在单例对象被使用时才会创建对象实例,我们也可以在程序初始化时就完成该对象的创建。如以下代码所示:

var (
instance *Singleton
)

// init function.
func init() {
instance = &Singleton{Name: "singleton"}
}

// Singleton definition.
type Singleton struct {
Name string
}

// NewSingleton return singleton instance.
func NewSingleton() *Singleton {
return instance
}

这种方式的优点是在高并发场景下无需加锁,可以直接使用该对象实例。但其缺点是如果程序中没有使用到该单例对象时会浪费一部分存储空间,而且在程序初始化的过程中创建实例可能会减慢程序的启动速度。

功能测试

在实现了单例模式后,我们需要对上述代码进行功能测试。主要包括两点,其一是在单 goroutine 场景下,多次获取的单例对象应该相同;其二是在多 goroutine 的场景下,多个 goroutine 获取的单例对象也应该全部相同。测试代码如下所示:

import (
"fmt"
"testing"
)

func TestSingleton(t *testing.T) {
s1 := NewSingleton()
s2 := NewSingleton()
if s1 != s2 {
t.Fatal("Singleton instances are not equal")
} else {
t.Log("Singleton instances are equal")
}
}

func TestParallelSingleton(t *testing.T) {
count := 10
instances := make([]*Singleton, count)
// Parallel test.
for i := 0; i < count; i++ {
i := i
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
t.Parallel()
instances[i] = NewSingleton()
})
}
t.Cleanup(func() {
// Check all the singletons are equal.
for i := 1; i < count; i++ {
if instances[i] != instances[i-1] {
t.Errorf("Singleton instances %d and %d are not equal", i-1, i)
}
}
t.Log("All singleton instances are equal")
})
}

在并发测试的代码中,我们使用 t.Parallel 方法实现了多测试用例的并发执行。在并发测试时,各个测试用例均是在单独的 goroutine 中异步执行的,我们无法直接判断它们是否已经全部执行完成,但是 Go 的测试包中提供了 t.Cleanup 方法,该方法注册的函数会在全部测试用例执行完成后才会执行,因此可以借助它来完成后续的比对操作。

另外,需要注意的是,使用含有数据竞态 (data race) 的代码做并发性测试是没有任何意义的,它会产生各种可能的结果。

最后使用以下命令来检验测试结果:

$ go test -race -v
=== RUN TestSingleton
singleton_test.go:14: Singleton instances are equal
--- PASS: TestSingleton (0.00s)
=== RUN TestParallelSingleton
=== RUN TestParallelSingleton/0
=== PAUSE TestParallelSingleton/0
=== RUN TestParallelSingleton/1
=== PAUSE TestParallelSingleton/1
=== RUN TestParallelSingleton/2
=== PAUSE TestParallelSingleton/2
=== RUN TestParallelSingleton/3
=== PAUSE TestParallelSingleton/3
=== RUN TestParallelSingleton/4
=== PAUSE TestParallelSingleton/4
=== RUN TestParallelSingleton/5
=== PAUSE TestParallelSingleton/5
=== RUN TestParallelSingleton/6
=== PAUSE TestParallelSingleton/6
=== RUN TestParallelSingleton/7
=== PAUSE TestParallelSingleton/7
=== RUN TestParallelSingleton/8
=== PAUSE TestParallelSingleton/8
=== RUN TestParallelSingleton/9
=== PAUSE TestParallelSingleton/9
=== CONT TestParallelSingleton/3
=== CONT TestParallelSingleton/4
=== CONT TestParallelSingleton/7
=== CONT TestParallelSingleton/9
=== CONT TestParallelSingleton/2
=== CONT TestParallelSingleton/8
=== CONT TestParallelSingleton/1
=== CONT TestParallelSingleton/5
=== CONT TestParallelSingleton/6
=== CONT TestParallelSingleton/0
=== CONT TestParallelSingleton
singleton_test.go:36: All singleton instances are equal
--- PASS: TestParallelSingleton (0.00s)
--- PASS: TestParallelSingleton/3 (0.00s)
--- PASS: TestParallelSingleton/4 (0.00s)
--- PASS: TestParallelSingleton/7 (0.00s)
--- PASS: TestParallelSingleton/9 (0.00s)
--- PASS: TestParallelSingleton/2 (0.00s)
--- PASS: TestParallelSingleton/8 (0.00s)
--- PASS: TestParallelSingleton/1 (0.00s)
--- PASS: TestParallelSingleton/5 (0.00s)
--- PASS: TestParallelSingleton/6 (0.00s)
--- PASS: TestParallelSingleton/0 (0.00s)
PASS
ok go-examples/patterns 0.020s

参考资料

  1. Head First 设计模式
  2. Go 语言中的单例模式
  3. Go Patterns
  4. Go Testing

欢迎关注我的其它发布渠道