调试经验 | C++ memory order和一个相关的稳定性问题

1 引言

在网上看了不少memory order的文章,结果越看越糊涂。本觉得懂了,结果碰到问题仍是不懂。反反复复,最终才造成一套能够逻辑自洽的解释。记录在此,既但愿减小后来者被错误文档误导的痛苦,也但愿有高手能够不吝赐教指点一二。android

在阐述memory order以前,首先须要介绍memory model,中文翻译为“内存模型”。关于这个概念,网上有很多文不对题的解释,尤为是Java的memory model。很多文章将将它理解为JVM中各块内存区域的分布和做用,实际上是张冠李戴了。c++

  • Java memory model: Java内存模型,它是对于语言的描述,属于一个抽象的概念。Java语言经过JVM跑在不一样的操做系统和硬件平台上,而不一样硬件平台对于代码的优化策略是不一样的。过分优化虽然可以得到更好的性能,但也会下降程序的可编程性,使得并发的程序结果与预期不符。所以一方面为了限制底层的优化策略(告诉他们什么能够作,什么不能够作),另外一方面让程序员能够明确的获知并发程序将来的运行状况,最终语言的设计者在两者之间定下一份“契约”,双方都按照这份“契约”来进行本身关于并发的全部操做。这份“契约”就叫作内存模型。
  • JVM memory structure: JVM内存结构,它是对于虚拟机的描述,属于一个具体的概念。它描述了JVM运行后内存中各块区域的做用及其中存储的数据类型。

2 C++ Memory Order

2.1 Memory Order的基本概念

C++内存模型和Java内存模型同样,都属于语言层面的抽象规范。而memory order则属于其中的子概念,于C++11中正式被引入。它定义了一个原子操做与其附近全部其余与memory交互的操做之间的重排(reorder)限制。为了方便理解,其定义能够被拆分为如下几个小点来细化:程序员

  1. memory order限定了两个内存操做之间是否能够被重排。一条CPU指令能够是与内存有关的读写操做,也能够是跳转,数值运算之类的与内存无关的操做。因为共享数据的竞争状态只受到内存操做的影响,因此memory order也只限制内存操做之间的重排。
  2. memory order是与原子对象绑定的一个属性,因此其限定的两个内存操做中必然有一个是与之绑定的原子对象的读写操做。至于另一个操做对象是原子的仍是非原子的,答案是都有可能。
  3. memory order限定了操做是否能够被重排,所以是针对单一线程的限定。至于两个线程间的数据可见性特色,那是由于memory order对各自线程的重排操做作了限定后带来的“附加好处”,而不是它自身定义的内容。

对于C++而言,普通开发者是不须要也不会接触到memory order这个概念的,由于他们被保护得很好。这里的“保护”指的是:编程

  1. 普通开发者对于并发时的数据竞争其实经过Mutex和Lock就已经足够解决了。
  2. 当开发者使用Atomic对象时,它默认的memory order便是最为严格的sequential consistent,所以能够充分保证原子对象之间不会发生重排操做。

既然如此,那memory order这个概念为何还会诞生?缘由也有两个:markdown

  1. 普通开发者只知道用Mutex和Lock,但Mutex和Lock又是如何实现的呢?又或者说假设有大牛开发者想要实现一套属于本身的Mutex库呢?
  2. 虽然Mutex和Lock的开销对于普通开发者可有可无,但对于某些追求极致性能的场合,这类开销也会变得面目可憎。所以有些大牛开发者开始追求无锁化编程,他们本身能够处理好各类重排的可能,而且但愿在语言方面放开对重排的限制。memory order越弱,指令可被优化的程度就越高。

2.2 Memory Order的详细划分

Memory order总共有6种类型,但这里我只准备介绍4种。memory_order_consume和memory_order_acq_rel被排除在外的缘由以下:并发

  1. Hans Bohem,一直到2017年的ISO C++ Concurrency Study Group (WG21/SG1)主席,在CppCon 2016上的报告中明确指出memory_order_consume的设计尚有缺陷,建议你们不要使用。
  2. memory_order_acq_rel用在RMW(Read-Modify-Write)的操做上,但其语义本质就是memory_order_acquire和memory_order_release的结合。

接下来首先出场的是memory_order_acquire和memory_order_release。memory_order_acquire只用于原子化的load(读操做)操做,而memory_order_release只用于原子化的store(写操做)操做。其写法一般以下所示:函数

std::atomic<int> x;
x.load(std::memory_order_acquire);
x.store(1, std::memory_order_release);
复制代码

Memory_order_acquire

memory_order_acquire禁止了该load操做以后的全部读写操做(不论是原子对象仍是非原子对象)被重排到它以前去运行。oop

Memory_order_release

memory_order_release禁止了该store操做以前的全部读写操做(不论是原子对象仍是非原子对象)被重排到它以后去运行。性能

在同一个原子化对象上使用这两种memory order将会获得一个额外的好处,即两个线程在知足某种条件时将会拥有特定的数据可见性。这句话比较拗口,下面用图示来展开说明。优化

当flag.load在时间上晚发生于flag.store时,Thread 1上flag.store以前的全部操做对于Thread 2上flag.load以后的全部操做都是可见的。若是flag.load发生的时间早于flag.store,那么两个线程间则不拥有任何数据可见性。

为了保证flag.load在时间上晚发生于flag.store,咱们能够经过if逻辑来进行选择。所以,下面的写法将会永远assert经过。

接着咱们考虑当全部Atomic对象的读都采用memory_order_acquire,写都采用memory_order_release时,两个不一样Atomic对象的操做之间是否会发生重排。

读写操做之间的关系总共有四种:读读,读写,写写,写读。对于前三种操做关系,memory_order_acquire和memory_order_release均可以保证两条针对原子对象的操做不发生重排。但针对最后一种操做关系“写读”则没法保证。缘由是前面一条指令是store,memory_order_release只能保证store以前的指令不重排到store以后,却没法禁止位于其后的load指令重排到它前面;后面一条指令是load,memory_order_acquire只能保证load以后的指令不重排到load以前,却没法禁止位于其前的store指令重排到它后面。最终store指令将有可能重排到load指令以后,这种没法禁止的重排关系咱们简称为SL。

Memory_order_acquire和memory_order_release是程度中等的memory order,比他们强一些的就叫作memory_order_seq_cst(sequential consistent),它只比memory_order_release/memory_order_acquire多一个功能,便可以禁止SL的重排。而memory_order_release则至关于自废武功,两个不一样原子对象间的操做能够随便重排,它只保证针对同一个原子对象的操做不发生重排。

3 ART Mutex的问题

3.1 ART Mutex原理简介

ART虚拟机实现了本身的Mutex,其中最关键的函数即是Mutex::ExclusiveLock和Mutex::ExclusiveUnlock。Android Q以前的Mutex实现代码以下。

Mutex::ExclusiveLock:

其中最关键的操做是①和②:

①表示该Mutex多了一个竞争者,因为是原子化对象的++操做,所以采用默认的memory order: memory_order_seq_cst。

②是一个RMW(Read-Modify-Write)操做,它会读取state_的值,并和1进行比较。若是相等则将此线程挂起;若是不相等则直接返回0,让该线程从新判断是否能够得到Mutex。因为是默认的load操做,所以也采用memory_order_seq_cst。

Mutex::ExclusiveUnlock:

其中最关键的操做是③和④:

③也是一个RMW操做,它会读取state_的值,并和cur_state进行比较。若是相等,则令state_等于0;若是不相等,则返回false。因为是默认操做,因此load和store都采用memory_order_seq_cst。

④读取num_contenders的值,可是传入了memory_order_relaxed,代表对该操做作了最弱的重排限制。

3.2 老版本的Mutex为何有问题?

上面的代码在多数状况下都没有问题,可是按照以下的顺序执行便会出错。

Thread 1执行解锁的操做,Thread 2执行上锁的操做。

因为③是RMW操做,实质上能够拆分为多条指令,③.a和③.b表示其中的load和store操做。CompareAndSet虽然是原子化操做,可是它只保证在执行过程当中该原子对象的值不会被外界改动。至于其余指令是否能够重排到③.a和③.b之间,则由具体的memory order决定。

操做④能够被重排在③.a和③.b之间的缘由:

虽然③.a和③.b的memory order为memory_order_seq_cst,可是当重排的另外一个操做不是memory_order_seq_cst修饰的原子化操做时,memory_order_seq_cst便退化成了memory_order_acquire或memory_order_release(取决于操做是load仍是store)。所以③.a只能限定④不重排到它以前,而③.b对④则没有任何重排限制。所以,④能够被重排到③.a和③.b之间。

从Thread 1的视角看,①和②都是针对原子对象的操做,所以两者的执行顺序必须等同代码顺序,也即①在②以前执行。另外①②在③.a和③.b中间执行并不影响③的原子性,所以也是被容许的。

一旦程序按照这样的顺序执行,便会致使Thread 2释放锁但不唤醒Thread 1,以致于Thread 1一直睡下去。而究其缘由,这一切都是由④重排到③.a和③.b之间致使的。

3.3 Mutex的问题该如何修复?

如今咱们从新思考③.b和④之间的关系。

③.b是一个原子化对象的store操做,④是一个原子化对象的load操做。这是一个典型的SL(store load)情形,而限制他们被重排只有一种方法:将两者都用memory_order_seq_cst修饰。

所以解决的方案也比较简单,也即将num_contenders_的memory order改成memory_order_seq_cst。

固然,这么更改之后会对性能产生必定的影响。由于Mutex在虚拟机中被大量的使用,它任何小小的改动都会影响深远。正是由于这个缘由,Google在Android Q上对Mutex的实现进行了优化,将num_contenders_和state_合并为一个原子化对象,这样就不存在两个不一样原子化对象操做之间的重排关系了。合并后的原子化对象叫作state_and_contenders_,其最低位的0或1表明state_,而高位的数字除以2便表明num_contenders_。

具体的change在这里,感兴趣的伙伴能够继续研究。

相关文章
相关标签/搜索