用C++实现单例模式2——线程安全

 上篇文章提及到单例的懒汉模式是线程不安全的,会产生race condition从而产生多次初始化的情况。要想在多线程下工作,最容易想到的就是用锁来保护共享变量了。下面是伪代码:

template<typename T>
class Singleton
{
public:
static T& getInstance()
{
{
    MutexGuard guard(mutex_) // RAII
    if (!value_)
    {
        value_ = new T();
    }
}
    return *value_;
}
private:
    Singleton();
    ~Singleton();
    static T* value_;
    static Mutex mutex_;
};
template<typename T>
T* Singleton<T>::value_ = NULL;
template<typename T>
Mutex Singleton<T>::mutex_;
  这样在多线程下就能正常工作了。但是多个线程调用getInstance的时候,线程A第一次进入临界区,发现value_为NULL值就创建对象实例返回,接着线程B进入临界区获得锁,同时线程C也 调用 getInstance,因为临界区被线程B占有,所以线程C被阻塞等待。但是线程B和线程C只是读操作,读操作是不需要加锁,所以对读操作加锁是浪费的,加锁的代码过高,特别是在高并发的情景下,这个锁的代价是非常高的。这个时候,为了解决这个问题,DCL写法就被聪明的先驱者发明了。

  DCL即double-checked locking。在普通加锁的写法中,每次调用getInstance都会进入临界区,这样在heavy contention的情况下该函数就会成为系统性能的瓶颈,这个时候就有先驱者们想到了DCL写法,也就是进行两次check,当第一次check为空时,才加锁,再进行第二次check,再为空才创建。

template<typename T>
class Singleton
{
public:
    static T& getInstance()
    {
        if(!value_)
        {
            MutexGuard guard(mutex_);
            if (!value_)
            {
                value_ = new T();
            }
        }
        return *value_;
    }
private:
    Singleton();
    ~Singleton();
    static T* value_;
    static Mutex mutex_;
};

template<typename T>
T* Singleton<T>::value_ = NULL;

template<typename T>
Mutex Singleton<T>::mutex_;

    有人会问为什么还要第二次check呢?假如不要第二次check,线程A和线程B都进行了第一次check,线程A先获得锁就创建对象实例,之后线程B已经check为空,那么线程B也创建了对象实例,这样就有两个对象实例,破坏了唯一性。
      在相当长的一段时间内,大家都以为这段代码很完美,直到1999年某专家发现这代码有漏洞,由于内存读写reorder不安全,会导致双检查锁失效。问题出在value_ = new T();这行代码。每行代码都它的指令序列,通常大家以为指令序列会按照想像的那样执行,但实际上代码到汇编层次指令序列有可能和我们的假设的不一样,还有线程之间的切换是在指令层次进行的。
     我们先看看第12行value_ = new T这一句发生了什么:
        1.分配了一个T类型对象所需要的内存。
        2.在分配的内存处构造T类型的对象。
        3.把分配的内存的地址赋给指针value_
    主观上,我们会觉得计算机在会按照1、2、3的步骤来执行代码,但是问题就出在这。实际上只能确定步骤1最先执行,经过reorder后可能是先执行步骤3最后才步骤2。假如某一个线程A在调用getInstance的时候第12行的语句按照1、3、2的步骤执行,那么当刚刚执行完步骤3的时候发生线程切换,计算机开始执行另外一个线程B。因为第一次check没有上锁保护,那么在线程B中调用getInstance的时候,不会在第一次check上等待,而是执行这一句,那么此时value_已经被赋值了,就会直接返回*value_然后执行后面使用T类型对象的语句,但是在A线程中步骤3还没有执行!也就是说在B线程中通过getInstance返回的对象还没有被构造就被拿去使用了!这样就会发生一些难以debug的灾难问题。
        C#使用volatile关键字来解决这个问题,告诉编译器不要reorder这行代码,严格按照1、2、3的步骤来执行代码。
   关于DCL问题的详细讨论分析,可以参考Scott Meyer的paper:《C++ and the Perils of Double-Checked Locking》: http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf
        不过在新的C++11中,这个问题得到了解决。因为新的C++11规定了新的内存模型,保证了执行上述3个步骤的时候不会发生线程切换,相当这个初始化过程是“原子性”的的操作,DCL又可以正确使用了,不过在C++11下却有更简洁的多线程Singleton写法了,这个留在下一篇再介绍。
        在C++11之前的版本下,要使其正确执行的话,就得在步骤2、3直接加上一道memory barrier(内存屏障),强迫CPU执行的时候按照1、2、3的步骤来运行,还需要用RCU技法,即read-copy-update(读拷贝更新机制)。
        关于 内存屏障的详细概念参见 :
         还有read-copy-update(读拷贝更新机制)的概念参见:
        以下是使用 内存屏障技术来实现DCLP的代码:
