5评论

读书笔记-设计模式-可复用版-Singleton 单例模式

Mitty 2019-03-03 2.6k浏览

想免费获取内部独家PPT资料库?观看行业大牛直播?点击加入腾讯游戏学院游戏开发行业精英群711501594

Singleton单例设计模式是最简单,最常用,最易于理解的一种设计模式


你几乎可以在任何项目中看到Singleton使用的地方,只要对象是“独一无二”的,我们都可以设置成Singleton模式


(单例模式应该是所有模式中,最有的讲的,因为涉及到多线程会牵扯出来不少的额外的知识)


关键词:

1.lock&deadlock

2.lazy&greedy

3.memory barrier

4.beforeFieldInit


比如游戏中的各种管理器(GameManager,AchievementManager,LevelManager,LoginManager....),工具类,甚至是某些全局常驻的UI视图类,只要他是独一无二,

都可以定义为Singleton


概念:

保证当前类的实例,在整个程序的运行周期中,有且仅有一个实例,并提供一个访问它的全局接口。


单例模式,有如下几个特点:


1.这个类是无法直接进行new初始化的,类的构造函数需要设置为private私有

2.通常单例的类是密封的sealed(JIT优化),不可以派生重写,否则实例就不唯一了

3.单例要对外提供一个获取Instance实例的接口,且是静态的


最简单的单例模式例子:



我们直接通过Singleton.Instance.xxxx就可以方便的调用指定的方法了,在C#中,可以将Instance设置为属性,这样连()都不需要了


这是最简单的单例模式的代码,但这种写法非常的糟糕,下面会说明原因。


难道单例设计模式只有这一点儿可讲吗?


如果涉及到多线程,就需要处理同步的问题,并且在实际应用中,很常见,只要涉及到网络,通常都会涉及到多线程。


也就是说,上面的写法糟糕在它不是线程安全的(Thread Unsafe)!


他会带来的问题是,当我们去调用Instance()时,instance = new Singleton()可能会被初始化多次,这样实例就不唯一了!


举例说明:


public class InstanceTest
  private static InstanceTest instance;
  private InstanceTest() { }

  public static InstanceTest GetInstance()
  {
    if (instance == null)
    {
      instance = new InstanceTest();
      Debug.Log("create new instacne");
    }
    return instance;
  }
  public void SaySomething(string text)
  {
    Debug.Log(text);
  }
定义一个简单的单例类,创建三个线程来调用

Thread thread = new Thread(new ThreadStart(ThreadTest));
thread.Name = "thread_1";
Thread thread2 = new Thread(new ThreadStart(ThreadTest));
thread2.Name = "thread_2";
Thread thread3 = new Thread(new ThreadStart(ThreadTest));
thread3.Name = "thread_3";

public void ThreadTest()
 InstanceTest.GetInstance().SaySomething("hello " + Thread.CurrentThread.Name);

可以多运行几次,会发现有如下情况出现:


create new instance 被执行了两次,实例化了两次,因为同时有多个线程都执行到if(instance==null)判定的部分,也就分别创建了实例

为何会造成这种情况出现?

在执行判断语句之前,instance可能已经创建了,但内存模型并不能保证instance的值可以被其它的线程看到,除非通过了适当的内存屏障(Memory Barrier)

这里引出了一个新的概念:

这里提到的读写操作是保证了数据可以被其它的线程可见

Memory Barrier就是一条CPU指令,他可以确保操作执行的顺序和数据的可见性

编译器和 CPU 可以在保证输出结果一样的情况下对指令重排序,使性能得到优化,这样就会倒置,代码的编写顺序和内存的读写顺序因为重排序的原因,变成乱序了(但结果保证是一样的),但在多线程中,这种优化会带来数据不同步的问题。

再举个例子,比如你在编写代码的时候,先修改A,再修改B,但内存处理可能并不是按照这个顺序的,可能会调换位置,并且修改的值可能一直保存在了寄存器中,没有更新到缓存或是主存,这样其它线程读取的时候,并不能保证每次读取到的都是新值!

为了解决这种编码和内存读取乱序的问题,就出现了Memory Barrier(内存屏障)

