c++:从单例到内存屏障

本文深入探讨了C++中实现线程安全单例模式的原理,包括双重检查锁定和内存屏障的作用。通过分析指令重排可能导致的问题,解释了为何需要使用内存屏障来确保线程安全。同时,介绍了C++11标准中的原子操作和内存顺序,以及如何利用它们改进双重检查单例。最后,讨论了volatile关键字在多线程环境中的局限性,并提到了C++11中静态局部变量初始化的线程安全特性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

首先我们用c++写一个经典的单例:

#include <mutex>
  1. class Singleton {

  2.  
  3. public:

  4.  
  5.     static Singleton* GetInstance() {

  6.  
  7.         if (!instance_) {

  8.  
  9.             std::lock_guard<std::mutex> lock(lock_);

  10.  
  11.             if (!instance_) {

  12.  
  13.                 instance_ = new Singleton();

  14.  
  15.             }

  16.  
  17.         }

  18.  
  19.         return (Singleton*)instance_;

  20.  
  21.     }

  22.  
  23.  
  24. private:

  25.  
  26.     static std::mutex lock_;

  27.  
  28.     static volatile Singleton* instance_;

  29.  
  30.  
  31. private:

  32.  
  33.     Singleton() {};

  34.  
  35.     virtual ~Singleton() {};

  36.  
  37.     Singleton(const Singleton &) = delete;

  38.  
  39.     Singleton& operator=(const Singleton &) = delete;

  40.  
  41. };

  42.  
  43.  
  44. std::mutex Singleton::lock_;

  45.  
  46. volatile Singleton* Singleton::instance_ = nullptr;


在面试时,单例是个经典的题目,从中可以考察一个程序猿对c++语法和多线程相关的知识。一般情况下,对应届生面到单例的双重检查,就算是达到目的了。一般认为双重检查的单例就是线程安全且性能优良的了

指令重排


现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序


假如这两个线程里的变量读写操作都是原子操作,那么assert断言会触发吗?答案是会,程序在执行的时候可能先给y赋值为2,但是x任然为0。原因可能是:

  1. 编译器在生成指令时做了重排,因为对编译器来说线程A中的两个赋值操作没有任何联系
  2. CPU在执行时做了指令重排
  3. CPU读取的是多级缓存中的缓存值

编译器和CPU做重排操作,都是为了提升程序执行的性能(虽然这个简单的例子中看不出来)

双重检查的线程安全

接下来讨论双重检查的单例线程安全性。**instance_ = new Singleton();**这个操作实际上是三个步骤:

 
  1. tmp = operator new(sizeof(Singleton));

  2. new(tmp) Singleton;

  3. instance_ = tmp;

分配内存,执行构造,把地址赋值给instance_。但是结合之前的指令重排可以知道,编译器并不会被约束去执行这些步骤,很多时候第二步和第三步会交换,也就是先给instance_赋值然后再构造。这时候如果还没有进行构造时线程被挂起,另一个线程访问单例就会认为instance_已经构造完毕进而使用了未构造的对象,我们的程序就会crash。那么怎么写一个线程安全的双重检查?这需要用到内存屏障

## 内存屏障(memory barriers)
内存屏障,也叫内存栅栏(Memory Fence)。分编译器屏障(Compiler Barrier,也叫优化屏障) 和CPU内存屏障,其中编译器屏障只对编译器有效。我们一般提的内存屏障是指CPU内存屏障。

使用内存屏障,就是我们使用具有同步语义的指令来标记真正需要同步的变量和操作,告诉CPU和编译器不要对这些标记好的同步操作和变量做违反顺序一致性的优化,而其它未被标记的地方可以做原有的优化,这样就可以解决指令重排导致的问题

c++11增加了原子操作和内存屏障相关的功能,通过引入atomic头文件即可使用,在c++11之前需要使用pthread或者windows api来执行原子操作和内存屏障。atomic头文件中定义了一个模板类型atomic<T>,它封装了原子操作以及memory order相关的特性,并对各种整型(char、short、int、long等)、指针等类型提供了特化版本。对atomic模版封装的变量进行读写操作是原子的。他提供了两个基本的函数:

 
  1. void store (T val, memory_order sync = memory_order_seq_cst) noexcept;

  2.  
  3. T load (memory_order sync = memory_order_seq_cst) const noexcept;

