Skip to content

zhihu 如何理解 C++11 的六种 memory order?

A

从一个程序员角度的 Take away:

虽然是六种类型,但是理解了四种**同步**的情形基本就差不多了。

\1. Relaxed ordering: 在单个线程内,所有原子操作是顺序进行的。按照什么顺序?基本上就是代码顺序(sequenced-before)。这就是唯一的限制了!两个来自不同线程的原子操作是什么顺序?两个字:任意。

\2. Release -- acquire: 来自不同线程的两个原子操作顺序不一定?那怎么能限制一下它们的顺序?这就需要两个线程进行一下同步(synchronize-with)。同步什么呢?同步对一个变量的读写操作。线程 A 原子性地把值写入 x (release), 然后线程 B 原子性地读取 x 的值(acquire). 这样线程 B 保证读取到 x 的最新值。注意 release -- acquire 有个牛逼的副作用:线程 A 中所有发生在 release x 之前的写操作,对在线程 B acquire x 之后的任何读操作都可见!本来 A, B 间读写操作顺序不定。这么一同步,在 x 这个点前后, A, B 线程之间有了个顺序关系,称作 inter-thread happens-before.

NOTE: 上面这段话,其实是在使用Release -- acquire来实现thread synchronization,关于此的例子,参见:

1、modernescpp Acquire-Release Fences

\3. Release -- consume: 我去,我只想同步一个 x 的读写操作,结果把 release 之前的写操作**都**顺带同步了?如果我想避免这个额外开销怎么办?用 release -- consume 呗。同步还是一样的同步,这回副作用弱了点:在线程 B acquire x 之后的读操作中,有一些是依赖于 x 的值的读操作。管这些依赖于 x 的读操作叫 赖B读. 同理在线程 A 里面, release x 也有一些它所依赖的其他写操作,这些写操作自然发生在 release x 之前了。管这些写操作叫 赖A写. 现在这个副作用就是,只有 赖B读 能看见 赖A写. (卧槽真累)

有人问了,说什么叫数据依赖(carries dependency)?其实这玩意儿巨简单:

S1. c = a + b;
S2. e = c + d;

S2 数据依赖于 S1,因为它需要 c 的值。

\4. Sequential consistency: 理解了前面的几个,顺序一致性就最好理解了。Release -- acquire 就同步一个 x,顺序一致就是对所有的变量的所有原子操作都同步。这么一来,我擦,所有的原子操作就跟由一个线程顺序执行似的。


评论里有很多关于**x86内存模型**的指正,放在这里:

Loads are not reordered with other loads.Stores are not reordered with other stores.Stores are not reordered with older loads.

然后最重要的:

Loads may be reordered with older stores to different locations.

因为 store-load 可以被重排,所以x86不是顺序一致。但是因为其他三种读写顺序不能被重排,所以x86是 acquire/release 语义。

NOTE:

下面是理解上面这段话的参考文章:

1、"preshing Memory Barriers Are Like Source Control Operations # #StoreLoad"

2、工程hardware的Memory-ordering章节

其中对sequential consistency 和 store-load reordering进行了解释。

aquire语义:load 之后的读写操作无法被重排至 load 之前。即 load-load, load-store 不能被重排。

release语义:store 之前的读写操作无法被重排至 store 之后。即 load-store, store-store 不能被重排。


最简单的试试 relaxed ordering 的方法就是拿出手机。写个小程序,故意留个 race condition,然后放到 iPhone 或者安卓手机上调,不用 release -- acquire 保准出错。然而这种 bug 你在 x86 的 host 机上是调不出来的,即便拿模拟器也调不出来

A

NOTE: 这个回答我认为是最好的答案

看了一下,觉得没有触及实质的回答,所以补充一下。

这个问题的难点在于,很多人以为它们是限制多线程之间的执行顺序(包括我写这篇回答时的最高赞回答看起来也是这么认为的),然而其实不是

事实上,**Sequentially-consistent ordering**是目前绝大多数编译器的缺省设置。如果按照高赞回答的意思,那么多线程如果使用了atom操作,貌似就几乎变成了单线程(或者回合制)?真的吗?

NOTE:

1、这段话从反面驳斥了"限制多线程之间的执行顺序",因为如果是"限制多线程之间的执行顺序",并且std::atomic默认是**Sequentially-consistent ordering**,那么所有的thread都会被限制,那么整个就成为单线程了,显然不是如此的。

控制的是同一个线程内指令的执行顺序

既然是多线程,那么线程之间的执行顺序就一定不是确定性的(你只能在某些**点**同步它们,但是在任何其它的地方是没有办法保证执行顺序的)。C++11所规定的这6种模式,其实并不是限制(或者规定)两个线程该怎样同步执行,而是在规定一个线程内的指令该怎样执行

NOTE:

1、"你只能在某些**点**同步它们,但是在任何其它的地方是没有办法保证执行顺序的"的解释如下:

目前的技术水平,是无法对线程之间的执行顺序进行控制的,因为它的执行是由OS scheduler控制的

2、目前对single thread的执行顺序进行控制是能够实现的

3、目前只需要对shared data进行同步,因此它是**同步点**