相当于告诉 CPU 和编译器先于这个命令的必须”先“执行,后于这个命令的必须”后“执行。
内存屏障也会强制更新一次不同CPU的缓存,会将屏障前的写入操作,刷到到缓存中,这样试图读取该数据的线程,会得到新值。确保了同步。

有点儿抽象,上一个例子说明:

int _answer;
bool _complete;
void A()
  _answer = 123;
  _complete = true;
 
void B()
  if (_complete)
  {
  Console.WriteLine(_answer);
  }

如果方法A和方法B,在不同的线程上并行,B得到的结果有没有可能是”0“?
答案是肯定的。上面提到过,CPU和编译器为了优化,会对CPU指令进行重排序
也可能会进行缓存优化,导致其它线程不能马上看到变量的值。

也就是说:
_answer = 123;
_complete = true;

_complete = true;因为指令重排序(reorder)可能会先执行,这样B输出的_answer就是0了。

解决方法就是添加内存屏障(Memory Barrier)


插入一个内存屏障,直接引用上面提到过后段话

"相当于告诉 CPU 和编译器先于这个命令的必须”先“执行,后于这个命令的必须”后“执行。内存屏障也会强制更新一次不同CPU的缓存,会将屏障前的写入操作,刷到到缓存中,这样试图读取该数据的线程,会得到新值。确保了同步。"


对例子做出如下修改:

int _answer;
bool _complete;
 
 void A()
 {
  _answer = 123;
  Thread.MemoryBarrier();  // 屏障 1
  _complete = true;
  Thread.MemoryBarrier();  // 屏障 2
 }
 
 void B()
 {
  Thread.MemoryBarrier();  // 屏障 3
  if (_complete)
  {
   Thread.MemoryBarrier();    // 屏障 4
   Console.WriteLine (_answer);
  }
 }

屏障 1 和 4 可以使这个例子不会打印 “ 0 “。屏障 2 和 3 提供了一个“最新(freshness)”保证:它们确保如果B在A后运行,读取_complete的值会是true。

相关参考:
http://wiki.jikexueyuan.com/project/disruptor-getting-started/storage-barrier.html
https://blog.csdn.net/shanyongxu/article/details/48053675
https://www.zhihu.com/question/20228202


那么,如何处理多线程模式下,单例模式是线程安全的(Thread Safe),即有没有更简

单的方法来处理指令重排序的问题?


通过lock语句,实际上,lock锁定,隐含的执行了Memory Barrier(内存屏障)


这篇文章进行了很好的讲解,在大话设计模式也有不错的解释

http://csharpindepth.com/Articles/Singleton#exceptions

(可以直接参考这两部分的资料)


线程安全的实现




通过lock语句,获取一个共享对象上的锁,保证当前只能有一个线程进入lock代码块,其它线程需要等待,lock语句执行时,会加锁,lock语句在结束以后,会释放锁,这样其它的进程才可以再进来,这样就保证了Instance不会被实例化多次。


在上面的网址中有这样一段话:

(as locking makes sure that all reads occur logically after the lock acquire, and unlocking makes sure that all writes occur logically before the lock release) 


locking确保了所有逻辑上读取会在lock acquire(获取lock)之后发生,unlocking确保了所有逻辑上的写入会在lock release(lock释放)之前发生,这样就保证了,我在解锁前,instance的值会被写入到内存中,同时也就保证了下一个线程可以正确的获取instance的值,保证了有序性,也就不会出现Instance被创建两次的情况!


在之前的测试代码中,将方法做如下修改:


 public void ThreadTest()
  {
    lock(obj)
    {
      InstanceTest.GetInstance().SaySomething("hello " + Thread.CurrentThread.Name);
    }
  }

再执行,看看控制台的输出:


create new instance仅创建了一次,并且调用顺序也是有序的。


关于lock(xxx)中锁定的对象:


lock 关键字可确保当一个线程位于代码的临界区时,另一个线程不会进入该临界区。 如果其他线程尝试进入锁定的代码,则它将一直等待(即被阻止),直到该对象被释放。

lock 关键字在块的开始处调用 Enter,而在块的结尾处调用 Exit。


private static readonly object padlock = new object();


是定义的私有的只读的共享对象,默认是CLR在启动装载时,就会创建,但在有些代码中,还可以见到这种写法:


lock(this)

lock(typeof(type))

lock("xxx")