store用来写原子变量,load读原子变量。这两个函数的第二个参数memory_order是个枚举,表示c++11标准定义的不同内存操作顺序(memory order),我们用此来操作不同的内存屏障

 
  1. typedef enum memory_order {

  2.  
  3.     memory_order_relaxed,   // relaxed

  4.  
  5.     memory_order_consume,   // consume

  6.  
  7.     memory_order_acquire,   // acquire

  8.  
  9.     memory_order_release,   // release

  10.  
  11.     memory_order_acq_rel,   // acquire/release

  12.  
  13.     memory_order_seq_cst    // sequentially consistent

  14.  
  15. } memory_order;


这些枚举值在多线程的内存访问顺序上提供了不同程度的约束,其中memory_order_relaxed是最弱的,最强的是memory_order_seq_cst(它也是所有原子操作的默认参数)。

memory_order_relaxed


松散的内存屏障,表示**没有指令同步的限制**,std::atomic使用此操作时,只保障原子性,无指令同步操作。图片来源于网络


图中上方表示编译后的程序的指令执行顺序,下方代表CPU实际执行顺序。图中,a、b、b代表普通变量,x代表atomic类型变量,且设置为memory_order_relaxed操作方式。

此时在一个线程上执行时,在这个线程认为不影响最终结果的前提下,实际执行时指令可能完全是乱的。写入a、写入b的操作实际执行时可能是调换了;写入c的操作可能实际在写入x之后执行;读出b的操作实际在写入x之前执行

memory_order_release


单向释放内存屏障,表示**线程中的读写指令不能重排到此写原子变量指令之后,另一个执行读原子变量的线程,可以正确读取指令之前的变量**
 

release

此时在一个线程上执行时,写入x的内存屏障操作之后的指令允许重排到x之前,但是写入x之前的指令不会被重排到x后面。不过写入x指令前后的那些指令的顺序是允许重排的。所以使用memory_order_release屏障后,可以保障另一个线程在执行了读取x操作之后,读取a、b、c的值是正确的,因为a、b、b的写入操作一定不会被重排到x操作之后

memory_order_acquire


单项加载内存屏障,表示**线程中的读写指令不能重排到此读屏障指令之前,另一个执行写原子变量的线程里写操作之前的变量,可以被此线程 读取**
 

此时在一个线程上执行时,读取x的内存屏障操作之前的指令允许重排到x之后,但是读取x之后的指令不会被重排到x前面。实际上memory_order_release用于写入、memory_order_acquire用于读取,他们是成对使用:线程A使用memory_order_release写原子变量x,线程A使用memory_order_acquire读原子变量x。线程A写x之前的操作,都可以被线程B在读x之后看到
 

图中,thread_1执行写入x操作之前,先写了a、b变量,在thread_2执行读取x操作后,再去读取a、b的值,这是正确的操作。但是如果thread_2在读取x之前去读a变量,就没法保证读到的a变量是thread_1在写入x之前最后写入a的值。同时thread_2读取变量c的值,不一定是thread_1写入x后写入的c,因为这个写入c的操作允许被重排到写入x之前

memory_order_consume

这个内存屏障与memory_order_acquire的功能相似,而且大多数编译器并没有实现这个屏障,所以就不单独说了

memory_order_acq_rel

双向读写内存屏障,相当于结合了memory_order_release、memory_order_acquire。表示**线程中此屏障之前的的读写指令不能重排到屏障之后,屏障之后的读写指令也不能重排到屏障之前**。此时需要不同线程都是用**同一个原子变量**,且都是用memory_order_acq_rel

memory_order_seq_cst


