C/C++编程:内存模型和数据竞争

1059 篇文章 275 订阅

内存模型

曾经C++并没有自己的内存模型,程序只能自己在代码中使用asm插入硬件相关的barrier指令。软件工程师必须对每种硬件平台非常熟悉,熟悉其硬件内存模型的特点,以及每个barrier指令的作用,才能写出正确的多线程无锁代码。这是一个蛮荒时代。

2004年,Java 5.0引入了适用于多线程环境的内存模型. 自C++11开始,C++终于有了自己的内存模型。

  • Java内存模型在很大程度上影响了C++内存模型,但后者走得更远。因为它允许开发者打破顺序一致性(Sequential Consistency,我们会在下文中讲解),以获得更好的控制。
  • 不像Java,C++ 没有自己的虚拟机,要理解C++语言的内存模型,可以参考图6-1。可以认为C++内存模型是一个Relaxed Memory Consistency模型,不过缺省它是一个SC for Data-Race-Free Programs。
  • 对于memory location,thread,以及data race的定义,我们无需太多的关注,重点了解的就是memory ordering部分,也是无锁编程的基础。
    在这里插入图片描述

语言级的内存模型,可以提供更高的抽象概念,避免软件工程师直接同硬件指令打交道。而处理不同硬件的逻辑则交给了编译器,这有益于我们写出正确的跨平台的无锁程序,并且效率更高。

  • 在计算中,内存模型描述了多线程如何通过内存进行交互和使用共享数据。换句话说,内存模型约束了处理器对内存的读写访问。
  • 内存模型是多线程环境能够可靠工作的基础,因为内存模型需要对多线程环境的运作细节进行完备的定义。
  • 简单来讲,可以认为内存模型是一种契约。它定义一套操作手法以及这些操作手法背后的详细含义。开发者利用这套操作完成数据的同步以避免竞争条件,而系统(包括:编译器,操作系统和处理器)保证执行的逻辑符合内存模型对于相关操作的定义 – 即实现契约。

内存模型又可以分成两个层面来看:

  • 硬件内存模型,它定义了再执行二进制程序时,硬件可以执行的优化和内存指令重排序(Memory Reordering),这里的硬件也就是处理器(CPU)。
  • 语言内存模型,它定义了将源代码编译成机器码时,编译器可以执行的优化和内存指令重排序。典型的就是JVM,它有自己的内存模型。

这些优化都可能会导致程序实际的执行顺序与源代码之间的差异,也就是发生了重排。编译优化是为了结合编译器对上下文的分析,根据源代码生成更加高效的二级制代码。在硬件层面,CPU对内存的访问可以统一为读(Load)和写(Store)两个操作。简单来说,对于的内存操作重排序可能有4种组合:

  • Load-Load重排,对于两个Load操作L1和L2,处理器执行的顺序是L1->L2,而另一个处理器看到的顺序则是L2->L1。
  • Store-Store重排,对于两个Store操作S1和S2,处理器执行的顺序是S1->S2,而另一个处理器看到的顺序则是S2->S1。
  • Load-Store重排,对于一个Load操作L1和一个Store操作S1,处理器执行的顺序是L1->S1,而另一个处理器看到的顺序则是S1->L1。
  • Store-Load重排,对于一个Load操作L1和一个Store操作S1,处理器执行的顺序是S1->L1,而另一个处理器看到的顺序则是L1->S1。

当然,这些内存重排只有在多核处理器上才会出现,不同的处理器对于内存重排的限制也不同。对内存重排的约束越弱,我们就称之为Weak Memory Model(弱内存模型),反之,就是Strong Memory Model(强内存模型)。我们通常说x86是一个强内存模型的CPU,因为它不会出现Load-Load,Store-Store,Load-Store这三种内存重排序(当然,实际情况会更复杂)。相对应的,Arm则是典型的弱内存模型。

内存模型主要包含了下面三个部分:

  • 原子操作:顾名思义,这类操作一旦执行就不会被打断,你无法看到它的中间状态,它要么是执行完成,要么没有执行。
  • 操作的局部顺序:一系列的操作不能被乱序。
  • 操作的可见性:定义了对于共享变量的操作如何对其他线程可见。

C++11引入memory order的意义在于我们现在有了一个与运行平台无关和编译器无关的标准库, 让我们可以在high level languange层面实现对多处理器对共享内存的交互式控制。我们的多线程终于可以跨平台啦!我们可以借助内存模型写出更好更安全的并发代码。真棒,简直不要太优秀~

字节

