上篇博客提到了,编译阶段和运行阶段的重排在各种多核平台上可能会导致非预期的内存序,而线程间的数据依赖只有程序员自己最清楚,也就是是否有必要进行某些数据同步。同步是有开销的,而且不同的硬件平台开销还不一样,减少不必要的同步有时候能够显著提高性能,无论是strong memory model还是weak memory model,因为性能的考量各种主流多核平台都不会保证顺序一致性,或者有些场景默认的内存序又太过严格,程序员理解了各个内存序后再根据实际情况进行选择才能在保证正确的情况的下尽可能地提高性能。
为了能够便捷、统一地在各平台指定内存序,c++11标准开始在C++的语言定义层面上引进memory order,memory_order给程序员提供了一种指定内存序而不需要在乎硬件具体底层实现的手段,编码者通过指定memory_order告诉编译器需要什么样的内存序,编译器会根据cpu平台类型选用合适的手段来保证对应的同步,从而能够相对便利地写出高效的跨平台的多线程代码。在此之前要指定内存序要么依赖第三方库,要么需要根据运行的平台直接调用相关cpu指令等,会麻烦很多。
作为一个语言层面引入的新特性,为了保证逻辑的严谨,采用了形式化定义的方式,采用采用形式话定义,就不可避免地会涉及到各种术语和关系,而要想尽可能透彻地理解c++的memory order,吃透这些术语和关系是很有必要的,虽然看上去很晦涩和繁琐,这篇博客首先会简单介绍下六种memory_order,随后会重点聊一下这些术语和关系。
1 c++的六种memory_order
cppreference.com上列出了memory_order的定义:
std::memory_order specifies how memory accesses, including regular,
non-atomic memory accesses, are to be ordered around an atomic
operation。
也就是指定了包括普通的非原子内存访问在内的原子操作周围的内存访问方式。可以看到,c++的memory order提供的是一种通过原子变量限制内存序的手段,也就是说原子变量不光有我们熟知的原子变量的特性,还能限制包括非原子变量在内的各种变量的内存操作,除了依附在原子变量操作的memory order,c++11还引入了 std::atomic_thread_fence,也能达到类似的效果,这个后面再讨论。std::memory_order是一个枚举,一共有六种,下面分别简单介绍下各自的用途和效果,本篇文章不会详细展开。下面的内容主要是以cppreference上std::memory_order的英文文档内容为基础,加上一些自己的理解和描述的润色,可以结合cppreference一起看,虽然cppreference上有中文版,但因为一些术语的翻译和排版的关系,个人觉得看起来很费劲,不建议直接看中文版。
1.1 memory_order_relaxed
这个很好理解,宽松的内存序,使用这个内存序的原子操作就真的仅仅是保证自身的原子性,不会对任何其他变量的读写产生影响。如果确实不需要其他的内存同步,那么这是最好的选择,比如原子计数器。
1.2 memory_order_consume
memory_order_consume适用于load operation(原子读操作),对于采用此内存序的load operation,我们可以称为consume operation,设有一个原子变量M上的consume operation,对周围内存序的影响是:当前线程中该consume operation后的依赖该consume operation读取的值的load 或strore不能被重排到该consume operation前,其他线程中所有对M的release operation(下面memory_order_release讲到的)及其之前的对数据依赖变量的写入都对当前线程从该consume operation开始往后的操作可见,相比较于下面讲的memory_order_acquire,memory_order_consume只是阻止了之后有依赖关系的重排。绝大部分平台上,这个内存序只会影响到编译器优化,依赖于dependency chain。但实际上很多编译器都没有正确地实现consume,导致等同于acquire。
1.3 memory_order_acquire
memory_order_acquire适用于load operation,对于采用此内存序的load operation,我们可以称为acquire operation,设有一个原子变量M上的acquire operation,对周围内存序的影响是:当前线程中该acquire operation后的load 或strore不能被重排到该acquire operation前,结合下面的memory_order_release我们能推导出从而会有其他线程中所有对M的release operation及其之前的写入都对当前线程从该acquire operation开始往后的操作可见。
1.4 memory_order_release
memory_order_release适用于store operation(原子写操作),对于采用此内存序的store operation,我们可以称为release operation,设有一个原子变量M上的release operation,对周围内存序的影响是:该release operation前的内存读写都不能重排到该release operation之后。结合memory_order_acquire的左右从而有:
- 当前线程截止到该release operation的所有内存写入都对另外线程对M的acquire operation以及之后的内存操作可见,这就是release acquire 语义。
- 当前线程截止到该operation的所有M所依赖的内存写入都对另外线程对M的consume operation以及之后的内存操作可见,这就是release consume语义。
1.5 memory_order_acq_rel
memory_order_acq_rel适用于read-modify-write operation,对于采用此内存序的read-modify-write operation,我们可以称为acq_rel operation,既属于acquire operation 也是release operation. 设有一个原子变量M上的acq_rel operation:自然的,因为同时具有两种属性,所以该acq_rel operation之前的内存读写都不能重排到该acq_rel operation之后,该acq_rel operation之后的内存读写都不能重排到该acq_rel operation之前. 其他线程中所有对M的release operation及其之前的写入都对当前线程从该acq_rel operation开始的操作可见,并且截止到该acq_rel operation的所有内存写入都对另外线程对M的acquire operation以及之后的内存操作可见。
1.6 memory_order_seq_cst
memory_order_seq_cst 可以用于 load operation,release operation, read-modify-write operation三种操作,用于 load operation的时候有acquire operation的特性,用于 store operation的时候有release operation的特性, 用于 read-modify-write operation的时候有acq_rel operation的特性,除此之外,有个很重要的附加特性,一个单独全序,也就是所有的线程会观察到一致的内存修改顺序,也就是第一篇博客提到的顺序一致性的强保证。
以上就是六种内存序的定义和效果,单独理解每一个memory_order并不难,真正比较麻烦的是在实际应用中各种内存序组合后的效果,这也是memory order的重点所在,有一点强调一下,就是这里所谓的指定内存序,指的是对执行语句所在的线程内部的限制,也就是之影响一个cpu核心,但是这些对单线程内部的限制组合起来就能实现多线程之间数据同步的效果,我们利用的就是各种内存序之间的组合效果来保证程序在实际运行时候的正确memory order,这个后面的博客会展开讲,而要深层次理解这部分最好能了解形式化定义中术语描述的各种关系,这也是本篇接下来的内容。
2 形式化定义用到的术语和关系
2.1 Sequenced-before
这个定义的是同一个线程内的一种根据表达式求值顺序来的一种关系,完整的规则定义很复杂,可以参考http://en.cppreference.com/w/cpp/language/eval_order,其中最直观常用的一条规则简单来说如下:每一个完整表达式的值计算和副作用都Sequenced-before于下一个完整表达式的值计算和副作用。从而也就有以分号结束的语语句Sequenced-before于下一个以分号结束的语句,比如:
r2 = x.load(std::memory_order_relaxed); // C
y.store(42, std::memory_order_relaxed); // D
从而有 C Sequenced-before D。
2.2 Carries dependency
Carries dependency,字面意思是携带依赖,描述的是有sequenced-before关系的求值操作之间的一种依赖关系,对于求值A和求值B,A Carries dependency into B的意思是B对A有依赖,在A sequenced-before B的前提下,如果:
- A是B的一个操作数
- A写入了某个标量对象(float、int、指针枚举等)M,B从M里读
- 求值X依赖A,B依赖X
那么我们说B依赖A。
2.3 Modification order
Modification order,字面意思是修改顺序,这个是针对原子变量的一个概念,对于c++里的原子变量,有个很重要的保证,那就是:对于任意特定的原子变量的所有修改操作都会以一个特定于该原子变量的全序发生,换句话说,不管是什么序的原子变量操作,针对原子变量本身是会有一个全序的,因此也保证了原子变量的操作在各线程之间有如下四个一致性(为了描述简洁下面说的读写都是针对某一个特定的原子变量):
- 写-写一致性:如果写A happens-before 写 B ,那么在Modification order中A 比B早出现
- 读-读一致性:如果读A happens-before 读B, 并且如果 A 读到的是来自写X, 那么B读到的要么也是写X的值,要么是Modification order中比X后出现的一个写Y 的值.
- 读-写一致性:如果读A happens-before 写B, 那么A读到的值来自于modification order 中的一个早于B出现的写X
- 写-读一致性:如果一个写X happens-before 读B , 那么B读取的值可能来自写X或者modification order 中在X之后出现的写Y。
happens-before是内存序里很重要的一种关系,下面会详细阐述,这里只需要记住,如果A Happens-before B,那么A对内存的修改将会在B操作之前对B可见。上面四条规则,看上去很复杂和繁琐,但阐述的内容其实很好理解。
2.4 Release sequence
Release sequence,指的是某个原子变量M上由一个release operation开始的一个序列,正式定义如下:
原子变量M上的release sequence headed by A指的是M的Modification order上的一个最大连续子序列,该序列由A开始,当前线程内对M的写操作(这个在c++20被去掉了,这里先忽略),以及其他线程对M的read-modify-write操作(test-and-set, fetch-and-add, compare-and-swap等)组成。
对应的,有一个比较重要的release sequence rule,能够保证特定情况下多个acquire线程和个release操作形成Synchronizes-with关系,比如线程A release store,B fetch_sub,后面展开讲release acquire语义的时候会详细讲下,暂时了解Release sequence这个概念就行。
2.5 Synchronizes-with
Synchronizes-with,也就是同步关系,用来描述对内存的修改操作(包括原子和非原子操作)对其他线程可见,有一点需要注意,这是一个运行时的关系,也就是说不是代码层面的,代码只是让程序在运行的时候有可能建立这种关系,并且一旦建立了就有可见性的保证。简单来说,如果A和B建立了Synchronizes-with关系,那么A之前的内存修改对B之后都可见,preshing大神给的例子很好,如下分别是建立了Synchronizes-with关系和没建立的情况:
下图中描述的运行情况是g_guard值在thread1中写入比thread2 中的读取晚,那么就没有建立Synchronizes-With关系,thread1中前面的内存不保证对thread2后面的操作可见。
在Synchronizes-With关系的实际应用中,往往有两个关键元素,用于建立Synchronizes-with关系的原子变量和需要在线程间共享的数据,比如上面图里的g_guard和g_payload,当然guard本身也可以是需要共享的数据,比如队列的top和bottom。
这里先提一嘴,release 和 acquire就能够建立Synchronizes-with关系。
2.6 Dependency-ordered before
这个关系是针对consume来的,对于分属不同线程的赋值A和赋值B,如果他们之间有以下关系之一:
- A对某个原子变量M做release操作,B对M做consume操作,并且B读到了release sequence headed by A中的任意一个值。这条就是上面说的release sequence rule的关键。
- A is dependency-ordered before X,并且 X carries dependency into B。前面说过了,carries dependency into是线程内的一种关系,这里就是X和B属于同一线程,并且B对X有依赖。
我们就说 A dependency-ordered before B。
2.7 Inter-thread happens-before
Inter-thread happens-before,看字面意思就知道是定义了线程间赋值操作之间的关系,假如有线程1中的赋值操作A,线程2中的赋值操作B,如果满足以下任意一条,那么有A inter-thread happens before B:
- A synchronizes-with B
- A dependency-ordered before B
- A synchronizes-with 某个 evaluation X, 并且 X sequenced-before B
- A sequenced-before 某个 evaluation X, and X inter-thread happens-before B
- A inter-thread happens-before 某个evaluation X,并且X inter-thread happens-before B
虽然看上去有点繁琐,其实每一条都是很自然的,核心就是其中的传导关系,多线程编程中,两个操作如果能推导出Inter-thread happens-before的关系,那么内存修改的可见性是有保证的,换句话说就是不用担心重排带来的影响。
2.8 Happens-before
Happens-before是特别重要的一个概念,定义的是两个evaluation(可以通俗地理解为包括读写在内地一些值操作)之间的一种关系,不管是线程内部还是线程之间,evaluation A和evaluation B如果满足下面两者条件之一,我们就说A Happens-before B:
- A sequenced-before B
- A inter-thread happens before B
换句话说,如果A和B处于一个线程内部,只要A sequenced-before B那么就满足A Happens-before B,如果A和B分属两个线程,那么如果有A inter-thread happens before B这层关系那么就满足A Happens-before B。
重点来了,如果A Happens-before B,那么A对内存的修改将会在B操作之前对B可见,也就是下面讲到的Visible side-effects,我们利用memory_order就是为了确保某些evaluation操作之间有Happens-before关系。
2.9 Visible side-effects
先解释下涉及到的一个概念Scalar,指的是以下类型:
- arithmetic (integral, float)
- pointers: T * for any type T
- enum
- pointer-to-member
- nullptr_t
可见副作用,通俗地讲就是写能被读看见,正式定义如下:
有对同一Scalar类型的M的写A和读B,如果满足同时满足下述两个条件那么A的副作用对B可见:
- A happens-before B。
- 不存在一个对M有副作用的写X, A happens-before X 并且X happens-before B。
换句话说,就是A和B有happens-before 关系,并且二者之间没有其他的写,否则可能会读到其他写的内容,从而A的副作用不保证对B可见。
参考:
https://en.cppreference.com/w/cpp/atomic/memory_order
https://preshing.com