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

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

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

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

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

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

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

顺序一致模式(sequentially consistent)

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

观察以下代码:

 -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)

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

       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):

 -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)这个关系限制, 宽松模式仅需极少的同步指令即可实现.这种模式下,不同于之前的顺序一致模式,我们可以对原子变量操作进行各种优化了,譬如执行死代码删除等等.

看一下之前的示例:

-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):

-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 :

 -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)另一个写入(尽管运行时才能确定实际的先后顺序),并且这个顺序是多线程一致的(通过必要的同步操作),所以代码中如果一个断言通过,那么另一个断言就一定会失败.

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

 -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 的写入(译注:注意代码前提).

 -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 形成依赖关系).

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

总结

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

-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 中的两个断言都不会失败.

-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 中的断言是可能失败的.

-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) 应该也能体现出消费/释放模式和获取/释放模式之间的不同,更多的细节可以参看文章最后的示例)

-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)

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

混合使用内存模式

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

-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 */

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

但这仍然是一个存在的问题,所以让我们来试着"求解"一下…

想一想代码中各个同步点到底会发生了什么:

写入(store)同步会首先执行写入指令,然后执行必要的系统状态刷新指令
读取(load)同步会首先执行必要的系统状态获取指令,然后执行加载指令
线程 1 : y.store 使用了宽松模式,所以这个写入操作不会产生同步指令(即系统状态刷新指令),并且该操作可能被优化操作重排,接下来的 x.store 使用了顺序一致模式,所以该操作会强制刷新线程 1 中的各个状态(用于线程间的同步),并且会保证之前的 y.store 先发生于 x.store.

线程 2 : x.load 使用了宽松模式,所以该操作不会产生同步指令,即便线程 1 将其状态刷新到了系统之中, 线程 2 也并没有确保自己与系统之间的同步(因为没有执行同步指令).这意味着线程 2 中的数据处于一种未知状态之中,即使线程 2 读取到了 x 的数值为 10, 线程 1 中 x.store(10) 之前的写入(y.store (20, memory_order_relaxed))对线程 2 也不一定是可见的,所以线程 2 中的断言可能会失败.

但奇怪的是, 线程 2 中对 y 的读取使用了顺序一致模式(y.load(memory_order_seq_cst)),这会产生一个同步操作(在读取操作之前),进而导致线程 2 与系统发生同步(读取到 y 的最新数值),于是断言就不会失败了… 有些混乱,对吧~

线程 3 : y.load 使用了获取模式,所以他会在读取之前执行获取系统状态的指令,但不幸的是,线程 2 中的 y.store 使用的是宽松模式,所以不会产生系统状态刷新的指令,并且可能被优化操作重排(译注:重排的影响在这个例子中应该可以忽略),所以线程 3 中的断言仍然可能是失败的.

最后要说明的一点是: 混合使用内存模式是危险的,尤其是当模式中包含宽松模式的时候.小心的混合使用 顺序一致模式(seq_cst) 和 获取/释放模式(acquire/release) 应该是可行的,但是需要你熟稔这两个模式的各种工作细节,除此之外,你可能还需要一些优秀的调试工具!!!

后记

关于 std:memory_order_consume, 自 C++11 引入以来,似乎从来没有被编译器正确实现过(编译器都直接将其当作

std:memory_order_acquire 来处理), C++17 则直接将其列为暂时不推荐使用的特性, C++20 中有可能将其废弃.

内存模型这个话题确实有些晦涩,网上相关的资料也很多,初次接触的朋友推荐从这里的系列博文开始.

网上还有不少很好的文章,譬如这里,这里这里.

感到疑问的朋友也可以直接留言,大家一起讨论.

以上所述是小编给大家介绍的C++中的内存同步模式详解整合,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对我们网站的支持!

(0)

