gpt4 book ai didi

详解C++中的内存同步模式(memory order)

转载 作者:qq735679552 更新时间:2022-09-28 22:32:09 26 4
gpt4 key购买 nike

CFSDN坚持开源创造价值,我们致力于搭建一个资源共享平台,让每一个IT人在这里找到属于你的精彩世界.

这篇CFSDN的博客文章详解C++中的内存同步模式(memory order)由作者收集整理,如果你对这篇文章有兴趣,记得点赞哟.

内存模型中的同步模式(memory model synchronization modes) 。

原子变量同步是内存模型中最让人感到困惑的地方.原子(atomic)变量的主要作用就是同步多线程间的共享内存访问,一般来讲,某个线程会创建一些数据,然后给原子变量设置标志数值(译注:此处的原子变量类似于一个flag);其他线程则读取这个原子变量,当发现其数值变为了标志数值之后,之前线程中的共享数据就应该已经创建完成并且可以在当前线程中进行读取了.不同的内存同步模式标识了线程间数据共享机制的"强弱"程度,富有经验的程序员可以使用"较弱"的同步模式来提高程序的执行效率. 。

每一个原子类型都有一个 load() 方法(用于加载操作)和一个 store() 方法(用于存储操作).使用这些方法(而不是普通的读取操作)可以更清晰的标示出代码中的原子操作. 。

?
1
2
3
atomic_var1.store(atomic_var2.load()); // atomic variables
    vs
  var1 = var2;  // regular variables

这些方法还支持一个可选参数,这个参数可以用于指定内存模型的同步模式. 。

目前这些用于线程间同步的内存模式共有 3 种,我们依此来看下~ 。

顺序一致模式(sequentially consistent) 。

第一种模式是顺序一致模式(sequentially consistent),这也是原子操作的默认模式,同时也是限制最严格的一种模式.我们可以通过 std::memory_order_seq_cst 来显示的指定这种模式.这种模式下,线程间指令重排的限制与在顺序性代码中进行指令重排的限制是一致的. 。

观察以下代码

?
1
2
3
-Thread 1-    -Thread 2-
y = 1      if (x.load() == 2)
x.store (2);    assert (y == 1)

虽然代码中的 x 和 y 是没有关联的两个变量,但是代码中指定的内存模型(译注:代码中没有显示指定,则使用默认的内存模式,即顺序一致模式)保证了线程 2 中的断言不会失败.线程 1 中 对 y 的写入 先发生于(happens-before) 对 x 的写入,如果线程 2 读取到了线程 1 对 x 的写入(x.load() == 2),那么线程 1 中 对 x 写入 之前的所有写入操作都必须对线程 2 可见,即使对于那些和 x 无关的写入操作也是如此.这意味着优化操作不能重排线程 1 中的两个写入操作(y = 1 和 x.store (2)),因为当线程 2 读取到线程 1 对 x 的写入之后,线程 1 对 y 的写入也必须对线程 2 可见. 。

(译注:编译器或者 CPU 会因为性能因素而重排代码指令,这种重排操作对于单线程程序而言是无感知的,但是对于多线程程序而言就不是了,拿上面代码举例,如果将 x.store (2) 重排于 y = 1 之前,那么线程 2 中即使读取发现 x == 2 了,但此时 y 的数值也不一定是 1) 。

加载操作也有类似的优化限制

?
1
2
3
4
5
6
7
8
       a = 0
       y = 0
       b = 1
-Thread 1-       -Thread 2-
x = a.load()      while (y.load() != b)
y.store (b)        ;
while (a.load() == x)  a.store(1)
  ;

线程 2 一直循环到 y 发生数值变更,然后对 a 进行赋值;线程 1 则一直在等待 a 发生数值变化. 。

从顺序性代码的角度来看,线程 1 中的代码 ‘while (a.load() == x)' 似乎是一个无限循环,编译器编译这段代码时也可能会直接将其优化为一个无限循环(译注:优化为 while (true); 之类的指令);但实际上,我们必须保证每次循环都对 a 执行读取操作(a.load()) 并且将其与 x 进行比较,否则线程 1 和 线程 2 将不能正常工作(译注:线程 1 将进入无限循环,与正确的执行结果不一致). 。

