设计模式系列之单例模式

单例模式是使用最普遍,也最简单的设计模式之一,做用是保证一个类只有一个实例。单例模式是对全局变量的一种改进,避免全局变量污染命名空间。由于如下几个缘由,全局变量不能做为单例的实现方式:c++

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)产生了。ui

DCLP的经典实现以下:atom

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

相关文章
相关标签/搜索