C++多线程系列-3 内存序列和内存屏障

1、内存序列

Load指令用于从内存中读取数据放入寄存器中;Store指令用于将寄存器中的数据保存到内存

有四种不同的方式来组合加载和存储操作:

Load-Load: 一个load操作后跟一个load操作。
Load-Store: 一个load操作后跟一个store操作。
Store-Load: 一个store操作后跟一个load操作。
Store-Store: 一个store操作后跟一个store操作。

2、内存栅栏

C++支持的栅栏类型:std::atomic_thread_fence和std::atomic_signal_fence。

std::atomic_thread_fence : 同步线程间的内存访问。
std::atomic_signal_fence : 线程内信号之间的同步。
std::atomic_thread_fence可以防止特定的操作翻过栅栏。不需要原子变量,通常称为栅栏或内存屏障。

2.1 线程三种内存栅栏

通常,栅栏有三种:全栅(full fence)、获取栅栏(acquire fence)和释放栅栏(release fence)。提醒一下,获取是一个加载操作, 释放是一个存储操作。如果在加载和存储操作的四种组合之间,放一个内存屏障中会发生什么情况呢?

1、全栅(full fence):任意两个操作之间使用完整的栅栏std::atomic_thread_fence(),可以避免这些操作的重新排序。不过,对于存储-加载操作来说,它们可能会被重新排序。
2、获取栅栏(acquire fence): std::atomic_thread_fence(std::memory_order_acquire)避免在获取栅栏之前的读操作,被获取栅栏之后的读或写操作重新排序。
3、释放栅栏(release fence): std::atomic_thread_fence(std::memory_order_release)避免释放栅栏之后的写操作,在释放栅栏之前通过读或写操作重新排序。

2.1.1 全栅

在这里插入图片描述
当然,可以显式地调用std::atomic_thread_fence(std::memory_order_seq_cst),而不是std::atomic_thread_fence()。默认情况下,栅栏使用内存序为顺序一致性。如果对全栏使用顺序一致性,那么std::atomic_thread_fence也将遵循全局序。

2.1.2获取栅栏

在这里插入图片描述

2.1.3 释放栅栏

在这里插入图片描述

3 、C++ 内存序

内存序有如下:

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

1.1 顺序一致性(Sequential Consistency)
假设有两个线程,分别是线程 A 和线程 B,那么这两个线程的执行情况有三种:第一种是线程 A 先执行,然后再执行线程 B;第二种情况是线程 B 先执行,然后再执行线程 A;第三种情况是线程 A 和线程 B 同时并发执行,即线程 A 的代码序列和线程 B 的代码序列交替执行。尽管可能存在第三种代码交替执行的情况,但是单纯从线程 A 或线程 B 的角度来看,每个线程的代码执行应该是按照代码顺序执行的,这就是朴素的代码顺序一致性模型。

顺序一致性是个好东西,所有操作都按照代码指定的顺序进行,并且所有 CPU(多核)对这些操作的组合结果与全局顺序一致,但是这种严格的排序也限制了现代 CPU 利用硬件进行并行处理的能力,会严重拖累系统的性能。所以,现代 CPU 普遍不支持严格的顺序一致性,来看个例子:

k=3;    //#1
x=a;    //#2 a 没有使用过,在内存中
y=k;    //#3 k 由于刚刚使用过,可能还在寄存器中暂存

在这个例子中,#2 和 #3 的代码并不能保证是顺序执行,编译器可能会让 CPU 在等待数据总线返回 a 的值之前,先执行 y=k 的赋值,因为 k 在之前的代码刚刚访问过,可能还暂存在某个寄存器中,访问更快速。虽然对于单线程来说,这两行代码的顺序并不影响全局结果,但是对于多线程环境来说,如果其他线程也有对 x 和 y 的访问,那么这个线程内的局部优化可能会影响其他线程的结果。

阻止内存访问和指令的重排的方法很多,可以使用编译选项停止此类优化,或者使用预编译指令将不希望被重排的代码分隔开,比如 GCC 可用:

k=3;
x=a;   
asmvolatile("":::"memory");  //阻止这两行代码顺序改变
y=k;

如果是 Visual C++,可以用 _ReadWriteBarrier() 代替。在 CPU 指令层面上,x86/64 的处理器没有提供数据装载和存储屏障之类的设施,但是提供了一些指令可以达到类似的效果,比如 mfence 指令就是一个硬件层面的内存栅栏。除此之外,各种操作系统也提供了类似功能的原语,比如 linux 内核提供了 smp_mb 宏。但是以上这些方法都依赖平台和编译器,使得 lock-free 编程困难重重。C++11 atomic 库的出现使得情况开始好转,它从语言层面提供了一种标准化的方法,使得编写可移植的 lock-free 代码变得更容易了。

