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();

优点

这种创建单例模式优点:

  1. 将默认构造函数声明为private,阻止编译器自动声明为public,从而阻止客户自行创建新实例;
  2. 将copy构造函数、assignment运算符设为private,可以阻止用户拷贝实例,从而创建第二个实例;
  3. 将析构函数设为private,可以禁止客户delete单例实例(有的编译器可能会报错);
  4. 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;
}
posted @ 2022-06-16 23:47  明明1109  阅读(625)  评论(0编辑  收藏  举报