是的,我知道这部分的文档(规定)以及给出的例子里面,满屏都是多线程。但是其实讨论的是单线程的问题。更为准确地说,是在讨论单线程内的指令执行顺序对于多线程的影响的问题

NOTE:

1、"更为准确地说,是在讨论单线程内的指令执行顺序对于多线程的影响的问题"的解释如下:

其实它所讨论的是memory ordering对多线程的影响,在这方面已经有了很多的总结,参见:

a、What-cause-unsafety 章节

什么是原子操作?

首先,什么是原子操作?原子操作就是对一个内存上变量(或者叫左值)的读取-变更-存储(load-add-store)作为一个整体一次完成。

让我们考察一下普通的非原子操作:

x++

这个表达式如果编译成汇编,对应的是3条指令:

mov(从内存到寄存器),
add,
mov(从寄存器到内存)

那么在多线程环境下,就存在这样的可能:当线程A刚刚执行完第二条指令的时候,线程B开始执行第一条指令。那么就会导致线程B没有看到线程A执行的结果。如果这个变量初始值是0,那么线程A和线程B的结果都是1。

如果我们想要避免这种情况,就可以使用原子操作。使用了原子操作之后,你可以认为这3条指令变成了一个整体,从而别的线程无法在其执行的期间当中访问x。也就是起到了锁的作用。

所以,atom本身就是一种锁。它自己就已经完成了线程间同步的问题。这里并没有那6个memory order的什么事情。

为什么要在同步点进行控制

问题在于以这个原子操作为中心,其前后的代码。这些代码并不一定需要是原子操作,只是普通的代码就行。

什么问题?比如还有另外一个变量y,在我们这个原子操作代码的附近,有一个

y++

那么现在的问题是,这个y++到底会在我们的x++之前执行,还是之后?

注意这完全是单线程当中指令的执行顺序问题,与多线程风马牛不相及。但是,这个问题会导致多线程执行结果的不同。理解了这个,就理解了那6种memory order。

NOTE:

1、上面这段话所描述的其实 "更为准确地说,是在讨论单线程内的指令执行顺序对于多线程的影响的问题"

为啥?因为我们对x进行原子操作的地方,锁定了线程间的关系,是一个**同步点**。那么,以这个点为基准,我们就可以得出两个线程当中其它指令执行先后顺序关系。

比如,A线程先对x进行了自增操作。因为对x的访问是原子的,所以B线程执行该行代码(假设代码当中对x的访问只有这一处)的时间点必然在A完成之后。

那么,如果在A线程当中,y++是在x++之前执行的,那么我们就可以肯定,对于B线程来说,在x++(同步点)之后所有对y的参照,必定能看到A线程执行了y++之后的值(注意对y的访问并非原子)

NOTE: 其实就是建立 happens-before relation

Memory reordering

但是有个问题。如果在程序当中y++紧靠x++,那么其实它到底是会先于x++执行(完毕),还是晚于x++执行(完毕),这个是没准儿的。

为啥呢?首先编译器对代码可能进行指令重排。也就是说,编译器编译之后(特别是开了优化之后)的代码执行顺序,是不一定严格按照你写代码的顺序的。

但是如果仅仅如此,也只是二进制(机器码)的顺序与源代码不同,还不至于导致A和B当中的指令执行顺序不同(因为A和B执行的是相同的机器码程序)。但是实际上,在非常微观的层面上,A和B也是可能不同的,甚至于,A每次执行到这里顺序都不见得一样。

啥?...还真的是这样。原因在于当代CPU内部也有指令重排。也就是说,CPU执行指令的顺序,也不见得是完全严格按照机器码的顺序。特别是,当代CPU的IPC(每时钟执行指令数)一般都远大于1,也就是所谓的**多发射**,很多命令都是同时执行的。比如,当代CPU当中(一个核心)一般会有2套以上的整数ALU(加法器),2套以上的浮点ALU(加法器),往往还有独立的乘法器,以及,独立的Load和Store执行器。Load和Store模块往往还有8个以上的队列,也就是可以同时进行8个以上内存地址(cache line)的读写交换。

是不是有些晕?简单来说,你可以理解当代CPU不仅是多核心,而且每个核心还是多任务(多指令)并行的。计算机课本上的那种一个时钟一条指令的,早就是老黄历了 (当然,宏观来看基本原理并没有改变)

什么是memory order

看到这里还没有晕的话,那么恭喜你,你快要理解什么是**memory order**了。所谓的**memory order**,其实就是限制编译器以及CPU对单线程当中的指令执行顺序进行重排的程度(此外还包括对cache的控制方法)。这种限制,决定了**以atom操作为基准点(边界),对其**之前**的**内存访问命令,以及**之后**的**内存访问命令**,能够在多大的范围内自由重排(或者反过来,需要施加多大的保序限制)。从而形成了**6种模式。它本身与多线程无关,是限制的单一线程当中指令执行顺序。但是(合理的)指令执行顺序的重排在单线程环境下不会造成逻辑错误而在多线程环境下会,所以这个问题的**目的是为了解决多线程环境下出现的问题

NOTE: 对atomic variable的load、store是基准点,可以对其前后的load、write顺序进行控制