3.1 happens-before

从程序代码执行的时间顺序看,先执行的操作会 “happens-before” 后执行的操作。对顺序一致性模型来说,前面的代码会 “happens-before” 后面的代码,但是对于现代 CPU 和编译器的并发优化结果来说,代码位置的先后顺序并不一定能保证与执行的先后顺序一致。既然物理上的代码顺序无法保证执行的顺序一致性,那么我们需要定义一个逻辑上的执行顺序一致性,这就是 “happens-before 原则”。如果 A 操作在时间上 “happens-before” B 操作,则认为 A 操作对 B 操作是可见的,即 B 操作能 “看到” A 操作的结果。反过来也一样,如果 A 操作希望对 B 操作是可见的,那么必需满足 A “happens-before” B 这个原则。对于数据并发访问的控制来说,happens-before 原则非常重要,它是判断是否存在数据竞争、线程(数据访问)是否安全的主要依据。

虽然现代 CPU 在执行代码序列的时候是乱序的,但是从程序员的视角看代码的执行必需具备某种顺序确定性才能保证一个功能的正常运行,而满足 happens-before 原则是这种确定性的前提。满足 happens-before 原则需要在软件层面上提供一种比使用 “锁” 更高的语义,用来保证内存访问不仅访问安全,而且逻辑上也符合达成某种任务所需要的操作顺序。C++ 的多线程内存模型就是从语言层面上提供的一种基础设施,用于程序员控制内存访问序列化的行为。

在这个模型中对应着四个值,分别是 memory_order_consume、memory_order_acquire、memory_order_release 和 memory_order_acq_rel,它们有的适用于读取操作,有的适用于存储操作,有的既能用于读取操作,也能用于存储操作。用这些描述符,配合相应的内存读写操作,可以实现相对严格一点的内存访问顺序控制。它们通常不是单独使用,而是配合使用,比如 Release-Acquire 顺序、Release-Consume 顺序以及 “读-修改-写”顺序。

3.2.1 memory_order_release

一个对原子变量 A 的存储(store)操作如果施加了 memory_order_release 标记,它对内存操作的影响是:当前线程中这个存储操作之前的内存读、写操作都不可以被重排在这个存储操作之后;其他线程中如果对原子变量 A 施加了带有 memory_order_acquire 标记的读取(load)操作,则当前线程中这个存储操作之前的其他内存读、写操作对这个线程可见;其他线程中如果有对这个原子变量 A 施加了带有 memory_order_consume 标记的操作,则当前线程中所有原子变量 A 所依赖的内存写操作对其他线程可见(没有依赖关系的内存操作就不能保证顺序)。

上述描述有点抽象,不好理解,但是不要紧,下面节结合 memory_order_acquire 和 memory_order_consume 介绍 Release-Acquire 顺序和 Release-Consume 顺序的时候会用具体的例子详细说明。这里只需记住一点:对当前线程来说,当前带有 memory_order_release 标记的原子操作之前的所有内存读写,在一定条件下不会被重排到这个原子操作的后面,至于对其他线程的影响都是这个对当前线程的约束所产生的副作用。很多资料对 memory_order_release 标记的理解有误,他们认为 memory_order_release 标记之前的所有内存读写都不会被重排在这个标记的操作之后,其实是不对的,这里要强调是有条件的,这个条件就是与之配对使用的是 memory_order_acquire 还是 memory_order_consume。

3.2.2 memory_order_acquire

一个内存读取操作如果施加了 memory_order_acquire 标记,它对内存操作的影响是:对当前线程来说,这个读取操作后面的所有的内存读和写操作都不能被重排到这个读取操作之前;其他线程中如果有对当前读取的原子变量施加了带有 memory_order_release 标记的写操作,则这个原子变量写操作之前的内存读、写操作对当前线程都是可见的。这里的可见请结合 happens-before 原则理解,即那些内存读写操作会确保完成,不会被重新排序。

