搞懂无锁编程的重要一步是完全理解内存顺序!

本教程由作者和ChatGPT通力合作完成。

都有哪几种?

c++的内存模型共有6种

  • memory_order_relaxed
  • memory_order_consume
  • memory_order_acquire
  • memory_order_release
  • memory_order_acq_rel
  • memory_order_seq_cst

万事开头难,如何入手?

如果你有幸阅读过cpprefence的这一章节,我想你一定会对这些概念的晦涩难懂有深刻的印象!有大量的教程都会从memory_order_relaxed的概念开始介绍,我认为这是不妥的,如果对内存顺序没有一个大致的了解之前,没有对比,你根本无法得知“宽松”到底意味着什么,到底宽松在什么地方。

因此,我觉得有必要先对 memory_order_acquirememory_order_release 进行了解。我们需要指导 memory_order_acquirememory_order_release 会对我们的代码到底产生怎样的影响,需要理解他们为什么这样命名。

内存顺序 memory_order_acquire 表示该操作需要在后续的读操作中确保获得在当前操作之前已经完成的所有写操作的结果。即,在当前操作之前,所有的写操作必须在内存中完成,然后该操作才能进行。这意味着 memory_order_acquire 会阻止处理器和编译器在该操作和后续读操作之间重新排序。

内存顺序 memory_order_release 表示该操作需要在当前操作之前确保所有已经完成的读和写操作都要被立即刷新到内存中。也就是说,该操作会将其前面的写操作立即刷新到内存中,这样后续的读操作就能够访问到这些数据了。同时,该操作之后的写操作也不能被重排序到该操作之前。

以上是ChatGPT的解释,不是我的解释,仅仅依靠文本解释,往往会让人一头雾水。如果用来解释概念的概念仍然是你不懂得概念,那么这个解释本身就成为了学习的门槛,因此,我们必须要抽丝剥茧,慢慢来。

在上述解释中,我们注意到,这里有一个关键概念:指令重排。

是的,无论是否在多线程环境下,由于编译器对代码的优化,实际的汇编指令的顺序,有可能与c++代码的顺序不一致(处理器也会对指令进行重排)。

这种重排可以分为三种类型:

  1. 编译器重排:编译器在生成目标代码时,可能会重新排列原本在c++源代码中出现的语句,以优化代码执行速度。
  2. 处理器重排:处理器会通过乱序执行、流水线等技术来优化指令执行的速度。
  3. 内存系统重排:内存系统也会对指令进行重排,以最小化内存访问延迟。

指令重排其实是一种优化手段,但它的出现也为多线程编程带来了麻烦,下面的例子展示了指令重排是如何影响多线程编程的:

int x = 0;
int y = 0; void thread1() { y = 2;
} void thread2() {
while (y != 2) {}
assert(x == 1);
}

如果此时运行两个线程,我们期望的事情是,线程2一直等到y == 2,然后检查x是否为1,当y已经等于2时,线程1执行了 x = 1;y = 2;

因此x一定等于1。

但由于指令重排,线程1的执行顺序有可能是

y = 2;
x = 1;

如果线程1刚刚执行完y=2,线程2就开始执行,此时循环条件失败,断言语句在x=1前执行了,那么此时就会断言失败。

体会到指令重排给我们带来的麻烦了吗?

且看我们如何使用内存顺序来避免这种情况

int x = 0;
std::atomic_int y(0);
void thread1() {
x = 1;
y.store(2, std::memory_order_release);
}
void thread2() {
int tmp;
do {
tmp = y.load(std::memory_order_acquire);
} while (tmp != 2);
assert(x == 1);
}

在这个例子中memory_order_release memory_order_acquire 起到了什么作用呢?他是如何帮助我们解决重排问题的?让我们一步步解释:

在这个例子中,使用了memory_order_releasememory_order_acquire两个内存顺序模型。

在解释这两个内存顺序模型之前,有必要介绍两个原子操作:

  • store :写操作,第一个参数为要写入的数值,第二个参数可以设置内存顺序模型
  • load:读操作,返回读到的值,参数为内存顺序模型

y.store(2, std::memory_order_release)的意思是将2原子的写入y中,并使用memory_order_release要求内存顺序。

tmp = y.load(std::memory_order_acquire)的意思从y中读取值并赋值给tmp,并使用memory_order_acquire内存顺序。

单独的原子操作只能影响单个线程,无论它携带怎样的内存顺序模型,仅仅使用对单个线程的某个原子操作使用顺序模型一般来说是没有任何意义的,就拿这个示例来说,线程1的写操作的memory_order_release表现用于保证在这个原子操作后,x=1必定是生效的,且在线程2的读操作使用memory_order_acquire内存顺序模型读取到的数值一定是线程1存储后的数值。这对原子操作本身和x都是一样的。

为了更加清楚的表达它们的概念,体会它们在多线程编程中的协作,我将会给出另一个示例,并携带注释,注释按照[1] [2] [3]的循序观看,请仔细阅读,确保已经完全理解:

#include <atomic>
#include <cassert>
#include <string>
#include <thread> std::atomic<std::string *> ptr;
int data; void producer() {
auto *p = new std::string("Hello");
data = 42;
//store是一个写操作,std::memory_order_release要求在这个写指令完成后,所有的写操作必须也是完成状态,也就是说,
//编译器看到这条指令后,就不能将data = 42这条指令移动到store操作之后,这便是memory_order_release对内存顺序的要求
//memory_order_release中的release指的是将修改后(写操作)的结果释放出来,一旦其他线程使用了 memory_order_acquire,就可以观测到
//上述对内存写入的结果(release也可理解为释放内存的控制权)
ptr.store(p, std::memory_order_release);//[1]
} void consumer() {
std::string *p2;
//此处等待ptr的store操作 while保证了时间上的同步,也就是会等到ptr写入的那一刻,memory_order_release,memory_order_acquire保证
//内存上的同步,也就是producer写入的值一定会被consumer读取到
while (!(p2 = ptr.load(std::memory_order_acquire)))//[3]
;
//如果执行到此处,说明p2是非空的,也就意味着ptr load 到了一个非空的字符串,也就意味着 data = 42的指令已经执行了(memory_order_release保证),
//且此时data必定等于42 ,p2必定为“Hello”(memory_order_acquire保证)
assert(*p2 == "Hello");// 绝无问题//[2]
assert(data == 42); // 绝无问题
} int main() {
std::thread t2(consumer);
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::thread t1(producer); t1.join();
t2.join();
}

这些协作关系是你的经验总结还是C++预定义的?

这些协作方式当然不是我杜撰的,而是在cppreference中有详细的解释,经过上述的例子,我们可以在从头看看文档中的内容了,请注意本章节存在大量摘抄文本,但是我仍然希望你能够在理解上一章节的基础上仔细阅读这些文本,文档是对概念最准确的解释,是必须要跨越的一关:

原文: std::memory_order - cppreference.com

首先让我们看看文档是如何定义这些内存操作的:

解释
memory_order_relaxed 宽松操作:没有同步或顺序制约,仅对此操作要求原子性(见下方 宽松顺序)。
memory_order_consume 有此内存顺序的加载操作,在其影响的内存位置进行_消费操作_:当前线程中依赖于当前加载的该值的读或写不能被重排到此加载前。其他释放同一原子变量的线程的对数据依赖变量的写入,为当前线程所可见。在大多数平台上,这只影响到编译器优化(见下方 释放消费顺序)。
memory_order_acquire 有此内存顺序的加载操作,在其影响的内存位置进行_获得操作_:当前线程中读或写不能被重排到此加载前。其他释放同一原子变量的线程的所有写入,能为当前线程所见(见下方释放获得顺序)。
memory_order_release 有此内存顺序的存储操作进行_释放操作_:当前线程中的读或写不能被重排到此存储后。当前线程的所有写入,可见于获得该同一原子变量的其他线程 释放获得顺序),并且对该原子变量的带依赖写入变得对于其他消费同一原子对象的线程可见(见下方 释放消费顺序)。
memory_order_acq_rel 带此内存顺序的读修改写操作既是_获得操作_又是_释放操作_。当前线程的读或写内存不能被重排到此存储前或后。所有释放同一原子变量的线程的写入可见于修改之前,而且修改可见于其他获得同一原子变量的线程。
memory_order_seq_cst 有此内存顺序的加载操作进行_获得操作_,存储操作进行_释放操作_,而读修改写操作进行_获得操作_和_释放操作_,再加上存在一个单独全序,其中所有线程以同一顺序观测到所有修改(见下方序列一致顺序)。

接下来是他们之间的协作关系,这部分我会以自己的理解阐述它们,如果对原文感兴趣请点击上方链接:

宽松顺序:memory_order_relaxed

与其他线程没有协作,仅仅保证原子性,也就是允许指令重排,仅仅保证这个原子变量的原子性。

释放获得顺序 memory_order_release memory_order_acquire

释放获得顺序就是我上面给的例子那样,它规定了原子操作store with memory_order_release 时,在此代码行上方的内存读写操作都必须到位,而 load with memory_order_acquire 是一定可以取到 memory_order_release 所约束的那些变量的所写入的值。

释放消费顺序 memory_order_release memory_order_consume

释放消费顺比释放获得顺序要更加宽松,仅仅同步了原子操作本身的原子变量以及产生依赖关系的变量。

#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"); // 绝无出错: *p2 从 ptr 携带依赖
assert(data == 42); // 可能也可能不会出错: data 不从 ptr 携带依赖
} int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join(); t2.join();
}

此代码不能保证data在线程中同步。

序列一致顺序 memory_order_seq_cst

简单来说就是拒绝一切重排,对所有线程可见,而获得释放操作只能影响相关线程。