static T& getInstance()
{
    if(!value_)
    {
        MutexGuard guard(mutex_);
        if (!value_)
        {
            T* p = static_cast<T*>(operator new(sizeof(T)));
            new (p) T();
            // insert some memory barier
            value_ = p; // RCU method
        }
    }
    return *value_;
}
   也许有人会说,你这已经把先前的value_ = new T()这一句拆成了下面这样的两条语句, 为什么还要在后面插入some memory barrier?

T* p = static_cast<T*>(operator new(sizeof(T)));
new (p) T();

    原因是现代处理器都是以Out-of-order execution(乱序执行)的方式来执行指令的。现代CPU基本都是多核心的,一个核包含多个执行单元。例如,一个现代的Intel CPU 包含6个执行单元,可以做一组数学,条件逻辑和内存操作的组合。每个执行单元可以做这些任务的组合。这些执行单元并行地操作,允许指令并行地执行。如果从其它 CPU 来观察,这引入了程序顺序的另一层不确定性。
    如果站在单个CPU核心的角度上讲,它(一个CPU核心)看到的程序代码都是单线程的,所以它在内部以自己的“优化方式”乱序、并行的执行代码,然后保证最终的结果和按代码逻辑顺序执行的结果一致。但是如果我们编写的代码是多线程的,当不同线程访问、操作共享内存区域的时候,就会出现CPU实际执行的结果和代码逻辑所期望的结果不一致的情况。这是因为以单个CPU核心的视角来看代码是“单线程”的。
    所以为了解决这个问题,就需要memory barrier了,利用它来强迫CPU按代码的逻辑顺序执行。例如上面改动版本的getInstance代码中,因为第10行有memory barrier,所以CPU执行第9、10、11按“顺序”执行的。即使在CPU核心内是并行执行指令(比如一个单元执行第9行、一个单元执行第11行)的,但是他们在退役单元(retirement unit)更新执行结果到通用寄存器或者内存中时也是按照9、10、11顺序更新的。例如一个单元A先执行完了第11行,CPU让单元A等待直到执行第9行的单元B执行完成并在退役单元更新完结果以后再在退役单元更新A的结果。
    memory barreir是一种特殊的处理器指令,他指挥处理器做下面三件事
        *刷新store buffer。
        *等待直到memory barreir之前的操作已经完成。
        *不将memory barreir之后的操作移到memory barreir之前执行。
    通过使用memory barreir,可以确保之前的乱序执行已经全部完成,并且未完成的写操作已全部刷新到主存。因此,数据一致性又重新回到其他线程的身边,从而保证正确内存的可见性。实际上,原子操作以及通过原子操作实现的模型(例如一些锁之类的),都是通过在底层加入memory barrier来实现的。
    至于如何加入memory barrier,在unix上可以通过内核提供的barrier()宏来实现。或者直接嵌入ASM汇编指令mfence也可以,barrier宏也是通过该指令实现的。
    关于memory fence,不同的CPU,不同的编译器有不同的实现方式,要是直接使用还真是麻烦,不过,c++11中对这一概念进行了抽象,提供了方便的使用方式 。




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

X-Programer

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值