:::tips 本质上, 单例模式就是 整个系统下只能有一个实例

:::

为什么要使用单例?

单例设计模式(Singleton Design Pattern), 一个类只允许创建一个对象(或者实例), 这种设计模式就叫做单例模式

饿汉式

创建时,就进行初始化

  1. type singleton struct{}
  2. var ins *singleton = &singleton{}
  3. func GetIns() *singleton{
  4. return ins
  5. }
  • 这种方法是 线程安全 的,因为一开始就只会创建一份
  • 缺点是不能支持延迟加载, 启动时间加长 ,可能会持续占用部分内存

懒汉式

被调用时,如果没有初始化才进行初始化

  1. type singleton struct{}
  2. var ins *singleton
  3. func GetIns() *singleton{
  4. if ins == nil {
  5.   ins = &singleton{}
  6. }
  7. return ins
  8. }
  • 这种方法 线程不安全 写的时候一定要注意并发问题
  • 支持延迟加载, 但是也不见得好, 如果处理时间很长, 中途卡顿体验更差

golang中的单例并发问题

隐藏的梦魇 - 并发下的单例错误

由于golang 的并发很容易实现, 所以很容易产生一些并发不安全的代码, 比如

  1. package singleton
  2. type singleton struct {}
  3. var instance *singleton
  4. func GetInstance() *singleton {
  5. if instance == nil {
  6. instance = &singleton{} // 不是并发安全的
  7. }
  8. return instance
  9. }
  • 这里看似进行了检查, 但是在并发下, instance 是有可能被多次创建, 导致覆盖的
  • 如果有代码保留了 instance 的不同实例, 会产生潜在的不同代码行为,很容易产生难以调试的bug, 而且debug的时候打上断点,反而调试不出问题, 绝对会抓狂

猪突猛进 - 加锁下的性能问题

应对并发问题, 我们最容易想到的就是 加锁 然鹅加锁会带来严重的性能问题

  1. var mu Sync.Mutex
  2. func GetInstance() *singleton {
  3. mu.Lock() // 如果实例存在没有必要加锁
  4. defer mu.Unlock()
  5. if instance == nil {
  6. instance = &singleton{}
  7. }
  8. return instance
  9. }
  • 我们引入了 Sync.Mutex 来保证 instance 只有一个实例, 虽然这完成了我们的目的, 然而这样做却给高并发下埋下了不小的性能隐患
  • 加锁本身没有问题, 问题在于 每一个 goroutine 来获取 instance 时都会有一次锁操作, 并且一次只能有一个 goroutine 获取得到 instance ,这是一个非常大的性能隐患, 在高并发的场景下 必然会产生性能问题

双重保险 - Check-Lock-Check模式

在C++和其他语言中, 确保最少的锁定并且仍然是并发安全的最佳和最安全的方法是在获取锁定时利用 Check-Lock-Check

  1. if check(){
  2. lock(){
  3. if check(){
  4. // 执行加锁的安全代码
  5. }
  6. }
  7. }
  • 首先进行检查,在执行锁操作, 这样在实例化之后, 就不会产生重复加锁的问题, if 语句的消耗远小于 加锁
  • 但是 加锁前也有可能其他人也拿到锁, 所以还需要在内部再检查一次, 避免覆盖问题
  1. func GetInstance() *singleton {
  2. if instance == nil { // 不太完美 因为这里不是完全原子的, 并发上来依然会出问题
  3. mu.Lock()
  4. defer mu.Unlock()
  5. if instance == nil {
  6. instance = &singleton{}
  7. }
  8. }
  9. return instance
  10. }
  • 通过 sync/atomic 的进行原子操作
  1. import "sync"
  2. import "sync/atomic"
  3. var initialized uint32
  4. ... // 此处省略
  5. func GetInstance() *singleton {
  6. if atomic.LoadUInt32(&initialized) == 1 { // 原子操作
  7. return instance
  8. }
  9. mu.Lock()
  10. defer mu.Unlock()
  11. if initialized == 0 {
  12. instance = &singleton{}
  13. atomic.StoreUint32(&initialized, 1)
  14. }
  15. return instance
  16. }

但是,这样还是有点繁琐了, 每次都这样写还是很烦

不行我就是要偷懒 - 官方实现

这种常用的功能怎么能不封装, 官方在 sync 中已经做了封装, 看下源码吧(部分注释有删改)

  1. // Once is an object that will perform exactly one action.
  2. type Once struct {
  3. // done indicates whether the action has been performed.
  4. // It is first in the struct because it is used in the hot path.
  5. // The hot path is inlined at every call site.
  6. // Placing done first allows more compact instructions on some architectures (amd64/x86),
  7. // and fewer instructions (to calculate offset) on other architectures.
  8. done uint32
  9. m Mutex
  10. }
  11. func (o *Once) Do(f func()) {
  12. if atomic.LoadUint32(&o.done) == 0 { // check
  13. // Outlined slow-path to allow inlining of the fast-path.
  14. o.doSlow(f)
  15. }
  16. }
  17. func (o *Once) doSlow(f func()) {
  18. o.m.Lock() // lock
  19. defer o.m.Unlock()
  20. if o.done == 0 { // check
  21. defer atomic.StoreUint32(&o.done, 1)
  22. f()
  23. }
  24. }
  • 实现就是上面 check-lock-checkatomic 的写法, 封装成了一个模块而已

于是我们可以这样来写

  1. package singleton
  2. import (
  3. "sync"
  4. )
  5. type singleton struct {}
  6. var instance *singleton
  7. var once sync.Once
  8. func GetInstance() *singleton {
  9. once.Do(func() {
  10. instance = &singleton{}
  11. })
  12. return instance
  13. }

C#中的单例并发问题

和golang相同, C#也会和golang一样遇到并发问题

涛声依旧 - 并发错误创建出多个实例

  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Text;
  5. using System.Threading;
  6. using System.Threading.Tasks;
  7. namespace DesignPattern {
  8. class DoMain{
  9. static void Main (string[] args) {
  10. try {
  11. TaskFactory taskFactory = new TaskFactory();
  12. List<Task> taskList = new List<Task>();
  13. for (int i = 0; i < 5; i++) {
  14. taskList.Add(taskFactory.StartNew(() => {
  15. Singleton singleton = Singleton.CreateInstance();
  16. }));
  17. }
  18. } finally {
  19. }
  20. Console.ReadKey();
  21. }
  22. }
  23. class Singleton {
  24. private static Singleton singleton;
  25. private Singleton () {}
  26. public static Singleton CreateInstance () {
  27. if (singleton == null) {
  28. Console.WriteLine("创建");
  29. singleton = new Singleton();
  30. }
  31. return singleton;
  32. }
  33. }
  34. }

23种设计模式 -- 单例模式 - 图1

  • 该有的问题还是有, 加锁也会产生效率上的问题
  • 所以依然是采用 check-lock-check 是最佳实践

宝刀未老 - Check-Lock-Check模式

修改为 check-lock-check

  1. ...
  2. public static Singleton CreateInstance () {
  3. if (singleton == null) {
  4. lock (singleton_lock) {
  5. if (singleton == null) {
  6. Console.WriteLine("创建");
  7. singleton = new Singleton();
  8. }
  9. }
  10. }
  11. return singleton;
  12. }
  13. ...

23种设计模式 -- 单例模式 - 图2

我就是超级懒 - 官方实现

惰性初始化关键字 lazy 可以惰性初始化, 如果想直接初始化, 用静态变量即可

  1. public sealed class Singleton {
  2. private Singleton () {
  3. }
  4. ////private static readonly Singleton singleInstance = new Singleton();
  5. private static readonly Lazy<Singleton> Instancelock = new Lazy<Singleton>(() => new Singleton());
  6. public static Singleton GetSingleton {
  7. get {
  8. return Instancelock.Value;
  9. }
  10. }
  11. }

单例模式之殇 - 为什么不推荐使用单例模式

对OOP的各种特性支持不友好

违背了基于接口而不是实现的原则

  1. public class Order {
  2. public void create(...) {
  3. //...
  4. long id = IdGenerator.getInstance().getId();
  5. //...
  6. }
  7. }
  8. public class User {
  9. public void create(...) {
  10. // ...
  11. long id = IdGenerator.getInstance().getId();
  12. //...
  13. }
  14. }
  • 这里依赖了实现, 如果有一天要 User和Order的id生成方法变了, 那么所有的地方都需要进行改动

:::info 另外, 单例对继承,多态支持也不友好, 虽然并非不能实现, 但是单例的继承,多态 实现起来会非常奇怪, 导致代码可读性变差, 不明白设计意图的人, 很那看懂这样的设计

所以, 一旦选择了单例类, 也就意味着你放弃了继承和多态这两个强有力的OOP特性, 也就损失了应对未来需求变化的扩展性

:::

隐藏了类之间的依赖关系

代码的可读性非常重要, 阅读代码的时候, 我们希望一眼就能看出类与类之间的依赖关系, 搞清楚这个类依赖了哪些外部类

通过构造函数, 参数传递等方式声明类之间的依赖关系, 我们通过查看函数定义, 就能很容易识别. 但是单例类不需要显示创建, 不需要参数传递, 直接就可以调用. 如果代码比较复杂, 这种调用关系会非常隐蔽, 阅读代码的时候, 就必须查看每个函数的代码实现, 才能知道这个类到底依赖了哪些单例类

单例模式对扩展性不友好

单例类只能有一个对象实例, 如果有一天, 我们需要在代码中创建一个以上的实例, 那么代码就需要有比较大的改动

单例模式对代码的可测试行不友好

单例模式会影响代码的可测试性, 如果单例类依赖比较中的外部资源, 比如DB, 我们在写单元测试的时候, 希望通过mock的方式替换掉,而单例类这种硬编码的方式,导致无法实现mock替换

单例不支持有参数的构造函数

但这个缺点可以通过配置文件的方式克服