代码改变世界

C++11 并发指南七(C++11 内存模型一:介绍)

2013-11-14 16:38 by Haippy, ... 阅读, ... 评论, 收藏, 编辑

第六章主要介绍了 C++11 中的原子类型及其相关的API,原子类型的大多数 API 都需要程序员提供一个 std::memory_order(可译为内存序,访存顺序) 的枚举类型值作为参数,比如:atomic_storeatomic_loadatomic_exchangeatomic_compare_exchange 等 API 的最后一个形参为 std::memory_order order,默认值是 std::memory_order_seq_cst(顺序一致性)。那么究竟什么是 std::memory_order 呢,为了解答这个问题,我们先来讨论 C++11 的内存模型。

一般来讲,内存模型可分为静态内存模型和动态内存模型,静态内存模型主要涉及类的对象在内存中是如何存放的,即从结构(structural)方面来看一个对象在内存中的布局,以一个简单的例子为例(截图参考《C++  Concurrency In Action》 P105 ):

上面是一个简单的 C++ 类(又称POD: Plain Old Data,它没有虚函数,没有继承),它在内存中的布局如图右边所示(对于复杂类对象的内存布局,请参考《深度探索C++对象模型》一书)。

动态内存模型可理解为存储一致性模型,主要是从行为(behavioral)方面来看多个线程对同一个对象同时(读写)操作时(concurrency)所做的约束,动态内存模型理解起来稍微复杂一些,涉及了内存,Cache,CPU 各个层次的交互,尤其是在共享存储系统中,为了保证程序执行的正确性,就需要对访存事件施加严格的限制。

文献中常见的存储一致性模型包括顺序一致性模型,处理器一致性模型,弱一致性模型,释放一致性模型,急切更新释放一致性模型、懒惰更新释放一致性模型,域一致性模型以及单项一致性模型。不同的存储一致性模型对访存事件次序的限制不同,因而对程序员的要求和所得到的的性能也不一样。存储一致性模型对访存事件次序施加的限制越弱,我们就越有利于提高程序的性能,但编程实现上更困难。

顺序一致性模型由 Lamport 于 1979 年提出。顺序一致性模型最好理解但代价太大,原文指出:

... the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program.

该模型指出:如果在共享存储系统中多机并行执行的结果等于把每一个处理器所执行的指令流按照某种方式顺序地交织在一起在单机上执行的结果,则该共享存储系统是顺序一致性的。

顺序一致性不仅在共享存储系统上适用,在多处理器和多线程环境下也同样适用。而在多处理器和多线程环境下理解顺序一致性包括两个方面,(1). 从多个线程平行角度来看,程序最终的执行结果相当于多个线程某种交织执行的结果,(2)从单个线程内部执行顺序来看,该线程中的指令是按照程序事先已规定的顺序执行的(即不考虑运行时 CPU 乱序执行和 Memory Reorder)。

我们以一个具体的例子来理解顺序一致性:

假设存在两个共享变量a, b,初始值均为 0,两个线程运行不同的指令,如下表格所示,线程 1 设置 a 的值为 1,然后设置 R1 的值为 b,线程 2 设置 b 的值为 2,并设置 R2 的值为 a,请问在不加任何锁或者其他同步措施的情况下,R1,R2 的最终结果会是多少?

 

线程 1 线程 2
a = 1; b = 2;
R1 = b; R2 = a;

 

由于没有施加任何同步限制,两个线程将会交织执行,但交织执行时指令不发生重排,即线程 1 中的 a = 1 始终在 R1 = b 之前执行,而线程 2 中的 b = 2 始终在 R2 = a 之前执行 ,因此可能的执行序列共有 4!/(2!*2!) = 6 种:

 

情况 1 情况 2 情况 3 情况 4 情况 5 情况 6
a = 1;
b = 2;
a = 1;
a = 1;
b = 2;
b = 2;
R1 = b;
R2 = a;
b = 2;
b = 2;
a = 1;
a = 1;
b = 2;
a = 1;
R1 = b;
R2 = a;
R1 = b;
R2 = b;
R2 = a;
R1 = b;
R2 = a;
R1 = b;
R2 = a;
R1 = b;
R1 == 0, R2 == 1
R1 == 2, R2 == 0
R1 == 2, R2 == 1
R1 == 2, R2 == 1
R1 == 2, R2 == 1
R1 == 2, R2 == 1

 

上面的表格列举了两个线程交织执行时所有可能的执行序列,我们发现,R1,R2 最终结果只有 3 种情况,分别是 R1 == 0, R2 == 1(情况 1),R1 == 2, R2 == 0(情况2) 和 R1 == 2, R2 == 1(情况 3, 4, 5,6)。结合上面的例子,我想大家应该理解了什么是顺序一致性。

因此,多线程环境下顺序一致性包括两个方面,(1). 从多个线程平行角度来看,程序最终的执行结果相当于多个线程某种交织执行的结果,(2)从单个线程内部执行顺序来看,该线程中的指令是按照程序事先已规定的顺序执行的(即不考虑运行时 CPU 乱序执行和 Memory Reorder)。

当然,顺序一致性代价太大,不利于程序的优化,现在的编译器在编译程序时通常将指令重新排序(当然前提是保证程序的执行结果是正确的),例如,如果两个变量读写互不相关,编译器有可能将读操作提前(暂且称为预读prefetch 吧),或者尽可能延迟写操作,假设如下面的代码段:

int a = 1, b = 2;

void func()
{
    a = b + 22;
    b = 22;
}

 在GCC 4.4 (X86-64)编译条件下,优化选项为 -O0 时,汇编后关键代码如下:

movl    b(%rip), %eax ; 将 b 读入 %eax
addl    $22, %eax ; %eax 加 22, 即 b + 22
movl    %eax, a(%rip) ; % 将 %eax 写回至 a, 即 a = b + 22
movl    $22, b(%rip) ; 设置 b = 22

而在设置 -O2 选项时,汇编后的关键代码如下:

movl    b(%rip), %eax ; 将 b 读入 %eax
movl    $22, b(%rip) ; b = 22
addl    $22, %eax ; %eax 加 22
movl    %eax, a(%rip) ; 将 b + 22 的值写入 a,即 a = b + 2

由上面的例子可以看出,编译器在不同的优化级别下确实对指令进行了不同程度重排,在 -O0(不作优化)的情况下,汇编指令和 C 源代码的逻辑相同,但是在 -O2 优化级别下,汇编指令和原始代码的执行逻辑不同,由汇编代码可以观察出,b = 22 首先执行,最后才是 a = b + 2, 由此看出,编译器会根据不同的优化等级来适当地对指令进行重排。在单线程条件下上述指令重排不会对执行结果带来任何影响,但是在多线程环境下就不一定了。如果另外一个线程依赖 a,b的值来选择它的执行逻辑,那么上述重排将会产生严重问题。编译器优化是一门深奥的技术,但是无论编译器怎么优化,都需要对优化条件作出约束,尤其是在多线程条件下,不能无理由地优化,更不能错误地优化。

另外,现代的 CPU 大都支持多发射和乱序执行,在乱序执行时,指令被执行的逻辑可能和程序汇编指令的逻辑不一致,在单线程条件下,CPU 的乱序执行不会带来大问题,但是在多核多线程时代,当多线程共享某一变量时,不同线程对共享变量的读写就应该格外小心,不适当的乱序执行可能导致程序运行错误。因此,CPU 的乱序执行也需要作出适当的约束。

综上所述,我们必须对编译器和 CPU 作出一定的约束才能合理正确地优化你的程序,那么这个约束是什么呢?答曰:内存模型。C++程序员要想写出高性能的多线程程序必须理解内存模型,编译器会给你的程序做优化(静态),CPU为了提升性能也有乱序执行(动态),总之,程序在最终执行时并不会按照你之前的原始代码顺序来执行,因此内存模型是程序员、编译器,CPU 之间的契约,遵守契约后大家就各自做优化,从而尽可能提高程序的性能。

C++11 中规定了 6 中访存次序(Memory Order),如下:

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

std::memory_order 规定了普通访存操作和相邻的原子访存操作之间的次序是如何安排的,在多核系统中,当多个线程同时读写多个变量时,其中的某个线程所看到的变量值的改变顺序可能和其他线程写入变量值的次序不相同。同时,不同的线程所观察到的某变量被修改次序也可能不相同。然而,如果保证所有对原子变量的操作都是顺序的话,可能对程序的性能影响很大,因此,我们可以通过 std::memory_order 来指定编译器对访存次序所做的限制。因此,在原子类型的 API 中,我们可以通过额外的参数指定该原子操作的访存次序(内存序),默认的内存序是 std::memory_order_seq_cst

我们可以把上述 6 中访存次序(内存序)分为 3 类,顺序一致性模型(std::memory_order_seq_cst),Acquire-Release 模型(std::memory_order_consume, std::memory_order_acquire, std::memory_order_release, std::memory_order_acq_rel,) 和 Relax 模型(std::memory_order_relaxed)。三种不同的内存模型在不同类型的 CPU上(如 X86,ARM,PowerPC等)所带来的代价也不一样。例如,在 X86 或者 X86-64平台下,Acquire-Release 类型的访存序不需要额外的指令来保证原子性,即使顺序一致性类型操作也只需要在写操作(Store)时施加少量的限制,而在读操作(Load)则不需要花费额外的代价来保证原子性。

===================================== TL;DR =====================================