相关推荐

  • 深入理解c/c++ 内存对齐

    内存对齐,memory alignment.为了提高程序的性能,数据结构(尤其是栈)应该尽可能地在自然边界上对齐.原因在于,为了访问未对齐的内存,处理器需要作两次内存访问:然而,对齐的内存访问仅需要一次访问.内存对齐一般讲就是cpu access memory的效率(提高运行速度)和准确性(在一些条件下,如果没有对齐会导致数据不同步现象).依赖cpu,平台和编译器的不同.一些cpu要求较高(这句话说的不准确,但是确实依赖cpu的不同),而有些平台已经优化内存对齐问题,不同编译器的对齐模数不同.总

  • 浅谈C++ 类的实例中 内存分配详解

    一个类,有成员变量:静态与非静态之分:而成员函数有三种:静态的.非静态的.虚的. 那么这些个东西在内存中到底是如何分配的呢? 以一个例子来说明: #include"iostream.h" class CObject { public: static int a; CObject(); ~CObject(); void Fun(); private: int m_count; int m_index; }; VoidCObject::Fun(){ cout<<"Fu

  • C++内存泄漏及检测工具详解

    首先我们需要知道程序有没有内存泄露,然后定位到底是哪行代码出现内存泄露了,这样才能将其修复. 最简单的方法当然是借助于专业的检测工具,比较有名如BoundsCheck,功能非常强大,相信做C++开发的人都离不开它.此外就是不使用任何工具,而是自己来实现对内存泄露的监控,分如下两种情况: 一. 在 MFC 中检测内存泄漏 假如是用MFC的程序的话,很简单.默认的就有内存泄露检测的功能. 我们用VS2005生成了一个MFC的对话框的程序,发现他可以自动的检测内存泄露.不用我们做任何特殊的操作. 仔细

  • C++ 类中有虚函数(虚函数表)时 内存分布详解

    虚函数表 对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的.简称为V-Table.在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承.覆盖的问题,保证其容真实反应实际的函数.这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数. 这里我们着重看一下这张虚函数表.C++的编译器应该是

  • 由static_cast和dynamic_cast到C++对象占用内存的全面分析

    static_cast和dynamic_cast是C++的类型转换操作符.编译器隐式执行的任何类型转换都可以由static_cast显式完成,即父类和子类之间也可以利用static_cast进行转换.而dynamic_cast只能用于类之间的转换.那么dynamic_cast的存在还有什么意义呢?因为dynamic_cast提供了一个重要的特性:运行时类型检查来保证转换的安全性. 用static_cast转换存在的危险 我们知道,一个基类指针不需要进行明确的转换操作,就可以指向基类对象或者派生类

  • C++程序检测内存泄漏的方法分享

    一.前言 在Linux平台上有valgrind可以非常方便的帮助我们定位内存泄漏,因为Linux在开发领域的使用场景大多是跑服务器,再加上它的开源属性,相对而言,处理问题容易形成"统一"的标准.而在Windows平台,服务器和客户端开发人员惯用的调试方法有很大不同.下面结合我的实际经验,整理下常见定位内存泄漏的方法. 注意:我们的分析前提是Release版本,因为在Debug环境下,通过VLD这个库或者CRT库本身的内存泄漏检测函数能够分析出内存泄漏,相对而言比较简单.而服务器有很多问

  • 养成良好的C++编程习惯之内存管理的应用详解

    开篇导读    虽然本系列文章定位为科普读物,但本座相信它们不但适合新手们学习借鉴,同时也能引发老鸟们的反思与共鸣.欢迎大家提出宝贵的意见和反馈 ^_^ 在开篇讲述本章主要内容之前,本座首先用小小篇幅论述一下一种良好的工作习惯 -- 积累.提炼与求精.在工作和学习的过程中,不断把学到的知识通过有效的方式积累起来,形成自己的知识库,随着知识量的扩大,就会得到从量变到质变的提升.另外还要不断地对知识进行提炼,随着自己知识面的扩大以及水平的提升,你肯定会发现原有知识库存在着一些片面.局限.笨拙甚至错误

  • 基于C++中常见内存错误的总结

    在系统开发过程中出现的bug相对而言是比较好解决的,花费在这个上面的调试代价不是很大,但是在系统集成后的bug往往是难以定位的bug(最好方式是打桩,通过打桩可以初步锁定出错的位置,如:进入函数前打印日志,离开时再次打印日志).而这些难以定位的bug基本分为2类:内存错误和并非问题. 1.内存泄露如果在堆栈上分配的内存使用完成后没有释放就会造成内存泄露.少量的内存泄露不至于让程序崩溃,但是大量的内存泄露就会导致内存耗尽,后续内存分配失败,从而导致程序崩溃.长时间运行软件,即使只有一两处泄露,同样

  • c++实现逐行读取配置文件写入内存的示例

    不解析配置内容,只读取文件内容,剪去注释和首尾空格后写入缓存: vector<string> 中.供其他方法使用.代码是在做一个MFC小工具时写的. ReadProtocol.h 复制代码 代码如下: /*** 从文件中 读取 protocol 的内容 写入缓存* 供外部方法使用* Alex Liu, 2014*/ #pragma once #include <vector>#include <map>#include <list>#include <

  • 浅谈C++内存分配及变长数组的动态分配

    第一部分 C++内存分配 一.关于内存 1.内存分配方式 内存分配方式有三种: (1)从静态存储区域分配.内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在 例如全局变量,static变量. (2)在栈上创建.在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存 储单元自动被释放.栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限. (3) 从堆上分配,亦称动态内存分配.程序在运行的时候用malloc或new申请任意多少的内存,程序员

随机推荐