AlexiaChen / AlexiaChen.github.io

My Blog https://github.com/AlexiaChen/AlexiaChen.github.io/issues
88 stars 11 forks source link

单例模式的线程安全 #39

Closed AlexiaChen closed 1 year ago

AlexiaChen commented 4 years ago

title: 单例模式的线程安全 date: 2017-02-27 15:48:40 tags:

在写单例模式的时候,一般我们都需要保证这个单例类的线程安全,当然,网络上有大部分“解决方案了”,加锁和双重检查锁配合来“保证”单例类的线程安全,可是,如果把指令重排序也考虑到其中的话,这样的写法,就是非线程安全了。

随便列出网络上几篇博文的单例模式都不是线程安全的:

以下是网络上大部分的“经典”的“线程安全”单例模式实现:

@ actually not thread safe
class singleton
{

private:
    static singleton* p{nullptr};
public:
    static std::mutex m_mutex;
    static singleton* getInstance();
public:
    singleton(const singleton& s) = delete;
};

singleton* singleton:: getInstance()
{
    // double-check locker
    if (p == nullptr) // first check
    {

        {
            std::lock_guard<std::mutex> lock(m_mutex); // lock
            if (p == nullptr)   // second check
                p = new singleton(); // but this may cause non-thread-safe
        }

    }
    return p;
}

首先,先讲解一下,指令重排会发生在系统的好几个层面,我分别用C++和Java来讲解:

所以了,到头来,指令重排是不可能消除的,这是编译器和CPU优化的领域,就不进行过多的探讨。

下面来讲解一下以上的单例类的代码,以及为什么它在指令重排序的情况下会变成非线程安全:

我上面把代码的注释写了,问题就出现在new 那里,熟悉C++的开发者可能知道new语句的大概执行动作,我把它分解成如下几步:

  1. 分配对象的内存空间,可以简单理解为malloc
  2. 调用对象的构造函数初始化对象(内存空间上的对象状态此刻是合法的了)
  3. 把内存空间的地址赋值给p指针

注意了,如果编译器生成的代码,和CPU内部的执行顺序永远是按以上的顺序执行,那么永远都不用担心线程安全的问题,但是由于happens-before语义,2和3步骤没有严格的依赖顺序,编译器有些时候为了优化,完全可以把3放到2时候执行,2也会放到3时候执行。那么这样情况下,可以就会变成下面这样了:

  1. 分配对象的内存空间,可以简单理解为malloc
  2. 把内存空间的地址赋值给p指针 (此刻内存空间上的对象状态不合法,没初始化)
  3. 调用对象的构造函数初始化对象(内存空间上的对象状态此刻是合法的了)

还想深入了解happens-before的,可以看这里

那么,指令重排下,以上的单例代码可能变成以下:

@ actually not thread safe
class singleton
{

private:
    static singleton* p{nullptr};
public:
    static std::mutex m_mutex;
    static singleton* getInstance();
public:
    singleton(const singleton& s) = delete;
};

singleton* singleton:: getInstance()
{
    // double-check locker
    if (p == nullptr) // first check
    {

        {
            std::lock_guard<std::mutex> lock(m_mutex); // lock
            if (p == nullptr)   // second check
             {
                    //以下是抽象出来的伪代码
                    memory_addr = malloc();   //1:分配对象的内存空间  
                    p = memory_addr;     //2:设置p指向刚分配的内存地址  
                                 //注意,此时对象还没有被初始化!  
                    singleton_constructor(memory);  //3:初始化对象  
             }
        }

    }
    return p;
}

聪明的人,一下子就可以看出来以上的代码在多线程下可能会导致的问题了, 还不明白的话,我做了一个线程执行时间表:

时间序列 线程A 线程B
t1 分配对象的内存空间
t2 设置p指向内存空间
t3 判断p是否为nullptr
t4 由于p不为nullptr,线程B将访问p指向的对象(而这个时候对象还没有初始化)
t5 初始化对象(调用构造函数)
t6 访问p指向的对象

线程B拿到一个未初始化的对象(对象状态不合法)去操作,结果肯定就出错了。

那么如何写才能保证线程安全呢? 我给出几种方案:

@ thread safe
// 以下代码虽然是C++ 11的,但是你可以把它理解非C++ 11,如果是C++ 11的话,从标准上就保证 静态初始化就是线程安全的,完全没必要像下面这样做了
class singleton
{

private:
    static atomic < singleton* > p{nullptr};
public:
    static std::mutex m_mutex;
    static singleton* getInstance();
public:
    singleton(const singleton& s) = delete;
};

singleton* singleton:: getInstance()
{
    // double-check locker
    if (p == nullptr) // first check
    {

        {
            std::lock_guard<std::mutex> lock(m_mutex); // lock
            if (p == nullptr)   // second check
             {
                    p = new singleton(); 
             }
        }

    }
    return p;
}

如果是C++ 11的代码,完全没必要这么繁琐了,可以直接像下面这么干:

// C++ 11标准保证这是线程安全的,当然也得看编译器产商怎么实现了,比如悲剧的是
// VS2013下这么做是非线程安全的,但VS2015下绝对是线程安全
// 参考: https://msdn.microsoft.com/en-gb/library/hh567368.aspx
singleton*& getInst()
{
    static singleton* p = new singleton();
    return p;
}

详情请戳这里

如果是Java代码,建议按以下这么干,保证线程安全:

public class singleton {
    private static class Holder {
        static final singleton INSTANCE = new singleton();
    }

    public static singleton getInstance() {
        return Holder.INSTANCE;
    }
    // rest of class omitted
}

以上Java代码完全不需要加锁,Java的类加载器已经保证必定会在访问类的时候,最先初始化singleton。

详情请戳这里这里

EOF