likes
comments
collection
share

golang 设计模式-单例模式

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

在日常开发中,单例模式是一种比较常见的设计模式,单例模式是指某个变量在内存中只会创建一次。在计算机系统中,线程池、缓存、日志对象、打印机常被设计成单例,因为这些应用或多或少具有资源管理器的功能,单例模式的优势在于通过控制实例产生的数量,从而达到控制和节省资源的目的。

所以,如果希望系统中某个变量只初始化一次,单例模式则是最好的解决方案。

单例模式从实现上分为两种:

  • 饿汉模式
  • 懒汉模式

饿汉模式是提前就把某个变量初始化好,但这样会产生一个问题,如果一个变量使用频率不高,且占用内存还特别大,饿汉模式就不太适用了;相比之下,懒汉模式的优势更明显,懒汉模式是指: 在程序需要某个变量的时候才会去初始化,所以接下来主要围绕单例模式的懒汉模式展开。

实现懒汉模式的最常见的方式是双重检查锁(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 行之间:

  1. check: 19 行先判断实例是否为空,为空说明需要初始化 instance 对象,非空直接返回。
  2. lock: 20 行加一个互斥锁,目的是防止多个 goroutine 同时初始化 instance,21 行解锁。
  3. 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 去执行,依旧有数据竞争的警告,我们通过以下图说明下:

golang 设计模式-单例模式

当 main goroutine 正在执行读 i 操作时,second goroutine 在执行写 i 操作,因为存在一个写变量 i 的 goroutine,所以会发生数据竞争。

生产环境可用的双重检查锁

明确了数据竞争的概念后,我们分析下双重检查锁遇到的问题,如下图所示:

golang 设计模式-单例模式

通过上图可知,出现了数据竞争,因为 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 运行以上代码,就没有数据竞争的问题了。其改动后的逻辑如下图所示:

golang 设计模式-单例模式

因为读写锁中,读锁和写锁是互斥的。所以 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 去运行代码,没有数据竞争的出现。我们通过下图分析代码逻辑。

golang 设计模式-单例模式

这两个 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(加锁实现)43934619913.76 ns/op
BenchmarkV2Atomic(atomic实现)10000000002.266 ns/op
BenchmarkV3SyncOnce(sync.once实现)10000000001.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 实现,然后比较三种方式的优劣,得出结论: 每种方式都要其应用场景,要具体问题具体分析。

最后,希望本篇文章真的能给你带来收获,您的点赞和评论将是我持续创作的巨大动力。

引用

go.dev/src/sync/on…

stackoverflow.com/a/62260652/…

medium.com/golang-issu…