三者一样糟糕


 MSDN的文档中,并不建议这样做,因为一切public的地方,都是不安全的,超出了代码的控制范围,可能会产生deadlock (死锁)


应避免锁定 public 类型,否则实例将超出代码的控制范围。


Stackoverflow中也有讨论:

https://stackoverflow.com/questions/251391/why-is-lockthis-bad



大致翻译下,通常来讲,最好避免去锁定一个公共的类型(某个具体的类 typeof(type)),或者超出控制范围的对象实例(即公共的实例),例如,如果实例可以被公共public说,那么,lock(this)会出现问题,因为这样,其它部分的代码也可以lock这个实例,这会带来的直接问题就是死锁,两个或多个线程等待同一个对象的释放。


锁定一个公共的数据类型(data type),而不是对象,会引起相同的问题


锁定字符串是尤其危险的,字符串比较特殊,他是”暂留”在CLR运行时中,意味着,相同的字符串,实际上是全局同一个对象的,也会引起相同的问题,死锁(deadlock)


使用危险的字符串,模拟一个死锁的例子:


Thread thread = new Thread(new ThreadStart(DeadLock1));
thread.Name = "thread_1";
Thread thread1 = new Thread(new ThreadStart(DeadLock2));
thread1.Name = "thread_2";

thread.Start();
thread1.Start();


public void DeadLock1()
  {
    lock ("A")
    {
      Debug.Log(Thread.CurrentThread.Name + " get lock A");
      lock ("B")
      {
        Debug.Log(Thread.CurrentThread.Name + " get lock B");
      }
      Debug.Log(Thread.CurrentThread.Name + " release lock B");
    }
    Debug.Log(Thread.CurrentThread.Name + " release lock A");
  }

public void DeadLock2()
  {
    lock ("B")
    {
      Debug.Log(Thread.CurrentThread.Name + " get lock B");
      lock ("A")
      {
        Debug.Log(Thread.CurrentThread.Name + " get lock A");
      }
      Debug.Log(Thread.CurrentThread.Name + " release lock A");
    }
    Debug.Log(Thread.CurrentThread.Name + " release lock B");
  }


创建两个线程,分别执行DeadLock1,DeadLock2两个方法,运行结果是:

thread_1 get lock A

thread_2 get lock B


产生死锁!


解释下死锁的产生:

假设线程一先执行,线程一执行了DeadLock1,进入方法内部,lock("A")//获取引用对象“A"的锁,这时候,另外一个线程也执行了DeadLock2,lock("B")//获取引用对象“B"的锁


A和B均被锁住(locking)


假设A先继续向下执行,执行到lock("B"),但此时B被线程二锁住,线程一处于等待,

线程二继续执行,执行到lock("A"),但A被线程一锁住,尴尬情况就出现了,线程一在等待

线程二释放B,而线程二在等待线程一释放A,就这么僵持,死锁!


相应的,lock(this),lock(typeof(type)) 也会引起相同的问题,这里在使用中,一定要注意!


那么什么是最佳的写法?


最佳的方法就是提供一个private/protected的静态成员,控制他的访问范围,专门用于locking


private static readonly object padlock = new object();


lock(padlock)

....


现在接手的一个项目中,就是使用的lock(this),同事通过代码质量管理工具SonarQube,有提示此句有错误,当时不以为然......


通过locking,解决了同步的问题,但遗憾的是,通过上面可知,lock语句这么强大,肯定是有性能消耗的,所以这种方式在频繁的调用中,每次都要lock acquire/lock release,性能堪忧,尤其是放在Update中的时候......所以下面是一个优化的方案:


采用双重检查锁定(double-check locking)



乍看上去,代码很奇怪,为何要在lock的外面,再加一层if(instance==null)的判定

原因是当其中一个线程持有共享对象的锁,并进入lock语句,完成instance的创建,然后释放锁(这个过程的读取和写入都是在after lock acquire和before lock release发生的,也就确保内存上的数据会更新),下一个线程执行时,执行到if(instance==null)时会返回,因为上一个线程已经完成了Instance的创建,所以下一个线程就不会再执行lock语句了,这样就提高了性能,上面的代码是每一次都会调用lock,而加上double-check,就可以避免每次都调用lock了


