单例模式
这篇文章介绍了一下单例模式中的饥汉式和懒汉式,并且进一步的分析了一下 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 的方式。
拓展
- 执行异常后,可以加继续执行 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
}
- 带执行结果的 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
}