并发编程-内存模型

Posted dsw846

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程-内存模型相关的知识,希望对你有一定的参考价值。

在C++1x标准中,一个重大的更新就是引入了C++多线程内存模型。本文的主要目的在于介绍C++多线程内存模型涉及到的一些原理和概念,以帮助大家理解C++多线程内存模型的作用和意义。
 
1. 顺序一致性模型(Sequential Consistency)
在介绍C++多线程模型之前,让我们先介绍一下最基本的顺序一致性模型。对多线程程序来说,最直观,最容易被理解的执行方式就是顺序一致性模型。顺序一致性的提出者Lamport给出的定义是:
“… 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).从单个线程的角度来看,每个线程内部的指令都是按照程序规定的顺序(program order)来执行的;
(2).从整个多线程程序的角度来看,整个多线程程序的执行顺序是按照某种交错顺序来执行的,且是全局一致的;
 
下面我们通过一个例子来理解顺序一致性。假设我们有两个线程(线程1和线程2),它们分别运行在两个CPU核上,有两个初始值为0的全局共享变量x和y,两个线程分别执行下面两条指令:
-Thread 1-
a = 1;
R1 = b;

-Thread 2-
b = 1;
R2 = a;
在不加任何锁或者其他同步措施的情况下,两个线程将会交织执行,但交织执行时指令不发生重排,即线程 1 中的 x = 1 始终在 r1 = y 之前执行,而线程 2 中的 y = 2 始终在 r2= x之前执行 ,因此可能的执行序列共有 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)。结合上面的例子,我想大家应该理解了什么是顺序一致性。

当然,顺序一致性代价太大,不利于程序的优化,现在的编译器在编译程序时通常将指令重新排序(当然前提是保证程序的执行结果是正确的),例如,如果两个变量读写互不相关,编译器有可能将读操作提前(暂且称为预读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 之间的契约,遵守契约后大家就各自做优化,从而尽可能提高程序的性能。
 
2. C++多线程内存模型
为了更容易的进行多线程编程,程序员希望程序能按照顺序一致性模型执行;但是顺序一致性对性能的损失太大了,CPU和编译器为了提高性能就必须要做优化。为了在易编程性和性能间取得一个平衡,一个新的模型出炉了:sequential consistency for data race free programs,它就是即将到来的C++1x标准中多线程内存模型的基础。对C++程序员来说,随着C++1x标准的到来,我们终于可以依赖高级语言内建的多线程内存模型来编写正确的、高性能的多线程程序。
 
我们可以把定义在<atomic>中的memory_order分为 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)则不需要花费额外的代价来保证原子性。
 
C++内存模型可以被看作是C++程序和计算机系统(包括编译器,多核CPU等可能对程序进行乱序优化的软硬件)之间的契约,它规定了多个线程访问同一个内存地址时的语义,以及某个线程对内存地址的更新何时能被其它线程看见。这个模型约定:没有数据竞跑的程序是遵循顺序一致性的。该模型的核心思想就是由程序员用同步原语(例如锁或者C++1x中新引入的atomic类型的共享变量)来保证你程序是没有数据竞跑的,这样CPU和编译器就会保证程序是按程序员所想的那样执行的(即顺序一致性)。换句话说,程序员只需要恰当地使用具有同步语义的指令来标记那些真正需要同步的变量和操作,就相当于告诉CPU和编译器不要对这些标记好的同步操作和变量做违反顺序一致性的优化,而其它未被标记的地方可以做原有的优化。编译器和CPU的大部分优化手段都可以继续实施,只是在同步原语处需要对优化做出相应的限制;而且程序员只需要保证正确地使用同步原语即可,因为它们最终表现出来的执行效果与顺序一致性模型一致。由此,C++多线程内存模型帮助我们在易编程性和性能之间取得了一个平衡。
 
