C# Star
首发于C# Star
单例模式小结

单例模式小结

单例模式的标准定义是只生成对象的一个实例,宽泛点也可以说是保证一个操作只被执行一次。本文重点不在于讨论“模式”的设计意义(如节省系统资源等等),而在于“单例”的实现方式。

通常我们只有在多线程的环境下,才有使用单例模式的需求。话休烦絮,先看以下代码:

//以下代码来自参考网址
public sealed class Singleton
{
    private static Singleton instance = null;

    private Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            if (instanc== null)
            {
                instance = new Singleton();
            }
            return instance;
        }
    }
}

当有2个(或更多)线程竞相访问Singleton的Instance属性时,我们假设线程A更快些,正准备执行“instance = new Singleton();”这行代码,此时系统切换到线程B(关于多线程机制的详细介绍,建议参考《CLR via C#》,笔者看的是第4版),线程B刚好开始执行instance的null判断,此时线程A由于尚未执行new的操作,所以对线程B来说“instance == null”结果仍为True,也开始准备执行“new Singleton()”。当线程A、B执行完毕后,虽然instance作为静态变量还是只有一个实例,但是事实上线程A、B都创建过了Singleton的实例,具体instance最终的值是谁创建的,取决于两个或多个线程谁最后执行完创建实例的操作。很显然,这样的代码在多线程环境下是很浪费资源的,所以……只能放在各种单例模式的博客、讨论、文章(包括本文)里作为反面教材

事实上,如果可以确保每次只有一个线程能执行上述代码,其实也是可以的,于是又有了以下代码:

//以下代码来自参考网址
public sealed class Singleton
{
    private static Singleton instance = null;
    private static readonly object padlock = new object();

    Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            lock (padlock)
            {
                if (instance == null)
                {
                    instance = new Singleton();
                    //Do a heavy task
                }
                return instance;
            }
        }
    }
}

现在我们将执行NullCheck以及New实例的操作加锁(lock),仍以我们上文讨论的2个线程为例:现在线程A在执行该操作时,在未创建完实例退出锁之前,线程B将不能执行NullCheck操作,处于停滞状态。等到线程A执行完毕后,线程B才能执行锁内的代码,此时NullCheck的结果返回false。这样就确保了Singleton的实例只会被创建一次,而坏处其实就是锁带来的性能损耗。形象点来说:这里的instance就像个单间厕所,线程A在如厕的时候,线程B只能眼巴巴地在门外憋着等,什么都做不了。示例中锁内的代码只是执行了很简单的操作,如果是比较复杂耗时的操作,那么后来的线程C、D、F……只能跟线程B一样乖乖在“厕所”门外憋着肚子排队了。(在本文的参考网址中,作者还特别有心地提到了为什么特意加了一个“padlock”作为锁的对象,不清楚锁的可以了解下。这里简而言之,其实"lock(this)"是可以的,只是这样的代码习惯受人诟病,因为如果不对多线程十分理解且对当前代码块的功能定位十分自信的话,可能会导致“死锁(DeadLock)”或者引发其他性能问题,相当于埋下一颗雷,至于是你本人在未来的某天踩还是后面接任的人踩……都不是件令人开心的事情)

现在,我们有些心疼在“厕所”门外憋着肚子的线程B等众兄弟,于是有人提出了一个解决方案:“线程A在厕所拉完肚子冲完水就可以了,后面擦屁股、提裤子等操作就完全没必要再占着茅坑了,线程A在自己的虚空世界中完成这些操作吧。”代码来了:

//以下代码来自参考网址
public sealed class Singleton
{
    private static Singleton instance = null;
    private static readonly object padlock = new object();

    Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            if (instance == null)
            {
                lock (padlock)
                {
                    if (instance == null)
                    {
                        instance = new Singleton();
                        //Do a heavy task
                    }
                }
            }
            return instance;
        }
    }
}

