欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 汽车 > 新车 > 并发编程(14)——内存栅栏

并发编程(14)——内存栅栏

2024/11/29 8:11:13 来源:https://blog.csdn.net/m0_63086198/article/details/144122971  浏览:    关键词:并发编程(14)——内存栅栏

文章目录

  • 十四、day14
  • 1. 内存栅栏
    • 1.1 什么是栅栏
    • 1.2 栅栏和原子操作的对比
      • 1.2.1 获取操作
      • 1.2.2 释放操作
    • 1.3 线程可见顺序
    • 1.4 通过栅栏保证指令编排顺序
    • 1.5 通过栅栏令非原子操作服从内存次序
    • 1.6 同步线程间的内存访问

十四、day14

在学习完内存模型、内存序、原子类型、操作的相关内容后,并通过Acquire-Release模型和原子操作实现了无锁环形并发队列,今天学习关于内存栅栏的内容。

参考:

恋恋风辰官方博客

C++ 内存模型

Introduction | Concurrency-with-Modern-C++

C++编程:内存栅栏(Memory Barrier)详解及在多线程编程中的应用-CSDN博客


1. 内存栅栏

1.1 什么是栅栏

栅栏主要用于强制施加内存次序,却无须更改任何数据,通常于服从 memory_order_relaxed 次序的原子操作组合使用。用大白话来说,栅栏用于阻止编译器或CPU对某些内存操作进行重排,确保在它之前的操作完成后,才会执行它之后的操作,从而维护内存操作的顺序一致性。因为 memory_order_relaxed 是最宽松的内存序,它只能保证原子性,却不能保证多线程之间的先行性和顺序性,所以服从 memory_order_relaxed 次序的原子操作通常会通过编译器进行指令重排,而栅栏会限制这种重排。

我们之前学习了互斥量 mutex:拿到mutex锁的线程将拥有唯一进入临界区的资格(共享互斥除外)。其实 mutex 的加锁和解锁之间也起到了”栅栏“的作用,因为栅栏中的代码不会被编译器重排到栅栏之外(但不保证栅栏之外的内容进入栅栏之中)。

如下图的三种情况,第一种可能会被优化成第二种。但是第二种情况不会被优化成第三种:

在这里插入图片描述


通常,栅栏有三种:

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

获取是一个加载操作, 释放是一个存储操作。如果在加载和存储操作的四种组合之间,放一个内存屏障中会发生什么情况呢?

  • ① Load-Load:读接着读
  • ② Load-Store:先读后写
  • ③ Store-Load:先写后读
  • ④ Store-Store:写接着写

全栅可以防止①②④三种情况下的指令重排,但不能防止③被重排

那么,哪些操作可以翻过栅栏,通过下面的图例进行解释,其中红色的斜杆表示这种类型的乱序会被禁止。

  1. 全栅

在这里插入图片描述

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

  1. 获取栅栏

在这里插入图片描述

acquire fence 阻止了所有在它之前的读操作与在它之后的读写操作乱序,保证栅栏之后的读取操作会看到栅栏之前的操作的影响

  1. 释放栅栏
    在这里插入图片描述

release fence 阻止了所有在它之前的读写操作与在它之后的写操作乱序

三种类型的fence均不会禁止先写后读的乱序。

从C++11开始,提供了下面两种栅栏类型:

  • std::atomic_thread_fence : 同步线程间的内存访问。
  • std::atomic_signal_fence : 线程内信号之间的同步。

我们一般使用后者多一些。

1.2 栅栏和原子操作的对比

获取-释放栅栏与原子获取-释放内存序有着相似的同步方式和顺序,但二者有以下两种区别:

  • 栅栏不需要原子操作
  • 获取-释放栅栏比原子操作更重量级

获取-释放操作的主要思想是,在线程间建立同步和排序约束,这些同步和顺序约束也适用于使用宽松次序的原子操作或非原子操作。注意,获取-释放操作是成对出现的。此外,对获取-释放语义的原子变量的操作,必须作用在相同的原子变量上。不过,我们现在先将这些操作分开来看。