3. C++1x中引入的atomic类型
C++作为一种高性能的系统语言,其设计目标之一就在于提供足够底层的操作,以满足对高性能的需求。在这个前提之下,C++1x除了提供传统的锁、条件变量等同步机制之外,还引入了新的atomic类型。相对于传统的mutex锁来说,atomic类型更底层,具备更好的性能,因此能用于实现诸如Lock Free等高性能并行算法。有了atomic类型,C++程序员就不需要像原来一样使用汇编代码来实现高性能的多线程程序了。而且,把atomic类型集成到C++语言中之后,程序员就可以更容易地实现可移植的多线程程序,而不用再依赖那些平台相关的汇编语句或者线程库。
对常见的数据类型,C++1x都提供了与之相对应的atomic类型。以bool类型举例,与之相对应的atomic_bool类型具备两个新属性:原子性与顺序性。顾名思义,原子性的意思是说atomic_bool的操作都是不可分割的,原子的;而顺序性则指定了对该变量的操作何时对其他线程可见。在C++1x中,为了满足对性能的追求,atomic类型提供了三种顺序属性:sequential consistency ordering(即顺序一致性),acquire release ordering以及relaxed ordering。因为sequential consistency是最易理解的模型,所以默认情况下所有atomic类型的操作都会使sequential consistency顺序。当然,顺序一致性的性能相对来说比较差,所以程序员还可以使用对顺序性要求稍弱一些的acquire release ordering与最弱的relaxed ordering。
在下面这个例子中,atomic_bool类型的变量data_ready就被用来实现两个线程间的同步操作。需要注意的是,对data_ready的写操作仍然可以通过直接使用赋值操作符(即“=”)来进行,但是对其的读操作就必须调用load()函数来进行。在默认的情况下,所有atomic类型变量的顺序性都是顺序一致性(即sequential consistency)。在这个例子中,因为data_ready的顺序性被规定为顺序一致性,所以线程1中对data_ready的写操作会与线程2中对data_ready的读操作构建起synchronize-with的同步关系,即#2->#3。又因为writer_thread()中的代码顺序规定了#1在#2之前发生,即#1->#2;而且reader_thread中的代码顺序规定了#3->#4,所以就有了#1->#2->#3->#4这样的顺序关系,从而可以保证在#4中读取data的值时,#1已经执行完毕,即#4一定能读到#1写入的值。
#include <atomic>
#include <vector>
#include <iostream>

std::vector<int> data;
std::atomic_bool data_ready(false);

// 线程1
void writer_thread() {
    data.push_back(10); // #1:对data的写操作
    data_ready = true;  // #2:对data_ready的写操作
}

// 线程2
void reader_thread() {
    while(!data_ready.load()) { // #3:对data_ready的读操作
        std::this_thread::sleep(std::milliseconds(10));
    }
    std::cout << ”data is ” << data[0] << ”\n”; // #4:对data的读操作
}    
相信很多朋友会纳闷,这样的执行顺序不是显然的么?其实不然。如果我们把data_ready的顺序性制定为relaxed ordering的话,编译器和CPU就可以自由地做违反顺序一致性的乱序优化,从而导致#1不一定在#2之前被执行,最终导致#4中读到的data的值不为10。
简单的来说,在atomic类型提供的三种顺序属性中,acquire release ordering对顺序性的约束程度介于sequential consistency(顺序一致性)和relaxed ordering之间,因为它不要求全局一致性,但是具有synchronized with的关系。Relaxed ordering最弱,因为它对顺序性不做任何要求。由此可见,除非非常必要,我们一般不建议使用relaxed ordering,因为这不能保证任何顺序性。关于这三种属性更详细的信息大家可以参考[1]。
通过上面的例子我们可以看到,C++1x中的多线程内存模型为了通过atomic类型提供足够的灵活性和性能,最大限度地将底层细节(三种不同的顺序属性)暴露给了程序员。这样的设计原则一方面给程序员提供了实现高性能多线程算法的可能,但却也大大增加了使用上的难度。我个人的建议是,如果常规的mutex锁、条件变量、future信号能满足您的设计需求,那么您完全不需要使用atomic变量。如果您决定使用atomic变量,请尽量使用默认的顺序一致性属性。
 
4. 总结
本文对C++1x标准中新引入的多线程内存模型进行了简要介绍。C++1x多线程内存模型的引入使得广大C++程序员可以享受语言原生支持的多线程机制,并为实现高性能多线程算法提供了足够丰富的工具(例如atomic类型)。但是,多线程内存模型本身的复杂性,以及一些底层机制(例如不同的顺序性属性)的引入也给使用C++进行多线程编程带来了不小的复杂度。如何高效、可靠的利用好这些新引入的多线程机制将会成为一个新的挑战。

以上是关于并发编程-内存模型的主要内容,如果未能解决你的问题,请参考以下文章

并发编程系列之什么是Java内存模型?

java并发编程12.java内存模型

Swift新async/await并发中利用Task防止指定代码片段执行的数据竞争(Data Race)问题

Swift新async/await并发中利用Task防止指定代码片段执行的数据竞争(Data Race)问题

Java并发编程Java内存模型

3.java并发编程艺术-java内存模型