通过双重检查锁定机制,性能有了一定的提升,但他还是不够好,在参考的文档中有说到,他没法在Java中执行等,还有更好的方法,即不使用lock




没有使用lock,但也是线程安全的(thread-safe),这里使用到了静态构造函数

static Singleton()

{}

加上静态构造函数的原因是什么?


看上面有一段注释 :

// Explicit static constructor to tell C# compiler

// not to mark type as beforefieldinit


显示声明静态构造函数,告诉 C#编译器,不要将类型(type)标记为beforefieldInit,可以理解为字段初始化之前。也就是说,默认是beforefieldInit


什么是beforeFieldInit?


这里有一篇文章进行了很详细的解释

http://csharpindepth.com/Articles/BeforeFieldInit


这是一个.Net中关于类型构造器执行时机的问题,有两种方案:

beforefiledinit(默认)

precise

这两个模式的切换只需要添加一个static构造函数即可,存在静态构造函数则是precise方案,

没有static构造函数则是beforefieldinit方案


C#编译器,默认是beforefieldInit(为了提高性能),类型的初始化会在静态字段调用之前执行或者说一进方法就会执行。比如:


public  class MyClass
  {
    public static readonly DateTime Time = GetNow();
    private static DateTime GetNow()
    {
      Debug.Log("GetNow execute!");
      return DateTime.Now;
    }

  }


void Start()
 Debug.Log("Main execute!");
 Debug.Log("int: " + MyClass.Time);


当我们调用Start函数时,输出结果如下:




方法中,Main execute明明应该先执行,现在是GetNow execute!先执行了,也就是MyClass.Time 静态成员先进行了初始化,然后再是Main execute! 

最后是输出具体的时间


对于单例模式,这种执行顺序有的时候不是我们希望的,所以,为了解决这个问题,我们需要添加一个static静态构造函数


static MyClass()

{}


再次运行输出:



通常这才是我们需要的结果,所以在单例模式中,我们经常会看到一个静态构造函数(通常是空的!),就是为了解决静态字段提前初始化的原因。


默认是beforefieldinit的原因是性能更好,因为beforefieldinit,JIT只需要检查一次类型是否被初始化,而precise,JIT则需要每次都要检查类型是否被初始化。


这种初始化的方式叫lazy initilizer,可以翻译为延时初始化,或是懒汉初始化,相应的也会有lazy load


lazy的意思是:只有在我调用的时候,我才去初始化它!

不调用的时候,他就一直处于未初始化的状态。


一定要添加static静态构造函数吗? 答案是否定的,如果不带有静态构造函数,上面例子已经给出了,我们在方法中会调用该类的实例时,会先进行类型的初始化,这种方式被称为Greedy在,即饿汉式,只要我引用类中任意一个静态成员,调用之前,静态字段就会分配内存,而相应的Lazy是只有我调用的时候,才进行初始化。


严格意义上来讲,上面的不能算是完成的Full Lazy,在截图上说not quite as Lazy(没那么Lazy),原因如果类中有其它的静态字段,那么调用任意的静态字段,其它的字段也会被初始化。


比如,添加如下方法并调用:


 添加一个静态字段:
public static readonly DateTime Time1 = GetNow1();


private static DateTime GetNow1()
  Debug.Log("GetNow execute 1!");
  return DateTime.Now;



void Start()
  {
    Debug.Log("Main execute!");
    Debug.Log("Time:"+MyClass.Time1);

  }

只调用了MyClass.Time1,则Time静态字段也会被初始化


运行结果:


所以,他不能算严格意义上的Full Lazy.


所以他又提供了另外一个Full Lazy Version:



添加了一个Nested嵌套内部静态类,我只有调用Singleton.Instance时,Nested的静态字段才会被初始化。如果Singleton中有其它的静态方法,Nested均不会被初始化。


但通常,我们并不需要Full Lazy的版本,Fourth Version就可以满足了。


文档中还提供了最后一个实现版本:



如果你使用.Net 4或是更高的版本,可以使用System.Lazy<T> 实现Lazy版本,非常的简单,你要做的传递一个delegate,直接写一个Lamada表达式,里面初始化具体的Instance就好,简单且性能很好,并且你可以通过 IsValueCreated属性去判断Instance是否已经被创建。