最严格的内存屏障,顺序一致屏障。表示**线程中此屏障之前的的读写指令不能重排到屏障之后,屏障之后的读写指令也不能重排到屏障之前,并且两个相邻memory_order_seq_cst原子操作之间的其它操作(包括非原子变量操作),不能重排到这两个相邻操作之外**。memory_order_seq_cst比memory_order_acq_rel更强,memory_order_acq_rel的顺序保障,是要基于同一个原子变量的,也就是说,在这个原子变量之前的读写,不能重排到这个原子变量之后,同时这个原子变量之后的读写,也不能重排到这个原子变量之前。但是,如果两个线程基于memory_order_acq_rel使用了两个不同的原子变量x1, x2,那在x1之前的读写,重排到x2之后,是完全可能的,在x1之后的读写,重排到x2之前,也是被允许的。然而,如果两个原子变量x1,x2,是基于memory_order_seq_cst在操作,那么即使是x1之前的读写,也不能被重排到x2之后,x1之后的读写,也不能重排到x2之前,也就说,如果都用memory_order_seq_cst,那么程序代码顺序(Program Order)就将会是你在多个线程上都实际观察到的顺序(Observed Order)。(java只有这种memory order,通过java的关键字volatile来提供)。memory_order_acq_rel的操作针对同一个原子变量,而memory_order_seq_cst不限于同一个原子变量。

线程安全的单例

 
  1. class Singleton {

  2.  
  3. public:

  4.  
  5.     static Singleton* GetInstance() {

  6.  
  7.         Singleton *tmp = instance_.load(std::memory_order_acquire);

  8.  
  9.         if (!tmp) {

  10.  
  11.             std::lock_guard<std::mutex> lock(lock_);

  12.  
  13.             tmp = instance_.load(std::memory_order_relaxed);

  14.  
  15.             if (!tmp) {

  16.  
  17.                 tmp = new Singleton();

  18.  
  19.                 instance_.store(tmp, std::memory_order_release);

  20.  
  21.             }

  22.  
  23.         }

  24.  
  25.         return (Singleton*)instance_;

  26.  
  27.     }

  28.  
  29.  
  30. private:

  31.  
  32.     static std::mutex lock_;

  33.  
  34.     static std::atomic<Singleton*> instance_;

  35.  
  36.  
  37. private:

  38.  
  39.     Singleton() {};

  40.  
  41.     virtual ~Singleton() {};

  42.  
  43.     Singleton(const Singleton &) = delete;

  44.  
  45.     Singleton& operator=(const Singleton &) = delete;

  46.  
  47. };

  48.  
  49.  
  50. std::mutex Singleton::lock_;

  51.  
  52. std::atomic<Singleton*> Singleton::instance_ = nullptr;

原子操作配合memory_order_acquire、memory_order_release内存屏障,保证指令顺序不被打乱,来写出一个线程安全的双重检查单例。
但是实际上,**c++11**增加了一个重要的特性。就是多线程下局部静态变量的初始化由编译器来保证线程安全,只初始化一次,所以我们实际上写线程安全的单例可以这样:

 
  1. static Singleton* GetInstance() {

  2.  
  3.     static Singleton instance;

  4.  
  5.     return &instance;    

  6.  
  7. }

两行代码,搞定~~,编译器通常也是用上面的双重检查逻辑来实现这个功能

volatile


说道c++多线程,volatile就很容易被提起,它的作用有:
1. 告诉编译器,这个变量是易变的,当编译器遇到这个变量的时候,只能从变量的内存地址中读取这个变量,不可以从寄存器读取
2. 告诉编译器不要将变量优化掉,保证这个指令一定会被执行
3. 两个包含volatile变量的指令,编译后不可以乱序(编译器屏障)。但是实际执行时CPU可能打乱顺序

volatile关键字只针对编译器,对CPU没有作用,所以关于volatile在多线程中的作用的结论是:
1. 不保证原子性
2. 不保障实际执行顺序
3. 不提供CPU内存屏障

所以如果做多线程开发,变量在内存中的可见性、原子性、CPU执行顺序,最终还是要依赖CPU内存屏障来支持的。但**java、c#**中的volatile关键字的内部实现中使用了CPU内存屏障(而且是是memory_order_seq_cst),所以java和c#多线程开发中volatile是有用的