如果线程 A 中的一个原子变量的存储操作被标记为 memory_order_release,线程 B 中来自同一原子变量的读取操作被标记为memory_order_acquire,那么从线程 A 的角度来看,在原子变量存储之前发生的所有内存写入(包括非原子变量和使用 memory_order_relaxed 标记的原子变量)在线程 B 中都会产生作用。也就是说,一旦原子读取完成,线程 B 就可以保证看到线程 A 写入内存的所有内容。这里说的同步只在 release 和 acquire 相同原子变量的线程之间建立,其他线程可能看到与同步线程不同的内存访问顺序。

看一个典型的“生产者-消费者”例子:

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

void producer()
{
    std::string* p  =newstd::string("Hello");   //#1
    data =42;                                    //#2
    ptr.store(p,std::memory_order_release);      //#3
}

void consumer()
{
    std::string* p2;
    while(!(p2 = ptr.load(std::memory_order_acquire)));      //#4
    assert(*p2 =="Hello");                  //#5
    assert(data ==42);                      //#6
}

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

在这个例子代码中,原子变量 ptr 的 store 操作使用了 memory_order_release 标记,这意味着在 producer 线程中,#1 和 #2 的内存操作不会被重排到这个 store 操作之后。在 consumer 线程中,对同一原子变量 ptr 的 load 操作使用了 memory_order_acquire,这意味着当其确定取到不为 null 的值的时候,#1 和 #2 对 consumer 线程是可见的(即 #1 和 #2 一定会先执行),所以 #5 和 #6 的断言能保证成立。这个例子再次说明了 C++ 的 std::memory_order 不是用来做线程同步的,它的意义仅仅在于当 consumer 线程中对原子变量 ptr 取到非 null 的值的时候,producer 线程中使用的 memory_order_release 标记能确保 ptr 在存储为非 null 值之前 #1 和 #2 的赋值操作已经完成(因为不会被重排到这个存储操作之后)。

3.2.3 memory_order_consume

一个内存读取操作如果施加了 memory_order_consume 标记,它对内存操作的影响是:当前线程中任何与这个读取操作有依赖关系的读写操作都不会被重排到当前读取操作之前;其他线程中如果对这个读取操作的原子变量施加了带有 memory_order_release 标记的操作,则那个线程中所有与这个原子变量有依赖关系的写操作对当前线程是可见的。在大多数平台上,这个标记只影响编译器的优化结果(代码重排),不影响 CPU 的指令重排。

依赖关系非常容易理解,我们举个例子说明一下,如下代码中,变量 a 的值不依赖变量 b,但是变量 b 的值依赖于变量 a 的值,这就是 b 依赖 a 的关系。

int a =1;
int b = a +1;
再看一个例子:
std::atomic<std::string*> ptr;
int data;

std::string* p  =newstd::string("Hello");
data =42;                                   
ptr.store(p,std::memory_order_release);

在这个例子中,原子变量 ptr 依赖 p,但是不依赖 data,data 和 p 互相不依赖。

了解了依赖关系之后,回过头继续理解 memory_order_consume 标记的意义。如果线程 A 中的原子变量 store 操作被标记为 memory_order_release,线程 B 中读取同一个原子变量的 load 操作被标记为 memory_order_consume,则从线程 A 的角度来看,在原子变量存储之前发生的所有内存写入(包括非原子变量和使用 memory_order_relaxed 标记的原子变量)中,只有这个原子变量有依赖关系的内存读写才会保证不被重排到这个 store 操作之后。也就是说,当线程 B 使用带 memory_order_consume 标记的 load 操作时,线程 A 中只有与这个原子变量有依赖关系的内存读写操作才保证不会被重排到 store 操作之后。与之对应的是,如果线程 B 使用带 memory_order_acquire 标记的 load 操作时,线程 A 中所有在 store 之前的所有内存读写操作都保证不会被重排到 store 操作之后。

同样用上一节的例子,只是将 consumer 线程的 load 操作换成 memory_order_consume 标记,对比一下它们的差异:

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

void producer()
{
    std::string* p  =newstd::string("Hello");   //#1
    data =42;                                    //#2
    ptr.store(p,std::memory_order_release);      //#3
}

void consumer()
{
    std::string* p2;
    while(!(p2 = ptr.load(std::memory_order_consume)));      //#4
    assert(*p2 =="Hello");                  //#5
    assert(data ==42);                      //#6
}

producer 线程中的代码没有变化,但是 consumer 线程对同一个原子变量的 load 操作换成 memory_order_consume 标记,使得 producer 线程的行为发生明显变化:#1 的 p 与 原子变量 ptr 有依赖关系,所以不会被重排到 #3 之后,但是 data 与 ptr 没有依赖关系,所以 data = 42 这行代码可能会被重排到 #3 之后。这个变化使得 #6 的断言可能会出现失败的情况。

