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

3,639 阅读9分钟

1 引言

在网上看了很多memory order的文章,结果越看越糊涂。本以为懂了,结果碰到问题还是不懂。反反复复,最终才形成一套可以逻辑自洽的解释。记录在此,既希望减少后来者被错误文档误导的痛苦,也希望有高手可以不吝赐教指点一二。

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

  • 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这个概念为什么还会诞生?原因也有两个:

  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操作之后的所有读写操作(不管是原子对象还是非原子对象)被重排到它之前去运行。

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_relaxed则相当于自废武功,两个不同原子对象间的操作可以随便重排,它只保证针对同一个原子对象的操作不发生重排。

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在这里,感兴趣的伙伴可以继续研究。