31

读书笔记-设计模式-可复用版-Singleton 单例模式-腾讯游戏学院

 5 years ago
source link: http://gad.qq.com/article/detail/289380
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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实例的接口,且是静态的


最简单的单例模式例子:

5c7b8ed3aef4f.png



我们直接通过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);

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

5c7b8f04d26f6.png

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

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

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

这里引出了一个新的概念:
5c7b8f3e9e659.png

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

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

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


线程安全的实现


5c7b8f6fc7989.png



通过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);
    }
  }

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

5c7b8f9528f48.png

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


5c7b8fbea5058.png


大致翻译下,通常来讲,最好避免去锁定一个公共的类型(某个具体的类 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)


5c7b8fe72ebb3.png


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

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


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


5c7b900586c25.png



没有使用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函数时,输出结果如下:


5c7b90a40941f.png



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

最后是输出具体的时间


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


static MyClass()

{}


再次运行输出:


5c7b90bbd8aea.png


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


默认是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静态字段也会被初始化


运行结果:

5c7b90d39b0e0.png

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


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


5c7b90e3a0f33.png


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


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


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


5c7b90f5798ab.png


如果你使用.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帕丁顿熊,期待和您的交流


5c18e453e7d86.jpg




About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK