1. 前言
C++ 11之前是没有线程内存访问模型的,因此无法正确地原生地通过double-checked locking pattern (DCLP)来实现singleton,具体原因可见Scott Meyers的文章C++ and the Perils of Double-Checked Locking. 因此,为了通过DCLP来实现singleton, C++ 11之前只能采用pthread或直接调用平台相关汇编指令等不可跨平台移植的方式。
C++ 11建立了线程内存访问模型,从此之后我们可以通过原生C++来可移植地实现singleton. 由于singleton的实现方式依赖内存访问模型,因此本文先将对其做简要介绍,然后再详解其在singleton实现中的作用。
2. C++ 11 Memory Order简介
对于多核系统的多线程程序,如果不做同步处理,每个线程看到的内存内容可能是不同的,原因如下:
- CPU和编译器为了性能一般会重排指令,而指令重排的唯一限制是不影响单线程语义(AS-IF准则),因此可能会影响多线程语义;
- 对于多核CPU,每个核都有自己的L1/L2缓存,而这些缓存内容不一定和主内存同步,各核的缓存之间一般也不同步。
因此,如果不加内存顺序约束的话,某个线程将无法以正确的顺序看到别的线程的内存改动而出错。 比如,在singleton场景下,线程A构造了对象后再设置instance指针,线程B却可能先看到instance指针而后看到构造好的对象内存。
为了解决这个问题,CPU一般会提供多种内存屏障fence/barrier来约束CPU指令重排行为。 不同架构的CPU提供的默认内存顺序保障一般是不同的,提供的内存屏障类型一般也不相同。 C++对不同CPU内存屏障做了一个抽象,其能够描述目前市面上所以的CPU类型的内存顺序行为。
从逻辑上将,内存屏障可以被分为四类,如下:
其中,LoadLoad禁止Load间重排,StoreStore/LoadStore/StoreLoad也类似。 C++根据这些barrier的使用习惯抽象出来了三类内存顺序:
- relaxed: 不使用内存屏障,没有内存顺序约束
- acquire-release: LoadLoad/LoadStore合在一起构成了acquire fence, StoreStore/LoadStore合在一起构成了release fence;
- sequential consistency (seq-cst): StoreLoad fence要求某个核store后能被所有核load时看见,因此本质上是全局一致性顺序。
接下来,本节将对这三类内存顺序做简要介绍。
2.1. Relaxed内存顺序
Cpprefence对relaxed顺序(memoryorderrelaxed)的原文描述是"there are no synchronization or ordering constraints imposed on other reads or writes, only this operation's atomicity is guaranteed". 也就是说,relaxed顺序不对内存访问做任何限制,对原子变量而已也仅保证原子性,也就是其它线程不会看到中间态。 因此,仅使用relaxed顺序会导致前面描述过的不同线程将看到不同内存内容的问题。
2.2. Acquire-Release内存顺序
Acquire-release内存顺序可以被认为是通过轻量级fence来同步两个线程,前面的relaxed内存顺序不加任何同步,而下一小节要介绍的sequential consistency顺序则会通过重量级fence来同步所有线程。
Acquire和release fence会绑定fence之后的第一个load/store指令, 其会强制CPU在执行fence后的第一个load/store指令之前先commit掉前面所有的load/store指令。 具体而言:
- Load-Acquire fence本质上是LoadLoad/LoadStore fence, 其会绑定fence后的第一个load (LoadLoad) / store (LoadStore)指令, 使得下次load/store时作为一个batch commit掉前面所有的load, 从而使得fence前的load不能跨越fence后的第一个load/store, 并且还会再根据前一个load的batch信息来刷新当前thread中早于该batch的相关cache, 进而确保被load的payload值的至少和guard值一样新鲜. Load-Acquire的cppreference定义为"A load operation with this memory order performs the acquire operation on the affected memory location: no reads or writes in the current thread can be reordered before this load. All writes in other threads that release the same atomic variable are visible in the current thread (see Release-Acquire ordering below).", 可以看出其目的为通过限制指令重排而确保store可见性。 例如,thread2 load payload前先根据guard变量的batch信息来刷新和thread2 cache相关的其它thread1 store (个人理解是通过将thread2 cache中和thread1其它store相关的内存标记为expired来实现的), 从而将thread内happen-before关系拓展到thread间。需要注意的是,thread2 cache刷新在load而不是store中做,否则得刷新所有相关thread, 而seq-cst用的重量级fence则是在store时刷新所有其它thread cache. 若无acquire fence来将happen-before关系拓展到thread间,对于thread1 A -> thread1 B1和thread2 B2 -> thread2 C的操作顺序, 即使thread1 B1先于thread2 B2, 系统也并不能保证thread1 A happen-before thread2 C.
- Store-Release fence本质上是StoreStore/LoadStore fence, 其会绑定fence后面第一个store (StoreStore/LoadStore),使得下次store时作为一个batch commit掉前面的store/load, 从而使得fence前的store/load不能跨越fence后的第一个store, 进而确保payload store时间不会晚于guard store. Store-Release fence的cpprefernce定义为"A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable (see Release-Acquire ordering below) and writes that carry a dependency into the atomic variable become visible in other threads that consume the same atomic (see Release-Consume ordering below).", 可以看出其目的也是通过限制指令重排而确保store可见性。
Acquire和release fence都带有LoadStore fence, 其目的是方便mutex等组件可以将临界区的读写操作限制在mutex lock和unlock间。 具体而言,mutex lock使用了acquire fence, 而mutex unlock使用了release fence. 若acquire和release fence未带有LoadStore fence, 那么临界区的store可以被重排到mutex lock之前,而临界区的load可以被重排到mutex unlock()后,而这显然不能达到的mutex保护临界区的目标。
此外,C++还有std::memoryorderacqrel内存顺序,其同时使用了load-acquire和store-release fence, 其只能被用于read-modify-write (RMW)操作,本质上是进行acquire fence + RMW + release fence操作。 acqrel内存顺序的cpprefence对定义为"A read-modify-write operation with this memory order is both an acquire operation and a release operation. No memory reads or writes in the current thread can be reordered before the load, nor after the store. All writes in other threads that release the same atomic variable are visible before the modification and the modification is visible in other threads that acquire the same atomic variable.",
最后,C++还有一个memoryorderconsume内存顺序,其可被看着弱化版的load-acquire fence。其对指令重排的约束比load-acquire fence弱. consume fence会在执行ptr->i前先刷新ptr->i内存以确保ptr->i比ptr新。 Consume内存顺序的cpprefence定义为"A load operation with this memory order performs a consume operation on the affected memory location: no reads or writes in the current thread dependent on the value currently loaded can be reordered before this load. Writes to data-dependent variables in other threads that release the same atomic variable are visible in the current thread. On most platforms, this affects compiler optimizations only (see Release-Consume ordering below).", 可以看出其目的也是通过限制指令重排而确保store可见性。 例如,Acquire fence会刷新thread2 cache中和thread1 flag store相关的所有cache, 而consume则是按根据依赖关系需刷新cache。
2.3. Sequential Consistency内存顺序
Cpprefence对sequential consistency顺序(memoryorderseqcst)的原文描述是"A load operation with this memory order performs an acquire operation, a store performs a release operation, and read-modify-write performs both an acquire operation and a release operation, plus a single total order exists in which all threads observe all modifications in the same order". 也就是说,seq-cst顺序可以让所有线程都看到一致的内存变化,而这本质上是用了StoreLoad这个重量级fence。 StoreLoad fence会主动将其它所有CPU核心中和前面store相关的cache都标记为expired (而acquire/releaes fence仅在load时被动地刷新当前线程的相关cache), 从而使得所有CPU core都看到一致(consistency)的共享内存,从而退化为顺序(sequentially)执行。
X86可以通过lock add/xadd/xchg/cmpxchg这种RMW之类来实现StoreLoad fence, 而RMW从语义上会穿透CPU core cache来直接操作主内存(否则若各个线程将看到各自cpu core cache中不同的RMW obj)。 理论上,RMW从语义上可以通过仅刷新当前RMW obj对应的cache来实现穿透CPU core cache, 而没必要按照seq-cst/StoreLoad fence要求的那样会刷新其它所有相关cache以使得各个线程会看到一致内存,因此StoreLoad fence对RMW来说其实是overskilled的。 换句话来说,RMW本身从逻辑上无需像StoreLoad fence那样来维持seq-cst order, StoreLoad fence爷仅是实现RMW的overskilled方案之一。 另外,当多个线程通过RMW来修改同一个原子变量时,RMW从语义上会强制CPU在硬件层面上对这些线程进行序列化(而不是mutex这种OS层面上)以串行地执行对该原子变量的modify动作(虽然在OS层面上这些线程并行的)。 此外,当多个原子变量共享缓存行cache line时,多个线程对不同原子变量的并发RMW也将因cache pingpong而被迫在硬件层面上被序列化而恶化系统性能(false-sharing问题),其可以通过使用alignas(hardwaredestructiveinterferencesize)分隔这些原子变量来解决。
3. Singleton实现
Singleton的实现方式有两种,第一种是使用acquire/consume-release内存顺序的DLCP, 第二种是底层一般会依赖DLCP的Static Initializer语法糖。
3.1. DLCP实现方法
C++ 11之后我们可以采用标准库而无需依赖三方库地通过DCLP实现singleton, 代码如下:
std::atomic<Singleton*> Singleton::m_instance; std::mutex Singleton::m_mutex; Singleton* Singleton::getInstance() { Singleton* tmp = m_instance.load(std::memory_order_acquire); // 1.1, threadA // Singleton* tmp = m_instance.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire); // 1.2 // Singleton* tmp = m_instance.load(std::memory_order_consume); // 1.3.1 // Singleton* tmp = m_instance.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_consume); // 1.3.2 if (tmp == nullptr) { std::lock_guard<std::mutex> lock(m_mutex); tmp = m_instance.load(std::memory_order_relaxed); // 2 if (tmp == nullptr) { tmp = new Singleton; m_instance.store(tmp, std::memory_order_release); // 3.1, threadB // std::atomic_thread_fence(std::memory_order_release); m_instance.store(tmp, std::memory_order_relaxed); // 3.2 } } return tmp; }
Acquire fence和release fence需要配套使用才能形成synchronizes-with关系而将happen-before关系拓展到线程间以实现线程间内存同步,原因如下:
- 如果在1处不使用acquire fence,那么即使threadA在1处拿到了threadB在3处store的singleton指针,threadA可能也无法看到该指针指向的对象内存,因为此时该对象内存内容可能还没有从threadB的cache中传递过来。
- 如果在3处不使用release fence, 那么threadB可能在Singleton CTOR执行完成之前对minstance赋值,进而导致threadA看到所指对象尚未被完全构造的minstance指针。 而这根本原因是在单线程模型中,Singleton构造和minstance store是完全没有依赖关系的,因此CPU和编译器都可以这两者进行重排,而release fence可以避免这种重排。
1.2/3.2被注释代码中单独使用acquire/release fence的效果和1.1/3.1使用的带内存顺序的load/store效果一样,但是在某些架构下生成的汇编代码可能会不一样。 例如,ARMv8的ldar/stlr指令可以直接实现1.1/3.1, 而不是使用1.2/3.2对应的单独fence指令。
另外,对于singleton这种依赖指针来操作对象的做法,1.1/1.2处代码可以被替换为1.3.1/1.3.2,也就是将acquire order替换成consume order,这样做的好处时对于arm这种默认保持consume order的架构可以使用更轻量的汇编指令而提升运行效率。
最后,代码中2处无需使用任何fence的原因是threadB mutex unlock()和会threadA mutex lock()同步,而这是C++标准保证的,具体可参考CCIA 5.3.7.
3.2. Static Initializer实现方法
C++ 11规定局部静态变量初始化是线程安全的,因此可以如下代码来实现单例:
Singleton& Singleton::getInstance() { static Singleton instance; return instance; }
虽然C++标准没做具体规定如何实现这种语义,GCC等编译器在底层其实还是会用DCLP方式来实现之,因此这其实是一种语法糖。
4. 参考文档
- C++ Concurrency in Action, 2nd Edition: Chapter 5 - The C++ Memory Model and Operations on Atomic Types
- https://preshing.com/20130930/double-checked-locking-is-fixed-in-cpp11/
- https://preshing.com/20120930/weak-vs-strong-memory-models/
- https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
- https://en.cppreference.com/cpp/atomic/memory_order
- https://en.cppreference.com/cpp/atomic/atomic_thread_fence
- https://www.kernel.org/doc/Documentation/memory-barriers.txt