这就是大名鼎鼎的“双重锁(Double-Checked Locking)”。现在和线程A竞争的线程B,一起突破了第一关的NullCheck,线程B还是得老老实实地等线程A执行完锁内的代码;但是一旦线程A创建完实例,后来的线程C、D、F等一众壮汉,在第一关NullCheck时就绕道了,直接获得了instance的值,根本不需要在锁面前斗个你死我活。之前的“厕所”是A进了B必须等着、B进了C必须等着,现在“厕所”则是不巧跟线程A竞争的线程B必须等着,但是只要A冲完水(实例已经被创建),稍晚来的C、D、F等可以迅速获得坑位(这里突然觉得厕所的例子不太好,因为对多线程环境来说,此时C等一众兄弟相当于是重叠在同一个坑上迅速地拉完肚子冲完水……那画面……额……)。当初笔者第一次接触到双重锁,深感此方式通俗易懂、简便易行,工作中也时常使用。然而(又要长见识了!)——双重锁并不是完全没有缺点的。参考网址的作者给出了4个缺点,本文略作翻译:

  • 对Java来说该方法失效。具体原因懒得翻译了,毕竟C#才是我的真爱,跟Java不熟。
  • 依赖内存壁垒(Memory Barrier)技术。说实话现在的我也不是很懂(好尬),初步理解是作者好像更在意ECMA批准的CLI标准(在任意操作系统上的.Net平台都遵守该标准,如Mono),而微软可能针对自家Windows平台的.Net加了一些更强大的语义,这些语义可能会在其他平台上失效或不可用。这时候就需要对instance加volatile约束(关于volatile的用途,可以查阅MSDN),或者必须显式调用内存壁垒,一旦面临后者的情况,作者吐槽说:“连专家都很难分清哪些壁垒是必需的”。所以作者建议避免使用这种“不可控”的方案。
  • 更容易出错。这个容易理解,我们假设instance是一个ArrayList,锁内代码包含一些较重的任务,如instance值的初始化、增加元素、排序等等操作。第一个执行锁内代码的线程A,在创建实例后,后来的线程C就可以跳过锁直接获取到instance的值。然而线程A在创建实例后,仍会对instance进行一些操作,而线程C在获取到值后也可能在外部代码中对instance有操作,这就容易产生多线程之间的冲突,会导致各种不可测的结果(大多数都是我们不想要的)或抛出异常。
  • 还有比它性能更好的方案。

所以……性能更好的方案呢?先看最简单的:

//以下代码来自参考网址
public sealed class Singleton
{
    private static readonly Singleton instance = new Singleton();

    // Explicit static constructor to tell C# compiler not to mark type as beforefieldinit
    static Singleton()
    {
    }

    private Singleton()
    {
    }

    public static Singleton Instance
    {
        get
        {
            return instance;
        }
    }
}

如果从实现单例出发,这个方案的本质不难理解:类的静态构造函数在每个AppDomian里仅会执行一次。但那行注释很有意思:“显示调用静态构造函数以告诉C#编译器不要将类型标记为'BeforeFieldInit'”。“BeforeFieldInit”是啥?这是比较底层的玩意儿。对.Net来说,一个类的初始化过程是在构造器中进行的。构造器分为类型构造器和对象构造器,前者负责初始化静态成员,后者负责初始化类的实例。类型构造器强制是私有的、隐式调用的。JIT编译器生成静态构造函数的代码后,使用了2种方式决定何时执行静态构造函数:

  • BeforeFieldInit。这是默认的方式,即让CLR去决定该何时执行类的静态构造函数。也就是说,CLR可能在类未接收到任何调用之前,就已经执行了静态构造函数,以生成运行效率更高的代码。对上文给出的例子来说,若不显示声明静态构造函数,Singleton类极有可能在尚未被使用前静态成员instance就已经生成了;如果Singleton类还有其他静态成员,都会一起被初始化。假若该类一直未被调用,就造成了资源(主要是内存)浪费。
  • Precise。该方式会刚好在类的第一次创建实例之前,或第一次调用类的一个非继承字段或成员之前执行静态构造函数。它需要显示声明静态构造函数。

