Fork me on GitHub

设计模式系列之单例模式

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

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

posted on 2014-09-15 21:41  coderkian  阅读(1261)  评论(2编辑  收藏  举报


作者:coderkian
出处:http://www.cnblogs.com/coderkian/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。