1.2.1 获取操作

在原子变量(内存序为std::memory_order_acquire)上进行的加载 (读取)操作是一个获取操作,确保在此之后的所有读操作只会在此读操作完成之后进行
在这里插入图片描述

std::atomic_thread_fence内存序设置为std::memory_order_acquire,这对内存访问重排添加了更严格的约束:

在这里插入图片描述

1.2.2 释放操作

对内存序为 std::memory_order_release 的原子变量,进行存储(写)操作时,这些操作属于释放操作。确保在此之前的所有写操作都在此写操作完成前对其他线程可见

在这里插入图片描述

释放栅栏为

在这里插入图片描述

1.3 线程可见顺序

在六种内存序和三种内存模型中,只有 memory_order_seq_cst 以及其实现的 Sequencial consistent 模型能够保证原子变量修改的值在其他多线程中看到的顺序是一致的。但我们可以通过同步机制保证一个线程对原子变量的修改对另一个线程可见。通过“Syncronizes With” 的方式达到先行的效果。

如果我们线程1对原子变量 A 的store操作采用release内存序,而线程2对原子变量 B 的load采用acquire内存序,并不能保证变量A 的操作一定比 变量B的操作先执行。因为两个线程并行执行无法确定先后顺序,我们指的先行不过是说如果B读取了A操作的结果,则B依赖于A,则称A先行于B。

比如:

#include <iostream>
#include <atomic>
#include <thread>
#include <cassert>
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x()
{x.store(true, std::memory_order_release); //1
}
void write_y()
{y.store(true, std::memory_order_release); //2
}
void read_x_then_y()
{while (!x.load(std::memory_order_acquire));if (y.load(std::memory_order_acquire))   //3++z;
}
void read_y_then_x()
{while (!y.load(std::memory_order_acquire));if (x.load(std::memory_order_acquire))   //4++z;
}void TestAR()
{x = false;y = false;z = 0;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); //5std::cout << "z value is " << z.load() << std::endl;
}

在该段示例中,如果代码按以下逻辑执行,那么断言不会被触发,z始终不为0:

  1. 如果c线程执行函数 read_x_then_y 结束后,没有对z执行加加操作,那么说明c线程读取的x值为true, y值为false。
  2. 之后d线程读取时,如果保证执行到4处说明y为true,等d线程执行4处代码时x必然为true,那么肯定会z++。反过来也类似
  3. 如果x先被store为true,y后被store为true,c线程看到y为false时x已经为true了,那么d线程y为true时x也早就为true了,所以z一定会执行加加操作。

但是上述逻辑是错误的,因为只有先后一致次序才能保证全局一致性,而其他的几种内存序均不能保证多个线程看到的一个变量的值是一致的,更不能保证看到的多个变量的值是一致的。

线程d和线程d的载入操作3和4有可能都读取false值(与宽松次序的情况一样),因此有可能令断言触发错误。变量x和y分别由不同线程写出,所以两个释放操作都不会影响到其他线程。

从以下两个角度解释:

  1. CPU内存结构

假设a,b,c,d分别运行在不同的CPU内核上,那么 a 对x的操作如果放至cache中,而没更新至memory中,那么x会被线程c读取,而线程d是看不到的。同理,线程b对y的操作会先被线程d看见,而线程c看不到。如果线程a对x修改,那么线程c会退出循环,同时线程c看不到线程b对y的修改,那么线程c不会对z++;而线程d同理,它看到了y为true从而退出循环,但是看不到线程a对x的修改,此时z同样不会++。

如果 核1 先将y放入memory,那么核3就会读取y的值为true。那么t2就会运行至3处从while循环退出,进而运行至4处,此时核1还未将x的值写入memory。t2读取的x值为false,进而线程t2运行结束,然后核1将x写入true, t1结束运行,最后主线程运行至5处,因为z为0,所以触发断言。

