单例线程安全实现、DCLP及其注意事项、饿汉懒汉实现方式

本文详细介绍了C++中实现线程安全单例模式的几种方法,包括传统的加锁实现、双重检查锁定模式(DCLP)以及C++11的局部静态变量优化。讨论了volatile关键字在多线程环境中的局限性,并提出了内存屏障作为解决缓存一致性问题的可能方案。最后,对比了懒汉式和饿汉式的优缺点,建议在多线程环境中优先考虑C++11的局部静态变量实现。
摘要由CSDN通过智能技术生成

单例设计模式

确保一个类仅存在一个实例,并提供了对此唯一实例的全局访问点。可以认为单例是一种更加优雅的全局变量。相对于全局变量,它还有其他优点:

1. 确保一个类只创建一个实例
2. 为对象分配和销毁提供控制
3. 支持线程安全地访问对象的全局状态
4. 避免污染全局名字空间

用C++实现单例

为了阻止客户自行分配、销毁、复制该类对象,将默认构造、析构、复制构造和赋值操作符都声明为私有并且不实现。

class Singleton
{
public:
	static Singleton& GetInstance();//无论返回指针还是引用,都无法访问private成员
private:
	Singleton() 
	{ 
		cout << "Singleton()" << endl; 
	}
	~Singleton() 
	{
		cout << "~Singleton()" << endl;
	}
	Singleton(const Singleton&);
	Singleton& operator=(const Singleton&);
private:
	static std::mutex m_mutex;
};

在实现方面,Scott Meyers指出,“不同编译单元中的非局部静态对象的初始化顺序是未定义的”。非局部对象指声明在函数外的对象,静态对象包括全局以及在类、函数、文件作用域内声明为静态的对象。因此,初始化单例途径之一是在类的方法中创建静态变量。

Singleton& Singleton::GetInstance()
{
    //这句话非原子操作,编译器可实现为static char __buffer[] + placement new + return *reinterpret_cast<Singleton*>(__buffer)
	static Singleton instance;
	return instance;
}

该方法优点是,仅在第一次调用时才分配实例。
但缺点是,不是线程安全。多线程环境下,2个线程同时调用该方法,实例就有可能被构造2次,或者在一个线程未初始化完成之前另一个线程就使用了该实例。正如注释所言,存在竞争条件。

使单例线程安全

同大部分处理非线程安全代码类似,在表现出竞态条件的代码前后加锁。

Singleton& Singleton::GetInstance()
{
	std::lock_guard<std::mutex> lock(m_mutex);//无论是否初始化,都会锁定。
    static Singleton instance;
	return instance;
}

但该方案问题时开销较大。每个线程访问时,即便instance已经构造完成,也要加锁去读instance的状态,加锁颗粒度过大。于是有人发明出一种双重检查锁定模式(DCLP)来优化上面激进的加锁行为。

Singleton& Singleton::GetInstance()
{
	static Singleton* instance = nullptr;
	if (!instance)//已经初始化,不用锁定了。
	{
		std::lock_guard<std::mutex> lock(m_mutex);
		if (!instance)//未初始化,才锁定。(读之前确实需要锁定,因为对于instance读写都有可能)
		{
			//编译器可实现为(1)operator new + (2)placement new + (3)pointer assignment
			// 其中(2)(3)可调换
			instance = new Singleton();
		}
	}
	return *instance;
}

但是DCLP并不能保证所有编译器和所以处理器内存模式下都能正常工作。原因在于,instance = new Singleton();可拆分为:

1. 用operator new在堆上非配一块内存
2. 用placement new在刚刚分配的内存上调用构造函数以构造对象
3. 将已分配内存的指针赋予instance

在此3步中,2和3和可以调换的。这取决于编译器的实现或者CPU动态调度换序(尤其在多处理器环境下)。一旦一个A线程先执行3后执行2,另一个线程B在A之行为3后(还未执行2进行构造)发现instance已经非空,便不加锁直接访问,势必造成问题。
对于编译器而言,为了提高速度可能将一个变量缓存到寄存器而不写回,也可能为了效率交换2条不相干的相邻指令。那么使用volatile看似可以完美解决问题,volatile作用如下:

1. 阻止编译器对volatile变量进行优化,每次读写必须从内存里获取。
2. 阻止编译器调整volatile变量的指令顺序。

看似万无一失其实不然。volatile只能阻止编译器调整顺序,却无法阻止CPU动态调度换序。
在某些编译器中使用volatile可以达到内存同步的效果。但必须记住,这不是volatitle的设计意图,也不能通用地达到内存同步的效果。volatitle的语义只是防止编译器“优化”掉对内存的读写而已。它的合适用法,目前主要是用来读写映射到内存地址上的IO操作。由于volatile 不能在多处理器的环境下确保多个线程看到同样顺序的数据变化,在今天的通用程序中,不应该再看到volatitle的出现。

多处理器环境下,每个处理器都有各自的高速缓存,但所有处理器共享内存空间。这种架构需要设计者精确定义一个处理器该如何向共享内存执行写操作,又何时执行读操作,并使这个过程对其他处理器可见。我们很容易想象这样的场景:当某一个处理器在自己的高速缓存中更新的某个共享变量的值,但它并没有将该值更新至共享主存中,更不用说将该值更新到其他处理器的缓存中了。这种缓存间共享变量值不一致的情况被称为缓存一致性问题(cache coherency problem)。

假设处理器A改变了共享变量x的值,之后又改变了共享变量y的值,那么这些新值必须更新至内存中,这样其他处理器才能看到这些改变。然而,由于按地址顺序递增刷新缓存更高效,所以如果y的地址小于x的地址,那么y很有可能先于x更新至主存中。这样就导致其他处理器认为y值的改变是先于x值的。对DCLP而言,将上述2,3步骤交换所导致的问题上面已经分析过了。

为了解决这一问题,通常方法是在可能换序的指令中插入内存屏障。该屏障可能基于某CPU架构所提供,而C++98语言本身由于缺乏对并发的支持,或多或少在可移植性兼容性有所欠缺,直到C++11才有更为通用的内存模型供使用。

Singleton& Singleton::GetInstance()
{
	static Singleton* instance = nullptr;
	if (!instance)
	{
		std::lock_guard<std::mutex> lock(m_mutex);
		if (!instance)
		{
                        Singleton* tmp = new Singleton();
                        barrier();//内存屏障
			instance = tmp;
		}
	}
	return *instance;
}

或者在C++11中,局部静态变量本身就是线程安全的,能够确保只在初次调用GetInstance()时候创建一次实例(推荐方法)。

class Singleton
{
public:
    static Single &GetInstance();

private:
    Singleton();
    ~Singleton();
    Singleton(const Singleton &signal);
    const Singleton &operator=(const Singleton &signal);
};

Single &Single::GetInstance()
{
    // 局部静态方式实现单例
    static Single signal;
    return signal;
}

而上述在客户调用GetInstance()时才生成实例的方式成为懒汉式。由于加锁导致性能的损失以及编译器和平台多样性导致上述难以调试的时效问题,可以考虑使用饿汉式。顾名思义,如确保在main()之前调用GetInstance(),通常这时程序仍是单线程的,从而避免了使用互斥锁的需求。

static Singleton& foo = Singleton::GetInstance();

int main()
{
    ...
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值