mutex 互斥量只保证了多个线程不会同时操作访问,但是不能保证访问的顺序,即在临界区内,CPU的时间片还是会在不同的线程中切换的,执行顺序无法保证。
Memory Order
内存顺序描述了计算机 CPU 获取内存的顺序,内存的排序既可能发生在编译器编译期间,也可能发生在 CPU 指令执行期间。
为了尽可能地提高计算机资源利用率和性能,编译器会对代码进行重新排序, CPU 会对指令进行重新排序、延缓执行、各种缓存等等,以达到更好的执行效果。
当然,任何排序都不能违背代码本身所表达的意义,并且在单线程情况下,通常不会有任何问题。
但是在多线程环境下,比如无锁(lock-free)数据结构的设计中,指令的乱序执行会造成无法预测的行为。
所以我们通常引入内存栅栏(Memory Barrier)这一概念来解决可能存在的并发问题。
Memory Barrier
内存栅栏是一个令 CPU 或编译器在内存操作上限制内存操作顺序的指令,通常意味着在 barrier 之前的指令一定在 barrier 之后的指令之前执行。
在 C11/C++11 中,引入了六种不同的 memory order,可以让程序员在并发编程中根据自己需求尽可能降低同步的粒度,以获得更好的程序性能。
这六种 order 分别是:
relaxed, acquire, release, consume, acq_rel, seq_cst
RocksDB SkipList Memory Order
RocksDB SkipList 支持一写多读。它涉及了三种 memory order,包括 relaxed, release 和 acquire。
我们把所有涉及 memory order 的操作分为三类:
- SkipList 的 max_height_,代表跳跃表的高度。这个值始终使用 relaxed 语义去进行读写,并且只有在插入的时候才可能会改变。
【只需要保证原子互斥性,无须附加内存顺序】
- SkipList 的节点写操作。
for (int i = 0; i < height; i++) {
x->NoBarrier_SetNext(i, prev_[i]->NoBarrier_Next(i)); // relaxed
prev_[i]->SetNext(i, x); // release 【之前对原子变量i的更新,在其他线程的acquire里都可见】
}
对于正在初始化的节点来说,我们使用 relaxed 语义,即 NoBarrier_SetNext() 和 NoBarrier_Next(),因为这时候节点还没有正式被加入到 SkipList,即对读线程不可见,所以可以使用较弱的 relaxed 语义. 初始化完成后使用 release 语义将节点插入到 SkipList 中,即 SetNext()。
根据 release 语义,之前所有 relaxed 操作在这个节点被插入到 SkipList 后,对其他线程的 acquire 操作都是可见的。
注意这里插入节点的整个过程并不是原子的,在每一层插入节点才是原子的。
所以有个值得注意的点是在节点插入时我们采用从下到上的方式,因为对于 SkipList 来说,key 在 SkipList 内意味着 key 一定在 level 0,
所以如果从上到下插入的话可能出现幻读【突然多了个,或者突然少了个】,即在上层查找比较的时候存在这个 key,但是当下降到 level 0 时发现这个 key 并不存在。
- SkipList 的节点读操作。
对于节点的所有读操作,都会使用 acquire 语义,也就是 Next() 函数,因为要保证我们读取的节点是最新的。
除了顺序插入这个优化,在这个优化里会用 relaxed 语义进行节点读取,也就是 NoBarrier_Next() 函数,因为对于写来说,会有外部同步,所以即使前后两次插入线程不同,使用 relaxed 语义也能读到最新的节点。
https://www.zhihu.com/question/24301047
atomic<>函数的第二个参数可以指定内存顺序。内存顺序都是针对同一个atomic变量来讲的。
https://stackoverflow.com/questions/38280633/c11-the-difference-between-memory-order-relaxed-and-memory-order-consume
虽然是六种类型,但是理解了四种同步的情形基本就差不多了。
1. Relaxed ordering:
在单个线程内,所有原子操作是顺序进行的。按照什么顺序?基本上就是代码顺序(sequenced-before)。这就是唯一的限制了!两个来自不同线程的原子操作是什么顺序?两个字:任意。
在不同线程间:imposes no memory order at all,只有原子性; 其他的类型,除了原子性外,附加了额外的内存访问先后顺序。
Relaxed operation: there are no synchronization or ordering constraints, only atomicity is required of this operation.
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.
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,顺序一致就是对所有的原子变量的操作都同步。这么一来,我擦,所有的原子操作就跟由一个线程顺序执行似的。
是默认的选项,这个选项不允许reorder,所有线程看到的内存访问顺序都一样,那么也会带来一些性能损失。
- 如果是读操作就使用 acquire 语义,如果是写操作就使用 release 语义,如果是读取-更新-写入就是 acq_rel 语义
- 同时会对所有使用此 memory order 的原子操作进行同步【有内存的访问顺序限制】,所有线程看到的内存操作的顺序都是一样的。
通常情况下所以你如果不确定怎么这些 memory order,就用这个。
https://en.cppreference.com/w/cpp/atomic/memory_order
评论里有很多关于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 语义。
aquire语义:load 之后的读写操作无法被重排至 load 之前。即 load-load, load-store 不能被重排。
release语义:store 之前的读写操作无法被重排至 store 之后。即 load-store, store-store 不能被重排。
最简单的试试 relaxed ordering 的方法就是拿出手机。写个小程序,故意留个 race condition,然后放到 iPhone 或者安卓手机上调,不用 release -- acquire 保准出错。然而这种 bug 你在 x86 的 host 机上是调不出来的,即便拿模拟器也调不出来。
http://senlinzhan.github.io/2017/12/04/cpp-memory-order/
Relaxed ordering: 对执行顺序无要求,只保证不会同时访问,最合适的场景的计数器
理解 C++ 的 Memory Order
为什么需要 Memory Order
如果不使用任何同步机制(例如 mutex 或 atomic),在多线程中读写同一个变量,那么,程序的结果是难以预料的。简单来说,编译器以及 CPU 的一些行为,会影响到程序的执行结果:
- 即使是简单的语句,C++ 也不保证是原子操作。
- CPU 可能会调整指令的执行顺序。
- 在 CPU cache 的影响下,一个 CPU 执行了某个指令,不会立即被其它 CPU 看见。
原子操作说的是,一个操作的状态要么就是未执行,要么就是已完成,不会看见中间状态。例如,在 C++11 中,下面程序的结果是未定义的:
|
1
2
3
4
|
int64_t i = 0; // global variable
Thread-1: Thread-2:
i = 100; std::cout << i;
|
C++ 并不保证i = 100是原子操作,因为在某些 CPU Architecture 中,写入int64_t需要两个 CPU 指令,所以 Thread-2 可能会读取到i在赋值过程的中间状态。
另一方面,为了优化程序的执行性能,CPU 可能会调整指令的执行顺序。为阐述这一点,下面的例子中,让我们假设所有操作都是原子操作:
|
1
2
3
4
5
6
7
|
int x = 0; // global variable
int y = 0; // global variable
Thread-1: Thread-2:
x = 100; while (y != 200)
y = 200; ;
std::cout << x;
|
如果 CPU 没有乱序执行指令,那么 Thread-2 将输出100。然而,对于 Thread-1 来说,x = 100;和y = 200;这两个语句之间没有依赖关系,因此,Thread-1 允许调整语句的执行顺序:
|
1
2
3
|
Thread-1:
y = 200;
x = 100;
|
在这种情况下,Thread-2 将输出0或100。
CPU cache 也会影响到程序的行为。下面的例子中,假设从时间上来讲,A 操作先于 B 操作发生:
|
1
2
3
4
|
int x = 0; // global variable
Thread-1: Thread-2:
x = 100; // A std::cout << x; // B
|
尽管从时间上来讲,A 先于 B,但 CPU cache 的影响下,Thread-2 不能保证立即看到 A 操作的结果,所以 Thread-2 可能输出0或100。
从上面的三个例子可以看到,多线程读写同一变量需要使用同步机制,最常见的同步机制就是std::mutex和std::atomic。
然而,从性能角度看,通常使用std::atomic会获得更好的性能。
C++11 为std::atomic提供了 4 种 memory ordering:
- Relaxed ordering 【同一个线程内对atomic变量X的代码是顺序的;不同线程间无序,但不会同时访问】
- Release-Acquire ordering 【A线程release(X)前的所有写操作,在B线程acquire(X)后都可见;A线程中release前的代码不可以被优化移到release后;B线程中acquire后的代码不可以被优化移到acquire之前。】
- Release-Consume ordering 【A线程release(X)前的跟X相关的写操作,在B线程acquire(X)后都可见】
- Sequentially-consistent ordering 【默认顺序】
默认情况下,std::atomic使用的是 Sequentially-consistent ordering。但在某些场景下,合理使用其它三种 ordering,可以让编译器优化生成的代码,从而提高性能。
Relaxed ordering
在这种模型下,std::atomic的load()和store()都要带上memory_order_relaxed参数【只保证互斥访问,不保证顺序】。
Relaxed ordering 仅仅保证load()和store()是原子操作,除此之外,不提供任何跨线程的同步。
先看看一个简单的例子:
|
1
2
3
4
5
6
|
std::atomic<int> x = 0; // global variable
std::atomic<int> y = 0; // global variable
Thread-1: Thread-2:
r1 = y.load(memory_order_relaxed); // A r2 = x.load(memory_order_relaxed); // C
x.store(r1, memory_order_relaxed); // B y.store(42, memory_order_relaxed); // D
|
执行完上面的程序,可能出现r1 == r2 == 42。理解这一点并不难,因为编译器允许调整 C 和 D 的执行顺序。如果程序的执行顺序是 D -> A -> B -> C,那么就会出现r1 == r2 == 42。
线程2中的C,D操作并一定有序,因为C的原子变量是X, D的原子变量是Y。 是两个不同的原子变量,不保证有序。
如果某个操作只要求是原子操作,除此之外,不需要其它同步的保障,就可以使用 Relaxed ordering。程序计数器是一种典型的应用场景:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
#include <cassert>
#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> cnt = {0};
void f()
{
for (int n = 0; n < 1000; ++n) {
cnt.fetch_add(1, std::memory_order_relaxed);
}
}
int main()
{
std::vector<std::thread> v;
for (int n = 0; n < 10; ++n) {
v.emplace_back(f);
}
for (auto& t : v) {
t.join();
}
assert(cnt == 10000);
return 0;
}
|
Release-Acquire ordering【看下面的代码用法】
在这种模型下,store()使用memory_order_release,而load()使用memory_order_acquire。
这种模型有两种效果,第一种是可以限制 CPU 指令的重排:
- 在
store()之前的所有读写操作,不允许被移动到这个store()的后面。
- 在
load()之后的所有读写操作,不允许被移动到这个load()的前面。
除此之外,还有另一种效果:
假设 Thread-1 store()的那个值,成功被 Thread-2 load()到了,那么 Thread-1 在store()之前对内存的所有写入操作【即使是普通的非原子变量】,
此时对 Thread-2 来说,都是可见的。
下面的例子阐述了这种模型的原理:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
#include <thread>
#include <atomic>
#include <cassert>
#include <string>
std::atomic<bool> ready{ false };
int data = 0;
void producer()
{
data = 100;
ready.store(true, std::memory_order_release);
}
void consumer()
{
while (!ready.load(std::memory_order_acquire))
;
assert(data == 100);
}
int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
|
让我们分析一下这个过程:
- 首先 A 不允许被移动到 B 的后面。
- 同样 D 也不允许被移动到 C 的前面。
- 当 C 从 while 循环中退出了,说明 C 读取到了 B
store()的那个值,此时,Thread-2 保证能够看见 Thread-1 执行 B 之前的所有写入操作(也即是 A)。
参考资料
这些顺序可以混用,不是说只能release-acquire, release-consume, released-released, seq_cst-seq-cst这样配对使用。