C++11 修复了DCL双重检查锁定问题

相关文章一:

众多设计模式中,单例模式比较常见的一种,面试和工作中也会经常接触到。本文以一个C++开发者的角度来探讨单例模式几种典型实现。设计模式经典GoF定义的单例模式需要满足以下两个条件:

  1. 保证一个类只创建一个实例。
  2. 提供对该实例的全局访问点。

如果系统有类似的实体(有且只有一个,且需要全局访问),那么就可以将其实现为一个单例。实际工作中常见的应用举例

  • 日志类,一个应用往往只对应一个日志实例。
  • 配置类,应用的配置集中管理,并提供全局访问。
  • 管理器,比如windows系统的任务管理器就是一个例子,总是只有一个管理器的实例。
  • 共享资源类,加载资源需要较长时间,使用单例可以避免重复加载资源,并被多个地方共享访问。

Lazy Singleton

首先看GoF在描述单例模式时提出的一种实现,教科书式的例子,对C++有些经验应该对该实现都有些印象

//头文件中
class Singleton  
{
    public:
        static Singleton& Instance()
        {
            if (instance_ == NULL)
            {
                instance_ = new Singleton;
            }
            return *instance_;
         }
    private:
        Singleton();
        ~Singleton();
        Singleton(const Singleton&);
        Singleton& operator=(const Singleton&);
    private:
        static Singleton* instance_;
};
//实现文件中
Singleton* Singleton::instance_ = 0;  

实现中构造函数被声明为私有方法,这样从根本上杜绝外部使用构造函数生成新的实例,同时禁用拷贝函数与赋值操作符(声明为私有但是不提供实现)避免通过拷贝函数或赋值操作生成新实例。

提供静态方法Instance()作为实例全局访问点,该方法中先判断有没有现成的实例,如果有直接返回,如果没有则生成新实例并把实例的指针保存到私有的静态属性中。

注意,这里Instance()返回的实例的引用而不是指针,如果返回的是指针可能会有被外部调用者delete掉的隐患,所以这里返回引用会更加保险一些。并且直到Instance()被访问,才会生成实例,这种特性被称为延迟初始化(Lazy initialization),这在一些初始化时消耗较大的情况有很大优势。

Lazy Singleton不是线程安全的,比如现在有线程A和线程B,都通过instance_ == NULL的判断,那么线程A和B都会创建新实例。单例模式保证生成唯一实例的规则被打破了。

Eager Singleton

这种实现在程序开始(静态属性instance初始化)的时就完成了实例的创建。这正好和上述的Lazy Singleton相反。

//头文件中
class Singleton  
{
    public:
        static Singleton& Instance()
        {
            return instance;
        }
    private:
        Singleton();
        ~Singleton();
        Singleton(const Singleton&);
        Singleton& operator=(const Singleton&);
    private:
        static Singleton instance;
}
//实现文件中
Singleton Singleton::instance;  

由于在main函数之前初始化,所以没有线程安全的问题,但是潜在问题在于no-local static对象(函数外的static对象)在不同编译单元(可理解为cpp文件和其包含的头文件)中的初始化顺序是未定义的。如果在初始化完成之前调用 Instance()方法会返回一个未定义的实例。

Meyers Singleton

Scott Meyers在《Effective C++》(Item 04)中的提出另一种更优雅的单例模式实现,使用local static对象(函数内的static对象)。当第一次访问Instance()方法时才创建实例。

class Singleton  
{
    public:
        static Singleton& Instance()
    {
        static Singleton instance;
        return instance;
    }
    private:
        Singleton();
        ~Singleton();
        Singleton(const Singleton&);
        Singleton& operator=(const Singleton&);
};

C++0x之后是该实现线程安全的,有兴趣可以读相关的标准草案(section 6.7)编译器支持程度不一定,但是G++4.0及以上是支持的。

双检测锁模式(Double-Checked Locking Pattern)

Lazy Singleton的一种线程安全改造是在Instance()中每次判断是否为NULL前加锁,但是加锁是很慢的。 
而实际上只有第一次实例创建的时候才需要加锁。双检测锁模式被提出来,改造之后大致是这样