附:本文剩余部分将介绍其他的存储器一致模型中的其他几种较常见的模型:处理器一致性(Processor Consistency)模型,弱一致性(Weak Consistency)模型,释放一致性(Release Consistency)模型。[注:以下内容来自中国科学院计算技术研究所胡伟武老师写的《计算机体系结构》(清华大学出版社),该书是胡伟武老师给研究生讲课所用的教材,本文略有删改]

处理器一致性(Processor Consistency)模型:处理器一致性(Processor Consistency)模型比顺序一致性模型弱,因此对于某些在顺序一致性模型下能够正确执行的程序在处理器一致性条件下执行时可能会导致错误的结果,处理器一致性模型对访存事件发生次序施加的限制是:(1). 在任意读操作(Load)被允许执行之前,所有在同一处理器中先于这一 Load 的读操作都已完成;(2). 在任意写操作(Store)被允许执行之前,所有在同一处理器中先于这一 Store 的访存操作(包括 Load 和 Store操作)都已完成。上述条件允许 Store 之后的 Load 越过 Store 操作而有限执行。

弱一致性(Weak Consistency)模型:弱一致性(Weak Consistency)模型的主要思想是将同步操作和普通的访存操作区分开来,程序员必须用硬件可识别的同步操作把对可写共享单元的访存保护起来,以保证多个处理器对可写单元的访问是互斥的。弱一致性对访存事件发生次序的限制如下:(1). 同步操作的执行满足顺序一致性条件; (2). 在任一普通访存操作被允许执行之前,所有在同一处理器中先于这一访存操作的同步操作都已完成; (3). 在任一同步操作被允许执行之前,所有在同一处理器中先于这一同步操作的普通操作都已完成。上述条件允许在同步操作之间的普通访存操作执行时不用考虑进程之间的相关,虽然弱一致性增加了程序员的负担,但是它能有效地提高系统的性能。

释放一致性(Release Consistency)模型:释放一致性(Release Consistency)模型是对弱一致性(Weak Consistency)模型的改进,它把同步操作进一步分成了获取操作(Acquire)和释放操作(Release)。Acquire 用于获取对某些共享变量的独占访问权,而 Release 则用于释放这种访问权,释放一致性(Release Consistency)模型访存事件发生次序的限制如下:(1). 同步操作的执行满足顺序一致性条件; (2). 在任一普通访存操作被允许执行之前,所有在同一处理器中先于这一访存操作的 Acquire 操作都已完成; (3). 在任一 Release 操作被允许执行之前,所有在同一处理器中先于这一 Release 操作的普通操作都已完成。

在硬件实现的释放一致性模型中,对共享单元的访存是及时进行的,并在执行获取操作(Acquire)和释放操作(Release)时对齐。在共享虚拟存储系统或者在由软件维护的数据一致性的共享存储系统中,由于通信和数据交换的开销很大,有必要减少通信和数据交换的次数。为此,人们在释放一致性(Release Consistency)模型的基础上提出了急切更新释放一致性模型(Eager Release Consistency)和懒惰更新释放一致性模型(Lazy Release Consistency)。在急切更新释放一致性模型中,在临界区内的多个存数操作对共享内存的更新不是及时进行的,而是在执行 Release 操作之前(即退出临界区之前)集中进行,把多个存数操作合并在一起统一执行,从而减少了通信次数。而在懒惰更新释放一致性模型中,由一个处理器对某单元的存数操作并不是由此处理器主动传播到所有共享该单元的其他处理器,而是在其他处理器要用到此处理器所写的数据时(即其他处理器执行 Acquire 操作时)再向此处理器索取该单元的最新备份,这样可以进一步减少通信量。

===============================================================================

好了,本文主要介绍了内存模型的相关概念,并重点介绍了顺序一致性模型(附带介绍了几种常见的存储一致性模型),并以一个实际的小例子向大家介绍了为什么程序员需要理解内存模型,总之,C++ 程序员要想写出高性能的多线程程序必须理解内存模型,因为编译器会给你的程序做优化(如指令重排等),CPU 为了提升性能也有多发射和乱序执行,因此程序在最终执行时并不会按照你之前的原始代码顺序来执行,所以内存模型是程序员、编译器,CPU 之间的契约,遵守契约后大家就各自做优化,从而尽可能提高程序的性能。

下一节我将给大家介绍 C++11 内存模型中的 6 种访存次序(或内存序)(std::memory_order_relaxed, std::memory_order_consume, std::memory_order_acquire, std::memory_order_release, std::memory_order_acq_rel, std::memory_order_seq_cst)各自的意义以及常见的用法,希望感兴趣的同学继续关注,如果您发现文中的错误,一定尽快告诉我 ;-)

另外,后续的几篇博客我会给大家介绍更多的与内存模型相关的知识,我在 Github 上维护了一个页面,主要是与内存模型相关资料的链接,感兴趣的同学可以参考里面的资料自己阅读。