c++ 内存顺序的更多相关文章

  1. 详解java中CAS机制所导致的问题以及解决——内存顺序冲突

    [CAS机制] 指的是CompareAndSwap或CompareAndSet,是一个原子操作,实现此机制的原子类记录着当前值的在内存中存储的偏移地址,将内存中的真实值V与旧的预期值A做比较,如果不一 ...

  2. cpu内存访问速度,磁盘和网络速度,所有人都应该知道的数字

    google 工程师Jeff Dean 首先在他关于分布式系统的ppt文档列出来的,到处被引用的很多. 1纳秒等于10亿分之一秒,= 10 ^ -9 秒  ---------------------- ...

  3. 【旧文章搬运】从PEB获取内存中模块列表

    原文发表于百度空间,2008-7-25========================================================================== PEB中的L ...

  4. [翻译]内存一致性模型 --- memory consistency model

    I will just give the analogy with which I understand memory consistency models (or memory models, fo ...

  5. Java 并发系列之三:java 内存模型(JMM)

    1. 并发编程的挑战 2. 并发编程需要解决的两大问题 3. 线程通信机制 4. 内存模型 5. volatile 6. synchronized 7. CAS 8. 锁的内存语义 9. DCL 双重 ...

  6. 转 Cache一致性和内存模型

    卢本伟牛逼,写得很好 https://wudaijun.com/2019/04/cpu-cache-and-memory-model/ 本文主要谈谈CPU Cache的设计,内存屏障的原理和用法,最后 ...

  7. 内存模型 Memory model 内存分布及程序运行中(BSS段、数据段、代码段、堆栈

    C语言中内存分布及程序运行中(BSS段.数据段.代码段.堆栈) - 秦宝艳的个人页面 - 开源中国 https://my.oschina.net/pollybl1255/blog/140323 Mem ...

  8. 内存模型与c++中的memory order

    概 c++的atomic使用总会配合各种各样的memory order进行使用,memory order控制了执行结果在多核中的可见顺序,,这个可见顺序与代码序不一定一致(第一句代码执行完成的结果不一 ...

  9. LevelDB学习笔记 (3): 长文解析memtable、跳表和内存池Arena

    LevelDB学习笔记 (3): 长文解析memtable.跳表和内存池Arena 1. MemTable的基本信息 我们前面说过leveldb的所有数据都会先写入memtable中,在leveldb ...

  10. 全网最硬核 Java 新内存模型解析与实验单篇版(不断更新QA中)

    个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判.如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 i ...

随机推荐

  1. python_列表和元组的转换

    1, 通过list函数将元组的数据获取到,保存到新定义的列表里面.备注:元组的数据不会更改. info_tuple = ("小明", 24, 1.75) info_list = l ...

  2. .NET CORE 下收发邮件之 MAILKIT

    背景 利用代码发送邮件在工作中还是比较常见的,相信大家都用过SmtpClient来处理发送邮件的操作,不过这个类以及被标记已过时,所以介绍一个微软推荐的库MailKit来处理. MailKit开源地址 ...

  3. 杭电oj--1019题C++实现

    这道题有两个问题: 首先,是求利用数论的辗转相除法求最大公约数,后再求最小公倍数m*n/gcd(m,n),其中,m*n可能会超过int 数据范围,所以,该语句换成m/gcd(m,n)*n. 然后是如果 ...

  4. C语言II一作业02

    1.作业头 | 这个作业属于哪个课程 | < https://edu.cnblogs.com/campus/zswxy/SE2020-3> | | ---- | ---- | ---- | ...

  5. 导航条透明,ios11系统,会出现偏移64的问题

    在当前页面加入下面方法 - (void)viewWillAppear:(BOOL)animated{ [super viewWillAppear:animated]; [self.navigation ...

  6. Debug --> wireshark中的lua插件使用

    一.使用Lua脚本对pcap文件按流进行存储 https://zhuanlan.zhihu.com/p/35188803 二.使用tshark对pcap报文进行批量切流 https://blog.cs ...

  7. Jmeter、Postman之RSA加密登录接口测试

    方法1:直接用在线加密工具进行加密,得到密码 参考地址 https://www.toolscat.com/decode/rsa 输入公钥和密码,直接加密即可   方法2:postman工具 步骤1:接 ...

  8. MLR算法双面网络时钟

    MLR算法双面网络时钟------专业LED时钟厂家![点击进入] MLR算法双面网络时钟 NTP电子时钟功能说明: 双面NTP电子时钟采用独特结构设计,可根据客户要求订制显示布局:年.月.日.时.分 ...

  9. MD5加密汇总

    1 #region MD5 2 /// <summary> 3 /// 16位MD5加密 4 /// </summary> 5 /// <param name=" ...

  10. numpy基本使用(一)

    一.简介  NumPy(Numerical Python) 是用于科学计算及数据处理的Python扩展程序库,支持大量的维度数组与矩阵运算,此外也针对数组运算提供大量的数学函数库. 二.数据结构  n ...