static Singleton& Instance()  
{
    if (instance_ == NULL) 
    {
        Lock lock; //基于作用域的加锁,超出作用域,自动调用析构函数解锁
        if (instance_ == NULL)
        {
              instance_ = new Singleton;
        }
    }
    return *instance_;
}

既然只需要在第一次初始化的时候加锁,那么在这之前判断一下实例有没有被创建就可以了,所以多在加锁之前多加一层判断,需要判断两次所有叫Double-Checked。理论上问题解决了,但是在实践中有很多坑,如指令重排、多核处理器等问题让DCLP实现起来比较复杂比如需要使用内存屏障,详细的分析可以阅读这篇论文

在C++11中有全新的内存模型和原子库,可以很方便的用来实现DCLP。这里不展开。有兴趣可以阅读这篇文章《Double-Checked Locking is Fixed In C++11》。

pthread_once

在多线程编程环境下,尽管pthread_once()调用会出现在多个线程中,init_routine()函数仅执行一次,pthread_once是很适合用来实现线程安全单例。

template<typename T>  
class Singleton : boost::noncopyable  
{
    public:
        static T& instance()
        {
            pthread_once(&ponce_, &Singleton::init);
            return *value_;
        }

        static void init()
        {
            value_ = new T();
        }
    private:
        static pthread_once_t ponce_;
       static T* value_;
};
template<typename T>  
pthread_once_t Singleton<T>::ponce_ = PTHREAD_ONCE_INIT;

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

这里的boost::noncopyable的作用是把构造函数, 赋值函数, 析构函数, 复制构造函数声明为私有或者保护。

总结

单例模式的实现方法很多,要写一个完美的实现很难代码也会很复杂。但是掌握基础的实现还是很必要的,然后在实际应用中不断地去优化和探索。除了线程安全,一些场景下还有需要考虑资源释放,生命周期等相关问题,可以参见《Modern C++ Design》中对Singleton的讨论。

话说如下是 c++11标准做法

class Singleton {
public:
  static Singleton& getInstance(){
    static Singleton instance;
    return instance;
  }
};

作者:知乎用户
链接:https://www.zhihu.com/question/50533404/answer/156455984
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

文章二:

转自http://developer.51cto.com/art/201311/419604.htm

双重检查锁定模式(DCLP)在无锁编程(lock-free programming)中经常被讨论,直到2004年,JAVA才提供了可靠的双重检查锁定实现。而在C++11之前,C++没有提供一种该模式的可移植的可靠实现。

随着双重检查锁定模式在各语言实现上存在的缺点暴露,人们开始研究如何安全可靠地实现它。2000年,一个JAVA高性能研究小组发布了一篇声明《双重检查锁定可能导致锁定无效》。2004年,Scott Meyers 和Andrei Alexandrescu联合发表了一篇名为《C++实现双重检查锁定存在严重缺陷》。这两篇论文都是重点阐述了双重检查锁定(DCLP)是什么,以及双重检查锁定的意义,和当前的各语言实现存在诸多不足。

现如今,JAVA为了安全地实现双重检查锁定修改了其内存模型,并引入了关键词volatile。与此同时,C++构建了一个全新的内存模型和原子 操作库(atomic),使得不同编译器实现双重检查锁定(DCLP)更为容易。为了在更早期的C\C++编译器中实现DCLP,在C++11引入了一个 名为Mintomic的库,在今年早些时候由我发布了。

过去的一段时间,我都着力于C++中实现DCLP的研究。

什么是双重检查锁定?

