设计模式系列之单例模式 – coderkian

单例模式是使用最广泛,也最简单的设计模式之一,作用是保证一个类只有一个实例。单例模式是对全局变量的一种改进,避免全局变量污染命名空间。因为以下几个原因,全局变量不能作为单例的实现方式:

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

本文链接:设计模式系列之单例模式,转载请注明。



You must enable javascript to see captcha here!

Copyright © All Rights Reserved · Green Hope Theme by Sivan & schiy · Proudly powered by WordPress

无觅相关文章插件,快速提升流量