c++ 内存顺序

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

本教程由作者和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

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

posted @ 2023-03-13 20:21  Smalldy  阅读(561)  评论(0编辑  收藏  举报