单件模式 gaunthan Posted on Jan 18 2017 ? Design Patterns ? ? C++ ? > **单件模式**(Singleon Patter)确保一个类只有一个实例,并提供一个全局访问点。 <!--more--> ## 概述 有一些对象我们只需要一个,比如线程池(threadpool)、缓存(cache)、注册表(registry)对象。这类对象只能有一个实例,如果制造出多个实例,就会导致许多问题产生。 许多时候,通过程序员之间的约定就能达到上面的要求。但是,跟其他的模式一样,单件模式是经得起时间考验的,可以确保只有一个实例会被创建。单件模式也给了我们一个全局的访问点,和全局变量一样方便,又没有全局变量的缺点。 如果将对象赋值给一个全局变量,则在程序一开始就创建好了对象。如果这个对象非常耗费资源,而程序在这次执行过程中一次都没有用到它,就造成了浪费。而如果利用单件模式,则可以在需要时才创建对象。 ## 结构 单件模式是所有设计模式中类图最简单的,它的类图只有一个类:  尽管从类设计的视角看它很简单,但是实现上还是会遇到各种各样的问题。同时注意`getInstance()`方法是一个[静态工厂](http://gaunthan.leanote.com/post/%E5%B7%A5%E5%8E%82%E6%A8%A1%E5%BC%8F)。 ## 实现思路 ### 私有化构造函数 为了能够完全掌控对象的创建,第一步显然是将构造函数私有化。C++11 添加了`delete`关键字,使用它更能表现我们的意图: ```cpp class Singleton { private: Singleton() { } // 私有化构造函数 public: // 拷贝类函数都是不需要的,因此标记为 delete // deleted 函数应该声明为 public Singleton(const Singleton&) =delete; Singleton& operator=(const Singleton&) =delete; }; ``` ### 提供创建方法 由于类的构造函数被设置为私有,因此只有类内代码或友元可以访问它。因此,为了能够提供一个创建接口,我们需要添加一个静态方法: ```cpp class Singleton { // ... public: static Singleton& getInstance() // 提供静态工厂方法 { return *new Singleton; } }; ``` 这样我们就可以通过这个静态方法来管理对象的创建。 ### 约束唯一 接下来的问题是怎么禁止多次创建,即只允许有且仅有一个 Singleton 的实例存在。实现单件模式时,最容易遇到问题的就是这一步骤了。 ## 经典的实现方式 经典的实现方式是先判断对象是否已经创建,若已经创建,则直接返回对象的引用;否则,新建实例,然后标记为已经创建,接着返回它。可以直接用一个指针变量来存储创建的对象,并起标记作用。 注意,经典的实现方式在单线程环境下是正确的,但在多线程环境下它的正确性已经得不到保障了。 ### C++ ```cpp class Singleton { // 省略删除函数的声明 public: static Singleton& getInstance() { if (!pInstance_) pInstance_ = new Singleton; return *pInstance_; } private: static Singleton* pInstance_; }; // 下面的静态成员变量的定义需要放置在源文件中 Singleton* Singleton::pInstance_ = nullptr; ``` ### Java ```java public class Singleton { private static Singleton uniqueInstance; private Singleton() { } public static Singleton getInstance() { if (!uniqueInstance) { uniqueInstance = new Singleton; } return uniqueInstance; } } ``` 上面这种在调用 `getInstance` 时才创建实例的模式称为“**懒汉单件模式**”,J. Nakamura 把它叫作 "Gamma Singleton", 因为这是在他的 《设计模式》一书中的命名。 上面的代码可能存在着一些让人疑惑的地方: 1. new 失败怎么办? 2. 并发调用 `getInstance()` 会怎么样? 有两种原因会导致问题1发生: - 内存不足。这种情况下程序恐怕也难以继续运行。现代计算机的内存一般都比较充足,很少会遇到这种情况。这里为了简洁,就不进行这方面的处理。有兴趣的读者可以看看相关的文章:[《c++ new抛出bad_alloc异常后该怎么做?》](https://www.zhihu.com/question/24926411)。 - 构造函数抛出异常。既然选择让构造函数可以抛出异常,那么该做什么完全取决于自己怎么想。我们可以 retry 创建操作,也可以直接 rethrow。 这样看来,问题1不用给予太多关注。实际上问题2才是真正严重的问题。在多线程环境下,并发调用 `getInstance()` 可能会带来[并发问题](http://leanote.com/blog/post/5783b869ab644133ed0088db)。可能同时有多个线程通过了`if(!uniqueInstance)`测试,从而创建多个实例并且覆盖掉之前创建的实例,导致资源泄漏、实例不唯一,从而使得单件属性被违背。这意味着我们需要同步`getInstance()`方法。 ## 处理多线程 ### 处理并发访问 为了防止出现上面提到的问题,需要对 uniqueInstance(或 pInstance_)的访问加上互斥属性。 #### C++ C++11 引入了线程库以及一系列的同步设施,可以使用 mutex 头文件提供的 lock_guard 以及 mutex 类实现 RAII 式地临界区: ```cpp #include <mutex> // 提供互斥锁设施 mutex 以及 lock_guard class Singleton { // 省略删除函数的声明 public: static Singleton& getInstance() { // guard 构造时自动上锁,析构时自动解锁 std::lock_guard<std::mutex> guard(lock_); if (!pInstance_) pInstance_ = new Singleton; return *pInstance_; } private: static Singleton* pInstance_; static std::mutex lock_; }; // 下面的静态成员变量的定义需要放置在源文件中 Singleton* Singleton::pInstance_ = nullptr; std::mutex Singleton::lock_; ``` #### Java Java 内置关键字 `synchronized` 可以实现线程同步。只需为方法加上该关键字,它便成为同步方法: ```Java public class Singleton { private static Singleton uniqueInstance; private Singleton() { } public static synchronized Singleton getInstance() { if (!uniqueInstance) { uniqueInstance = new Singleton; } return uniqueInstance; } } ``` ## 改善同步带来的性能代价 同步虽然保证了正确性,但却带来了性能代价。因此在这样做之前,我们需要仔细考虑一下,这些额外的性能是否是我们可以负担的或者可以避免的。实际上确实有一些方法可以用来避免这些负担。 ### 使用静态变量 #### 提前创建:eagerly create 如果应用程序总是创建并使用单件实例,或者在创建或运行时方面的负担不太繁重,则可以**提前**(eagerly)创建此单件。“提前创建“有时候也被称为**”急切创建“**或**“饿汉式”**,而上面的那种只在需要时才创建的方式,则被称为**“懒汉式”**。 提前创建方式的实现如下面的Java代码所示: ```java public class Singleton { private static Singleton uniqueInstance = new Singleton(); private Singleton() { } public static Singleton getInstance() { return uniqueInstance; } } ``` 利用这种做法,我们依赖 JVM 在加载这个类时马上创建此唯一的单件实例。JVM 保证在任何线程访问该静态变量前,一定先创建此实例。使用C++也能如此实现单件模式,但其实有更好的做法。 #### 静态局部创建:Meyers Singleton 如果担心全局创建的方式带来不必要的构造函数调用,则可以把静态变量的定义放到 `getInstance()` 中,如下面的 C++ 代码所示: ```cpp class Singleton { private: Singleton() { } public: Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; static Singleton& getInstance() { static Singleton uniqueInstance; return uniqueInstance; } }; ``` ”静态局部创建“是由 Meyers 大师提出来的,因此也称为 **Meyers Singleton**。这种版本的单件模式被认为是最适合 C++ 的,因为 C++ 标准规定了并发访问 `getInstance()` 时,第一次访问操作将会为该变量初始化,而其他操作都要等到初始化完成: > §6.7.4 of the (C++11) standard: > If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization. #### dead-reference problem C++ 标准没有对静态变量的初始化顺序做出规定,因此当多个静态变量互相交互的时候,就会出现问题。由于构造顺序的依赖不满足,可能会引用到未初始化的内存。 假设某个程序使用了三个单件类:Keyboard、Display 和 Log。前二者分别代表其实际物体,后者用于错误报告。假设 Log 的构造比较昂贵,因此最好在出现错误时才将 Log 实例出来。这样一来,如果程序执行过程没有任何错误发生,Log 就根本不会被创建。 程序会向 Log 报告 Keyboard 或 Display 实例时产生的任何错误,Log 也会收集 Keyboard 和 Display 被摧毁时(可能)发生的错误。 如果我们以 Meyers Singleton 实现上述三者,则程序不正确。假设 Keyboard 成功构造后 Display 构造失败,于是 Display 的构造函数会产生一个 Log 记录错误,而且程序准备结束。此时静态对象会被摧毁,次序与创建次序相反。因此 Log 会在 Keyboard 之前被摧毁。但如果摧毁 Keyboard 的过程中发生了错误并向 Log 报告,`Log::getInstance()` 会传回一个 reference,指向一个已被摧毁的 Log 对象。于是程序就步入未定义行为的殿堂了。这就是所谓的 **dead-reference** 问题。 如果程序中使用多个互相关联的 Singletons,我们就无法提供某种自动化方法来控制它们的寿命。一个设计合理的 Singleton 至少应该执行 “dead-reference 检测“。为做到这一点,可以通过一个静态成员变量 destroyed_ 来追踪析构行为:初始为 false,Singleton 的析构函数会将它设为 true。 ```cpp class Singleton { public: // 省略删除函数声明 public: static Singleton& getInstance() { if (!pInstance_) { if (destroyed) { // Check for dead reference. onDeadReference(); } else { // First call--initialize create(); } } return pInstance_; } private: static void create() { static Singleton theInstance; pInstance_ = &theInstance; } static void onDeadReference() { throw std::runtime_error("Dead reference detected"); } ~Singleton() { pInstance_ = nullptr; destroyed_ = true; } static Singleton* pInstance_; static bool destroyed_; }; // 下面的静态成员变量的定义需要放置在源文件中 Singleton* Singleton::pInstance_ = nullptr; Singleton* Singleton::destroyed_ = false; ``` 现在程序消除未定义行为了,但对于我们而言,抛出异常并未真正解决实际问题:我们希望 Log 无时无刻不存在,不论它当初何时被构造。极端情况下我们甚至需要再次产生 Log(尽管它已经被摧毁),这样就可以随时使用它。这就是 **Phoenix Singleton** 设计模式背后的思想。 #### Phoenix Singleton 就像传说中的凤凰(phoenix)可以不断浴火重生一样,Phoenix Singleton 能够在被摧毁之后复活。我们依然可以保证任何时候都只有一个实例,但如果检测到 dead-reference,我们便在原内存空间产生一个新的 Singleton 对象。仅有的修改出现在 `onDeadReference()` 中: ```cpp class Singleton { // ... static void onDeadReference() { create(); new(pInstance_) Singleton; // Using placement new atexit(killPhoenixSingleton); // Needed. destroyed_ = false; } void killPhoenixSingleton() { pInstance_->~Singleton(); } }; ``` 既然我们通过 placement new 来使 Phoenix Singleton 重生,我们就不再能够像对待静态变量那样,靠编译器技巧摧毁它。我们既然手工创建了它,就必须手工摧毁它,`atexit(killPhoenixSingleton);` 保证了这一点。 现在,让 Log 成为 Phoenix Singleton 就可以解决问题了。 #### 堆与静态变量 如果程序对内存有特殊的布局要求,使得我们只能把实例存放在堆中呢?依据 C++ 中局部静态变量的初始化规则,我们其实可以这样做: ```cpp class Singleton { // ... public: static Singleton& getInstance() { static Singleton* pInstance = new Singleton(); return *pInstance; } }; ``` ### 双重检测加锁: DCLP 现在,是时候回到前面的问题了:怎么解决线程同步带来的代价?仔细想想,同步后的 getInstance() 代价之所以大大提高,是因为我们每次都是先上锁再进行的检测,而上锁其实只在第一次创建实例时需要。因此,我们可以用一种技术来避免创建实例之后重复同步的代价。这种技术被称为**双重检测加锁**(double-cheched locking, DCL)。也可以看成是 **DCLP**(DCL Pattern) 的一次应用。 这样一来,只在第一次调用 getInstance() 期间才会进行同步,完全避免了后续的同步代价。实现如下 Java 代码所示: ```Java public class Singleton { private volatile static Singleton uniqueInstance; private Singleton() { } public static Singleton getInstance() { if (uniqueInstance == null) { // 第一重检查 synchronized (Singleton.class) {// 第一次访问,需要同步 if (uniqueInstance == null) {// 第二重检查 uniqueInstance = new Singleton(); } } } return uniqueInstance; } } ``` 注意不要忘记添加 `volatile` 关键字。此外,双重加锁不适用于1.4及更早版本的 Java。 ## 静态创建与动态创建 由于静态变量天生就符合单件的思想,因此非常适合用来实现单件模式,而且这种方式避免了动态创建对象所引起的线程同步问题。 然而,由于对象是静态分配的,因此对象的生命周期是不可控的,这意味着如果这个对象需要的存储空间很大,而某些时候又完全没有使用到该对象,则会带来巨大的浪费。当然,这也说明使用堆创建对象有可能带来昂贵的运行时代价。 总之,使用堆带来了动态创建/释放对象的好处,却带来了线程同步问题;而使用静态分配虽避免了同步问题,却又无法动态管理对象的生命周期。因此,这两种方式各有优劣,需要根据实际的应用场景才能决定使用哪个更适合。 ## C++ 中的 DCLP ### Before C++11 在 C++11 首次规定了多线程内存模型之前,很难在 C++ 中实现 DCL(双重检测加锁)。在2004年的时候,C++ 大师 Scott Meyers 与 Andrei Alexandrescu 共同发布了一篇文章,提及了 C++ 中实现 DCL 的困难与痛苦。[^C++ and the Perils of Double-Checked Locking] [^C++ and the Perils of Double-Checked Locking]:[C++ and the Perils of Double-Checked Locking](http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf) 虽然困难重重,但在 C++11 之前实现 DCL 单件还是有办法的,只是这种方式是不可移植的:  导致不可移植的是插入内存屏障的两个操作,而也正是它们才解决了 DCL 中存在的那个难题。 ### After C++11 所幸,C++11 标准重新制定了其多线程内存模型,并提供了有力的线程相关设施。以往在 C++ 中执行多线程的不确定行为,现在都得到了一定程度的规定。按照 Jeff Preshing 的指导,在 C++11 中有多种方式解决以往的那些问题。[^Double-Checked Locking is Fixed In C++11] [^Double-Checked Locking is Fixed In C++11]:[Double-Checked Locking is Fixed In C++11](http://preshing.com/20130930/double-checked-locking-is-fixed-in-cpp11/) #### Using Acquire and Release Fences 例如,可以使用 C++11 提供的 Acquire and Release Fences:  使用 Acquire and Release Fences 已经完美地完成了任务,同时也能够生成可供优化的代码,而且在多处理器系统上也能正常工作。然而,这种方式不是那么流行。 #### Using Low-Level Ordering Constraints 在 C++11 中,还可以使用原子操作和底层顺序约束来完成任务:  相比前一种实现方案,这种方案没那么严格,因此也给了优化编译器更多的余地去优化代码。 使用独立的 fences 相当于筑起了一座城墙,外面的人进不来,里面的人出不去。而使用低级顺序约束,则相当于设立了安检亭,没被重点关注(指定 ordering)的人可以直接通过,而被指定了顺序的人,则只能等待一方通过后再走。 #### Using C++11 Sequentially Consistent Atomics C++11 提供了 SC 原子操作,它表现得跟 Java 5+ 中的 volatile 一样,保证不存在数据竞争,即满足 Data Race Free:  顺序一致性是符合程序员预期的,因此使用这种方式更容易编写代码。然而,C++ 和 Java 的内存模型都不是 SC 的,因为 SC 约束过强,编译器很难优化代码。在大部分平台上,这种方式生成的代码性能比较低。 ## volatile 在 DCLP 中的作用 ### In Java 如果取消上面的 DCLP Java 代码中的 volatile 关键字,会发生什么? 要理解这个问题,需要明白执行 `uniqueInstance = new Singleton()` 时会发生什么: 1. 为实例申请内存空间 2. 调用实例的构造函数,对这段空间初始化 3. 将 uniqueInstance 指向该对象 注意这三个步骤的顺序不是绝对的:编译器可能会重排序指令,调换步骤2和步骤3。 如果真发生了这样的事情,那么总的执行步骤是1-3-2。假设一个线程执行到步骤3时被挂起了,这时另一个线程开始运行,它在双重检测加锁的第一次检测时,发现 uniqueInstance 已经不为空,于是返回这个引用给调用代码,而调用代码接着使用这个对象。这时候问题发生了:我们访问了未经初始化的内存空间。结果可能是 segment fault(对于 C++而言) 或者抛出异常(对于 Java 而言)。 因此,对于之前那段 DCL Java 代码而言,如果去掉 volatile 关键字,则实现代码在多线程环境下仍然会出现错误。 ### In C/C++ 对于 C/C++ 而言,volatile 关键字并没有 happens-before 语义。C/C++ 中的 volatile 关键字多用于修饰内存映射的 I/O 寄存器(memory-mapped I/O register),因此多用在嵌入式开发中。一般而言,在多线程环境中使用 volatile 关键字,几乎可以确定是一个程序 Bug,见 Linux kernel 文档 [Why the "volatile" type class should not be used](http://elixir.free-electrons.com/linux/v4.0/source/Documentation/volatile-considered-harmful.txt)。或者阅读我对于这篇文档的翻译:[[译] 为什么不该在 C/C++ 中使用 volatile](http://leanote.com/blog/post/59087251ab64414416004a84)。 ## References - 弗里曼. Head First 设计模式 [M]. 中国电力出版社, 2007. - Andrei Alexandrescu, 侯捷, 於春景. C++ 设计新思维 [M]. 华中科技大学出版社, 2003. - Nystrom R. Game Programming Patterns[J]. 2014. - [维基百科——volatile 变量](https://zh.wikipedia.org/wiki/Volatile%E5%8F%98%E9%87%8F) - [剖析为什么在多核多线程程序中要慎用 volatile 关键字?](http://www.parallellabs.com/2010/12/04/why-should-we-be-care-of-volatile-keyword-in-multithreaded-applications/) - [C++11: Safe double checked locking for lazy initialization. Possible?](http://stackoverflow.com/questions/12302057/c11-safe-double-checked-locking-for-lazy-initialization-possible/12302355#12302355) 赏 Wechat Pay Alipay 命令模式 工厂模式