C++内存模型与内存序

一、内存可见性和顺序性问题

在单核时代,程序按照代码的书写顺序运行,一切都看似自然。但在多核 CPU 的世界里,程序执行的环境变得复杂,主要源于三大优化:

  1. 编译器优化重排

    • 编译器为了提升程序性能,会在不改变单线程语义的前提下,重新排列指令的执行顺序。
    • 示例:a = 1; b = 2; 编译器可能会先执行 b=2,再执行 a=1,因为这可能能更好地利用CPU指令流水线。在单线程下,这没问题。但在多线程下,如果另一个线程正在读取 ab,它可能会看到 b 变成了2而 a 还是0,这就产生了问题。
  2. CPU指令级优化重排(指令并行)

    • 现代CPU为了充分利用其内部的多功能单元,也会对指令进行重排执行(Out-of-Order Execution)。
    • 如果一条指令需要等待内存读取(很慢),CPU可能会先执行后面不依赖该结果的指令。
    • 这在单线程下完美无误,但在多线程下,其它线程看到的内存操作顺序可能和代码顺序不一致。
  3. CPU缓存一致性延迟(内存缓存)

    • 每个CPU核心都有自己的高速缓存。当一个核心修改了数据时,该数据首先存在于它的缓存中,不会立即写回主内存。其他核心的缓存中的副本也不会立即更新。
    • 示例:线程A在CPU0上执行 data = 100; flag = true;。由于缓存,flag=true 可能会先被写入内存并被线程B看到,而 data=100 还留在CPU0的缓存里。线程B看到 flag 为 true 后去读 data,读到的却是旧的0。

编译器和CPU的优化破坏了代码的“顺序性”,加上多级缓存的存在,导致一个线程对内存的修改何时以及以何种顺序被另一个线程看到,变得不确定。这就需要一套严格的规则来约束这些优化,这套规则就是内存模型,而规则的具体条款就是内存序

二、内存模型要解决什么

内存模型就像一个契约,它规定了多线程程序中,对内存访问行为的最低保证:哪些优化是允许的哪些必须禁止。。它主要规定了两件事:

  1. 同步(Synchronization)“某个线程对内存地址的更新何时能被其它线程看见”

    • 这解决了可见性问题。它确保当一个线程修改了共享数据后,其他线程在某个时间点之后一定能看到这个更新。最严格的保证是立即看到,最宽松的保证是迟早会看到
  2. 顺序(Ordering)“某个线程对内存地址访问附近可以做怎么样的优化”

    • 这解决了顺序性问题。它限制了编译器和CPU能否对指令进行重排。它规定了:在当前线程中,某些特定操作之前或之后的指令,必须保持怎样的相对顺序。

三、C++中的6种内存序

C++11标准在原子操作中引入了6种内存序,用来精确控制同步和顺序的强度。从弱到强,它们提供了不同的性能和正确性保证。

内存序中文名作用性能适用场景
std::memory_order_relaxed松散内存序只保证原子性。不提供任何同步和顺序保证。最高计数器递增,顺序无关紧要,只需原子。
std::memory_order_consume消费内存序依赖顺序保证。当前线程中后续依赖于此原子变量的操作必须在本操作之后执行。极少使用,难以正确理解和使用。
std::memory_order_acquire获得内存序读操作。当前线程中后续的所有读写操作必须在本操作之后执行。配对使用,用于实现同步
std::memory_order_release释放内存序写操作。当前线程中之前的的所有读写操作必须在本操作之前执行。一个线程释放锁,另一个线程获取锁。
std::memory_order_acq_rel获取-释放内存序读-修改-写操作。同时具备 acquirerelease 的效果。用于“比较并交换”(CAS)等RMW操作。
std::memory_order_seq_cst顺序一致性内存序默认模式。最强保证。全局顺序一致。所有线程看到的操作顺序都一样。最低最容易理解,适用于大多数需要同步的场景。
  • memory_order_relaxed:松散内存序,只用来保证对原子对象的操作是原子的,在不需要保证顺序时使用;可以理解大家各自干各自的,最终结果没错就行。

在这里插入图片描述

  • memory_order_release:释放操作,在写入某原子对象时, 当前线程的任何前面的读写操作都不允许重排到这个操作的后面 去,并且当前线程的所有内存写入都在对同一个原子对象进行获 取的其他线程可见;通常与memory_order_acquirememory_order_consume 配对使用

在这里插入图片描述

在这里插入图片描述

  • memory_order_acquire:获得操作,在读取某原子对象时, 当前线程的任何后面的读写操作都不允许重排到这个操作的前面 去,并且其他线程在对同一个原子对象释放之前的所有内存写入 都在当前线程可见;

在这里插入图片描述

