单例模式是使用最广泛,也最简单的设计模式之一,作用是保证一个类只有一个实例。单例模式是对全局变量的一种改进,避免全局变量污染命名空间。因为以下几个原因,全局变量不能作为单例的实现方式:
1. 不能保证只有一个全局变量
2. 静态初始化时可能没有足够的信息创建对象
3. c++中全局对象的构造顺序是未定义的,如果单件之间存在依赖将可能产生错误
单例模式的实现代码很简单:
//singleton.hpp #ifndef SINGLETON_HPP #define SINGLETON_HPP class Singleton{ public: static Singleton* getInstance(); private: static Singleton* pInstance; }; #endif 1 //singleton.cpp 2 #include "singleton.hpp" 3 4 Singleton* Singleton:: pInstance = nullptr; 5 6 Singleton* Singleton::getInstance(){ 7 if(nullptr == pInstance){ 8 pInstance = new Singleton; 9 } 10 return pInstance; 11 }
单例模式这么简单,本来讲到这里就可以结束了。不过如果把上面代码放到多线程编程中使用就不那么可靠了。在《C++and the Perils of Double-Checked Locking》这篇文章中,Scott Meyers和Andrei Alexandrescu以单例模式为例详细讲述了多线程编程中的坑。下面的内容基本出自这篇论文,跟大家分享一下,非常经典。
上面的实现在单线程时没有问题,现在假设有两个线程A和B,A执行到第8行后因中断挂起,这时候instance还没有创建,B执行到第8行,于是A和B都会创建Singleton对象,
现在就有两个单例对象了,这当然是错误的。改成线程安全很不难,进入 getInstance加个锁就能保证每次只有一个线程进入函数,于是只会有一个线程实例化 pInstance。
Singleton* Singleton::getInstance(){ Lock lock; if(nullptr == instance){ pInstance = new Singleton; } return pInstance; }
但是每次调用 getInstance都加锁是一件效率非常低的事情,特别是这里只有第一次实例化 pInstance 时才需要互斥,以后都不需要锁。于是DCLP(Double-Checked Locking Pattern)产生了。
DCLP的经典实现如下:
Singleton* Singleton::instance() { if (pInstance == 0) { // 1st test Lock lock; if (pInstance == 0) { // 2nd test pInstance = new Singleton; } } return pInstance; }
通过两次检测 pInstance,这样实例化后所有的调用都不需要加锁。看样子问题已经解决了,互斥锁保证了只有一个线程会实例化 pInstance,以后的调用不需要锁,性能也不会有问题,很完美是不是。让我们一步步来看看这里面隐藏的坑。
pInstance = new Singleton;
这条实例化语句其实做了3件事情:
1. 分配一块动态内存
2. 在这块内存上调用Singleton构造函数构造对象
3. pInstance指向这块内存
问题的关键是第2和第3步可能会被编译器因优化原因调换顺序,先给pInstance赋值,在构建对象。在单线程上这是行的通的,因为编译器优化的原则是不改变结果,调换2,3两步对结果并没有影响。于是代码就类似于下面这样:
Singleton* Singleton::instance() { if (pInstance == 0) { Lock lock; if (pInstance == 0) { pInstance = // Step 3 operator new(sizeof(Singleton)); // Step 1 new (pInstance) Singleton; // Step 2 } } return pInstance; }
再来考虑两个线程A和B,
1. A第一次检查 pInstance,获取锁,执行第1和第3步,挂起,这时候 pInstance非空,但是还没有调用构造函数,pInstance指向的是未初始化内存。
2. 线程B检查 pInstance,发现非空,于是跳出函数,后面开始使用 pInstance,一个未初始化的对象。
DCLP只有在步骤1,2,3按照严格顺序执行时才能保证正确,然而,c/c++并没有这方面的支持,c/c++语言本身没有多线程,编译器优化只要保证单线程语义正确就行,多线程是不考虑的。为了保证第2步在第3步之前完成,可能需要增加一个临时变量,
Singleton* Singleton::instance() { if (pInstance == 0) { Lock lock; if (pInstance == 0) { Singleton* temp = new Singleton; // initialize to temp pInstance = temp; // assign temp to pInstance } } return pInstance; }
很可惜,temp很可能也会被编译器优化掉。为了防止优化,文章围绕volatile关键字做了详细的讨论,刘未鹏以及何登成都深入解释了volatile关键字在多线程编程中的效果,volatile明确告诉编译器不要对被修饰的变量做优化,包括读写值时必须直接读取内存值,两个volatile变量的先后顺序不可变等。不过
1. volatile只能保证单线程内指令顺序不变,不能保证多线程间的指令顺序的正确性
2. 一个volatile对象只有在构造函数完成后才具有volatile特性,所以仍然存在前面讨论的问题。
总之,volatile无法保证多线程正确。
另外,在多处理器机器上,还存在cache一致性问题。如果线程A和B在不同的处理器上,
即使A严格按照1,2,3步骤执行,在将cache写回主存的过程中仍然可能改变顺序,因为按照内存地址升序顺序写回数据可以提高效率。
彻底的解决方法是使用memory barrier,这篇文章给出了c++11中的做法,
std::atomic<Singleton*> Singleton::instance; std::mutex Singleton::m_mutex; Singleton* Singleton::getInstance(){ Singleton* tmp = instance.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire); if(nullptr == tmp){ std::lock_guard<std::mutex> lock(m_mutex); tmp = instance.load(std::memory_order_relaxed); if(nullptr == tmp){ tmp = new Singleton(); std::atomic_thread_fence(std::memory_order_release); instance.store(tmp, std::memory_order_relaxed); } } return instance; }
为了实现线程安全的DCLP,可谓费劲周章。其实有时候我们也可以采取另外的解决问题的方式,比如多线程程序开始只有主线程,我们可以先在主线程中初始化单例模式,然后再创建其他线程,从而完全避免以上问题,这也是我们公司项目中采用的方法!
Reference
http://preshing.com/20130930/double-checked-locking-is-fixed-in-cpp11/
C++ and the Perils of Double-Checked Locking