如果你想在多线程编程中安全使用单件模式(Singleton),最简单的做法是在访问时对其加锁,使用这种方式,假定两个线程同时调用Singleton::getInstance方法,其中之一负责创建单件:

 
 
  1. Singleton* Singleton::getInstance() { 
  2.     Lock lock;      // scope-based lock, released automatically when the function returns 
  3.     if (m_instance == NULL) { 
  4.         m_instance = new Singleton; 
  5.     } 
  6.     return m_instance; 

使用这种方式是可行的,但是当单件被创建之后,实际上你已经不需要再对其进行加锁,加锁虽然不一定导致性能低下,但是在重负载情况下,这也可能导致响应缓慢。

使用双重检查锁定模式避免了在单件对象已经创建好之后进行不必要的锁定,然而实现却有点复杂,在Meyers-Alexandrescu的论文中也 有过阐述,文中提出了几种存在缺陷的实现方式,并逐一解释了为什么这样实现存在问题。在论文的结尾的第12页,给出了一种可靠的实现方式,实现依赖一种标 准中未规范的内存栅栏技术。

 
 
  1. Singleton* Singleton::getInstance() { 
  2.     Singleton* tmp = m_instance; 
  3.     ...                     // insert memory barrier 
  4.     if (tmp == NULL) { 
  5.         Lock lock; 
  6.         tmp = m_instance; 
  7.         if (tmp == NULL) { 
  8.             tmp = new Singleton; 
  9.             ...             // insert memory barrier 
  10.             m_instance = tmp; 
  11.         } 
  12.     } 
  13.     return tmp; 

这里,我们可以看到:如模式名称一样,代码中实现了双重校验,在m_instance指针为NULL时,我们做了一次锁定,这一过程在最先创建该对象的线程可见。在创建线程内部构造块中,m_instance被再一次检查,以确保该线程仅创建了一份对象副本。

这是双重检查锁定的实现,只不过在被高亮的代码行中还缺乏了内存栅栏技术做保证,在此文写就之际,C/C++各编译器未对该实现进行统一,而在C++11标准中,对这种情况下的实现进行了完善和统一。

在C++11中获取和释放内存栅栏

在C++11中,你可以获取和释放内存栅栏来实现上述功能(如何获取和释放内存栅栏在我上一篇博文中有讲述)。为了使你的代码在C++各种实现中具 备更好的可移植性,你应该使用C++11中新增的atomic类型来包装你的m_instance指针,这使得对m_instance的操作是一个原子操作。下面的代码演示了如何使用内存栅栏,请注意代码高亮部分:

 
 
  1. std::atomic<Singleton*> Singleton::m_instance; 
  2. std::mutex Singleton::m_mutex; 
  3.   
  4. Singleton* Singleton::getInstance() { 
  5.     Singleton* tmp = m_instance.load(std::memory_order_relaxed); 
  6.     std::atomic_thread_fence(std::memory_order_acquire);  // 编注:原作者提示注意的 
  7.     if (tmp == nullptr) { 
  8.         std::lock_guard<std::mutex> lock(m_mutex); 
  9.         tmp = m_instance.load(std::memory_order_relaxed); 
  10.         if (tmp == nullptr) { 
  11.             tmp = new Singleton; 
  12.             std::atomic_thread_fence(std::memory_order_release); // 编注:作者提示注意的 
  13.             m_instance.store(tmp, std::memory_order_relaxed); 
  14.         } 
  15.     } 
  16.     return tmp; 

上述代码在多核系统中仍然工作正常,这是因为内存栅栏技术在创建对象线程和使用对象线程之间建立了一种“同步-与”的关系(synchronizes-with)。Singleton::m_instance扮演了守卫变量的角色,而单件本身则作为负载内容。

two-cones-dclp

而其他存在缺陷的双重检查锁定实现都缺乏该机制的保障:在没有“同步-与”关系保证的情况下,第一个创建线程的写操作,确切地说是在其构造函数中, 可以被其他线程感知,即m_instance指针能被其他线程访问!创建单件线程中的锁也不起作用,由于该锁对其他线程不可见,从而导致在某些情况下,创 建对象被执行多次。

如果你想了解关于内存栅栏技术是如何可靠实现双重检查锁定的内部原理,在我的前一篇文章中有一些背景信息(previous post),之前的博客也有一些相关内容。

使用Mintomic 内存栅栏

Mintomic是一个很小的c库,提供了C++11 atomic库中的一些功能函数子集,包含获取和释放内存栅栏,同时它能工作在早期的编译器之上。Mintomic依赖于与C++11相似的内存模型—— 确切地说是不使用Out-of-thin-air存储——这一技术在早期编译器中未进行实现,而这是在没有C++11标准情况下我们能做的最好实现。以我 多年C++多线程开发的经验看来,Out-of-thin-air存储并不流行,而且大多数编译器会避免实现它。

下面的代码演示了如何使用Mintomic的获取和释放内存栅栏机制实现双重检查锁定,基本上与上面的例子类似:

 
 
  1. mint_atomicPtr_t Singleton::m_instance = { 0 }; 
  2. mint_mutex_t Singleton::m_mutex; 
  3.   
  4. Singleton* Singleton::getInstance() { 
  5.     Singleton* tmp = (Singleton*) mint_load_ptr_relaxed(&m_instance); 
  6.     mint_thread_fence_acquire(); 
  7.     if (tmp == NULL) { 
  8.         mint_mutex_lock(&m_mutex); 
  9.         tmp = (Singleton*) mint_load_ptr_relaxed(&m_instance); 
  10.         if (tmp == NULL) { 
  11.             tmp = new Singleton; 
  12.             mint_thread_fence_release(); 
  13.             mint_store_ptr_relaxed(&m_instance, tmp); 
  14.         } 
  15.         mint_mutex_unlock(&m_mutex); 
  16.     } 
  17.     return tmp; 

为了实现获取和释放内存栅栏,Mintomic会试图在其支持的编译器平台产生最高效的机器码。例如,下面的汇编代码来自Xbox 360,使用的是PowerPC处理器。在该平台上,内联的lwsync关键字是针对获取和释放内存栅栏的优化指令。

ppc-double-checked-mintomic

上述采用C++11标准库编译的例子在PowerPC处理器编译应该会产生一样的汇编代码(理想情况下)。不过,我没有能够在PowerPC下编译C++11来验证这一点。

使用C++11低阶指令顺序约束

在C++11中使用内存栅栏锁定技术可以很方便地实现双重检查锁定。同时也保证在现今流行的多核系统中产生优化的机器码(Mintomic也能做到 这一点)。不过使用这种方式并不是常用,在C++11中更好的实现方式是使用保证低阶指令执行顺序约束的原子操作。之前的图片中可以看到,一个写-释放操 作可以与一个获取-读操作同步:

 
 
  1. std::atomic<Singleton*> Singleton::m_instance; 
  2. std::mutex Singleton::m_mutex; 
  3.   
  4. Singleton* Singleton::getInstance() { 
  5.     Singleton* tmp = m_instance.load(std::memory_order_acquire); 
  6.     if (tmp == nullptr) { 
  7.         std::lock_guard<std::mutex> lock(m_mutex); 
  8.         tmp = m_instance.load(std::memory_order_relaxed); 
  9.         if (tmp == nullptr) { 
  10.             tmp = new Singleton; 
  11.             m_instance.store(tmp, std::memory_order_release); 
  12.         } 
  13.     } 
  14.     return tmp; 

从技术上讲,使用这种形式的无锁同步比独立内存栅栏技术限制更低。上述操作只是为了防止自身操作的内存排序,而内存栅栏技术则阻止了临近操作的内存 排序。尽管如此,现今的x86/64,ARMv6 / v7,和PowerPC处理器架构,针对这两种形式产生的机器码应该是一致的。在我之前的博文中,我展示了C++11低阶指令顺序约束在ARM7中使用了 dmb指令,这和使用内存栅栏技术产生的汇编代码相一致。

上述两种方式在Itanium平台可能产生不一样的机器码,在Itanium平台上,C++11标准中的 load(memory_order_acquire)可以用单CPU指令:ld.acq,而store(tmp, memory_order_release)使用st.rel就可以实现。

在ARMv8处理器架构中,也提供了和Itanium指令等价的ldar 和 stlr 指令,而不同的地方是:这些指令还会导致stlr和后续ldar之间进一级的存储装载指令进行排序。实际上,ARMv8的新指令试图实现C++11标准中 的顺序约束原子操作,这会在后面进一步讲述。

使用C++顺序一致的原子操作

C++11标准提供了一个不同的方式来编写无锁程序(可以把双重检查锁定归类为无锁编程的一种,因为不是所有线程都会获取锁)。在所有原子操作库方 法中使用可选参数std::memory_order可以使得所有原子变量变为顺序的原子操作(sequentially consistent),方法的默认参数为std::memory_order_seq_cst。使用顺序约束(SC)原子操作库,整个函数执行都将保证 顺序执行,并且不会出现数据竞态(data races)。顺序约束(SC)原子操作和JAVA5版本之后出现的volatile变量很相似。

使用SC原子操作实现双重检查锁定的代码如下:和前面的例子一样,高亮的第二行会与第一次创建单件的线程进行同步与操作。

 
 
  1. std::atomic<Singleton*> Singleton::m_instance; 
  2. std::mutex Singleton::m_mutex; 
  3.   
  4. Singleton* Singleton::getInstance() { 
  5.     Singleton* tmp = m_instance.load(); 
  6.     if (tmp == nullptr) { 
  7.         std::lock_guard<std::mutex> lock(m_mutex); 
  8.         tmp = m_instance.load(); 
  9.         if (tmp == nullptr) { 
  10.             tmp = new Singleton; 
  11.             m_instance.store(tmp); 
  12.         } 
  13.     } 
  14.     return tmp; 

顺序约束(SC)原子操作使得开发者更容易预测代码执行结果,不足之处在于使用顺序约束(SC)原子操作类库的代码效率要比之前的例子低一些。例如,在x64位机器上,上述代码使用Clang3.3优化后产生如下汇编代码:

x64-double-checked-seq-cst

由于使用了顺序约束(SC)原子操作类库,变量m_instance的存储操作使用了xchg指令,在x64处理器上相当于一个内存栅栏操作。该指 令在x64位处理器是一个长周期指令,使用轻量级的mov指令也可以完成操作。不过,这影响不大,因为xchg指令只被单件创建过程调用一次。

不过,在PowerPC or ARMv6/v7处理器上编译上述代码,产生的汇编操作要糟糕得多,具体情形可以参见Herb Sutter的演讲(atomic Weapons talk, part 2.00:44:25 – 00:49:16)。

使用C++11数据顺序依赖原理

上面的例子都是使用了创建单件线程和使用单件其他线程之间的同步与关系。守卫的是数据指针单个元素,开销也是创建单件内容本身。这里,我将演示一种使用数据依赖来保护防卫的指针。

在使用数据依赖时候,上述例子中都使用了一个读-获取操作,这也会产生性能消耗,我们可以使用消费指令来进一步优化。消费指令(consume instruction)非常酷,在PowerPc处理器上它使用了lwsync指令,在ARMv7处理器上则编译为dmd指令。今后我会写一些文章来讲 述消费指令和数据依赖机制。

使用C++11静态初始化

一些读者可能已经知道C++11中,你可以跳过之前的检查过程而直接得到线程安全的单件。你只需要使用一个静态初始化:

C++11标准在6.7.4节中规定:

如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待完成该变量完成初始化。

上述操作在编译时由编译器保证。双重检查锁定则可以利用这一点。编译器并不保证会使用双重检查锁定,但是大部分编译器会这样做。gcc4.6使用-std=c++0x编译选项在ARM处理器产生的汇编代码如下:

clang-arm-static-init

由于单件使用的是一个固定地址,编译器会使用一个特殊的防卫变量来完成同步。请注意这里,在初始化变量读操作时没有使用dmb指令来获取一个内存栅 栏。守卫变量指向了单件,因此编译器可以使用数据依赖原则来避免使用dmb指令的开销。__cxa_guard_release指令扮演了一个写-释放来 解除变量守卫。一旦守卫栅栏被设置,这里存在一个指令顺序强制在读-消费操作之前。这里和前面的例子一样,对内存排序的进行适应性的变更。

前面的长篇累牍主要讲述了C++11标准修复了双层检查锁定实现,并且讲述了其他一些相关知识。

就我个人而言,我认为应当在程序初始化时就初始化一个singleton。使用双重检查锁定可以帮你将任意数据类型存储在一个无锁的哈希表中。这会在后续的文章进一步阐述。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值