Memory Order再探
之前的几篇文章中简单探讨了如何使用 std::atomic 及其特化进行编程,但是对于memory_order仍存一定疑虑,本文将从代码,编译器,CPU,内存的角度来介绍 memory_order 的实质。
多线程下程序执行三问题
之前的文章C++和双重检查锁定模式(DCLP)的风险介绍过由于编译器优化导致的“乱序执行”。
- 一个浅显简单的例子就是,同时声明的2个变量a和b,编译器并不对a和b的声明顺序做任何保证。更可能的情况是,编译器把变量a和b的声明移动到a和b分别使用之前。
问题1:编译器通常会以单线程的角度对代码进行优化,编译器只能确保代码满足逻辑需求,而不能保证代码以编写顺序执行。
-
对于现代CPU来说,除了硬盘,内存,寄存器之外还有第四级储存,即CPU cache,CPU cache可以被多个CPU核心(逻辑处理单元)共享,如常见的L2,L3 cache,也可以被每个核心独享,如常见的L1 cache。对于共享的cache,通常不用考虑数据一致性,但是对于独占的cache,就必须考虑数据一致性了。
所谓数据一致性,就是指同一个数据,可能在不同独占缓存中存有不同复制。例如对于同一个整数型变量A,单线程下先将A改为1,然后将A改为0,最后的结果当然是0。而如果变为一个线程(1个核心)将A改为1,另一个核心同时将A改为0。此时线程1和线程2修改的是A的不同复制,分属于不同独占缓存。在某个时间点上,独占缓存中的数据被写入到内存中,如果不加以保护,A就可能是0和1中的任意值。
问题2:每个线程都维护了自己的数据,对于同一变量的同时写入,如果不加以保护,则结果未知。
- 对于
string的拼接操作,拼接操作需要的数据大小可能超过独占缓存大小,此时在函数执行过程中,就存在着缓存和内存的数据交换。
问题3:大数据操作会导致缓存和内存交换,不同线程可能存在缓存和内存的交叉数据依赖,如果不加以保护,则线程不安全。
- 现代CPU为了性能设计的非常先进,导致某些指令的执行顺序并不一定是设计时候的顺序(这个设计包括整个代码到二进制),导致指令执行顺序表现为乱序。
对于以上问题,C++提出了解决方案:锁和原子操作库。
问题1
对于问题1,锁和memory_order(除了relaxed`,relaxed仅保证原子性)都会对编译器优化进行限制。
锁是最强的限制,和seq_cst是一样的效果,对于这两种情况,编译器会对代码执行顺序进行强约束,区别在于锁是针对于代码块的,而原子操作针对其他原子操作。
- consume:当前线程(一般也可理解为函数)中依赖于当前加载的该值的读或写不能被重排到此加载前。其他释放同一原子变量的线程的对数据依赖变量的写入,为当前线程所可见。
- acquire:当前线程中读或写不能被重排到此加载前。其他释放同一原子变量的线程的所有写入,能为当前线程所见。
- release:当前线程中的读或写不能被重排到此存储后。当前线程的所有写入,可见于获得该同一原子变量的其他线程),并且对该原子变量的带依赖写入变得对于其他消费同一原子对象的线程可见。
- acq_rel:带此内存顺序的读修改写操作既是获得操作又是释放操作。当前线程的读或写内存不能被重排到此存储前或后。所有释放同一原子变量的线程的写入可见于修改之前,而且修改可见于其他获得同一原子变量的线程。
- seq_cst:带此内存顺序的加载操作进行获得操作,存储操作进行释放操作,而读修改写操作进行获得操作和释放操作,再加上存在一个单独全序,其中所有线程以同一顺序观测到所有修改。
问题2,3
锁和原子操作对于CPU cache的访问可能会产生额外的同步指令(如mfence),将CPU cache和内存进行同步,此时多线程退化为单线程执行,保障了线程安全。
除了使用原子操作外,标准还额外提供了 std::atomic_thread_fence 和 std::atomic_signal_fence 来规定memory_order,区别是前者可能存在潜在的CPU指令,后者只影响编译器优化。