上面的代码隐式地使用LazyThreadSafetyMode.ExecutionAndPublication作为Lazy<Singleton>的线程安全模式。


但很多Unity项目依然是使用Stable .Net 3.5的版本,所以只能等.Net 4(or higher)以后才可以使用。


在文章的最后做了一些关于性能方面的讨论,到底哪个方案最好,如果说你要在Update中,每帧调用,带有lock的会被认为最为低效的,但为什么不声明一个变量先保存他的引用,然后再在Update中调用呢,如果是这样的话,性能最差的版本,也可以获得不错的表现:)


这也是为何很多版本中,经常能够看到单一lock的实现方式,有的文章说double-check更安全,其实并不是,是为了提高性能,避免反复的lock,通常这种性能上的差异可以忽略不计,正如上面最后那段话说的那样。


我个人偏向于single lock,not quite as lazy,full lazy 版本,如果是在.Net4.0(or highter),毫无疑问,我会选择System.Lazy<T>的版本


最后,如果当前的项目中,大量的应用了单例设计模式(只要满足实例全局独一无二),会引起什么问题?


1.调用方面,单例过多,不易于管理,可以通过维护一个关联列表或是使用外观设计模式(后面会讲到),提供统一的接口,减少依赖性


2.重复代码过多,单例部分的实现都是一样的,定义Instance,GetInstnace(),过多重复的代码显然不合理,可以通过泛型来提高复用性,减少重复的代码,且利于维护


范型单例:


public class Singleton<T> where T : class, new()
  private static readonly T instance = new T();
  public static T Instance
  {
    get
    {
      return instance;
    }
  }


public class InstanceTest : Singleton<InstanceTest>
  public static string Time = GetTime();
  public static string GetTime()
  {
    Debug.Log("Get Time");
    return "2019.03.03";
  }
  public void SaySomething(string text)
  {
    Debug.Log(text);
  }
  static InstanceTest()
  {}


调用代码:


InstanceTest.Instance.SaySomething("hello my buddy!");


对于静态构造函数:

  static InstanceTest()
  {}

通常不需要添加,没有那么严格的使用环境


Singleton<T>泛型的实现,还可以使用lock(single check or double check):


public class Singleton<T> where T : class, new()
  private static T instance;
  private static readonly object padLock = new object();
  public static T Instance
  {
    get
    {
      if (instance == null)
      {
        lock (padLock)
        {
          if (instance == null)
          {
            instance = new T();
          }

        }
      }
      return instance;

    }
  }

效果是一样的,只是性能上,相比第一个肯定要差一些,但上面的讨论中也提到,如果你一定要在Update中循环调用,应该声明一个引用来缓存它。这样两者之间的差别就可以忽略不计了。


最后说一说在Unity中的Singleton泛型如何实现,游戏中会有众多的MonoBehaviour,同样,我们也不需要每一个都实现重复的代码,下面的代码是Unity中实现Singleton的泛型模板,大致说下原理。


/*
 * Singleton.cs
 * 
 * - Unity Implementation of Singleton template
 * 
 */

using UnityEngine;

/// <summary>
/// Be aware this will not prevent a non singleton constructor
///  such as `T myT = new T();`
/// To prevent that, add `protected T () {}` to your singleton class.
/// 
/// As a note, this is made as MonoBehaviour because we need Coroutines.
/// </summary>
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
 private static T _instance;
 
 private static object _lock = new object();
 
 public static T Instance
 {
  get
  {
   if (applicationIsQuitting) {
    Debug.LogWarning("[Singleton] Instance '"+ typeof(T) +
             "' already destroyed on application quit." +
             " Won't create again - returning null.");
    return null;
   }
   
   lock(_lock)
   {
    if (_instance == null)
    {
     _instance = (T) FindObjectOfType(typeof(T));
     
     if ( FindObjectsOfType(typeof(T)).Length > 1 )
     {
      Debug.LogError("[Singleton] Something went really wrong " +
              " - there should never be more than 1 singleton!" +
              " Reopenning the scene might fix it.");
      return _instance;
     }
     
     if (_instance == null)
     {
      GameObject singleton = new GameObject();
      _instance = singleton.AddComponent<T>();
      singleton.name = "~"+ typeof(T).ToString();
      
      DontDestroyOnLoad(singleton);
      
      Debug.Log("[Singleton] An instance of " + typeof(T) + 
           " is needed in the scene, so '" + singleton +
           "' was created with DontDestroyOnLoad.");
     } else {
      Debug.Log("[Singleton] Using instance already created: " +
           _instance.gameObject.name);
     }
    }
    
    return _instance;
   }
  }
 }
 
 private static bool applicationIsQuitting = false;
 /// <summary>
 /// When Unity quits, it destroys objects in a random order.
 /// In principle, a Singleton is only destroyed when application quits.
 /// If any script calls Instance after it have been destroyed, 
 ///  it will create a buggy ghost object that will stay on the Editor scene
 ///  even after stopping playing the Application. Really bad!
 /// So, this was made to be sure we're not creating that buggy ghost object.
 /// </summary>
 public void OnDestroy () {
  applicationIsQuitting = true;
 }


