C++中线程安全并且高效的singleton

Singleton是一个非常常用的设计模式。几乎所有稍大的程序都会用到它。所以构建一个线程安全,并且高效的singleton很重要。既然要讨论这个主题,我们就先来定义一下我们的需求:

  1. Lazy initialization。只有在第一次使用的时候才需要初始化出一个singleton对象。这使得程序不需要考虑过多顺序耦合的情况。同时也避免了启动时初始化太多不知道什么时候才会用到的东西。
  2. 线程安全。多个线程有可能同时调用singleton。如果只需要单线程,那实在没什么需要讨论的。
  3. 高效。因为singleton会被反复调用,如果效率低的话浪费太大了。
  4. 通用。适合现有的各种平台,以及未来可能出现的平台。

有了这些需求,我们就可以开始讨论如何构造这么一个singleton。下面以C++为基础来解析这个问题。

原始版本

在《设计模式》一书中,给出了singleton的基本结构:

1
2
3
4
5
T& Singleton()
{
    static T instance;
    return instance;
}

这个实现是Lazy initialization(需求1),并且高效(需求3)和通用(需求4)。但不是线程安全的(需求2)。因为在C++98中,并没有任何关于多线程的概念。所以也并没有定义如果多个线程同时初始化一个static局部变量会出现什么。

改进1:加锁

最简单的线程安全改进就是加个锁:

1
2
3
4
5
6
7
8
std::mutex m;

T& Singleton()
{
    std::unique_lock lock(m);
    static T instance;
    return instance;
}

这个实现是Lazy initialization(需求1),并且是线程安全(需求2)和通用(需求4),但它并不高效(需求3)。因为这不但有lock/unlock的开销,还相当于把所有的调用都串行化了。对于频繁调用的情况来说不是个好事。

改进2:双重检查

一个广为人知的改进就是双重检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
T* instance = nullptr;
std::mutex m;

T& Singleton()
{
    if (nullptr == instance)
    {
        std::unique_lock lock(m);
        if (nullptr == instance)
        {
            instance = new T;
        }
    }
    return *instance;
}

这样的双重检查是lazy initialization(需求1),并比上一个实现提高了效率(需求3),因为除了第一次调用的时候需要加锁之外,其他都可以并行判断和返回。但是,这么做真的能线程安全吗(需求2)?这么做真的通用吗(需求2)?

在现代的编译器和CPU上,为了提高性能,真正的执行顺序和高级语言中的已经不一致了。双重检查也不例外。编译器生成的机器码会对内存读写指令进行重新排序,CPU的乱序执行会进一步改变内存读写指令的执行顺序。因为有了这些乱序的存在,instance = new T这一行并不能一次执行完。比如,instance = new T可以分解成3个操作,申请内存、赋值给instance、调用构造函数。后两个操作是以什么方式进行的,只有编译器能决定。如果先赋值给是,再调用构造函数,那么另一个线程同时调用了Singleton()的话,就会发现instance已经有值了,直接使用。但因为这时候还没有在instance上调用构造函数,对象还没被建立出来,这样就会引发严重的问题。

改进3:加上barrier

VC提供了一些intrinsic,以提示编译器和CPU内存读写的顺序。用了这些intrinsic之后,就能保证编译器和CPU不会制造这些混乱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
T* instance = nullptr;
std::mutex m;

T& Singleton()
{
    if (nullptr == instance)
    {
        std::unique_lock lock(m);
        if (nullptr == instance)
        {
            T* tmp = new T;
            MemoryBarrier();
            instance = tmp;
        }
    }
    return *instance;
}

有了MemoryBarrier(),就能保证到instance = tmp那行的时候,前面的new T一定已经完成。这样就是线程安全的了(需求2)。同时它也有双重检查的有点,lazy initialization(需求1)和高效(需求3)。那么,通用(需求4)吗?

很遗憾的是,在现有的x86、x64和ARM的内存模型上,这么做是可以的。但在某些很弱的内存模型上,比如已死多时的Alpha,这么做照样不行。未来的平台仍可能出现这样的弱内存模型,所以不得不防。在Alpha上,instance的值被放入寄存器之后,即便它后来被另一个线程赋值了,CPU也不打算再次读取这个地址。所以第二个if仍会通过,以至于new T再次被执行,于是singleton不再是singleton。

