Luan_233

ljj

@Luan_233

  • ❤️ Liked post in 计算机求职八股文
    学术专区
    Murphy Murphy Post #20

    内存屏障与编译器屏障(C++)

    内存屏障与内存顺序

    内存屏障用来阻止处理器重排内存操作,是硬件或编译器层面上的工具。

    内存顺序指在多线程程序中多个线程如何观察彼此的读写操作,是 C++ 提供的高级抽象。它通过指定不同的内存顺序模型隐式地插入内存屏障。程序员不需要直接操作内存屏障,而是通过原子操作与内存顺序模型来实现对内存访问顺序的控制。

    二者都是为了控制内存操作,防止指令重排、缓存一致性问题带来的意料之外的错误

    内存顺序

    讨论的是单线程内指令执行顺序对多线程影响的问题。不论携带怎样的内存顺序,单独的操作只会影响单个线程,但几个线程可能会经由变量的同步建立间接的顺序关系,从而被各自的内存顺序影响。

    内存顺序标记

    c++ 标准库定义了 6 种内存顺序,按照从宽松到严格的顺序:

    1. memory_order_relaxed:宽松操作,仅保证原子操作自身的原子性
    2. memory_order_consume:读屏障,只影响当前 load 原子变量及依赖变量的写入,其他与 memory_order_acquire 一样。C++20 中弃用
    3. memory_order_acquire:读屏障,当前线程所有的读写操作不得重排至当前操作之前,使得其他线程中相同原子变量在 release 之前的写入对当前线程时可见的。即对于相关内存位置的 load 施加“acquire”占有,一定读到最新值。
    4. memory_order_release:写屏障,当前线程所有的读写操作不得重排至当前操作之后,使得当前操作的写入对于其他线程中相同原子变量在 acquire 之后是可见的。即对于相关内存位置的 store 施加“release”释放,一定写入最新值。
    5. memory_order_acq_rel:读写屏障,当前线程中的读写操作不能重排至当前操作之后也不能重排至当前操作之前
    6. memory_order_seq_cst:读写屏障,对于所有线程看见的读写操作顺序是一致的

    其中

    • 所有原子操作默认的内存顺序标记是 std::memory_order_seq_cst
    • 两种读屏障:memory_order_consume 只影响当前变量和依赖它的变量;memory_order_acquire 影响当前操作前的所有变量
    • 两种写屏障:memory_order_acq_rel 只对当前线程有效;memory_order_seq_cst 对全局有效

    协作组合

    常见的几种协作组合:

    • 宽松顺序:memory_order_relaxed

      仅保证原子性,允许所有指令重排

    • 释放 - 获得序:memory_order_release+memory_order_acquire

      release 前的写操作一定到位,acquire 的读操作一定最新

    • 释放 - 消费序:memory_order_release+ memory_order_consume

      比释放获得序宽松一些,仅同步了原子操作本身的原子变量及与它产生依赖关系的变量

    • 序列一致顺序 memory_order_seq_cst

      拒绝一切重排,对所有线程可见

    举个例子:

    首先定义两个变量

    std::atomic<std::string *> ptr;
    int data;
    

    对于释放获得序:

    void producer() {
      auto *p = new std::string("Hello");
      data = 42;
      // 编译器不能将 data = 42 这条指令移动到 store 操作之后
      // memory_order_release 将修改后(写操作)的结果释放出来,其他线程使用 memory_order_acquire 可以观测到上述对内存写入的结果。此处保证 producer 的修改一定写入
      ptr.store(p, std::memory_order_release);
    }
    
    void consumer() {
      std::string *p2;
      // memory_order_acquire 读取到最新值,其他线程使用 memory_order_release 前的值一定可以读到。此处保证 consumer 一定读取到 producer 写入的值
      while (!(p2 = ptr.load(std::memory_order_acquire)))
        ;
      // 执行到此处说明 p2 是非空的,即 data = 42 的指令已经执行了(memory_order_release 保证)
      // 且此时 data 必定等于 42,p2 必定为“Hello”(memory_order_acquire 保证)
      assert(*p2 == "Hello");
      assert(data == 42); 
      // 不会出错
    }
    

    对于释放消费序:

    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 携带依赖
    }
    

    内存屏障

    c++ 标准库中提供接口

    • std::atomic_thread_fence:显式的内存屏障。与内存顺序标记配合使用,将线程分为若干个阶段,每个阶段需要等待其他线程完成特定操作之后才能进入下一阶段。对于所有数据有效,不仅仅是原子变量。

    使用时可以将原子变量都设置为 memory_order_relaxed,仅依靠内存屏障来实现对内存的控制

    例子:

    std::atomic<bool> x,y;
    void write_x_then_y() {
        x.store(true, std::memory_order_relaxed); // 1
        std::atomic_thread_fence(std::memory_order_release); // 2.a 写屏障
        y.store(true, std::memory_order_relaxed); // 2.b
    }
    void read_y_then_x() {
        while(!y.load(std::memory_order_relaxed)); // 3.a
        std::atomic_thread_fence(std::memory_order_acquire); // 3.b 读屏障
        assert(!x.load(std::memory_order_relaxed));// 4
    }
    

    可以认为:

    • 2.a 写屏障向下和 2.b 写操作相结合,将 2.b 提升为 release-store
    • 3.b 读屏障向上和 3.a 读操作相结合,将 3.a 提升为 acquire-load
    • 同步关系与前面举的内存顺序的例子相似,保证了 1 一定先于 4

    内存屏障与编译器屏障

    编译器屏障仅影响编译器。防止编译器重排指令,确保最终生成的汇编代码保留了源代码中指定的顺序,但不会生成实际的硬件指令,因此不会影响 CPU 或内存的访问顺序。不能处理线程并发带来的缓存一致性、内存可见性问题,常用于单核处理器。

    内存屏障同时影响编译器与 CPU。不仅防止编译器重排操作,还会生成硬件指令影响 CPU 的内存访问顺序。确保线程之间的数据同步,常用于多核处理器。

    编译器屏障

    • std::atomic_signal_fence :编译器屏障,对所有数据有效。用于防止编译器在信号处理程序或与硬件交互时重排指令。
    • volatile 关键字
      • 对于某个 volatile 变量,与该变量相关的操作必须对内存进行读写,而不能仅仅读写寄存器中的缓存值。
      • 对于多个 volatile 变量,它们之间的访问顺序不会被编译器改变

    编译器屏障相当于告诉编译器,这些变量的值可能会在程序的控制之外发生变化,不可以假设其不变。常用于信号处理、硬件交互、嵌入式系统中断程序。

    举个例子:

    // thread1
    volatile bool tag = true;
    volatile int x = 0;
    while(tag);
    assert(!x);
    
    // thread2
    x = 10;
    tag = false;
    

    在单核处理器上“伪并发”这两个线程,volatile 在此处有两个作用:

    • 每次都从内存中读取 tag 最新值,避免 thread1 死循环
    • x 和 tag 的读写顺序和源代码中一致,即 x=10 一定发生在 tag=false 之前

    但在多核处理器上“真并发”这两个线程时,即使 volatile 强制编译器生成了从主存读写的代码,具体执行顺序仍然由 CPU 决定。此时就需要使用内存屏障来实现需求的逻辑,保证内存操作按照指定顺序执行。

  • Ziqian_Chen Ziqian Chen Post #1

    acm 赛制,至多三人组队形式参赛。参赛费每队 800 元。

    报名细则请加 ACM 大群了解

    qq 群:526350936

    我的 ACM 之路果然有问题群聊二维码|357x375

    欢迎在话题下提问、寻求组队。