设计模式之一、单例模式及多线程安全

前言

这是在头条客户端面试的时候提到的,当时只知道单例模式保证对象唯一,没有考虑实际使用中会发生什么,面完了认真了解下“单例模式”,做下总结。

另外,面试和平时准备的东西还是有区别的,平时准备的可能比较基础(概念为主),面试更多是这些概念在实际使用中能否解决对应的问题,并是否会引入其他的问题等。

实际使用中,均是多进程、多线程编程为主,因此进程之间的通信(IPC),线程之间的同步很重要,在思考问题的时候,一定要考虑当前问题的解法,是否“多进程或多线程安全”,如果不安全,是否有解决方法来保证当前算法达到“多进程或多线程安全”的要求。

单例模式

单例模式

单例模式确保一个类只有一个对象,并提供一个全局访问点。

实现思路

  1. 构造函数设为私有
  2. 使用static定义对象指针,定义类的get_instance成员函数。

具体实现(Lazy Initlization)

按照以上的规则和要求,实现了一下,这是“Lazy Initlization”实现方式,将对象的生成推迟到第一次访问的时候

//1. Lazy initlization.
class Singleton{
public:
    static Singleton * get_instance(){
		if(instance_ == nullptr){
        	instance_ = new Singleton();
    	}
    	return instance_;
	};
protected:
    Singleton(){};
private:
    static Singleton * instance_;

};
Singleton * Singleton::instance_ = nullptr;

void test1(){
    Singleton * p1 = Singleton::get_instance();
    Singleton * p2 = Singleton::get_instance();

    printf("p1 addr : %p\n", *p1);
    printf("p2 addr : %p\n", *p2);
}

int main() {
	test1();
	
	return 0;
}

上面的代码中,test1函数中对象的地址输出均一样,说明访问的对象是唯一的

在单线程的情况下,该单例模式的实现是安全可用的

但是多线程环境中,尤其是在初始化的这段代码中:

static Singleton * Singleton::get_instance(){
    if(instance_ == nullptr){
        instance_ = new Singleton();
    }
    return instance_;
}

两个线程可能同时执行到if(instance ==nullptr)if(instance_\ == nullptr)这句,由于均是首次访问,条件都成立,然后都进行了对象的实例化,导致进程中有该类有多个对象

多线程安全的单例模式

而解决上述问题,最简单粗暴的方法是加锁,在每次判断的时候确保只有一个线程在执行该语句

//2. lazy initlization + mutex
static mutex m_;
static Singleton * Singleton::get_instance(){
	m_.lock();
    if(instance_ == nullptr){
        instance_ = new Singleton();
    }
    m_.unlock();
    return instance_;
}

然而这种加锁方法在每次判断前都会进行一次加锁,会极大的增加系统的开销

于是有人提出了“双检锁”的概念,相较于之前,增加了一次判断,并将“加锁”操作放到了第一次判断成立之后

//2.1 lazy initlization + double-check
static mutex m_;
static Singleton * Singleton::get_instance(){
	
    if(instance_ == nullptr){
    	m_.lock();
    	if(instance_ == nullptr){
			instance_ = new Singleton();
		}
        m_.unlock();
    }
    return instance_;
}

在线程较多的情况下,“双检锁”能明显的降低了“加锁”带来的开销

但这样真的就线程安全了么?

new 背后的操作

我们再来看看单例模式中最核心的一条语句:

if(instance_ == nullptr){
    	m_.lock();
    	if(instance_ == nullptr){
			instance_ = new Singleton();
		}
        m_.unlock();
    }

其中,instance=newSingleton();instance_ = new Singleton(); 用new实例化了一个对象,而new本身隐含了一下几个操作:

  1. 按照类的大小,申请对象的内存区域
  2. 执行类的构造函数
  3. 将内存区域的首地址返回给instance_

其中,2, 3在执行的时候可能会颠倒,即先返回地址,再执行构造函数

考虑这样的场景:

单例模式还未实例化,A线程先进入getinstanceget_instance函数,并加锁,执行instance=newSingleton();instance_ = new Singleton();这句的时候,new中的操作顺序是1, 3, 2,即地址返回,构造函数还未执行

但这种情况下,instance_ 已经不为空,另一个线程B在进行if(instance==nullptr)if(instance_ == nullptr)判断时,会直接得到instance_,并使用。

而此时线程A中的new还没来得及执行类的构造函数,所以线程B在使用instanceinstance指针的时候一定会出现问题。

由此,这种方式实现的“双检锁”并不能真正达到多线程安全

C++11中的双检锁

仔细思考下new背后的三个步骤,如果按照1, 2, 3的顺序执行的话,并不会发生问题。

再进一步的思考,这里实际上违反了"多线程操作的原子性",如果将1, 2, 3步骤按顺序封装成一个原子操作,即可解决问题。

C++11中的新特性能很好的解决这个问题,而且不止有一种解决方法,这里先提一种:

C++11原子操作

C++11中引入了原子操作,在多个线程中对这些类型的共享资源进行操作,编译器将保证这些操作都是原子性的。

对以上的代码修改如下:

//3. base 1. implemetation, use atomic.
class Singleton {
public:
	static Singleton * get_instance()
	{
		Singleton * temp = instance_.load();
		if(temp == nullptr) {
			m_.lock();
			temp = instance_.load();

			if(temp == nullptr) {
				temp = new Singleton();
				instance_.store(temp);
			}
			m_.unlock();
		}
		return temp;
	};
protected:
	Singleton() {};
private:
	static atomic<Singleton*> instance_;
	static mutex m_;
};

atomic<Singleton *> Singleton::instance_;
mutex Singleton::m_;

这里使用使用atomic来保证instance_操作时的原子性。

静态对象

我们分析下,上述发生的线程安全问题均是在初始化的时候,自然地,如果在进程刚开始的时候便生成对象,生命周期贯穿整个进程的周期,似乎可避免这个问题。

如果我们把单例对象类型设为static,这样唯一的对象会被分配在数据区,而不是new之后的堆区,便能达到以上的效果。

这里将对象定义为局部静态变量

这里还可能存在一个问题:static保证了对象的唯一性,但多线程的环境下,static修饰的对象可能会被初始化多次,这种情况怎么办?

万幸的是,C++11的新特性解决了我们这个顾虑。

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
如果当变量在初始化的时候,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。

换言之,局部静态变量只会被初始化一次

//4
class Singleton {
public:
	static Singleton & get_instance()
	{
		static Singleton instance;
		return instance;
	};
protected:
	Singleton() {};
};
//使用
void get_singleton_instance()
{
	Singleton & p = Singleton::get_instance();
	
	printf("instance addr : %p\n", &p);
}

多线程安全问题

写到这里就告一段落了,总结一下上面遇到的多线程问题:

  1. 多线程对共享资源的判断(一定要加锁进行访问判断,然后再进入临界区
  2. 访问被释放的资源(当一个线程对资源进行访问的时候,一定要确保该资源存在(因为别的线程可能将其资源释放)
  3. 对临界资源操作时,一定要保证操作的原子性

参考

这里只是简单总结了下单例模式以及常规的实现,没有涉及到生产环境中的具体使用(如,单例类被其他类继承该怎么办,使用中如何实现可靠的运算符重载等…),

想要更深一步了解单例模式的可以参考下面的资料。

  1. 面试中的Singleton,强烈推荐
  2. C++单例模式总结与剖析
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章