从实践的角度讲,所有的原子操作都相当于优化屏障(译注:用于阻止优化操作的指令).原子操作(load/store)可以类比为副作用未知的函数调用,优化操作可以在原子操作之间任意的调整代码顺序,但是不能越过原子操作(译注:原子操作类似于是优化调整的边界),当然,线程的私有数据并不受此影响,因为这些数据其他线程并不可见. 。

顺序一致模式也保证了所有线程间(原子变量(使用 memory_order_seq_cst 模式)的修改顺序)的一致性.以下代码中所有的断言都不会失败(x 和 y 的初始值为 0)

?
1
2
3
4
5
-Thread 1-    -Thread 2-          -Thread 3-
y.store (20);  if (x.load() == 10) {    if (y.load() == 10)
x.store (10);   assert (y.load() == 20)   assert (x.load() == 10)
          y.store (10)
         }

 从顺序性代码的角度来看,似乎这是(所有断言都不会失败)理所当然的,但是在多线程环境下,我们必须同步系统总线才能达到这种效果(以使线程 3 与线程 2 观察到的原子变量(使用 memory_order_seq_cst 模式)变更顺序一致),可想而知,这往往需要昂贵的硬件同步. 。

由于保证顺序一致的特性, 顺序一致模式成为了原子操作中默认使用的内存模式, 当程序员使用这种模式时,一般不太可能获得意外的程序结果. 。

宽松模式(relaxed) 。

与顺序一致模式相对的就是 std::memory_order_relaxed 模式,即宽松模式.由于去除了先发生于(happens-before)这个关系限制, 宽松模式仅需极少的同步指令即可实现.这种模式下,不同于之前的顺序一致模式,我们可以对原子变量操作进行各种优化了,譬如执行死代码删除等等. 。

看一下之前的示例

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-Thread 1-
y.store (20, memory_order_relaxed)
x.store (10, memory_order_relaxed)
 
-Thread 2-
if (x.load (memory_order_relaxed) == 10)
  {
   assert (y.load(memory_order_relaxed) == 20) /* assert A */
   y.store (10, memory_order_relaxed)
  }
 
-Thread 3-
if (y.load (memory_order_relaxed) == 10)
  assert (x.load(memory_order_relaxed) == 10) /* assert B */

由于线程间不再需要同步(译注:由于使用了宽松模式,原子操作之间不再形成同步关系,这里的不需要同步指的是不需要原子操作间的同步),所以代码中的任一断言都可能失败. 。

由于没有了先发生于(happens-before)的关系,从单一线程的角度来看,其他线程不再存在对其可见的特定原子变量写入顺序.如果使用时不是非常小心,宽松模式会导致很多非预期的结果.这个模式唯一保证的一点就是: 一旦线程 2 观察到了线程 1 中对某一原子变量的写入数值,那么线程 2 就不会再看到线程 1 对该变量更早的写入数值. 。

我们还是来看个示例(假定 x 的初始值为 0)

?
1
2
3
4
5
6
7
8
-Thread 1-
x.store (1, memory_order_relaxed)
x.store (2, memory_order_relaxed)
 
-Thread 2-
y = x.load (memory_order_relaxed)
z = x.load (memory_order_relaxed)
assert (y <= z)

代码中的断言不会失败.一旦线程 2 读取到 x 的数值为 2,那么线程 2 后面对 x 的读取操作将不可能取得数值 1(1 较 2 是 x 更早的写入数值).这一特性导致了一个结果: 如果代码中存在多个对同一变量的宽松模式读取,但是这些读取之间存在对其他引用(可能是之前同一变量的别名)的宽松模式读取,那么我们不能把这多个对同一变量的宽松模式读取合并(多个读取并成一个). 。

这里还有一个假定就是某一线程对于原子变量的宽松写入将在一段合理的时间内对另一线程可见(通过宽松读取).这意味着,在一些非缓存一致的体系架构上, 宽松操作需要主动的去刷新缓存(当然,刷新操作可以进行合并,譬如在多个宽松操作之后再进行一次刷新操作). 。

宽松模式最常用的场景就是当我们仅需要一个原子变量,而不需要使用该原子变量同步线程间共享内存的时候.(译注:譬如一个原子计数器) 。

获得/释放模式(acquire/release) 。

第三种模式混合了之前的两种模式.获得/释放模式类似于之前的顺序一致模式,不同的是该模式只保证依赖变量间产生先发生于(happens-before)的关系.这也使得独立读取操作和独立写入操作之间只需要比较少的同步. 。