MonoBehaviour是不能new实例化,通过我们在Awake中完成构造,所以和上面的有一定的区别。

MonoBehaviour均是存在于Scene中某一个GameObject上,Singleton是整个生命周期中有且仅有一个实例,所以需要将GameObject设置为DontDestroyOnLoad,这样UnLoadScene时,该GameObject不会被释放,Singleton依然可以正常使用。

还有一种情况,虽然是MonoBehaviour,但我并没有附加在任何一个GameObject上,这时候然调用的时候,会创建一个空的GameObject,并将Type以组件的形式添加到GameObject,并设置为DontDestroyOnLoad

默认使用单例的组件,都应该是DontDestroyOnLoad的,我们通常是启动的时候创建他们,而不是在游戏的过程中动态的处理,所以这里出现了另外一种情况,在某个GameObject上绑定了单例的类,但没有任何脚本执行了DontDestroyOnLoad,通过Singleton<T>调用的时候,
并不会主动的将当前的GameObject设置为DontDestroyOnLoad,那么当场景Unload的时候,
单例就会被释放,而出现调用Null的情况,要避免这种情况,尽量不要动态的去添加单例对象。它们应该预加载 。

最后提下在最近的工作上,碰到了一个关于单例设计模式的坑,上面有提到,只要是”独一无二的“的对象,通常都可以做成单例模式


因为接手的是一个第三方CP的项目,犹豫前期对代码的不了解以及一些疏忽,游戏中的GameComponent组件(游戏功能菜单),我们为了方便使用,设计成了单例模式(游戏并没有基于观察者模式去实现UI上的交互)


因为当前的场景中,只有一个GameComponent,这样使用单例是没有问题的,后来发现,当我们加载多人游戏的Scene时,我们调用GameComponent接口,功能不正常了,原因是多人游戏场景的Prefab中也绑定了一个GameComponent


这样就会倒置,GameComponent.Instance静态实例,在加载了多人游戏的Scene时,指向了多人游戏的GameComponent


void Awake()

instance = this;


而且在退出多人游戏后,逻辑上并没有直接Unload Scene,仅仅只是隐藏了Scene,GameComponent.Instance始终还是指向多人游戏的GameConponent,这样我们在调用GameComponent的接口时,影响的还是多人游戏,而单人游戏的GameComponent,并没有变化,这种问题很难发现,所以一定要注意这种情况!


先到此为止,没有想到的是小小的单例模式牵扯出来这么多的内容,周末晚愉快,take a snooze,然后去吃张记烤羊腿~)


参考文献:

1.设计模式-可复用版

2.大话设计模式

3.http://csharpindepth.com/Articles/Singleton#exceptions

4.http://www.cnblogs.com/tianguook/p/3663651.html

5.https://blog.csdn.net/gdou_yun/article/details/53131781


感谢您的阅读, 如文中有误,欢迎指正,共同提高 


欢迎关注我的技术分享的微信公众号,Paddtoning帕丁顿熊,期待和您的交流




本文作者

Mitty

欢迎关注我的技术分享微信公众号:PaddingtonCoder (Paddington帕丁顿熊,很喜欢这个名字,第一次出国就是英国,很意外的机会,了解了一点英国的历史,知道了帕丁顿熊,看了帕丁顿熊的电影,来到了伦敦的帕丁顿地铁站,随处可见肥肥的鸽子总是抢镜......很有趣儿)

腾讯游戏学院公众号