有人会问,volatile似乎就是为了解决这样的问题的啊,是不是在这里声明成volatile T* instance就行了?确实,volatile是让编译器生成代码的时候不做假设优化,所以两次读取instance会真的让编译器生成2次读取指令。但Alpha这种情况是CPU偷懒了,即便编译器让它读两次,它都不会就范。所以volatile无法解决这个问题。当然,应该还可以用Alpha的特殊指令来强制CPU再次读取某个变量,但这么做就会让程序不在通用。另外,volatile最多也就是保证那个变量自身不会被错误假设,但无法保证变量之间的读写顺序。尤其是,volatile变量和非volatile变量之间的读写顺序是完全无保证的。

改进4:atomic

Boost引入了atomic库,同时这也被放入C++11的标准库中。它不但提供了原子增减等计算,并且在实现上要求能保证编译器生成代码的读写顺序和CPU执行的读写顺序。也就是说,可以简单地用atomic来实现双重检查singleton。下面用到C++11的代码也可以用boost替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::atomic<T*> instance;
std::mutex m;

T& Singleton()
{
    if (nullptr == instance)
    {
        std::unique_lock lock(m);
        if (nullptr == instance)
        {
            instance = new T;
        }
    }
    return *instance;
}

反而变简单了,所有barrier的细节都放到了atomic里,又编译器附带的库来实现。这是第一个能满足上述4个需求的singleton实现。

就到此为止了?不。atomic在判断上还是有开销的,就这么直接使用会让那几行语句都顺序执行。前面提到了,编译器和CPU都倾向于乱序执行,为的是提高效率。都强制了顺序结果会没那么高效了。所以我们还应该能进一步改进。

改进5:更精确地控制

atomic的那些重载操作符因为没法知道用户会如何调用它们,所以只能做非常粗略的假设,也就是在前后都加上了barrier。实际上我们这里需要的只是一个方向的barrier(第一个if那行,读取instance之后加一个读barrier;以及new之后加一个写barrier),而不是两个方向。好在atomic提供了一套精确控制barrier方向的方法,可以用来改进这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::atomic<T*> instance;
std::mutex m;

T& Singleton()
{
    T* tmp = instance.load(std::memory_order_consume);
    if (nullptr == tmp)
    {
        std::unique_lock lock(m);
        tmp = instance.load(std::memory_order_relaxed);
        if (nullptr == tmp)
        {
            instance.store(new X(), std::memory_order_release);
        }
    }
    return *instance;
}

consume表示后面的读写不会被重排到这次load之前。release表示前面的读写不会被重排到这次store之后。relaxed表示无所谓顺序。这样就保证了在barrier最少的情况下达到目的。当然,这么做的前提仍然是库的实现要对。

改进6:返朴归真

因为编译器的原因,把singleton搞得这么复杂。能不能让编译器做点什么呢?能!C++11标准中特别提到了这种状况:

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.

换句话说,在多线程同时调用的情况下static只会被初始化一次。也就是说,对于一个符合这个要求的C++11编译器来说,只需要基本结构就可以了。

1
2
3
4
5
T& Singleton()
{
    static T instance;
    return instance;
}

是不是有一种返朴归真的感觉?因为编译器保证了static的行为,这里就完全不用担心多线程带来的内存泄漏或重复初始化。所以也不必要用那套反复的方法保证顺序了。当然,在编译器里用的就是全面提到的方法来保证static的行为。

目前实现了这个要求的C++编译器有,VC14(VS2015)和GCC 4.0以上。其他编译器暂时没查到资料。所以安全起见,这里最好用一个#ifdef,对于支持新static的用上改进6的方法,否则用改进5的方法。

总结

好了,目前为止我们得到了两个满足几乎所有singleton需求的实现。从此不必再拘泥于各种强制顺序、低效、不安全、平台相关的singleton了。



另外一篇文章:

想必每一位程序员都对设计模式中的单例模式非常的熟悉吧,以往我们用C++实现一个单例模式需要写以下代码:

 
 
 1 class CSingleton
 2 {
 3 private:
 4     CSingleton() //构造函数是私有的
 5     {
 6     }
 7     static CSingleton *m_pInstance;
 8 public:
 9     static CSingleton * GetInstance()
10     {
11         if (m_pInstance == NULL) //判断是否第一次调用
12             m_pInstance = new CSingleton();
13         return m_pInstance;
14     }
15 };
 
