likes
comments
collection
share

Go设计模式(一)从单例模式说起

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

我们这里讨论的单例模式(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

}

问题

  1. 指令重排序

instance = &Singleton{}这个语句,包括分配内存、内存初始化、指针赋值三个操作,这里内存初始化和指针赋值可能会发生指令重排序,可能导致第17行返回的instance没有完成内存初始化,而其他线程就会获得一个没有完成初始化的对象

Go设计模式(一)从单例模式说起

  1. golang赋值不保证原子性

这里敲黑板,golang里唯一保证原子性操作的是atomic包,其他任何操作都不能保证,所以instance == nil这个读操作是没法保证原子性的,而可能返回一个半初始化的指针

解决方法

  1. 读写锁

我们用一把读写锁来保证读取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

}
  1. 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

}
  1. 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

}
  1. 性能比较

上面两种方法(读写锁和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

引申两个问题

  1. 乐观锁

atomic方案比读写锁的方案高效,原因就在于前者本质上是一把乐观锁,加锁读的开销要远大于乐观读。

  1. 内联函数

细心的读者可能已经发现,上面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()

   }

}
转载自:https://juejin.cn/post/7287907117336035343
评论
请登录