在这里插入图片描述

  1. 从内存序来看

下图展示了两个线程间操作序列的执行关系,可以看到线程a核线程c之间存在先行关系,线程d和线程b之间也从在先行关系,但除此之外没有其他先行关系。内存序memory_order_acquire和memory_order_release只能保证 write_xwrite_y 先于相应的读取操作发生,但不能保证两个读之间也有先行关系,也不能保证线程a对线程d或线程b对线程c存在先行关系。

在这里插入图片描述

AR模型只能保证每个线程内部的顺序而不能保证线程之间的顺序完全正确

1.4 通过栅栏保证指令编排顺序

我们使用之前宽松次序的例子来理解栅栏的作用:

std::atomic<bool> x, y;
std::atomic<int> z;void write_x_then_y() {x.store(true, std::memory_order_relaxed);  // 1y.store(true, std::memory_order_relaxed);  // 2
}void read_y_then_x() {while (!y.load(std::memory_order_relaxed)) { // 3std::cout << "y load false" << std::endl;}if (x.load(std::memory_order_relaxed)) { //4++z;}
}int main(){x=false;y=false;z=0;std::thread t1(write_x_then_y);std::thread t2(read_y_then_x);t1.join();t2.join();assert(z.load() != 0); // 5
}

在文章并发编程(12)——内存次序与内存模型 | 爱吃土豆的个人博客中我们知道,因为宽松次序不保证线程的先行性与顺序性,所以断言5可能会触发。

我们之前是通过获取-释放模型来解决该问题:

void write_x_then_y3()
{x.store(true, std::memory_order_relaxed); // 1y.store(true, std::memory_order_release);   // 2
}
void read_y_then_x3()
{while (!y.load(std::memory_order_acquire));  // 3if (x.load(std::memory_order_relaxed))  // 4++z;
}

在宽松次序中,上面所有的内存序均为std::memory_order_relaxed,导致 2 和 3 不构成同步关系, 2 “ not synchronizes with “ 3。而这里通过使用Acquire-Release模型,2 和 3 可构成同步关系,即 2 “ synchronizes with “ 3。

当线程t2执行到4处时,说明ry以及被线程t1置为true,而1顺序先行2,所以在4处时也能看到1被修改了,进而可以推断断言不会被触发。

而除了使用AR模型外,我们也可以使用栅栏保证指令的写入顺序。

void write_x_then_y_fence()
{x.store(true, std::memory_order_relaxed);  //1std::atomic_thread_fence(std::memory_order_release);  //2y.store(true, std::memory_order_relaxed);  //3
}
void read_y_then_x_fence()
{while (!y.load(std::memory_order_relaxed));  //4std::atomic_thread_fence(std::memory_order_acquire); //5if (x.load(std::memory_order_relaxed))  //6++z;
}

尽管4和3我们采用的是std::memory_order_relaxed顺序,但是通过逻辑关系保证了3的结果同步给4,进而”3 happens-before 4”

因为我们采用了获取栅栏std::atomic_fence所以,5处能保证6不会先于5写入内存。2处的释放栅栏能保证1处的指令先于2写入内存,进而”1 happens-before 6”, 1的结果会同步给 6

在这里插入图片描述

该栅栏会保证两个store写操作不会被重排。

在这里插入图片描述

该栅栏会保证两个load写操作不会被重排。

所以 ”atomic_thread_fence”其实和”release-acquire”相似,都是保证memory_order_release之前的指令不会排到其后,memory_order_acquire之后的指令不会排到其之前。

1.5 通过栅栏令非原子操作服从内存次序

如果将x从原子类型改为普通的布尔类型,程序的行为同样相同:

bool x;
std::atomic<bool> y;
std::atomic<int> z;void write_x_then_y() {x = true;  // 1std::atomic_thread_fence(std::memory_order_release);  //2y.store(true, std::memory_order_relaxed);  // 3
}void read_y_then_x() {while (!y.load(std::memory_order_relaxed)) { // 4std::cout << "y load false" << std::endl;}std::atomic_thread_fence(std::memory_order_acquire); //5if (x) { // 6++z;}
}int main(){x=false;y=false;z=0;std::thread t1(write_x_then_y);std::thread t2(read_y_then_x);t1.join();t2.join();assert(z.load() != 0); // 7
}

即使1是非原子变量,3是relaxed次序,但只要加上栅栏2,那么就会形成1先行于3的关系(如果没有栅栏,那么服从relaxed次序的操作不会保证1先行2,因为relaxed次序中,同一线程中只有同一变量的操作服从先行,而同一线程的不同变量没有先行关系)

用更简洁的形式进行解释:

  1. 获取-释放栅栏阻止了原子和非原子操作跨栅栏的重排序。
  2. 释放栅栏与获取栅栏同步。
  3. 自由操作或非原子操作的所有结果(在释放栅栏之前),在获得栅栏之后都是可见的。

释放栅栏和获取栅栏之间的同步

这两个定义来自于N4659: Working Draft, Standard for Programming Language C++ ,并且标准文档的文字比较难懂:“如果操作X和操作Y对原子对象M的操作存在有原子操作,释放栅栏A同步于获取栅栏B;那么A的操作顺序位于X之前,X对M进行修改,Y位于B之前,并且Y读取X写入的值,或在进行释放操作时,释放序列X中的任何操作所写的值将被读取。”

让我借上面的代码段解释一下这段话:

  • atomic_thread_fence(memory_order_release) 是一个释放栅栏A。2处
  • atomic_thread_fence(memory_order_acquire) 是一个获取栅栏B。5处
  • std::atomic<bool> y是一个原子对象M。
  • y.store(true, std::memory_order_relaxed)是一个原子存储操作X。3处
  • while (!y.load(std::memory_order_relaxed)) )是一个原子加载操作Y。4处

能令非原子操作服从内存次序的不只有栅栏,我们亦可以通过memory_order_release和memory_order_consume来保证非原子操作服从内存次序,参考文章并发编程(12)——内存次序与内存模型 | 爱吃土豆的个人博客中关于Release-Consume的介绍。

1.6 同步线程间的内存访问

我们在说栅栏的时候,提到了从C++11开始,提供了下面两种栅栏类型:

  • std::atomic_thread_fence : 同步线程间的内存访问。
  • std::atomic_signal_fence : 线程内信号之间的同步。

我们只用了第一种方式,那么第二种如何使用?

std::atomic_signal_fence在线程和信号句柄间,建立了非原子和自由原子访问的内存同步序。通过一个例子进行说明:

#include <atomic>
#include <cassert>
#include <csignal>std::atomic<bool> a{false};
std::atomic<bool> b{false};extern "C" void handler(int){if (a.load(std::memory_order_relaxed)){std::atomic_signal_fence(std::memory_order_acquire);assert(b.load(std::memory_order_relaxed));}
}int main(){std::signal(SIGTERM, handler);b.store(true, std::memory_order_relaxed);std::atomic_signal_fence(std::memory_order_release);a.store(true, std::memory_order_relaxed);}

首先,第19行中为特定的信号SIGTERM设置了处理句柄。SIGTERM是程序的终止请求。std::atomic_signal_handler在释放操作std:: signal_fence(std::memory_order_release)(第22行)和获取操作std:: signal_fence(std::memory_order_acquire)(第12行)之间建立一个获取-释放栅栏。释放操作不能跨越释放栅栏进行重排序(第22行),而获取操作不能跨越获取栅栏进行重排序(第11行)。因此,第13行assert(b.load(std::memory_order_relax)的断言永远不会触发,因为a.store(true, std:: memory_order_relaxed)(第23行)执行了的话, b.store(true, std::memory_order_relax)(第21行)就一定执行过。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com