C++11中的内存模型

一、几种关系术语

1.1、sequenced-before

sequenced-before用于表示同一个线程中,两个操作上的先后顺序,这个顺序是非对称、可以进行传递的关系。

它不仅仅表示两个操作之间的先后顺序,还表示了操作结果之间的可见性关系。两个操作A和操作B,如果有A sequenced-before B,除了表示操作A的顺序在B之前,还表示了操作A的结果操作B可见

1.2、happens-before

与sequenced-before不同的是,happens-before它既包括同一个线程里的操作顺序,也包不同线程操作间的顺序,同样的也是非对称、可传递的关系。

如果A happens-before B,则A的内存状态将在B操作执行之前就可见

1.3、synchronizes-with

synchronizes-with 描述的是两个线程对同一个原子变量的修改和读取之间的关系,如果一个线程修改某变量的之后的结果能被其它线程可见,那么就是满足synchronizes-with关系的。

显然,满足synchronizes-with关系的操作一定满足happens-before关系了。

二、C++11中支持的内存模型

从C++11开始,就支持以下几种内存模型:

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

与内存模型相关的枚举类型有以上六种,但是其实分为四类,如下图所示,其中对一致性的要求逐渐减弱,以下来分别讲解。
在这里插入图片描述

在原子操作上添加六种内存顺序标记(中的一部分),会影响(但不一定改变;视 CPU 架构)原子操作附近的内存访问顺序(包括其他原子操作,亦包含对非原子变量的读写操作)。注意,内存顺序(通过六种标记)讨论的实际上是线程内原子操作附近非原子操作访问内存的顺序,而非是多线程之间的执行顺序。只不过,因为原子变量自身可能建立了线程间的同步关系,所以两个线程内各自的内存顺序会经由原子变量的同步建立间接的顺序关系。亦即,内存顺序本质上是在讨论单线程内指令执行顺序对多线程影响的问题

2.1、顺序一致顺序 (sequentially-consistent ordering)

memory_order_seq_cst 为参数对原子变量进行load、store或者read-modify-write操作。这是默认的内存模型,c++11所有的原子操作都是采用memory_order_seq_cst作为默认参数。所有以memory_order_seq_cst为参数的原子操作(不限于同一个原子变量),对所有线程来说有一个全局顺序(total order),并且两个相邻memory_order_seq_cst原子操作之间的其它操作(包括非原子变量操作),不能reorder到这两个相邻操作之外。【注:同一个程序的不同运行,这个全局顺序是可以不一样的】。

我们通过下面的例子来理解一下顺序一致性:

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

// 线程A
void write_x() {
    x.store(true, memory_order_seq_cst);
}

// 线程B
void write_y() {
    y.store(true, memory_order_seq_cst);
}

// 线程C
void read_x_then_y() {
    while (!x.load(memory_order_seq_cst))
        ;
    if (y.load(memory_order_seq_cst)) {
        ++z;
    }
}

// 线程D
void read_y_then_x() {
    while (!y.load(memory_order_seq_cst))
        ;
    if (x.load(memory_order_seq_cst)) {
        ++z;
    }
}

int main() {
    std::thread thread_a(write_x);
    std::thread thread_b(write_y);
    std::thread thread_c(read_x_then_y);
    std::thread thread_d(read_y_then_x);

    thread_a.join(); thread_b.join(); thread_c.join(); thread_d.join();
    assert(z.load() != 0);  // 一定不会失败
}

4个线程对两个原子变量x和y的操作都是memory_order_seq_cst,构成了顺序一致性(sequentially-consistent),因此对4个线程来说,对原子变量x和y的操作顺序是一致的,在这个全局一致的操作顺序里:

(Ⅰ)要么x.store(true, memory_order_seq_cst)发生在y.store(true, memory_order_seq_cst)之前,这时候线程D的x.load(memory_order_seq_cst)一定读到true,从而执行z++ ;

(Ⅱ)要么y.store(true, memory_order_seq_cst)发生在x.store(true, memory_order_seq_cst)之前,这时候线程C的y.load(memory_order_seq_cst)一定读到true,从而执行z++ ;