线程安全的无锁数据结构


一般情况下,我们业务上的多线程开发中不直接使用内存屏障,而是乖乖使用锁。因为内存屏障使用的确很麻烦。内存屏障不属于加锁,所以很多时候用来做一些无锁编程,比如设计无锁数据结构。具体的实践可以参考《C++ Concurrency IN ACTION》,书中讲的非常详细

回到单例


关于的线程安全的双重检查单例,chromium浏览器内核源码中base库中有更加细致的实现,有兴趣的可以研究一下:https://source.chromium.org/chromium/chromium/src/+/master:base/lazy_instance_helpers.h?q=lazy_instance_helpers.h&ss=chromium%2Fchromium%2Fsrc

 
  1. template <typename Type>

  2.  
  3. Type* GetOrCreateLazyPointer(subtle::AtomicWord* state,

  4.  
  5.                              Type* (*creator_func)(void*),

  6.  
  7.                              void* creator_arg,

  8.  
  9.                              void (*destructor)(void*),

  10.  
  11.                              void* destructor_arg) {

  12.  
  13.   DCHECK(state);

  14.  
  15.   DCHECK(creator_func);

  16.  
  17.  
  18.   // If any bit in the created mask is true, the instance has already been

  19.  
  20.   // fully constructed.

  21.  
  22.   constexpr subtle::AtomicWord kLazyInstanceCreatedMask =

  23.  
  24.       ~internal::kLazyInstanceStateCreating;

  25.  
  26.  
  27.   // We will hopefully have fast access when the instance is already created.

  28.  
  29.   // Since a thread sees |state| == 0 or kLazyInstanceStateCreating at most

  30.  
  31.   // once, the load is taken out of NeedsLazyInstance() as a fast-path. The load

  32.  
  33.   // has acquire memory ordering as a thread which sees |state| > creating needs

  34.  
  35.   // to acquire visibility over the associated data. Pairing Release_Store is in

  36.  
  37.   // CompleteLazyInstance().

  38.  
  39.   subtle::AtomicWord instance = subtle::Acquire_Load(state);

  40.  
  41.   if (!(instance & kLazyInstanceCreatedMask)) {

  42.  
  43.     if (internal::NeedsLazyInstance(state)) {

  44.  
  45.       // This thread won the race and is now responsible for creating the

  46.  
  47.       // instance and storing it back into |state|.

  48.  
  49.       instance =

  50.  
  51.           reinterpret_cast<subtle::AtomicWord>((*creator_func)(creator_arg));

  52.  
  53.       internal::CompleteLazyInstance(state, instance, destructor,

  54.  
  55.                                      destructor_arg);

  56.  
  57.     } else {

  58.  
  59.       // This thread lost the race but now has visibility over the constructed

  60.  
  61.       // instance (NeedsLazyInstance() doesn't return until the constructing

  62.  
  63.       // thread releases the instance via CompleteLazyInstance()).

  64.  
  65.       instance = subtle::Acquire_Load(state);

  66.  
  67.       DCHECK(instance & kLazyInstanceCreatedMask);

  68.  
  69.     }

  70.  
  71.   }

  72.  
  73.   return reinterpret_cast<Type*>(instance);

  74.  
  75. }

  76.  
  77.  
  78. bool NeedsLazyInstance(subtle::AtomicWord* state) {

  79.  
  80.   // Try to create the instance, if we're the first, will go from 0 to

  81.  
  82.   // kLazyInstanceStateCreating, otherwise we've already been beaten here.

  83.  
  84.   // The memory access has no memory ordering as state 0 and

  85.  
  86.   // kLazyInstanceStateCreating have no associated data (memory barriers are

  87.  
  88.   // all about ordering of memory accesses to *associated* data).

  89.  
  90.   if (subtle::NoBarrier_CompareAndSwap(state, 0, kLazyInstanceStateCreating) ==

  91.  
  92.       0) {

  93.  
  94.     // Caller must create instance

  95.  
  96.     return true;

  97.  
  98.   }

  99.  
  100.  
  101.   // It's either in the process of being created, or already created. Spin.

  102.  
  103.   // The load has acquire memory ordering as a thread which sees

  104.  
  105.   // state_ == STATE_CREATED needs to acquire visibility over

  106.  
  107.   // the associated data (buf_). Pairing Release_Store is in

  108.  
  109.   // CompleteLazyInstance().

  110.  
  111.   if (subtle::Acquire_Load(state) == kLazyInstanceStateCreating) {

  112.  
  113.     const base::TimeTicks start = base::TimeTicks::Now();

  114.  
  115.     do {

  116.  
  117.       const base::TimeDelta elapsed = base::TimeTicks::Now() - start;

  118.  
  119.       // Spin with YieldCurrentThread for at most one ms - this ensures maximum

  120.  
  121.       // responsiveness. After that spin with Sleep(1ms) so that we don't burn

  122.  
  123.       // excessive CPU time - this also avoids infinite loops due to priority

  124.  
  125.       // inversions (https://crbug.com/797129).

  126.  
  127.       if (elapsed < TimeDelta::FromMilliseconds(1))

  128.  
  129.         PlatformThread::YieldCurrentThread();

  130.  
  131.       else

  132.  
  133.         PlatformThread::Sleep(TimeDelta::FromMilliseconds(1));

  134.  
  135.     } while (subtle::Acquire_Load(state) == kLazyInstanceStateCreating);

  136.  
  137.   }

  138.  
  139.   // Someone else created the instance.

  140.  
  141.   return false;

  142.  
  143. }

  144.  
  145.  
  146. void CompleteLazyInstance(subtle::AtomicWord* state,

  147.  
  148.                           subtle::AtomicWord new_instance,

  149.  
  150.                           void (*destructor)(void*),

  151.  
  152.                           void* destructor_arg) {

  153.  
  154.   // Instance is created, go from CREATING to CREATED (or reset it if

  155.  
  156.   // |new_instance| is null). Releases visibility over |private_buf_| to

  157.  
  158.   // readers. Pairing Acquire_Load is in NeedsLazyInstance().

  159.  
  160.   subtle::Release_Store(state, new_instance);

  161.  
  162.  
  163.   // Make sure that the lazily instantiated object will get destroyed at exit.

  164.  
  165.   if (new_instance && destructor)

  166.  
  167.     AtExitManager::RegisterCallback(destructor, destructor_arg);

  168.  
  169. }