C++内存模型中的基本存储单位是字节。一个字节至少足够大,能够包含基本执行字符集的任何成员以及Unicode UTF-8编码形式的八位代码单元,并且由连续的位序列组成。

  • 字节(byte)是最小的可寻址内存单元。由连续的多个比特组成。,其大到足以保有任何 UTF-8 编码单元(256 个相异值)和 (C++14 起)基本执行字符集(96 个字符,要求必为单字节)的任何成员。与 C 相似,C++ 也支持 8 位或更大的字节。
  • char、unsigned char 和 signed char 的对象存储和值表示均使用恰好 1 字节。 于是,字节中有多少比特,可以通过 CHAR_BIT std::numeric_limits<unsigned char>::digits 获得

内存位置

C++中所有数据都是由对象组成的。这里的对象包括了简单基本类型(如和),也包括了指针类型(如)。当然,也包括各种定义的类的对象。
无论是什么类型,一个对象均包含了一个或多个内存位置。每个内存位置一定是下面两种情况中的一种::

  • 一个标量类型对象, 标量类型包括算术类型、指针类型、枚举类型或 std::nullptr_t
  • 长度不为零的位域组成的最长连续序列。
    • 位域声明具有以“位”为单位的明确大小的类数据成员。相邻的位域成员可以打包成共享和跨过各个字节。
    • 位域的值必须大于等于0。值0比较特殊,它仅允许使用在无名位域上。并且它具有特殊含义:它指定类定义中的下个位域将始于分配单元的边界。
      注意:语言中的许多特性会引入额外的内存位置。这些内存位置程序无法访问,而是为编译器实现自行管理。这些特性例如:引用和虚函数。
struct S {
    char a;     // 内存位置 #1
    int b : 5;  // 内存位置 #2
    int c : 11, // 内存位置 #2 (延续)
          : 0,
        d : 8;  // 内存位置 #3
    struct {
        int ee : 8; // 内存位置 #4
    } e;
} obj; // 对象 'obj' 由 4 个分离的内存位置组成

之所以介绍内存位置,是因为这与内存模型密切相关。

如果多个线程各自访问的是不同的内存位置,那么就不会有什么问题。但是,如果它们同时访问了相同的内存位置,那就要小心了。

线程与数据竞争