不论哪种情况,z值都会被修改,因此main()函数最后的assert语句一定不会失败。

2.2、获取-释放顺序 (acquire-release ordering)

  • memory_order_acquire:用来修饰一个读操作,表示在本线程中,所有后续的关于此变量的内存操作都必须在本条原子操作完成后执行。
  • memory_order_release:用来修饰一个写操作,表示在本线程中,所有之前的针对该变量的内存操作完成后才能执行本条原子操作
  • memory_order_acq_rel:同时包含memory_order_acquire和memory_order_release标志
struct Point {
  int x_;
  int y_;
};

Point g_point;
std::atomic<int> g_guard(0);

// 线程A
void writePoint() {
    g_point.x_ = 1;
    g_point.y_ = 2;
    
    // 以memory_order_release写入1到原子变量
    g_guard.store(1, memory_order_release);
}

// 线程B
void readPoint() {
    // 以memory_order_acquire读取原子变量,直到读到1 (线程A所写入的值)
    while (g_guard.load(memory_order_acquire) != 1) {
      this_thread::yield();
    }
    
    // while 循环结束时,一定是以memory_order_acquire读到线程A写入的值1
    assert(g_point.x_ == 1 && g_point.y_ == 2); // 不会失败
}

这个例子中的g_guard读写就是synchronize-with 关系, 这里synchronize-with 描述的是两个线程对同一个原子变量的修改和读取之间的关系,它在两个相关线程建提供了一个比较强的memory order约束: 线程A的store操作之前的所有内存修改,对线程B的load之后的操作都可见,并且线程A的store操作之前的指令不允许reorder到store操作之后,线程B的load操作之后的指令不允许reorder到load之前synchronize-with 关系像是在两个线程之间建立了一个内存屏障,这个屏障引入了一种较强的先后顺序,屏障前的内存修改对屏障后的所有操作都可见。

具体到上面这个例子,就是线程A对结构体g_point的修改,在线程B的“while循环最后一次g_guard.load(memory_order_acquire)”之后都是可见的,因此线程B的assert不会失败:
在这里插入图片描述

2.3、释放-消费顺序 (Release-Consume ordering)

一个线程以memory_order_release对原子变量进行store操作,另一个线程以memory_order_consume对同一个原子变量进行load操作,从上面对Acquire-Release模型的分析可以知道,虽然可以使用这个模型做到两个线程之间某些操作的synchronizes-with关系,然后这个粒度有些过于大了。

在很多时候,线程间只想针对有依赖关系的操作进行同步,除此之外线程中的其他操作顺序如何无所谓。比如下面的代码中:

b = *a; 
c = *b;

其中第二行代码的执行结果依赖于第一行代码的执行结果,此时称这两行代码之间的关系为“carry-a-dependency ”。C++中引入的memory_order_consume内存模型就针对这类代码间有明确的依赖关系的语句限制其先后顺序。

来看下面的示例代码:

#include <string>
#include <thread>
#include <atomic>
#include <assert.h>
struct X
{
    int i;
    std::string s;
};

std::atomic<X*> p;
std::atomic<int> a;

void create_x()
{
    X* x=new X;
    x->i=42;
    x->s="hello";
    a.store(99,std::memory_order_relaxed);
    p.store(x,std::memory_order_release);
}

void use_x()
{
    X* x;
    while((x = p.load(std::memory_order_consume)) != nullptr)
        std::this_thread::sleep_for(std::chrono::microseconds(1));
    assert(x->i==42);
    assert(x->s=="hello");
    assert(a.load(std::memory_order_relaxed)==99);
}
int main()
{
    std::thread t1(create_x);
    std::thread t2(use_x);
    t1.join();
    t2.join();
}

