Go设计模式(一)从单例模式说起
我们这里讨论的单例模式(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() } }