博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

memory order, atomic 【不错】

Posted on 2020-07-03 18:00  bw_0927  阅读(507)  评论(0)    收藏  举报

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 的操作分为三类:

  1. SkipList 的 max_height_,代表跳跃表的高度。这个值始终使用 relaxed 语义去进行读写,并且只有在插入的时候才可能会改变。

【只需要保证原子互斥性,无须附加内存顺序】 

  1.  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 并不存在。 

  1.  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,所有线程看到的内存访问顺序都一样,那么也会带来一些性能损失。

  1. 如果是读操作就使用 acquire 语义,如果是写操作就使用 release 语义,如果是读取-更新-写入就是 acq_rel 语义
  2. 同时会对所有使用此 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 将输出0100


  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 可能输出0100


  

从上面的三个例子可以看到,多线程读写同一变量需要使用同步机制,最常见的同步机制就是std::mutexstd::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::atomicload()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); // never failed
 
  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; // A
  ready.store(true, std::memory_order_release); // B
}
 
void consumer()
{
  while (!ready.load(std::memory_order_acquire)) // C
  ;
  assert(data == 100); // never failed // D
}
 
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这样配对使用。