在这里插入图片描述

  • memory_order_consume:同 memory_order_acquire 类似,区别是它仅对依赖于该原子变量操作涉及的对象,比如这个操作发生在原子变量 a 上,而 s = a + b;那 s 依赖于 a,但 b 不依赖于 a;当然这里也有循环依赖的问题,例如:t = s + 1,因 为 s 依赖于 a,那 t 其实也是依赖于 a 的;在大多数平台上,这 只会影响编译器的优化;不建议使用。

  • memory_order_acq_rel:获得释放操作,一个读‐修改‐写操作同时具有获得语义和释放语义,即它前后的任何读写操作都不允许重排,并且其他线程在对同一个原子对象释放之前的所有内存 写入都在当前线程可见,当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见;

  • memory_order_seq_cst:顺序一致性语义,对于读操作相当于获得,对于写操作相当于释放,对于读‐修改‐写操作相当于获得释放,是所有原子操作的默认内存序,并且会对所有使用此模型的原子操作建立一个全局顺序,保证了多个原子变量的操作在所有线程里观察到的操作顺序相同,当然它是最慢的同步模型。

四、代码示例

4.1 relaxed

代码创建了两个线程:

  • thread_func1循环100,000次,将递增的i值存储到原子变量x
  • thread_func2循环100,000次,将递减的-i值存储到原子变量x

由于使用了std::atomic<int>,保证了对x的读写操作是原子的(不会产生数据竞争),但使用std::memory_order_relaxed意味着:

  1. 不保证两个线程操作的执行顺序
  2. 不提供任何跨线程的内存可见性约束
  3. 最终x的值取决于哪个线程的最后一次存储操作获胜

程序的输出结果是不确定的,可能是任意一个线程最后写入的值,这展示了在松散内存序下,并发操作原子变量的结果特性。

#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> x{0};

void thread_func1()
{
    for (int i = 0; i < 100000; ++i)
    {
        x.store(i, std::memory_order_relaxed);
    }
}

void thread_func2()
{
    for (int i = 0; i < 100000; ++i)
    {
        x.store(-i, std::memory_order_relaxed);
    }
}

int main()
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

    t1.join();
    t2.join();

    std::cout << "Final value of x = " << x.load(std::memory_order_relaxed) << std::endl;

    return 0;
}

4.2 release/acquire

代码通过两个线程展示了一种同步机制

  • 线程a执行write_x_then_y,先以relaxed内存序设置xtrue,再以release内存序设置ytrue
  • 线程b执行read_y_then_x,先以acquire内存序自旋等待y变为true,然后读取x的值

这里的关键是releaseacquire的配对:

  • 当线程b通过acquire内存序看到ytrue时(操作3),可以保证它一定能看到线程ay.store(release)之前对x的修改(操作1)
  • 因此,x.load(relaxed)(操作4)必然会读到true,最终z的值一定是1
#include <atomic>
#include <thread>
#include <assert.h>
#include <iostream>

std::atomic<bool> x,y;
std::atomic<int> z;

// 写线程函数:先写x再写y
void write_x_then_y()
{
    x.store(true, std::memory_order_relaxed);  // 松散内存序写入x
    y.store(true, std::memory_order_release);  // release内存序写入y
}

// 读线程函数:先等y再读x
void read_y_then_x()
{
    // 用acquire内存序等待y变为true
    while(!y.load(std::memory_order_acquire));
    // release-acquire配对确保能看到x的写入
    if(x.load(std::memory_order_relaxed))
        ++z;
}

int main()
{
    x=false; y=false; z=0;
    std::thread a(write_x_then_y);
    std::thread b(read_y_then_x);
    a.join(); b.join();
    std::cout << z << std::endl;  // 结果一定是1
    return 0;
}

4.3 seq_cst

代码创建了四个线程,分别执行:

  • write_x:用顺序一致内存序将x设为true
  • write_y:用顺序一致内存序将y设为true
  • read_x_then_y:等待xtrue后,检查y是否为true,如果是则递增z
  • read_y_then_x:等待ytrue后,检查x是否为true,如果是则递增z

在循环20次的执行中,由于使用了std::memory_order_seq_cst,所有线程对原子变量的操作会形成一个全局统一的执行顺序,保证了:

  • 不可能出现z为0的情况
  • 每次运行的结果只能是z=1z=2
#include <atomic>
#include <thread>
#include <iostream>
#include <unistd.h>

std::atomic<bool> x, y;
std::atomic<int> z;

void write_x() {
    sleep(1);
    x.store(true, std::memory_order_relaxed);
}

void write_y() {
    sleep(1);
    y.store(true, std::memory_order_relaxed);
}

void read_x_then_y() {
    while (!x.load(std::memory_order_relaxed))
        ;
    if (y.load(std::memory_order_relaxed))
        ++z;
}

void read_y_then_x() {
    while (!y.load(std::memory_order_relaxed))
        ;
    if (x.load(std::memory_order_relaxed))
        ++z;
}

int main() {
    int zero_count = 0;
    for (int i = 0; i < 1000; i++) {
        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();
        
        std::cout << z.load() << " ";
    }
    
    // std::cout << "Total iterations with z=0: " << zero_count << std::endl;
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值