golang 设计模式-单例模式
在日常开发中,单例模式是一种比较常见的设计模式,单例模式是指某个变量在内存中只会创建一次。在计算机系统中,线程池、缓存、日志对象、打印机常被设计成单例,因为这些应用或多或少具有资源管理器的功能,单例模式的优势在于通过控制实例产生的数量,从而达到控制和节省资源的目的。
所以,如果希望系统中某个变量只初始化一次,单例模式则是最好的解决方案。
单例模式从实现上分为两种:
- 饿汉模式
- 懒汉模式
饿汉模式是提前就把某个变量初始化好,但这样会产生一个问题,如果一个变量使用频率不高,且占用内存还特别大,饿汉模式就不太适用了;相比之下,懒汉模式的优势更明显,懒汉模式是指: 在程序需要某个变量的时候才会去初始化,所以接下来主要围绕单例模式的懒汉模式展开。
实现懒汉模式的最常见的方式是双重检查锁(Check-Lock-Check),双重检查锁理解下来比较简单,但是如果要实现一个生产环境可用的、无 bug 的还是有些难度的,接下来我们将一步一步实现它。
简易版的双重检查锁
首先我们看以下代码,理解下双重检查锁。
package main
import (
"log"
"sync"
"time"
)
type Singleton struct {
Name string
}
var (
instance *Singleton
lock sync.Mutex
)
func GetInstance() *Singleton {
if instance == nil { // 19 行
lock.Lock() // 20 行
defer lock.Unlock() // 21 行
if instance == nil { // 22 行
instance = &Singleton{Name: "zhangsan"}
}
}
return instance
}
func main() {
for i := 0; i < 5; i++ {
go func() {
resp := GetInstance()
log.Printf("the value is : %s", resp.Name)
}()
}
time.Sleep(time.Second)
}
以上代码最核心的双重检查锁(check-lock-check)体现在 19-22 行之间:
- check: 19 行先判断实例是否为空,为空说明需要初始化 instance 对象,非空直接返回。
- lock: 20 行加一个互斥锁,目的是防止多个 goroutine 同时初始化 instance,21 行解锁。
- check: 22 行再次判断实例是否为空,再次判断的原因是:如果多个 goroutine 同时通过第一次 check,而且其中一个 goroutine 首先通过了第二次 check 并初始化了 instance 实例,那么剩余的 goroutine 的就不必再去初始化实例了。
我们执行以上程序,程序打印正常,看起来是没有问题。但是当执行命令 go run --race main.go
去检查是否有数据竞争时,会报WARNING: DATA RACE
的,这个 warning 表示存在数据竞争,而数据竞争也是会造成一些问题的,所以为了解决这个 warning,我们要先了解下数据竞争(data race)。
数据竞争
在 golang 有一篇专门的文章介绍数据竞争,数据竞争是指: 至少有两个 goroutine 同时去访问一个变量,而这两个 goroutine 中至少有一个会写这个变量。
A data race occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write.
我们通过一个简单的例子说明下,比如以下代码
package main
import "fmt"
func main() {
fmt.Println(getNumber())
}
func getNumber() int {
var i int
go func() {
i = 5
}()
return i
}
以上代码使用 go run --race main.go
去执行,依旧有数据竞争的警告,我们通过以下图说明下:
当 main goroutine 正在执行读 i 操作时,second goroutine 在执行写 i 操作,因为存在一个写变量 i 的 goroutine,所以会发生数据竞争。
生产环境可用的双重检查锁
明确了数据竞争的概念后,我们分析下双重检查锁遇到的问题,如下图所示:
通过上图可知,出现了数据竞争,因为 first goroutine 在初始化实例时,second goroutine 正在读实例。为了避免这种 case 的出现,我们需要把原来的互斥锁改成粒度更小的读写锁,目的是为了避免一个 goroutine 在初始化实例时,另一个 goroutine 正在读实例的这种case,修改后的部分代码如下:
var (
instance *Singleton
lock sync.RWMutex
)
// .....
func GetInstance() *Singleton {
// 实现一个读锁,保证和下面的 instance 赋值是同步的,保证原子性
lock.RLock()
lock.RUnlock()
if instance == nil {
lock.Lock()
defer lock.Unlock()
if instance == nil {
instance = &Singleton{Name: "zhangsan"}
}
}
return instance
}
改善后,我们再次通过 go run --race main.go
运行以上代码,就没有数据竞争的问题了。其改动后的逻辑如下图所示:
因为读写锁中,读锁和写锁是互斥的。所以 second goroutine 在 first goroutine 没有释放写锁之前,会一直 waiting ,直到 first goroutine 释放写锁,所以也就不会发生数据竞争。
另一种思路的双重检查锁
首先我们再次回到「简易版的双重检查锁」,在「简易版」中出现数据竞争的原因是: instance 变量在同一时刻既有 goroutine 读,也有 goroutine 写,本质原因是变量的修改非原子操作,而 golang 中提供了一个原子操作 package atomic ,atomic 的逻辑是实现在硬件层面之上,其意味着即使有多个 goroutine 修改同一个 atomic 变量,该变量也会正确更新且不会发生数据竞争。
以下是实现的代码逻辑:
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type Singleton struct {
Name string
}
var (
instance *Singleton
lock sync.Mutex
flag uint32
)
func GetInstance() *Singleton {
if atomic.LoadUint32(&flag) == 0 {
lock.Lock()
defer lock.Unlock()
if atomic.LoadUint32(&flag) == 0 {
instance = &Singleton{Name: "zhangsan"}
defer atomic.StoreUint32(&flag, 1)
}
}
return instance
}
同样我们使用 go run --race main.go
去运行代码,没有数据竞争的出现。我们通过下图分析代码逻辑。
这两个 goroutine 在每个阶段都不存在数据竞争的问题,所以程序可用。
golang 提供的双重检查锁
golang 也提供了一种双重检查锁的方法 sync.Once,sync.Once 只暴露了一个方法 Do,你可以多次调用 Do 方法,但是只有第一次调用 Do 方法时 f 参数才会执行,这里的 f 是一个无参数无返回值的函数。
func (o *Once) Do(f func())
我们看下其仅有的方法 once.do,本质就是上一 part 中「另一种思路的双重检查锁」实现。
下面用 sync.once 实现下单例模式。
package main
import (
"log"
"sync"
"time"
)
type Singleton struct {
Name string
}
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{Name: "zhangsan"}
})
return instance
}
同样通过 go run --race main.go
运行以上代码,正常运行,没发现问题。
三种双重检查锁的比较
下面我们会通过性能、适用场景两个方面比较下不同方式实现的双重检查锁的优劣。
性能比较
我们通过 go test -bench . -benchtime=5s .
运行以下代码的基准测试。
package main
import (
"sync"
"sync/atomic"
"testing"
)
type Singleton struct {
Name string
}
var (
instance *Singleton
lock sync.RWMutex
flag uint32
once sync.Once
)
func GetInstanceV1() *Singleton {
lock.RLock()
ins := instance
lock.RUnlock()
if ins == nil {
lock.Lock()
defer lock.Unlock()
if instance == nil {
instance = &Singleton{Name: "zhangsan"}
}
}
return instance
}
func GetInstanceV2() *Singleton {
if atomic.LoadUint32(&flag) == 0 {
lock.Lock()
defer lock.Unlock()
if atomic.LoadUint32(&flag) == 0 {
instance = &Singleton{Name: "zhangsan"}
defer atomic.StoreUint32(&flag, 1)
}
}
return instance
}
func GetInstanceV3() *Singleton {
once.Do(func() {
instance = &Singleton{Name: "zhangsan"}
})
return instance
}
func BenchmarkV1Mutex(b *testing.B) {
for n := 0; n < b.N; n++ {
GetInstanceV1()
}
}
func BenchmarkV2Atomic(b *testing.B) {
for n := 0; n < b.N; n++ {
GetInstanceV2()
}
}
func BenchmarkV3SyncOnce(b *testing.B) {
for n := 0; n < b.N; n++ {
GetInstanceV3()
}
}
运行的基准结果如下表所示:
样例 | 执行次数 | 执行每次操作的时间 |
---|---|---|
BenchmarkV1Mutex(加锁实现) | 439346199 | 13.76 ns/op |
BenchmarkV2Atomic(atomic实现) | 1000000000 | 2.266 ns/op |
BenchmarkV3SyncOnce(sync.once实现) | 1000000000 | 1.985 ns/op |
通过对比发现 sync.Once 的性能更加,其次是 atomic 实现,最后是加锁实现。
另外多说一句,sync.once 和 atomic 的实现基本类似,为什么 sync.once 的性能更佳呢,原因是 sync.once 实现了内联优化,这里就不展开了,感兴趣可以搜索下~
适用场景比较
通过上面的表格,发现 sync.once 的性能更佳,是否意味着,我们遇到单例模式就用 sync.once 了呢?实则不然,因为有些场景下使用读写锁的方式实现更容易实现,比如下面这个例子。
我们希望在 map 中实现一个单例,map 中的 key 对应的 value 如果不存在,就初始化一个,存在就返回,通过读写锁实现代码如下:
package main
import (
"fmt"
"strconv"
"sync"
"time"
)
type Singleton struct {
Name string
}
var (
lock sync.RWMutex
instances = map[string]*Singleton{}
)
func getInstance(key string) *Singleton {
lock.RLock()
if value, ok := instances[key]; ok {
lock.RUnlock()
return value
}
lock.RUnlock()
lock.Lock()
defer lock.Unlock()
if value, ok := instances[key]; ok {
return value
}
instance := &Singleton{Name: "zhangsan"}
instances[key] = instance
return instance
}
如果使用 sync.once 就较难实现,其本质原因是 sync.once 仅执行一次,而这里需要判断 map 中对应的 value 是否存在,要初始化多次实例,因此就不适合。
总结
本文重点围绕懒汉模式的双重检查锁进行展开,依次提出单例模式的实现方式有:加锁实现、atomic 实现、sync.once 实现,然后比较三种方式的优劣,得出结论: 每种方式都要其应用场景,要具体问题具体分析。
最后,希望本篇文章真的能给你带来收获,您的点赞和评论将是我持续创作的巨大动力。
引用
转载自:https://juejin.cn/post/7124720007447052302