以上的代码中:

  • create_x线程中的store(x)操作使用memory_order_release,而在use_x线程中,有针对x的使用memory_order_consume内存模型的load操作,两者之间由于有carry-a-dependency关系,因此能保证两者的先后执行顺序。所以,x->i == 42以及x->s==“hello"这两个断言都不会失败。
  • 然而,create_x中针对变量a的使用relax内存模型的store操作,use_x线程中也有针对变量a的使用relax内存模型的load操作。这两者的先后执行顺序,并不受前面的memory_order_consume内存模型影响,所以并不能保证前后顺序,因此断言a.load(std::memory_order_relaxed)==99真假都有可能。

以上可以对比Acquire-Release以及Release-Consume两个内存模型,可以知道:

  • Acquire-Release能保证不同线程之间的Synchronizes-With关系,这同时也约束到同一个线程中前后语句的执行顺序。
  • 而Release-Consume只约束有明确的carry-a-dependency关系的语句的执行顺序,同一个线程中的其他语句的执行先后顺序并不受这个内存模型的影响。

2.4、宽松顺序 (relaxed ordering)

以memory_order_relaxed作为参数的原子操作,这种类型对应的松散内存模型,这个对于操作顺序没有任何约束,只保证操作的原子性

atomic<int> x = {0};
atomic<int> y = {0};

// Thread 1:
r1 = y.load(std::memory_order_relaxed); // A
x.store(r1, std::memory_order_relaxed); // B

// Thread 2:
r2 = x.load(std::memory_order_relaxed); // C 
y.store(42, std::memory_order_relaxed); // D

可能产生r1 = r2 = 42 的结果,尽管在线程1里 A sequenced-before B, 在线程2里 C sequenced-before D,但是因为memory_order_relaxed不提供任何顺序约束,对于线程1来说,可能是D发生在C的前面,整个执行顺序可能是D ==> A ==> B ==> C,从而导致r1 = r2 = 42

三、与 volatile 的关系

voldatile关键字首先具有“易变性”,声明为volatile变量编译器会强制要求读内存,相关语句不会直接使用上一条语句对应的的寄存器内容,而是重新从内存中读取。

其次具有”不可优化”性,volatile告诉编译器,不要对这个变量进行各种激进的优化,甚至将变量直接消除,保证代码中的指令一定会被执行。

最后具有“顺序性”,能够保证Volatile变量间的顺序性,编译器不会进行乱序优化。不过要注意与非volatile变量之间的操作,还是可能被编译器重排序的。

需要注意的是其含义跟原子操作无关,比如:volatile int a; a++; 其中a++操作实际对应三条汇编指令实现”读-改-写“操作(RMW),并非原子的。

3.1、区别

上面内存模型操作都是原子的,而volatile不是原子的。

参考文档

https://www.codedump.info/post/20191214-cxx11-memory-model-2/

https://liam.page/2021/12/11/memory-order-cpp-02/

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
多线程内存模型是指在多线程环境下,不同线程之间共享的内存模型。在多线程编程,多个线程可以同时访问和修改同一个共享变量,但由于线程之间的并发执行,可能会出现一些并发问题,如数据竞争、原子性问题等,因此需要通过内存模型来规定多线程共享变量的访问和修改规则,以保证线程之间的正确协作。 常用的多线程内存模型有两种:顺序一致性内存模型和Java内存模型(Java Memory Model,JMM)。 顺序一致性内存模型是指对于每个线程来说,该线程的所有操作都是按照程序的顺序执行的,且所有线程之间的操作是按照全局顺序来执行的。这种内存模型相对简单,易于理解,但对程序的执行速度有一定的限制。 Java内存模型是针对Java语言的多线程内存模型。Java内存模型是基于顺序一致性内存模型的,但相对于顺序一致性内存模型,Java内存模型允许一定程度上的重排序,以提高程序的执行效率。Java内存模型主要定义了共享变量的访问规则,如可见性、原子性等,并通过使用volatile关键字和synchronized关键字等机制来实现线程之间的同步与协作。 对于多线程内存模型的理解和正确使用,对于编写高效且正确的多线程程序至关重要。在编写多线程程序时,需要根据具体需要选择合适的内存模型,并遵循相应的编程规范和约定,以确保多线程程序的正确性和可靠性。此外,还可以利用锁、原子类、线程安全的数据结构等工具来保证多线程程序的正确性。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值