假设 x 和 y 的初始值为 0

?
1
2
3
4
5
6
7
8
9
10
11
-Thread 1-
y.store (20, memory_order_release);
 
-Thread 2-
x.store (10, memory_order_release);
 
-Thread 3-
assert (y.load (memory_order_acquire) == 20 && x.load (memory_order_acquire) == 0)
 
-Thread 4-
assert (y.load (memory_order_acquire) == 0 && x.load (memory_order_acquire) == 10)

代码中的两个断言可能同时通过,因为线程 1 和线程 2 中的两个写入操作并没有先后顺序. 。

但是如果我们使用顺序一致模式来改写上面的代码,那么这两个写入操作中必然有一个写入先发生于(happens-before)另一个写入(尽管运行时才能确定实际的先后顺序),并且这个顺序是多线程一致的(通过必要的同步操作),所以代码中如果一个断言通过,那么另一个断言就一定会失败. 。

如果我们在代码中使用非原子变量,那么事情会变的更复杂一些,但是这些非原子变量的可见性同他们是原子变量时是一致的(译注:参看下面代码).任何原子写入操作(使用释放模式)之前的写入对于其他同步的线程(使用获取模式并且读取到了之前释放模式写入的数值)都是可见的. 。

?
1
2
3
4
5
6
7
-Thread 1-
y = 20;
x.store (10, memory_order_release);
 
-Thread 2-
if (x.load(memory_order_acquire) == 10)
  assert (y == 20);

线程 1 中对 y 的写入(y = 20)先发生于对 x 的写入(x.store (10, memory_order_release)),因此线程 2 中的断言不会失败(译注:这里说的有些简略,扩展来讲的话应该是线程 1 中 对 y 的写入 先发生于 对 x 的写入, 而线程 1 中 对 x 的写入 又同步于线程 2 中 对 x 的读取, 由于线程 2 中 对 x 的读取 又先发生于 对 y 的断言,于是线程 1 中 对 y 的写入 先发生于线程 2 中 对 y 的断言,这个 对 y 的断言 也就不会失败了).由于有上述的同步要求,原子操作周围的共享内存(非原子变量)操作一样有优化上的限制(译注:不能随意对这些操作进行优化,以上面代码为例,优化操作不能将 y = 20 重排于 x.store (10, memory_order_release) 之后). 。

消费/释放模式(consume/release) 。

消费/释放模式是对获取/释放模式进一步的改进,该模式下,非依赖共享变量的先发生于关系不再成立. 。

假设 n 和 m 是两个一般的共享变量,初始值都为 0,并且假设线程 2 和 线程 3 都读取到了线程 1 中对原子变量 p 的写入(译注:注意代码前提). 。

?
1
2
3
4
5
6
7
8
9
10
11
12
-Thread 1-
n = 1
m = 1
p.store (&n, memory_order_release)
 
-Thread 2-
t = p.load (memory_order_acquire);
assert ( *t == 1 && m == 1 );
 
-Thread 3-
t = p.load (memory_order_consume);
assert ( *t == 1 && m == 1 );

线程 2 中的断言不会失败,因为线程 1 中 对 m 的写入 先发生于 对 p 的写入. 。

但是线程 3 中的断言就可能失败了,因为 p 和 m 没有依赖关系,而线程 3 中读取 p 使用了消费模式,这导致线程 1 中 对 m 的写入 并不能与线程 3 中的 断言 形成先发生于的关系,该 断言 自然也就可能失败了.PowerPC 架构和 ARM 架构中,指针加载的默认内存模式就是消费模式(一些 MIPS 架构可能也是如此). 。

另外的,线程 1 和 线程 2 都能够正确的读取到 n 的数值,因为 n 和 p 存在依赖关系(译注: p.store (&n, memory_order_release), p 中写入了 n 的地址,于是 p 和 n 形成依赖关系). 。

内存模式的真正区别其实就是为了同步,硬件需要刷新的状态数量.消费/释放模式相较获取/释放模式而言,执行速度上会更快一些,可以用于一些对性能极度敏感的程序之中. 。

总结 。

内存模式其实并不像听起来的那么复杂,为了加深你的理解,我们来看下这个示例

