前言

《单例模式学习》中曾提到懒汉式DCLP的单例模式实际也不是线程安全的,这是编译器的指令重排导致的,本文就简单讨论一下指令重排对单例模式的影响,以及对应的解决方法。

指令重排简介

指令重排(Instruction Reordering)是编译器或处理器为了优化程序执行效率而对程序中的指令序列进行重新排序的过程。这种重排可以发生在编译时也可以发生在运行时,目的是为了减少指令的等待时间和提高执行的并行性。

指令重排可能会引入并发程序中的一些问题,特别是在多线程环境中,没有适当同步机制的情况下,可能会导致程序的执行结果不符合预期。

下面介绍指令重排在单例模式中的影响

指令重排对单例模式的影响

首先回顾一下懒汉式DCLP单例模式的代码

class CSingleton
{
public:
    static CSingleton* getInstance();

private:
    CSingleton()
    {
        std::cout<<"创建了一个对象"<<std::endl;
    }
    CSingleton(const CSingleton&) = delete;
    CSingleton& operator=(const CSingleton&) = delete;
    ~CSingleton()
    {
        std::cout<<"销毁了一个对象"<<std::endl;
    }
    static CSingleton* instance;  
    static std::mutex mtx;
};

CSingleton* CSingleton::instance; 
 
CSingleton* CSingleton::getInstance()
{
    mtx.lock();    
    if(nullptr == instance)
    {
        instance = new CSingleton();
    }
    mtx.unlock();    
    return instance;
}

注意这一句:

instance = new CSingleton();    //并非一个原子操作,不是可重入函数

instance的初始化其实做了三个事情:

  • ①内存分配:为CSingleton对象分配一片内存
  • ②对象构造:调用构造函数构造一个CSingleton对象,存入已分配的内存区
  • ③地址绑定:将指针instance指向这片内存区(执行完这步instance才是非 nullptr)

但是由于指令重排,编译器会将顺序改变为:

instance = //步骤三
operator new(sizeof(CSingleton));//步骤一
new(instance)CSingleton;//步骤二

现在考虑以下场景:
1.线程A进入getInstance(),判断instance为空,请求加锁,然后执行步骤一和三组成的语句,之后A被挂起。此时instance为非空指针(指向了一块内存),但instance指向内存里面的CSingleton对象还未被构造出来。
2.线程B进入getInstance(),判断instance非空(因为在A线程中instance已经为非空指针了),直接返回instance。之后用户使用该指针访问CSingleton对象,嘿!您猜怎么着,这个CSingleton对象还没被构造出来呢。

总的来说,只有步骤一和二在三前面执行,DCLP才有效

改进方法

std::call_once和std::once_flag

std::call_once配合std::once_flag确保了instance = new CSingleton()只会被执行一次,无论它被多少个线程访问。这避免了指令重排在多线程下导致的问题。

class CSingleton
{
private:
	...
public:
    static CSingleton* getInstance();   
private:
    static CSingleton* instance;
    static std::once_flag onceFlag;
}

CSingleton* CSingleton::instance;

std::once_flag CSingleton::onceFlag;

CSingleton* CSingleton::getInstance()
{
	    /*
	    call_once和once_flag保证了多线程下仅有一个线程可以执行该函数,因此无需手动加锁
	    而且当 std::call_once 被多次调用时(无论是由同一个线程还是不同的线程)
	    只有第一次调用会执行传递给它的函数
	    所有随后的调用,都不会再次执行该函数
	    */
	    std::call_once(onceFlag,[](){instance = new CSingleton();});
        return instance;
}

std::atomic和内存顺序

class CSingleton
{
private:
	...
public:
    static CSingleton* getInstance()
    
private:
    static std::atomic<CSingleton*> instance;
    static std::mutex mtx;
}

std::atomic<CSingleton*> CSingleton::instance;

std::mutex CSingleton::mtx;

CSingleton* CSingleton::getInstance()
{
    //核心框架还是双检查
	//保证了这个读操作之后发生的读写操作不会被重排到这个操作之前
    CSingleton* tmp = instance.load(std::memory_order_acquire);
    if (nullptr == tmp) 
    {
           std::lock_guard<std::mutex> lock(mtx);
           //再次获取,检查是否有其他线程在获取锁的过程中创建了实例
           tmp = instance.load(std::memory_order_relaxed);
           if (nullptr == tmp) 
           {
               tmp = new CSingleton();
               //保证了在这个写操作之前的所有操作都不会被重排到这个操作之后
               //确保了实例完全构造好之后,其他线程通过 `instance` 读取到的值是最新的
               instance.store(tmp, std::memory_order_release);
           }
    }
       return tmp;
}

局部静态变量

最后,害得是局部静态变量形式的单例模式,大道至简!

static CSingleton& getInstance() 
{
    static CSingleton instance;
    return instance;
}

具体原因见:《单例模式学习》

总结

本文讨论了指令重排对多线程下的单例模式的影响,并例举了几个解决方案。后面可能还会更新别的解决方案

参考文章

1.C++ and the Perils of Double-Checked Locking
2.Double-Checked Locking is Fixed In C++11