简单说下data race,对于同一块内存地址,如果一个evaluation正在写入时,另一个evaluation同时去读或者修改,就发生了冲突。我们说一个有evaluation冲突的程序发生了data race,除非满足:

  • 两个求值都在同一线程上,或者在同一信号处理函数中执行
  • 两个冲突的求值都是原子操作(见 std::atomic
  • 一个早于(happens-before)另一个(见 std::memory_order

data race将导致未定义行为。

int cnt = 0;
auto f = [&]{cnt++;};
std::thread t1{f}, t2{f}, t3{f}; // 未定义行为

我们已经知道,C++中的数据都是由对象组成。一个对象包含了若干个内存位置。

每个对象从初始化开始,直到最终销毁,在其生命周期的范围内,对它进行的访问必须有一个确定的修改顺序,这个顺序包含了所有线程的访问操作。

虽然程序的每一次运行,这个顺序可能是不一样的(例如:CPU资源的变化,调度器的影响),但是针对其中具体的某一次来说,必须有一个“一致的顺序”,这个顺序要被所有的线程认可,并且可见。

例如:一旦某个线程修改了一个数据,这个操作必须要让所有线程知道,在修改操作之后,所有线程都应该得到修改后的值。

从数据类型的角度来说,有两种情况:

  • 对于原子类型:由编译器保证数据的同步。
  • 对于非原子类型:由开发者保证。

而对于常用的有锁编程而言,可用 std::mutex 来避免数据竞争。

int cnt{0};
std::mutex mtx;
auto f = [&]{ std::lock_guard<std::mutex> lk(mtx); cnt++; };
std::thread t1{f}, t2{f}, t3{f};  // OK, by using mutex to ensure happens-before semantic

这就存在两个问题:

  • 可能会出现死锁
  • 并发的效率不够:一旦有一个线程进入临界区,其他线程只能等待。

锁就像是高速公路网之间的一个个独木桥,必须串行化排队。高速路网越发达,独木桥就越碍眼。那有没有一种策略可以让其他线程不用等待,实现更好的并发呢?

答案是肯定的,计算机的精英们又想出来另外一个方向,那就是Lock-free。Lock-free的核心就是CPU提供的CAS(Compare and Swap)原子操作能力,通过精妙的编程技法,去掉了对锁的依赖,从而更好的利用多核处理器的并发能力,提升程序的性能。

但是失去了锁的保护,我们不得不面临内存指令重排问题(Memory Reordering),也就是指令的执行顺序与代码顺序是不一致的。在多线程下,这可能会导致非预期的结果。编译器优化可能导致指令重排,CPU执行流水线也可能会导致指令重排。使用Lock-free编程,我们就要对这些重排有所预期,并采用手段来限制指令重排,以防止出现非预期的错误。这又引出了内存模型这一概念。

std::atomic<int> cnt{0};
auto f = [&]{cnt++;};
std::thread t1{f}, t2{f}, t3{f}; // OK

关系术语

C++11使用memory order来描述memory model, 而用来联系memory order的是atomic变量, atomic操作可以用load()和release()语义来描述

为了更好地描述内存模型,有4种关系术语需要了解一下。

sequenced-before

sequenced-before是一种单线程上的关系,这是一个非对称,可传递的成对关系。

  • 如果A is sequenced-before B(或者说,B is sequenced-after A),表示A的求值会在对B的求值开始之前完成。
  • 如果A is not sequenced-before B,而B is sequenced-before A,表示B的求值会在对A的求值开始之前完成
  • 如果A is not sequenced-before B,而B is not sequenced-before A,则A和B可能是无先后顺序的,它们可能会被交叉执行(编译器优化)。或者顺序是不确定的,可能A先执行,也可能B先执行。

同一个线程之内,语句A的执行顺序在语句B前面,那么就成为A sequenced-before B。它不仅仅表示两个操作之间的先后顺序,还表示了操作结果之间的可见性关系。两个操作A和操作B,如果有A sequenced-before B,除了表示操作A的顺序在B之前,还表示了操作A的结果操作B可见。例如:语句A是sequenced-before语句B的。

r2 = x.load(std::memory_order_relaxed); // A
y.store(42, std::memory_order_relaxed); // B

Carries dependency

Carriers dependency(数据依赖): 同一个线程内,表达式A sequenced-before 表达式B,并且表达式B的值是受表达式A的影响的一种关系, 称之为"Carries dependency"。简单来讲,就是B依赖A中的某个值,比如A修改了对象M,而B需要读取M的值。,例如:

int *a = &var1;
int *b = &var2;
c = *a + *b;

happens-before

happens-before关系表示的不同线程之间的操作先后顺序。happens-before 应该翻译成:前一个操作的结果可以被后续的操作获取。讲白点就是前面一个操作把变量a赋值为1,那后面一个操作肯定能知道a已经变成了1

同样的,这是一个非对称,可传递的关系。

  • 传递的:如果 A happens-before B,并且 B happens-before C,那么 A happens-before C。
  • 非自反的:对于任意evaluation A,A都不能happens-before A。
  • 非对称的:如果 A happens-before B,那么B不能happens-before A。

happens-before觉得着什么时候变量操作对你可见。

  • 我们知道cpu的运行极快,而读取主存对于cpu而言有点慢了,在读取主存的过程中cpu一直闲着(也没数据可以运行),这对资源来说造成极大的浪费。所以慢慢的cpu演变成了多级cache结构,cpu在读cache的速度比读内存快了n倍。
  • 当线程在执行时,会保存临界资源的副本到私有work memory中,这个memory在cache中,修改这个临界资源会更新work memory但并不一定立刻刷到主存中,那么什么时候应该刷到主存中呢?什么时候和其他副本同步?
    而且编译器为了提高指令执行效率,是可以对指令重排序的,重排序后指令的执行顺序不一样,有可能线程2读取某个变量时,线程1还未进行写入操作。这就是线程可见性的来源。
  • 所以为了解决多线程的可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它优化的不知道东南西北了!

Happens-before是一个运行时概念,描述的是两个事件(在计算机领域就是内存读写)的结果之间的偏序关系,我们不能静态的去理解它。A happens-before B并不意味着A就是在B之前被执行,而是说A的执行结果一定会对B可见,在B执行之前,可以看到A最后的执行结果,也就是side-effect。

happens before包含了inter-thread happens before和synchronizes-with两种关系。

synchronizes-with

Synchronizes-with是语言设计者们引入的一个术语,用于描述一个操作的内存效果可以保证对其它线程可见, 即如果一个线程修改某变量的之后的结果能被其它线程可见,那么就是满足synchronizes-with关系的,这是编写lock-free代码必须的保证,用于禁止非预期的内存重排序导致的非预期结果。

它不是程序语句之间的关系,而是这些语句在运行时的操作之间的关系,定义的是一种操作序。

原子类型与原子操作

要理解内存模型,首先需要掌握C++11提供的原子类型(atomic types)和原子操作(atomic operation)。

  • 原子类型不是一个类,而是一系列类,它们都位于头文件中。原子类型中包含了原子操作。但也有一些原子类型之外的原子操作。

    • “原子操作”正如其名称所示:该操作要么是执行完了,要么是没有执行,从任何一个线程中,都无法观察到中间状态。以原子读操作为例:如果有其他线程进行了原子写操作,那么原子读操作,要么获取到的是修改前的值,要么是修改后的,不会是修改了一半的值。
    • 而非原子类型就不一样了。如果尝试修改非原子类型对象,其他线程可能看到的既不是修改前的值,也不是修改后的值。

需要注意的是,所有原子类型都不支持拷贝和赋值。因为该操作涉及了两个原子对象:要先从另外一个原子对象上读取值,然后再写入另外一个原子对象。而对于两个不同的原子对象上单一操作不可能是原子的。

内存顺序

当多个线程中包含了多个原子操作,这些原子操作因为其的选择不一样,将导致运行时不同的内存模型强度。从强至弱,有三种情况:

  • Sequential Consistency:顺序一致性,简称 seq-cst。
  • Acquire and Release:获取和释放,简称 acq-rel。
    • 包括: memory_order_acquire, memory_order_release, memory_order_acq_rel
    • acq-rel 模型有如下保证:
      + 同一个对象上的原子操作不允许被乱序。
      + release操作禁止了所有在它之前的读写操作与在它之后的写操作乱序。
      + acquire操作禁止了所有在它之前的读操作与在它之后的读写操作乱序。
  • Relaxed:松散模型。

C++11标准中提供了6种memory order,来描述内存模型,其中建议忽略consume。

  • memory_order_relaxed
    • 仅保证原子操作,不对执行顺序做任何保证
    • 例如,在某一线程中,先写入A,再写入B。但是在多核处理器中观测到的顺序可能是先写入B,再写入A。自由内存顺序对于不同变量可以自由重排序。
  • memory_order_consume:
    • 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行
  • memory_order_acquire
    • 对应了读操作, 本线程中,所有后续的读操作必须在本条原子操作完成后执行
  • memory_order_release
    • 对应了写操作,本线程中,所有之前的写操作完成后才能执行本条原子操作
    • 例如,某一线程先写入A,再写入B,再以memeory_order_release操作写入C,再写入D。在多核处理器中观测到的顺序AB只能在C之前,不能出现C写入之后,A或B再写入的情况。但是,可能出现D重排到C之前的情况。
  • memory_order_acq_rel
    • 对应了既读又写, 同时包含memory_order_acquire和memory_order_release标记
  • memory_order_seq_cst
    • 全部存取都按顺序执行
    • 它是最严格的顺序一致性约束,不允许编译器或者CPU核心为优化而调整代码或者指令的执行顺序,保证在并发环境里任何CPU核心“看到”的指令顺序是相同的,程序的执行与单CPU单线程时相同,简化了程序员的工作。

在这里插入图片描述

memory order releaxed

relaxed表示一种最为宽松的内存操作约定,Relaxed ordering 仅仅保证load()和store()是原子操作, 除此之外,不提供任何跨线程的同步

                         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

上面的多线程模型执行的时候,可能出现r2 == r1 == 42。要理解这一点并不难,因为CPU在执行的时候允许局部指令重排reorder,D可能在C前执行。如果程序的执行顺序是 D -> A -> B -> C,那么就会出现r1 == r2 == 42。

如果某个操作只要求是原子操作,除此之外,不需要其它同步的保障,那么就可以使用 relaxed ordering。程序计数器是一种典型的应用场景:

#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;
}

memory order consume

consume要搭配release一起使用。很多时候,线程间只想针对有依赖关系的操作进行同步, 除此之外线程中其他操作顺序如何不关心,这时候就适合用consume来完成这个操作。例如:

b = *a;
c = *b

第二行的变量c依赖于第一行的执行结果,因此这两行代码是"Carries dependency"关系。显然,由于consume是针对有明确依赖关系的语句来限定其执行顺序的一种内存顺序, 而releaxed不提供任何顺序保证, 所以consume order要比releaxed order要更加地Strong。

#include <thread>
#include <atomic>
#include <cassert>
#include <string>

std::atomic<std::string*> ptr;
int data;

void producer()
{
    std::string* p  = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}

void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_consume)))
        ;
    assert(*p2 == "Hello");  // never fires: *p2 carries dependency from ptr
    assert(data == 42);      // may or may not fire: data does not carry dependency from ptr
}