?
1
2
3
4
5
6
7
8
9
10
11
12
13
-Thread 1-   
  y.store (20);
  x.store (10);
         
-Thread 2-       
if (x.load() == 10) { 
  assert (y.load() == 20)
  y.store (10)
}
 
-Thread 3-
if (y.load() == 10)
  assert (x.load() == 10)

当使用顺序一致模式时,所有的共享变量都会在各线程间进行同步,所以线程 2 和 线程 3 中的两个断言都不会失败. 。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
-Thread 1-   
  y.store (20, memory_order_release);
  x.store (10, memory_order_release);
         
-Thread 2-       
if (x.load(memory_order_acquire) == 10) {  
  assert (y.load(memory_order_acquire) == 20)
  y.store (10, memory_order_release)
}
 
-Thread 3-
if (y.load(memory_order_acquire) == 10)
  assert (x.load(memory_order_acquire) == 10)

获取/释放模式则只要求在两个线程间(一个使用释放模式的线程,一个使用获取模式的线程)进行必要的同步.这意味着这两个线程间同步的变量并不一定对其他线程可见.线程 2 中的断言仍然不会失败,因为线程 1 和 线程 2 通过对 x 的写入和读取形成了同步关系(译注:参见之前 获取/释放模式介绍中的说明),但是线程 3 并不参与线程 1 和 线程 2 的同步,所以当线程 2 和 线程 3 通过对 y 的写入和读取发生同步关系时, 线程 1 与 线程 3 并没有发生同步关系, x 的数值自然也不一定对线程 3 可见,所以线程 3 中的断言是可能失败的. 。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
-Thread 1-   
  y.store (20, memory_order_release);
  x.store (10, memory_order_release);
         
-Thread 2-       
if (x.load(memory_order_consume) == 10) {  
  assert (y.load(memory_order_consume) == 20)
  y.store (10, memory_order_release)
}
 
-Thread 3-
if (y.load(memory_order_consume) == 10)
  assert (x.load(memory_order_consume) == 10)

使用消费/释放模式的结果与获取/释放模式是一致的,区别只是 消费/释放模式需要更少的硬件同步操作,那么我们为什么不一直使用 消费/释放模式(而不使用获取/释放模式)呢?那是因为这个例子中没有涉及(非原子)共享变量,如果示例中的 y 是一个(非原子)共享变量,由于其与 x 不存在依赖关系(依赖关系是指原子变量的写入数值由(非原子)共享变量计算而得),那么我们并不一定能够在线程 2 中看到 y 的当前数值(20),即便线程 2 已经读取到 x 的数值为 10. 。

(译注:这里说因为没有涉及(非原子)共享变量所以导致消费/释放模式和获取/释放模式表现一致应该是不准确的,将示例中的 assert (y.load(memory_order_consume) == 20) 修改为 assert (y.load(memory_order_relaxed) == 20) 应该也能体现出消费/释放模式和获取/释放模式之间的不同,更多的细节可以参看文章最后的示例) 。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
-Thread 1-   
  y.store (20, memory_order_relaxed);
  x.store (10, memory_order_relaxed);
         
-Thread 2-       
if (x.load(memory_order_relaxed) == 10) {  
  assert (y.load(memory_order_relaxed) == 20)
  y.store (10, memory_order_relaxed)
}
 
-Thread 3-
if (y.load(memory_order_relaxed) == 10)
  assert (x.load(memory_order_relaxed) == 10)

如果所有操作都使用宽松模式,那么代码中的两个断言都可能失败,因为 宽松模式下没有同步操作发生. 。

混合使用内存模式 。

最后,我们来看下混合使用内存模式会发生什么

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-Thread 1-
y.store (20, memory_order_relaxed)
x.store (10, memory_order_seq_cst)
 
-Thread 2-
if (x.load (memory_order_relaxed) == 10)
  {
   assert (y.load(memory_order_seq_cst) == 20) /* assert A */
   y.store (10, memory_order_relaxed)
  }
 
-Thread 3-
if (y.load (memory_order_acquire) == 10)
  assert (x.load(memory_order_acquire) == 10) /* assert B */

首先,我必须提醒你不要这么做(混合使用内存模式),因为这会让人极度困惑.

最后此篇关于详解C++中的内存同步模式(memory order)的文章就讲到这里了,如果你想了解更多关于详解C++中的内存同步模式(memory order)的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。

26 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com