如果按照延迟性(Laziness)的要求(调用的时候才创建),我们就需要显示声明Singleton类的静态构造函数,让CLR采取Precise的方式执行相关代码。这可能会造成相对显著的性能影响,尤其对于循环体,例如:在一个循环中调用单例(包含首次调用),BeforeFieldInit方式可以让CLR决定在循环前就调用静态构造函数,该代码只会执行一次;而Precise模式只会在循环中执行静态构造函数,并在之后每次调用都会检查静态构造函数是否已被执行。所以如果Singleton类的初始化工作不是很繁重,且不会对其他的代码造成副作用,那么完全不必显示声明静态构造函数。

如果我们还是希望保证延迟性,同时还对性能有略苛刻的需求,可以对上面的方案再做优化:

//以下代码来自参考网址
public sealed class Singleton
{
    private Singleton()
    {
    }

    public static Singleton Instance { get { return Nested.instance; } }
        
    private class Nested
    {
        // Explicit static constructor to tell C# compiler not to mark type as beforefieldinit
        static Nested()
        {
        }

        internal static readonly Singleton instance = new Singleton();
    }
}

这种方案兼顾了延迟性和性能,是比较好的实现。Nested类是私有内部类,所以尽管instance是internal或者public的访问权限,但还是只有Singleton类里的成员可以访问,依然是安全的实现方式。但坏处还是挺明显的——写起来太麻烦啦!

于是,从.Net Framework 4.0开始,微软官方加入了封装好的“黑科技”:Lazy<T>。示例代码如下:

//以下代码来自参考网址,笔者做了些微修改
public sealed class Singleton
{
    private Lazy<Singleton> lazy;

    public Singleton Instance { get { return lazy.Value; } }

    public Singleton()
    {
        lazy = new Lazy<Singleton>(InitializeSingleton);
    }

    private Singleton InitializeSingleton()
    {
        Singleton data = new Singleton();
        //Do something
        return data;
    }
}

Lazy<T>本身是比较“轻量级”的。关于Lazy<T>的用法,参考给出的MSDN链接即可。示例代码使用了它的重载版本:Lazy<T>(Func<T>),它默认使用双重锁保证线程安全。当然,还可以通过另一个重载构造方法:Lazy<T>(LazyThreadSafetyMode)来指定使用CAS保证线程安全,不过这又应是另一篇文章去讲了。


好了,小结到这里也差不多了。除了第1种方案作为反面教材不推荐使用外,其余方案怎么取舍就见仁见智了。我给出的主要参考网址里,原文作者在文末总结中对以上方案做了排序(推荐程度从高至低):4>2>5=6>3。我建议有兴趣且有一定英语阅读能力的读者耐心地读一读原文,作者比较详细地给出了自己对各个方案的意见。我个人反倒更常用第3种方案,其次是第6种和第4种方案,当然我有我的原因:开发环境支持并且稳定。所以,方案2-6的取舍还是根据实际情况来吧。对我来说,“这些方案是怎么想出来的?”才是真的关注点;我也希望借此文能让读者对各种技术解决方案背后的原理产生兴趣!

最后的最后,关于配图多说一句:愿诸位程序猿早日摆脱“单身模式”!预祝国庆快乐!


主要参考网址:

Implementing the Singleton Patterncsharpindepth.com

次要参考:

volatile - C# 参考docs.microsoft.com图标What does beforefieldinit flag do?stackoverflow.com图标

编辑于 2019-01-07

文章被以下专栏收录

    本专栏主要用于C#相关知识的交流,在不说明的情况下所有代码为作者原创,如有雷同,实属巧合。欢迎投稿,要求:无论文字内容还是代码实现必须原创,上下文通顺,如有背景介绍务必通俗易懂。