C++ 单例模式浅析
单例模式的惯用实现
一直以来,我在C++中是这样实现单例模式的
class Singleton
{
public:
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
private:
Singleton();
~Singleton();
Singleton(const Singleton&);
const Singleton& operator=(const Singleton&);
};
客户端使用单例模式:
Singleton& obj = Singleton::GetInstance();
优点
这种创建单例模式优点:
- 将默认构造函数声明为private,阻止编译器自动声明为public,从而阻止客户自行创建新实例;
- 将copy构造函数、assignment运算符设为private,可以阻止用户拷贝实例,从而创建第二个实例;
- 将析构函数设为private,可以禁止客户delete单例实例(有的编译器可能会报错);
- GetInstance()可返回单例类的指针,也可以返回引用。返回指针时,客户可以删除对象。所以返回引用可以避免这种情况。
另外,为何将Singleton的static实例instance设为函数local变量(局部变量),而不是class或global变量?
因为,
不同编译单元中的非局部静态对象的初始化顺序是未定义的(Meyers,2005)。
而将单例的静态实例instance设为local变量,可以避免这种情况:在第一次调用GetInstance()时才分配实例。
缺点
然而,今天拜读《C++ API设计》后,发现
1)这种实现方式不是线程安全的。
2)正如《Modern C++ Design》提到,原因在于这种技术,依赖于静态变量标准的后进先出的销毁方式。特别地,如果单例的析构函数中,调用了其他单例,可能导致单例在预期时间前销毁。
例如,考虑2个单例模式Clipboard(剪切板),LogFile(日志文件)。当Clipboard实例化后,也实例化了LogFile,便于输出日志信息用于诊断。当进程退出时,由于LogFile是在Clipboard之后创建的,因此先销毁LogFile,再销毁Clipboard。但Clipboard的析构函数中调用了LogFile,记录Clipboard被销毁的记录,而此时LogFile已销毁。这可能导致程序退出时崩溃。
至于解决方案,可参阅《Modern C++ Design》。本文主要讲如何编写线程安全的单例。
线程安全的单例
为GetInstance加锁,确保线程安全
GetInstance()线程不安全,是因为在单例的静态初始化中存在竞态条件。如果恰好有2个线程同时调用该方法,那么实例有可能被构造2次,或者一个线程完全初始化实例前,另一个线程就调用了该实例。
下面是编译器扩展GetInstance()的可能结果:
Singleton& Singleton::GetInstance()
{
// 编译器可能生成的示例代码
extern void __DestructSingleton();
static char __buffer[sizeof(Singleton)];
static bool __initialized = false;
if (!__initialized) {
new(__buffer) Singleton(); // placement new
atexit(__DestroySingleton); // 进程退出时销毁实例
__initialized = true;
}
return *reinterpret_cast<Singleton*>(__buffer);
}
void __DestroySingleton
{
// 调用静态__buffer单例对象的析构函数
}
可以看到,整个过程并未加锁。因此,我们可以为GetInstance()加锁,确保线程安全:
static std::mutex mtx;
Singleton& Singleton::GetInstance()
{
std::lock_guard<std::mutex> lock(mtx); // 添加互斥锁, 确保线程安全
static Singleton instance;
return instance;
}
双重检查锁定模式
上面的方案有个很大的缺点:每次调用都要请求加锁,开销较大。如果频繁调用GetInstance()获取实例,可能影响性能。解决办法是:
1)建议客户端只调用一次,缓存实例;
2)下面要讲的方法,采用双重检查锁定模式(Double Check Locking Pattern, DCLP)
static std::mutex mtx;
Singleton& Singleton::GetInstance()
{
static Singleton* instance = NULL;
if (!instance) { // Check #1
std::lock_guard<std::mutex> lock(mtx);
if (!instance) { // Check #2
instance = new Singleton();
}
}
return *instance;
}
不过,DCLP也不能保证在所有编译器和处理器内存模型下,都能正常工作。例如,共享内存的对称多处理器通常突发式提交内存写操作,这会造成不同线程都写操作重新排序。可以通过用volatile关键字解决,将读写操作同步到易变数据(volatile data)中,但在多线程环境下也存在缺陷。
如果是Linux环境下使用POSIX线程,可以用pthread_once()。
去掉锁,选择更简单方式
针对不同编译器和平台,如果要都能正常工作,可能要做的工作过于复杂,很难调试。不妨考虑其他方案,避免使用惰性初始化,甚至避免使用锁。
1)静态初始化。静态初始化器在main函数调用之前,通常可以假定程序此时是单线程的,因此可以避免使用锁,直接创建单例的实例。不过,这种方式需要确保单例的构造函数不依赖于其他cpp文件中的非局部静态变量。
// 静态初始化,注意这段代码在函数外
static Singleton& instance = Singleton::GetInstance();
int main()
{...}
// 去掉GetInstance中的互斥锁
static Singleton& Singleton::GetInstance()
{
return instance;
}
2)在main函数多线程环境运行前初始化。原理同静态初始化。
但如果不注意,也可能导致线程不安全问题。
// 在多线程环境运行前构造单例
int main()
{
// 此时是单线程
Singleton& instance = Singleton::GetInstance();
// 运行多线程
EventLoopThreadPool pool;
pool.start();
return 0;
}
// 去掉GetInstance中的互斥锁
static Singleton& Singleton::GetInstance()
{
static Singleton instance;
return instance;
}
3)显式API初始化。如果之前不存在初始化例程,可以向库添加一个。这样可以从GetInstance()移除互斥锁。
// 初始化阶段调用一次Initialize,将互斥锁从高频调用的GetInstance()移除
static std::mutex mtx;
void Initialize()
{
std::lock_guard<std::mutex> lock(mtx);
Singleton::GetInstance();
}
// 去掉GetInstance中的互斥锁
static Singleton& Singleton::GetInstance()
{
static Singleton instance;
return instance;
}