我们这里讨论的单例模式(Singleton)是懒汉模式,即在实际需要的时候才开始初始化,双重检查锁是一种比较通用的懒汉单例模式的实现方式,所以这里我们从双重检查锁(Double Check Lock)说起。
第一个版本
下面这段代码是一个标准的双重检查锁,我们看下面这种写法会有什么问题?
// singleton v0
type Singleton struct{}
var (
instance *Singleton
lock sync.Mutex
)
func GetInstance() *Singleton {
if instance == nil {
lock.Lock()
defer lock.Unlock()
if instance == nil {
instance = &Singleton{}
}
}
return instance
}
问题
instance = &Singleton{}这个语句,包括分配内存、内存初始化、指针赋值三个操作,这里内存初始化和指针赋值可能会发生指令重排序,可能导致第17行返回的instance没有完成内存初始化,而其他线程就会获得一个没有完成初始化的对象
这里敲黑板,golang里唯一保证原子性操作的是atomic包,其他任何操作都不能保证,所以instance == nil这个读操作是没法保证原子性的,而可能返回一个半初始化的指针
解决方法
读写锁
我们用一把读写锁来保证读取instance的操作不会被指令重排序和非原子操作所影响
type Singleton struct{}
var (
instancev1 *Singleton
lockv1 sync.RWMutex
)
func GetInstancev1() *Singleton {
lockv1.RLock()
ins := instancev1
lockv1.RUnlock()
if ins == nil {
lockv1.Lock()
defer lockv1.Unlock()
if instancev1 == nil {
instancev1 = &Singleton{}
}
}
return instancev1
}
atomic
我们用一个原子赋值来保证只有当instance指针被完整赋值以后,才能被读到
type Singleton struct{}
var (
instancev2 *Singleton
lockv2 sync.Mutex
done uint32
)
func GetInstancev2() *Singleton {
if atomic.LoadUint32(&done) == 0 {
lockv2.Lock()
defer lockv2.Unlock()
if done == 0 {
defer atomic.StoreUint32(&done, 1)
instancev2 = &Singleton{}
}
}
return instancev2
}
sync.Once
实际上golang标准库里的sync.Once就是采用atomic实现的双重检查锁,所以golang里的最佳实践是直接使用sync.Once,这里敲下黑板
type Singleton struct{}
var (
instancev3 *Singleton
once sync.Once
)
func GetInstancev3() *Singleton {
once.Do(func() {
instancev3 = &Singleton{}
})
return instancev3
}
性能比较
上面两种方法(读写锁和atomic)看上去都能很好的实现单例模式,可是你有没有想过这两种方式的性能孰高孰低?
我们看一下实际的性能表现
// 读写锁
BenchmarkGetInstancev1-8 26312532 45.0 ns/op
// atomic
BenchmarkGetInstancev2-8 348387658 3.29 ns/op
// sync.Once
BenchmarkGetInstancev3-8 723899626 1.55 ns/op
引申两个问题
乐观锁
atomic方案比读写锁的方案高效,原因就在于前者本质上是一把乐观锁,加锁读的开销要远大于乐观读。
内联函数
细心的读者可能已经发现,上面sync.Once和atomic这两种方案本质是一样的,但是sync.Once的性能却更高,啥原因呢,what? 我们看下sync.Once的源码,就能发现,其实他利用了编译器优化,编译器会将规模比较小的代码做内联(inlining)处理,省去了函数调用的开销。之所有将doSlow单独封装成一个独立函数,是因为编译器不会将包含defer语句的函数做内联优化,所以doSlow单独封装后,Do函数就会被内联优化,因为doSlow只会被执行一次,后续都是调用Do就返回,所以Do的内联优化了函数调用的开销。
func (o *Once) Do(f func()) {
// Note: Here is an incorrect implementation of Do:
//
// if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the atomic.StoreUint32 must be delayed until after f returns.
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()
}
}