参考资料:
1. 《C++并发编程实战》(原名:C++ Concurrency IN ACTION, Anthony Williams)
1. 《 C++ and the Perils of Double-Checked Locking 》https://erdani.com/publications/DDJ_Jul_Aug_2004_revised.pdf
1. https://www.cnblogs.com/mataiyuan/p/13372374.html
1. https://www.cnblogs.com/aquester/p/10328479.html
1. https://www.dazhuanlan.com/2019/12/14/5df40323d6adf/

 

 

 

 

other

解决方法2:采用内存屏障的方法

std::atomic_thread_fence(std::memory_order_acquire);
获得是一个对内存的读操作,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去。

std::atomic_thread_fence(std::memory_order_release);
释放是一个对内存的写操作,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去。

acquire 和 release 通常都是配对出现的,目的是保证如果对同一个原子对象的 release 发生在 acquire 之前的话,release 之前发生的内存修改能够被 acquire 之后的内存读取全部看到。

class single3{
    public:
    static single3* getsingle3();

    private:
    single3(){}
    ~single3(){}
    
    pthread_mutex_t lock;
    atomic<single3*> single3:p;

};

single3* single3::getsingle3(){
    single3* temp=p.load(memory_order_relaxed);
    automic_thread_fence(memory_order_acquire);
    if(temp==nullptr){
        pthread_mutex_lock(lock);
        temp=p.load(memory_order_relaxed);
        temp=new single3();
        atomic_thread_fence(memory_order_release);
        p.store(temp,memory_order_relaxed);
    }
    return p;
}
————————————————
版权声明:本文为优快云博主「梅杏柿」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.youkuaiyun.com/meixingshi/article/details/116601777

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值