拜读了论文《C++ and the Perils of Double-Checked Locking》-- Scott Meyers and Andrei Alexandrescu September 2004
发现原来一个简单的单例模型,还有如此多的文章,上面提到的这篇文章值得反复读,从中可以衍生出很多并行编程的知识。
另外,C++11的内存模型和std::memory以及原子类,是我接下来关心的重点。
姑且把这个文章叫做《线程安全的单例模型演变》吧,从最简单的讲起
class Singleton {
public:
static Singleton* instance();
private :
static Singleton* pInstance;
};
Singleton* Singleton::instance() {
if (pInstance == 0) { //(*)
pInstance = new Singleton;
}
return pInstance;
}
这种单例模式的实现,很简单,但是是存在问题的。在(*)行我们可以看到,这个 ==0 的的检测,是没有多线程互斥的。所以,一旦有多个线程同时检查 ==0 条件为true,那么每个线程都会去 new Singleton。这样结果就不对了,而且会导致内存泄露。
2、第二种实现,加锁
Singleton* Singleton::instance() {
Lock lock; // acquire lock (params omitted for simplicity)
if (pInstance == 0) { //(*)
pInstance = new Singleton;
}
return pInstance;
} // release lock (via Lock destructor)
因为第一种实现方式错误的根源在于,==0的检测没有做到多线程的互斥,所以,我们可以在新的实现方式中,我们在检测 ==0 之前加锁了。这是一种解决问题很好的方法,但是每次获取这个单例对象的时候,都要加锁,性能会严重受到影响。在程序运行的过程中,除了第一次new发生的时候,(*)行这个==0的检测结果都是false的,这个加锁,有点浪费。
看下来,这种实现存在的是性能上的问题。那我们往后面看。
3、好了,我们大名鼎鼎的 Double-Checked-Locking来了,看代码:
Singleton* Singleton::instance() {
if (pInstance == 0) { // 1st test
Lock lock;
if (pInstance == 0) { // 2nd test
pInstance = new Singleton;
}
}
return pInstance;
}
这是一种很经典的实现,因为我们对指向单例对象的指针,写了两次的==0检测。因为程序运行过程中,大部分的时候,这个指针都是 !=0的,所以这个锁就可以避免了,只有在第一次new Singleton之前,可能会发生锁竞争。一旦new完成之后,锁就不会被用到了。
愿景是美好的,但是现实是残酷的,上面的实现仍然存在问题。问题源自于CPU的乱序执行。
简单讲一下乱序执行的概念,在现代CPU的实现中,允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。注意关键字:“不安程序规定的顺序”。
对于我们上面代码中一个语句: pInstance = new Singleton; 在计算机里面会被分解为3个指令: