C++ 内存模型 Memory Model

本文探讨了CPU的多核架构,一级、二级和三级缓存的作用,以及多线程中的数据竞争、指令重排问题。通过内存模型和原子操作如mutex、atomic及内存_order,讲解了如何管理和解决多线程中的同步问题。
摘要由CSDN通过智能技术生成

CPU 

现在CPU都是多核结构,每个核心都有自己的寄存器,一级缓存,二级缓存,以及共享的三级缓存。如下图,其中一级缓存分为指令缓存IL1和数据缓存DL1,二级缓存L2 256kB,三级缓存 L3 8MB。

从上图可以看出L3比L2大得多,但是L3离核心比较远,访问速度比较慢,L3后面则是与内存相连。当CPU核心要读取内存数据时,需要先从内存读取到L3,再到L2,再到L1,然后读取到寄存器中使用。

因CPU有多个核心,所以可以同时并行多个线程。CPU的每个核心可以有多个ALU(逻辑运算单元),也就是说单个核心内部也可以并行执行指令提高运行速度,而且CPU为进一步加快运行速度,还引入了乱序执行、分支预测等。

程序多线程的问题

数据竞争(Data Race)

在多线程中并行执行就会同时读取或修改数据,这可能会破坏数据。

执行顺序

程序在CPU中执行的顺序是不可确定的,除了因为上面提到的乱序执行、分支预测,还有CPU会对执行指令进行重排,且编译器在编译时也会对指令进行重排,提高运行速度。

指令重排

指令重排有一个要求:重排后的指令在单线程下执行的结果与重排前一致。  如下3行代码:

代码的执行顺序可以是:2、1、3,但是不能是 2、3、1。

Debug版本程序中不会进行指令重排,程序会按照代码逐行执行。程序在Release版本下才会进行指令重排,如果调试Release版本程序,并不一定完全按照代码顺序执行,特别是ARM处理器(手机上)。Debug版本因为缺少指令优化,执行速度会比Release版本慢很多。

多线程指令重排

指令重排对单线程没有影响,但多线程影响很大。例如以下代码:

如果writeThread中 1、2 两行代码进行指令重排,执行顺序位2、1,这会导致readThread读取到的data数据为0。

寄存器刷新

当某个线程修改数据,其首先修改的是线程运行所在核心的L1缓存,因为C++会保证缓存的一致性(cache coherence),数据会自动同步到L2缓存,然后同步到其他核心的L1缓存。但是数据并不会同步到核心的寄存器中,也就是说其他核心还会继续使用修改前的数据。例如以下代码:

// 需要在Release模式下测试, 代码在VS2022 Release模式上测试readThread会死循环
int data = 0;
bool ready(false);

std::thread readThread([&data, &ready]() {
    int n = 0;
    while (!ready) { ++n; /*++n 为防止空循环被过度优化*/ }
    std::cout << n << "Read Thread1: " + std::to_string(data) + "\n";
});
std::thread writeThread([&data, &ready]() {
    std::this_thread::sleep_for(std::chrono::microseconds(10)); // 延迟执行
    data = 5;
    std::atomic_thread_fence(std::memory_order_seq_cst); //防止指令重排
    ready = true;
});

readThread中whlie会出现死循环,因为writeTread修改了ready变量,但readThread所在核心的寄存器变量并没用刷新,ready始终是false。

内存模型 Memory Model

上面提到多线程的三个问题:数据竞争(Data Race),指令重排,寄存器刷新,内存模型可以用来解决这三个问题。

数据竞争(Data Race)

Data Race解决最简单的办法就是加锁,同mutex对象限制同时只有一个线程可以访问数据。C++ 20 还增加了Semaphores、barrier用于同步线程,这些对象在某些情况下也可也用于解决Data Race问题。

除了加锁还可以使用原子变量 atomic,原子变量可以保证每次只有一个线程操作数据。

寄存器刷新

当一个线程修改数据后,如果需要另一个线程读取使用最新的数据,可以使用以下方法:

1. 当线程获得锁时,此时会从新读取所有变量,刷新寄存器和缓存。

2. 使用原子操作,其他线程在使用原子变量每次都会重新从缓存中读取,从而刷新寄存器。原子变量有修改时,自动可以读取到最新数据。

3. 内存屏障,C++ 11中提供的函数std::atomic_thread_fence可以相当于设置内存屏障,通过调用这个函数,也可以刷新寄存器和缓存。

4. yield、sleep_for、sleep_until,相当于把线程切换回核心,此时会刷新寄存器和缓存。

指令重排

使用mutex阻止指令重排

mutex可以防止指令重排,mutex.lock可以阻止lock后面的指令重排到lock前面。mutex.unlock可以阻止unlock内部的指令重排到外面。 如下图:

使用原子变量阻止指令重排

默认情况下原子变量的读写都会阻止原子变量前面的指令重排到后面,也可以阻止后面的指令重排到前面。相当于原子变量读写就是一个屏障,阻止了指令重排时穿过屏障。

如过不使用原子变量可以使用函数std::atomic_thread_fence(std::memory_order_seq_cst),效果相同。

Atomic和Memory Oreder

C++ 11 原子操作中引入了Memory Oreder,原子操作函数load、store、fetch_add等函数可以设置一个Memory Order参数,用于控制指令重排和缓存刷新。 

  1. memory_order_seq_cst,Sequentially-consistent ordering,Atomic的默认操作,阻止原子变量操作前面的指令重排到后面,也可以阻止后面的指令重排到前面,相当于一个屏障,阻止了指令重排时穿过屏障,同时也会刷新缓存。
  2. memory_order_relaxed,不限制重排,只保证原子操作。
  3. memory_order_acquire ,memory_order_consume都用于读取函数,都可以限制后面的指令重排到读取前面,与mutex.lock相同,并刷新缓存。不同之处是consume只会刷新原子变量的缓存,acquire会刷新所有变量的缓存(包括非原子变量)。有些情况下consume效率更高,但是并不是所有设备都会实现consume,有些设备底层会使用acquire实现consume,C++ 17开始也不鼓励使用consume。
  4. memory_order_release,用于写入数据,可以限制前面的指令重排到写入后面,与mutex.unlock相同。
  5. memory_order_acq_rel,用于同时有读取和写入的函数,例如:exchange函数,对读取使用release,对写入使用acquire。

执行顺序

Sequenced-before Sequenced代表代码的顺序,Sequenced-before相当于指令的代码在前面。Sequenced-before只在线程内,不可以超出线程。Sequenced-before确定需要考虑运算符优先级,等于号的左右,函数参数调用顺序等等。

Synchronizes with 是多个线程之间一条指令必须在另一条指令前执行。例如生成者线程必须产生数据,消费者线程才能使用数据。产生数据的指令Synchronizes with使用数据的指令。

Happens-before 一个指令在另一个指令前执行,Sequenced-before是Happens-before的单线程形式,Synchronizes with是Happens-before的多线程形式。

同步

借助Atomic的Memory Order操作,可以实现Synchronizes with关系,在线程中实现同步,以及实现无锁编程(Lock Free)。

例如可以通过memory_order_release和memory_order_acquire实现同步生产者消费者,代码如下:

// 来源:https://en.cppreference.com/w/cpp/atomic/memory_order
#include <atomic>
#include <cassert>
#include <string>
#include <thread>
 
std::atomic<std::string*> ptr = nullptr;
int data = 0;
 
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_acquire)))
        ;
    assert(*p2 == "Hello"); // never fires
    assert(data == 42); // never fires
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

参考:

https://www.youtube.com/watch?v=IE6EpkT7cJ4

multithreading - Is there an issue with "cache coherence" on C++ multi-threading on a *Single CPU* (Multi-Core) on Windows? - Stack Overflow

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 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、付费专栏及课程。

余额充值