int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
}

assert(*p2 == “Hello”)永远不会失败,但assert(data == 42)可能会。原因是:

  • p2和ptr直接有依赖关系,但data和ptr没有直接依赖关系,
  • 尽管线程1中data赋值在ptr.store()之前,线程2看到的data的值还是不确定的。

memory order acquire

acquire和release也必须放到一起使用。 release和acquire构成了synchronize-with关系,也就是同步关系。在这个关系下:线程A中所有发生在release x之前的值的写操作, 对线程B的acquire x之后的任何操作都可见

#include <thread>
#include <atomic>
#include <cassert>
#include <string>
#include <iostream>

std::atomic<bool> ready{ false };
int data = 0;
std::atomic<int> var = {0};

void sender()
{
    data = 42;                                              // A
    var.store(100, std::memory_order_relaxed);              // B
    ready.store(true, std::memory_order_release);           // C
}
void receiver()
{
    while (!ready.load(std::memory_order_acquire))          // D
        ;
    assert(data == 42);  // never failed                    // E
    assert(var == 100);  // never failed                    // F
}

int main()
{
    std::thread t1(sender);
    std::thread t2(receiver);
    t1.join();
    t2.join();
}

上面的例子中:

  • sender线程中data = 42是sequence before原子变量ready的
  • sender和receiver在C和D处发生了同步
  • 线程sender中C之前的所有读写对线程receiver都是可见的 显然, release和acquire组合在一起比release和consume组合更加Strong!