那么什么时候用 memory_order_consume,什么时候用 memory_order_acquire 呢?当我们对一个原子变量使用带 memory_order_acquire 标记的 load 的时候,对那些使用 memory_order_release 标记 store 这个原子变量的线程来说,这些线程中在 store 之前的所有内存操作都不能被重排到 store 之后,这将严重限制 CPU 和编译器优化代码执行的能力。所以,当确定只需对某个变量限制访问顺序的时候,应尽量使用 memory_order_consume,减少代码重排的限制,对性能有好处。

3.2.4 memory_order_acq_rel

对单独的 load 或 store 操作施加内存顺序标记会对特定的 load 或 store 产生影响,但是对于 atomic_exchange,或者 compare_exchange 这样在一个原子操作中既有 load ,又有 store 的操作,如何对这一个原子操作中的两个动作施加标记呢?这就要用到 memory_order_acq_rel。对于施加了 memory_order_acq_rel 标记的原子操作,这个标记对当前线程的影响是:当前线程中此操作之前或之后的内存读写都不能被重新排序。对其他线程的影响是:在修改之前,如果其他线程中对这个原子变量施加了 memory_order_release 标记的 store 操作,则那个线程中所有 store 之前的内存写入操作对当前线程可见(当前线程做修改动作之间,能看到那个线程的写入结果);如果其他线程中对这个原子变量施加了 memory_order_acquire 标记的 load 操作,则当前线程的修改操作对那个线程也是可见的。

简单理解,这个标记相当于对当前原子操作中的 load 操作施加了 memory_order_acquire 标记,对 store 操作施加了 memory_order_release 标记。于此同时,当前线程中这个操作之前的内存读写不能被重排到这个操作之后,这个操作之后的内存读写也不能被重排到这个操作之前。

通过一个 3 线程的例子理解这个标记:

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

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

void thread_2()
{
	 while(flag.load(std::memory_order_acquire) !=1);//确保2执行
    int expected=1;  // #3
    while(!flag.compare_exchange_strong(expected,2,std::memory_order_acq_rel))//#4
    {
        expected =1;
    }
}

void thread_3()
{
    while(flag.load(std::memory_order_acquire)<2);//#5
    assert(data.at(0)==42);                         //#6
}

thread_2 线程中对原子变量 flag 的 compare_exchange 操作使用了 memory_order_acq_rel 标记,意味着这个线程中 #3 不会被重排到 #4 之后,也就是说,当 compare_exchange 操作发生的时候,能确保 expected 的值是 1,使得这个 compare_exchange 操作能够完成将 flag 替换成 2 的动作。thread_1 线程中对 flag 使用了带 memory_order_release 标记的 store,这意味着当 thread_2 线程中取 flag 的值得时候,#1 已经完成(不会被重排到 #2 之后)。当 thread_2 线程 compare_exchange 操作将 2 写入 flag 的时候,thread_3 线程中带 memory_order_acquire 标记的 load 操作能看到 #4 之前的内存写入,自然也包括 #1 的内存写入,所以 #6 的断言始终是成立的。

3.2.4 memory_order_seq_cst

顺序一致性模型对应的约束符号是 memory_order_seq_cst,这个模型对于内存访问顺序的一致性控制是最强的,类似于很容易理解的互斥锁模式,先得到锁的先访问。对原子变量的访问使用 memory_order_seq_cst 标记意味着:如果是读取数据的 load 操作,它相当于对 load 施加 memory_order_acquire 标记,如果是存储数据的 store 操作,它相当于对 store 施加 memory_order_release 标记。如果是 “读取-修改” 操作,它相当于施加了 memory_order_acq_rel 标记。对所有线程来说,这个标记会对所有使用此标记的原子变量进行同步,使得线程看到的内存操作的顺序都是一样的。前面介绍的 Release-Acquire 顺序或 Release-Consume 顺序都是用来同步一个原子变量的访问顺序,这个 memory_order_seq_cst 则是同步所有原子变量的访问顺序,就像是将所有原子变量的操作放在一个线程中顺序执行一样。

顺序一致性模型容易理解,std::atomic 的操作都使用 memory_order_seq_cst 作为默认值。如果你不确定使用何种内存访问模型,用 memory_order_seq_cst 能确保不出错。

内存序讲的好的学习链接
理解 C++ 的 Memory Order

  • 9
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值