当然,这份代码在单线程环境下是正确无误的,但是当拿到多线程环境下时这份代码就会出现race condition,因此为了能在多线程环境下实现单例模式,我们首先想到的是利用同步机制来正确的保护我们的shared data,于是在多线程环境下单例模式代码就变成了下面这样:
 
 
 1 class CSingleton
 2 {
 3 private:
 4     CSingleton() //构造函数是私有的
 5     {
 6     }
 7     static CSingleton *m_pInstance;
 8     mutex mtx;
 9 public:
10     static CSingleton * GetInstance()
11     {
12         mtx.lock();
13         if (m_pInstance == NULL) //判断是否第一次调用
14             m_pInstance = new CSingleton();
15         mtx.unlock();
16         return m_pInstance;
17     }
18 };
 
正确是正确了,问题是每次调用GetInstance函数都要进入临界区,尤其是在heavy contention情况下函数将会成为 系统的性能瓶颈,我们伟大的程序员发现我们不必每次调用GetInstance函数时都去获取锁,只是在第一次new这个实例的时候才需要同步,所以伟大的程序员们发明了著名的DCL技法,即Double Check Lock,代码如下:
 
 
 1 Widget* Widget::pInstance{ nullptr };
 2 Widget* Widget::Instance() {
 3     if (pInstance == nullptr) { // 1: first check
 4         lock_guard<mutex> lock{ mutW };
 5         if (pInstance == nullptr) { // 2: second check
 6             pInstance = new Widget(); 
 7         }
 8     } 
 9     return pInstance;
10 }
 
曾今有一段时间,这段代码是被认为正确无误的,但是一群伟大的程序员们发现了其中的bug!并且联名上书表示这份代码是错误的。要解释其中为什么出现了错误,需要读者十分的熟悉memory model,这里我就不详细的说明了,一句话就是在这份代码中第三行代码:if (pInstance == nullptr)和第六行代码pInstance = new Widget();没有正确的同步,在某种情况下会出现new返回了地址赋值给pInstance变量而Widget此时还没有构造完全,当另一个线程随后运行到第三行时将不会进入if从而返回了不完全的实例对象给用户使用,造成了严重的错误。在C++11没有出来的时候,只能靠插入两个memory barrier来解决这个错误,但是C++11已经出现了好几年了,其中我认为最重要的是引进了memory model,从此C++11也能识别线程这个概念了!
 
  因此,在有了C++11后我们就可以正确的跨平台的实现DCL模式了,代码如下:
 
 
 1 atomic<Widget*> Widget::pInstance{ nullptr };
 2 Widget* Widget::Instance() {
 3     if (pInstance == nullptr) { 
 4         lock_guard<mutex> lock{ mutW }; 
 5         if (pInstance == nullptr) { 
 6             pInstance = new Widget(); 
 7         }
 8     } 
 9     return pInstance;
10 }
 
C++11中的atomic类的默认memory_order_seq_cst保证了3、6行代码的正确同步,由于上面的atomic需要一些性能上的损失,因此我们可以写一个优化的版本:
 
 
 1 atomic<Widget*> Widget::pInstance{ nullptr };
 2 Widget* Widget::Instance() {
 3     Widget* p = pInstance;
 4     if (p == nullptr) { 
 5         lock_guard<mutex> lock{ mutW }; 
 6         if ((p = pInstance) == nullptr) { 
 7             pInstance = p = new Widget(); 
 8         }
 9     } 
10     return p;
11 }
 
但是,C++委员会考虑到单例模式的广泛应用,所以提供了一个更加方便的 组件来完成相同的功能:
 
1 static unique_ptr<widget> widget::instance;
2 static std::once_flag widget::create;
3 widget& widget::get_instance() {
4     std::call_once(create, [=]{ instance = make_unique<widget>(); });
5     return instance;
6 }
可以看出上面的代码相比较之前的示例代码来说已经相当的简洁了,但是!!!有是但是!!!!在C++memory model中对static local variable,说道:The initialization of such a variable is defined to occur the first time control passes through its declaration; for multiple threads calling the function, this means there’s the potential for a race condition to define first.因此,我们将会得到一份最简洁也是效率最高的单例模式的C++11实现:
 
1 widget& widget::get_instance() {
2     static widget instance;
3     return instance;
4 }
用Herb Sutter的话来说这份代码实现是“Best of All”的。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值