memory order release

release order一般不单独使用,它和acquire和consume组成2种独立的内存顺序搭配。

memory order acq_rel

#include <thread>
#include <atomic>
#include <cassert>
#include <vector>

std::vector<int> data;
std::atomic<int> flag = {0};

void thread_1()
{
    data.push_back(42);
    flag.store(1, std::memory_order_release);
}

void thread_2()
{
    int expected=1;
    while (!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel)) {
        expected = 1;
    }
}

void thread_3()
{
    while (flag.load(std::memory_order_acquire) < 2)
        ;
    assert(data.at(0) == 42); // will never fire
}

int main()
{
    std::thread a(thread_1);
    std::thread b(thread_2);
    std::thread c(thread_3);
    a.join(); b.join(); c.join();
}

memory order seq_cst

seq_cst表示顺序一致性内存模型,在这个模型约束下不仅同一个线程内的执行结果是和程序顺序一致的, 每个线程间互相看到的执行结果和程序顺序也保持顺序一致。显然,seq_cst的约束是最强的,这意味着要牺牲性能为代价。

     atomic int x (0);                        atomic int y (0);
    x. store (1, seq cst );         ||      y. store (1, seq cst );
    int r1 = y.load( seq cst ); ||      int r2 = x.load( seq cst );
                assert (r1 == 1 || r2 == 1);

下面是一个seq_cst的实例:

#include <thread>
#include <atomic>
#include <cassert>

std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};

void write_x()
{
    x.store(true, std::memory_order_seq_cst);
}

void write_y()
{
    y.store(true, std::memory_order_seq_cst);
}

void read_x_then_y()
{
    while (!x.load(std::memory_order_seq_cst))
        ;
    if (y.load(std::memory_order_seq_cst)) {
        ++z;
    }
}

void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst))
        ;
    if (x.load(std::memory_order_seq_cst)) {
        ++z;
    }
}

int main()
{
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    assert(z.load() != 0);  // will never happen
}

关于volatile

可能你会思考?volatile关键字能够防止指令被编译器优化,那它能提供线程间(inter-thread)同步语义吗?答案是:不能!!!

  • 尽管volatile能够防止单个线程内对volatile变量进行reorder,但多个线程同时访问同一个volatile变量,线程间是完全不提供同步保证。
  • 而且,volatile不提供原子性!
  • 并发的读写volatile变量是会产生数据竞争的,同时non volatile操作可以在volatile操作附近自由地reorder。

看一个例子,执行下面的并发程序,不出意外的话,你不会得到一个为0的结果。

#include <thread>
#include <iostream>

volatile int count = 0;

void increase() {
    for (int i = 0; i < 1000000; i++) {
        count++;
    }
}

void decrease() {
    for (int i = 0; i < 1000000; i++) {
        count--;
    }
}

int main() {
    std::thread t1(increase);
    std::thread t2(decrease);
    t1.join();
    t2.join();
    std::cout << count << std::endl;
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值