在学习C++11多线程的时候,会碰到一大堆概念,mutex, lock, atomic, memory model, memory barrier, lock-free等。要更好的理解,可以:
- 了解CPU的Memory Barriers机制(Paul McKenny的Memory Barriers: a Hardware View for Software Hackers)。了解CPU内部不同的核是怎么同步cache的,很有意思。这是理解memory order的基础。
- 然后看Jeff Preshing的blog, 他的帖子深入浅出,写得非常好。
- 《C++ Concurrency in action》, 作者是boost thread的作者。讲stl的thread的接口。
- 再看看Herb Sutter,Hans Boehm等人的文章。 Bartosz Milewski的博客也值得看看.
double-checked locking是一个用来学习多线程的好例子。 Scott Meyers和Andrei Alexandrescu两位大牛写过一篇paper,讨论了double-checked实现的困难(Java的memeory model没有完善之前有同样的问题). Jeff Preshing写篇文章讨论这个问题.
看完大牛们的文章, 一步一步来动手实现一个Singleton,最后用template泛化.
1. 单线程实现
Singleton的单线程实现很简单:
|
|
在单线程环境,这个版本工作的很好。 但在多线程线环境下有data race了, 关键在那个if判断, 多个线程可能同时进入if里面。
2. 多线程的尝试实现
C++11已经支持多线程,无需调用库,用std::mutex加个锁, 把if判断放到临界区里保护起来:
|
|
这个版本有什么问题呢?成本太高,每个调用都去获取锁,单例创建好之后,其实已经没有必要获取锁了,并发情况下会导致其他线程因等待锁而被系统休眠,成本太高了。 那么,每次调用都加锁, 在获取锁之前再加一个if(m_Instance == nullptr)判断, 是否可行?
想法很好,但是有严重的缺陷,来看看 m_Instance = new Singleton, 这个new操作是先分配一块空间,然后执行构造函数,相当于:
如果一个线程执行到step 1时, 另一个线程发现 m_Instance != nullptr, 直接把 m_Instance 返回,而Step 2 还没来得及执行,返回的指针指向一块并没有构造好的空间…
那么,来加一个临时变量,思路是让allocator和constructor都做完之后,再把指针赋给m_Instance,这样可行么?
但是,我们知道,编译器优化和CPU流水线执行都有可能对代码执行顺序进行re-order.(参考Memory Model), 这样:
3. C++11 Sequentially Consistent Atomics
要保证step 3在step 2之后执行,可以用Sequential ordering实现(即使用默认的memory_order_seq_cst),编译器会插入memery barrier来保证。
那么,我们来看看, atomic的load和store是怎样保证re-order之后语义还是正确的呢?
用gcc生成汇编代码(默认是AT&T风格汇编, 我习惯看intel风格的,加个masm=intel参数):
|
|
|
|
可以看到编译器在x86平台上为store()生成了mfence指令。 那load()为什么没有memory fence呢,是因为x86/64是”Strong”类型的CPU(细节可参考weak vs strong memory models和Paul McKenny的文章Memory Barriers).
4. 用Low-Level Ordering Constraints实现
一般来说,用默认的memory_order_seq_cst已经够用了,代码也简单一些。不过mfence指令的成本较高(几十倍于register to register指令,因为需要在CPU各个core和cache里进行复杂的通讯,同步cache line等等),如果是在高并发情景下,可以考虑进一步优化。可以用low-level的acquire/release operation. 有点晦涩,可以参考acquire and release fences和acquire and release semantics
|
|
再生成汇编代码,在x86/64平台上,可以看到,memory_order_release没有生成mfence指令. 为什么呢?
- acquire语义 = LoadLoad + LoadStore
- release语义 = LoadStore + StoreStore
|
|
而x86 CPU不需要barrier就能保证 LoadLoad, LoadStore, StoreStore, 所以, x86平台上acquire/release都不需要内存屏障指令. 这就是为什么编译后没有了mfence指令.
那,为什么代码里还要memory_order_relaxed、memory_order_release呢? 因为它们是语言层次上的抽象,可以阻止编译器的指令re-order. 同时保证了不同CPU平台的可移植性,在ARM, PowerPC等平台会生成对应的指令。
5. 用Template泛化
|
|
static方法
在C++ 11里, 用static变量只初始化一次的办法也行, C++ 11里也是线程安全的.
反汇编一下:
cxa_guard_acquire/cxa_guard_release是gcc的runtime自带的, 保证线程安全.
收工.