当前位置: 首页 > article >正文

Linux高级--3.3.2.6高并发编程之“内存屏障”“CPU屏障”“编译屏障”

一、内存屏障

Linux C 语言编程 中,内存屏障(Memory Barrier) 是一种用于控制内存访问顺序的技术。它主要用于多处理器系统中,确保某些操作按预期顺序执行,避免 CPU 和编译器对内存访问进行优化,从而影响程序的正确性。内存屏障的功能在多线程和并发编程中尤为重要。

什么是内存屏障?

内存屏障的障中文意思是保护和隔离的,也有阻止的意思,阻止的是CPU对变量的继续访问,停下来更新下变量,从而保护变量的一致性。 内存屏障是针对线程所有共享变量的,而原子操作仅针对当前原子变量。

内存屏障是一种指令,它的作用是禁止 CPU 重新排序特定的内存操作。它确保在屏障之前的所有读/写操作在屏障之后的操作之前完成。内存屏障一般被用来控制多处理器环境中的内存可见性问题,尤其是在进行原子操作、锁和同步时。

在多核处理器上,每个处理器都有自己的缓存,CPU 会将内存操作缓存到自己的本地缓存中。在不同的 CPU 之间,内存的可见性并非立刻同步,这就可能导致不同线程看到不同的内存值。通过内存屏障,可以确保特定的操作顺序,以避免此类问题。

内存屏障的类型

Linux C 中,内存屏障通常有以下几种类型,主要通过内核提供的原子操作或者内存屏障函数来实现。

  1. 全屏障(Full Barrier 或者 LFENCE、SFENCE)

    • 作用:以下两种相加。
    • 用途:确保所有的内存操作都在内存屏障前完成,通常用于同步和锁定操作。
    • 内核函数mb() (Memory Barrier)
  2. 读屏障(Read Barrier 或者 LFENCE)

    • 作用:保证屏障之前的所有读操作在屏障之后的读操作之前完成。---》翻译过来的有歧义,难以理解,那个“完成”是缓存同步主内存的意思。
    • 本质:作用是强制将 CPU核心  中的 L1/L2 缓存 中的共享变量值写回到 主内存。
    • 用途:在执行并行读操作时确保读顺序。
    • 内核函数rmb() (Read Memory Barrier)
  3. 写屏障(Write Barrier 或者 SFENCE)

    • 作用:保证屏障之前的所有写操作在屏障之后的写操作之前完成。--》翻译过来有歧义,难以理解,那个“完成”是主内存同步到缓存的意思。
    • 本质:作用是强制使数据从主内存加载,而不是直接使用可能已经过时的缓存数据。
    • 用途:用于确保写操作顺序。
    • 内核函数wmb() (Write Memory Barrier)
  4. 无序屏障(No-op Barrier)

    • 作用:没有实际影响,仅确保 CPU 不会重排序特定的指令。
    • 用途:常用于确保指令的顺序性而不做其他强制性的内存同步。

读写屏障的作用域

  • 读写屏障的作用域并不局限于当前函数或者某个函数调用的局部作用域,而是影响整个 当前线程的内存访问顺序。也就是说,只要在当前线程中,任何在屏障前后的内存操作都会受到屏障影响,而不管这些操作发生在同一个函数里还是不同的函数中。

线程之间的隔离

  • 读写屏障是 线程级别的,因此它们只影响执行这些屏障操作的线程。也就是说,如果线程 1 执行了写屏障,它只会影响线程 1 后续的内存操作,而不会直接影响其他线程。---》翻译过来的,其实就是这个线程的读写屏障只会引发自己线程变量与主内存的同步,管不到其他线程的同步。但是写屏障触发后 会 通知其他线程,如果有现代 CPU 使用缓存一致性协议(如 MESI)的话,其他线程会把主内存中的最新值更新到自己缓存中。
  • 读屏障不会触发其他线程去把自己的缓存同步到主内存中。
  • 如果想让多个线程之间的共享变量同步并保持一致性,通常需要在多线程间使用某些同步机制(如锁、原子操作等),而不仅仅是依赖于单个线程的屏障。

具体来说:

  • 写屏障(Write Barrier):会影响所有在屏障之前执行的写操作,无论这些写操作发生在当前函数内还是其他函数中。它确保屏障前的所有写操作都能同步到主内存,任何与此线程共享的缓存都能看到这些值。

  • 读屏障(Read Barrier):会影响所有在屏障之后执行的读操作,确保这些读操作从主内存读取最新的值,而不是从 CPU 核心的缓存中读取过时的值。读屏障会影响当前线程的所有后续读取操作,无论这些读取发生在哪个函数中。

内存屏障的使用

在 Linux 中,内存屏障主要通过一组原子操作宏来提供。这些操作用于确保不同 CPU 或线程之间的内存同步。常见的内存屏障宏包括:

  • mb():全屏障,防止 CPU 重排序所有内存操作。
  • rmb():读屏障,确保屏障之前的所有读操作完成。
  • wmb():写屏障,确保屏障之前的所有写操作完成。
示例代码
#include <stdio.h>
#include <stdint.h>

#define wmb() __asm__ __volatile__("sfence" ::: "memory")  // 写屏障
#define rmb() __asm__ __volatile__("lfence" ::: "memory")  // 读屏障
#define mb()  __asm__ __volatile__("mfence" ::: "memory")  // 全屏障

void example_memory_barrier() {
    int shared_variable = 0;

    // 写入数据
    shared_variable = 42;

    // 在这里使用写屏障,确保共享变量的写操作
    // 在执行屏障之后才会完成
    wmb();  

    // 读取共享数据
    printf("Shared Variable: %d\n", shared_variable);

    // 使用读屏障,确保屏障前的所有读取操作完成
    rmb();

    // 这里是确保顺序执行的一部分
    printf("Shared Variable read again: %d\n", shared_variable);
}

int main() {
    example_memory_barrier();
    return 0;
}

为什么需要内存屏障?

  1. 避免重排序:编译器和 CPU 会对内存访问进行优化,尤其是在多处理器系统中,这可能导致指令执行顺序与预期不一致,进而导致错误的程序行为。

  2. 保证内存一致性:当一个线程或 CPU 修改共享变量时,其他线程或 CPU 可能会看到不同的内存值,内存屏障可以保证修改操作在其他线程中是可见的。

  3. 同步操作:在多线程或多处理器环境中,内存屏障确保执行顺序和同步的正确性,尤其是在没有锁或原子操作的情况下。

缓存一致性协议(例如 MESI)

为了保证多核处理器之间缓存的数据一致性,现代 CPU 会使用 缓存一致性协议(如 MESI 协议,即 Modified、Exclusive、Shared、Invalid)。这个协议的作用是确保一个核心的缓存修改在其他核心的缓存中得到更新,避免出现“脏数据”。

但即便如此,MESI 协议的具体实现仍然依赖于硬件,缓存之间的同步可能不会在每一次内存访问时都发生。尤其是在没有任何同步机制的情况下,一个线程修改的值可能会暂时不被另一个线程看到,直到某些缓存刷新或同步操作发生。

Linux 内核中的内存屏障

在 Linux 内核中,内存屏障主要是通过原子操作来实现的。例如,atomic_setatomic_add 等原子操作通常会隐式地使用内存屏障来保证内存操作顺序。而直接的内存屏障通常通过 mb()wmb()rmb() 函数来实现。

总结

内存屏障在多核处理器和并发程序中非常重要,用于控制内存操作顺序,避免由于硬件优化或编译器优化引起的内存同步问题。Linux 提供了多种类型的内存屏障函数,程序员可以根据需要使用它们来确保内存操作的顺序性。

二、变量存贮与内存屏障

你提到的问题涉及 程序执行过程中变量的存储位置、内存可见性和线程切换 的多个方面。为了更清晰地解释,我们需要从操作系统的内存管理和多线程模型入手。

1. 程序执行前变量存在哪里?

在程序执行之前,变量的存储位置主要依赖于变量的类型和生命周期。变量可以存储在以下几个区域:

  • 栈区(Stack):局部变量通常会被分配到栈中。栈是线程私有的,每个线程都有一个独立的栈空间。
  • 堆区(Heap):动态分配的内存(通过 malloc()free() 等)会被存储在堆中。堆是共享的,不同线程可以访问堆中的数据。
  • 全局区/静态区(Data Segment):全局变量和静态变量通常存储在数据段中。在程序启动时,数据段会被分配并初始化。
  • 代码区(Text Segment):存储程序的代码(即机器指令),线程不直接操作。

2. 线程对变量的修改,为什么对其他线程不可见?

当一个线程修改了它的某个变量的值,这个变量的值并不一定立即对其他线程可见,主要是因为现代处理器通常会有 缓存(Cache),并且每个线程可能在自己的 寄存器局部缓存中 执行操作。具体原因如下:

  • CPU 缓存:每个 CPU 核心都有自己的缓存(例如 L1、L2 Cache),当一个线程运行时,它可能会先将某个变量加载到本地缓存中进行修改,而不是直接操作主内存。这样,修改后的值可能不会立刻反映到主内存中。
  • 内存可见性问题:因为不同的线程可能运行在不同的 CPU 核心上,并且每个核心有自己的缓存系统,其他线程(在不同的 CPU 核心上)可能无法直接看到修改后的变量值。除非通过某种同步机制(如内存屏障、锁、原子操作等)确保所有 CPU 核心的缓存一致性,否则修改的值不会立刻对其他线程可见。

3. 线程被切换时,变量的值存储到哪里?

当一个线程被 调度器切换(例如,从运行状态切换到阻塞状态或就绪状态)时,操作系统会保存该线程的 上下文(即该线程当前的执行状态)。这个过程称为 上下文切换

  • CPU 寄存器:线程的 寄存器状态(包括程序计数器、栈指针、CPU 寄存器中的数据等)会被保存在操作系统为该线程分配的 线程控制块(TCB) 中,或者在内核中由特定的机制(如进程控制块、线程栈)保存。
  • 内存:栈中的局部变量会被存储在 栈区,这些数据在线程切换时保持不变,直到线程恢复时。
  • CPU 缓存:在某些情况下,线程切换后,CPU 的缓存中的数据可能会被清除或更新,以保证在切换后的线程恢复时能正确访问内存。

4. 缓存与主内存

在现代多核处理器上,每个 CPU 核心(CPU Core)通常有自己的 本地缓存(例如 L1 缓存L2 缓存,甚至是更高层的缓存)。这些缓存的作用是加速内存访问,避免每次访问内存时都直接访问主内存(RAM)。因此,当线程对某个变量进行修改时,这个变量的值首先会被写入到该线程所在 CPU 核心的 缓存 中,而不一定立即写回到主内存。

5. 另一个线程接着运行,变量值从哪里拿?

当一个线程被切换出去后,另一个线程会接管 CPU 的执行,并且继续执行自己的代码。另一个线程获取变量的值依赖于以下几个因素:

  • 内存一致性:如果没有任何内存屏障或同步机制,另一个线程可能会读取到一个过时的缓存值。原因是线程 1 在修改变量时,可能只是修改了本地缓存,而没有把新值写回到主内存中;而线程 2 可能仍然读取到线程 1 的旧值。
  • 线程间同步:为了确保变量的最新值在多个线程之间可见,通常需要使用 同步原语(如互斥锁 mutex、条件变量 condvar、原子操作等)。如果使用了如 mutex 锁定共享资源,或者使用了 volatile 关键字或原子操作,线程间对变量的修改才会更可靠地同步和可见。
  • 缓存一致性协议:现代 CPU 通常使用 缓存一致性协议(如 MESI 协议)来确保不同 CPU 核心之间缓存的一致性。当一个线程修改某个变量时,其他线程会通过协议获取这个变量的最新值,避免缓存中的脏数据。

6. CPU 缓存与线程切换

这里有一个关键点:线程切换并不意味着缓存被清除。如果线程 1 在 CPU 核心 A 上运行,修改了全局变量,并且这个修改存储在 核心 A 的缓存 中,线程 1 被切换出去,CPU 核心 A 的缓存不会因为线程切换而清空。即使切换到其他线程,CPU 仍然会保留 核心 A 的缓存内容

当线程 2 在另一个 CPU 核心 B 上开始运行时,如果它访问相同的全局变量,它会根据 自己核心的缓存 来读取这个变量。如果线程 2 看到的是旧值,那么它就是从 核心 B 的缓存 中拿到的旧值,而不是从主内存 中读取的。

7. 内存一致性问题

内存一致性问题通常出现在 多核处理器 中,特别是当多个线程运行在不同的 CPU 核心上时。在这种情况下:

  • 线程 1 可能修改了一个全局变量的值,线程 1 所在的 CPU 核心会将该变量的新值写入到 该核心的缓存(例如 L1 或 L2 缓存)中。
  • 如果 线程 2 运行在 另一个 CPU 核心 上,它可能会直接从 自己本地的缓存 中读取这个变量,而不是从主内存读取。假如线程 2 在缓存中读取到的是 线程 1 之前的旧值(而不是修改后的新值),那么线程 2 就读取到过时的值。

8. 为什么会有内存屏障?

由于多核处理器中的 CPU 会对内存操作进行缓存优化,内存屏障(mb()wmb()rmb())的作用是 强制同步 内存操作,确保某些操作的顺序性和内存可见性。通过内存屏障,操作系统或程序员可以确保在特定的内存操作之前或之后的操作按顺序执行,避免缓存带来的不一致性。

9. 为什么一个线程拿到的值可能会是旧的?

这个问题的核心在于 缓存一致性内存可见性

  • 每个 CPU 核心都有独立的缓存,这意味着它们的缓存可能保存着不同版本的内存数据。
  • 当线程 1 修改变量时,虽然它的 本地缓存 中的数据会被更新,但主内存的更新可能并没有立刻发生,或者其他 CPU 核心的缓存并没有得到通知。
  • 如果没有同步机制(如内存屏障、锁、原子操作等),线程 2 可能会继续读取到 旧的缓存值,而不是线程 1 修改后的新值。

10. 综述:内存模型和线程切换

在现代操作系统和多核处理器中,内存模型非常复杂。通过以下几个概念可以理解线程的内存操作:

  1. 每个线程有自己的栈,但共享堆和全局变量。
  2. 缓存一致性问题:线程修改的变量不会立刻对其他线程可见。
  3. 上下文切换:线程切换时,寄存器、栈等状态会保存,并由操作系统恢复。
  4. 内存屏障和同步机制:确保变量在多个线程之间同步和可见。

示例

假设有两个线程 A 和 B,它们都操作同一个共享变量 x。假设线程 A 修改了 x 的值,但由于没有同步机制,线程 B 可能不会立刻看到修改后的值。

int x = 0;  // 共享变量

void thread_a() {
    x = 1;   // 修改共享变量
    wmb();   // 写屏障,确保修改的值会被其他线程看到
}

void thread_b() {
    rmb();   // 读屏障,确保读取的是线程 A 修改后的值
    printf("%d\n", x);  // 打印 x 的值
}

总结

  • 线程修改变量时,该变量可能存在于不同的缓存中,其他线程可能无法看到更新的值。
  • 上下文切换时,线程的寄存器和栈会被保存在操作系统的上下文中,恢复时会读取这些数据。
  • 内存屏障 用于控制内存操作的顺序性,确保修改的值能在不同线程间同步。

三、编译器屏障

编译器屏障(Compiler Barrier) 是一种用于控制编译器优化的机制,用来确保编译器在生成代码时不会对某些操作进行重排或优化,尤其是在多线程编程和硬件相关编程中非常重要。编译器屏障并不直接控制内存访问,而是用来防止编译器对指令的顺序进行不合适的重新排序,确保代码按照预期的顺序执行。

编译器屏障的作用

编译器屏障主要用于控制编译器如何处理代码中的指令顺序,尤其是:

  1. 防止指令重排序:编译器优化时可能会改变指令的顺序,重新排序内存访问或者其他指令。这可能导致多线程程序中某些预期的同步行为失败。编译器屏障防止这种行为。
  2. 确保指令的执行顺序:在多核处理器或并发编程中,编译器屏障确保某些关键操作(比如内存访问)在正确的顺序中执行,避免因编译器优化导致的不一致性。

编译器屏障与内存屏障的区别

  • 编译器屏障(Compiler Barrier):
    • 它的目的是确保编译器不会对代码进行不合理的优化或重排序,特别是在涉及并发或多核时。
    • 编译器屏障不能强制 CPU 层面执行同步操作,仅仅是防止编译器重排代码。
  • 内存屏障(Memory Barrier):
    • 内存屏障则是用于确保在多核或多线程环境中,内存访问按照特定的顺序发生,它直接控制内存操作和硬件级别的缓存同步。
    • 内存屏障不仅防止编译器重排,也会影响 CPU 对内存的读取和写入顺序。

编译器屏障的实际应用

编译器屏障通常用于那些需要确保内存或操作顺序的场景,尤其是在处理低级硬件和并发编程时。下面是一些编译器屏障的常见应用场景:

  1. 原子操作:在使用原子操作(比如 atomic_add)时,编译器屏障可用于确保原子操作的顺序性。
  2. 多线程同步:当线程间存在共享数据时,编译器屏障可以防止编译器重排序线程的操作,以保证正确的同步。
  3. 硬件访问:在直接操作硬件时,编译器屏障可以确保 I/O 操作按预期顺序执行,而不被编译器优化掉。

编译器屏障的实现

不同的编译器提供不同的方式来实现编译器屏障。常见的做法包括:

  1. volatile 关键字:在 C/C++ 中,volatile 可以告诉编译器不要优化对变量的访问,通常用于内存映射 I/O 或多线程共享变量。虽然它可以防止编译器优化,但它并不能防止 CPU 缓存重排序。

  2. 内联汇编:编译器屏障还可以通过内联汇编来实现。许多编译器(如 GCC)支持特定的内联汇编指令,用于告诉编译器不要重排某些指令。例如,GCC 提供了 asm volatile 来控制编译器优化。

  3. 编译器内置指令:某些编译器提供内置指令来实现编译器屏障。例如,GCC 中有 __asm__ __volatile__ ("": : : "memory"),这是一个编译器屏障,它不会生成任何机器指令,但会告知编译器不要重排此位置的内存操作。

示例:GCC 中的编译器屏障

在 GCC 中,可以使用 __asm__ __volatile__ ("": : : "memory") 来插入一个编译器屏障。它告诉编译器不要重新排序此点前后的内存操作。

示例代码:
#include <stdio.h>

volatile int shared_var = 0;

void func1() {
    shared_var = 1;  // 修改共享变量
    __asm__ __volatile__ ("" : : : "memory");  // 插入编译器屏障
}

void func2() {
    int local = shared_var;  // 读取共享变量
    printf("shared_var = %d\n", local);
}

int main() {
    func1();
    func2();
    return 0;
}

在这个示例中,__asm__ __volatile__ ("" : : : "memory") 强制插入一个编译器屏障,确保编译器在执行 shared_var = 1shared_var 之间的读写操作时不会对它们进行优化或重排序。

编译器屏障的局限性

虽然编译器屏障可以阻止编译器对代码的优化,但它并不能保证在多核处理器上,缓存之间的同步或内存访问的正确顺序。要确保内存的一致性,尤其是跨多个 CPU 核心的同步,还需要使用 内存屏障 等同步机制。

总结

  • 编译器屏障 主要用于控制编译器优化,防止编译器对代码执行顺序进行重排序。
  • 它不能控制内存访问的实际顺序,但可以防止编译器错误地优化掉重要的内存操作。
  • 它通常与 内存屏障 一起使用,以确保在多线程或并发环境下的正确性。

四、CPU屏障

CPU 屏障,也常称为 处理器屏障,是一个硬件层面的同步机制,主要用于确保 CPU 在执行指令时按特定的顺序访问内存。它是为了处理 CPU 的 指令重排内存缓存一致性多核 CPU 系统中的缓存同步 等问题。

在多核系统中,每个 CPU 核心都有自己的 缓存(L1, L2, L3 缓存),这些缓存可能存储过时的内存值,导致不同核心之间的数据不一致。CPU 屏障通过硬件指令,确保 CPU 按照特定的顺序执行内存操作,从而解决缓存一致性和内存重排序的问题。

CPU 屏障的作用

  1. 防止指令重排:现代 CPU 在执行指令时,通常会对指令进行重排序(指令乱序执行)以提高性能。CPU 屏障确保特定的指令顺序不被改变,避免并发编程中的数据不一致性。

  2. 确保内存操作顺序:CPU 屏障通过禁止指令重排和缓存同步,确保内存操作按预期的顺序发生。这对多核处理器尤其重要,避免不同核心之间的数据不一致问题。

  3. 控制缓存一致性:当一个核心修改内存中的某个值时,CPU 屏障可以确保这个修改值被写回主内存,并通知其他核心从主内存读取最新的值。

与内存屏障的区别

  • 内存屏障(Memory Barrier) 是一种在软件层面控制 CPU 内存操作顺序的机制。它可以是一个指令,告诉 CPU 按照特定顺序访问内存,避免乱序执行或缓存不一致。
  • CPU 屏障 则是硬件级别的机制,它通过处理器的硬件指令实现类似的同步操作。CPU 屏障直接控制 CPU 内部的缓存管理和指令流水线,从而确保内存操作的顺序。

CPU 屏障的类型

不同的 CPU 和架构(如 x86、ARM、PowerPC)提供不同的屏障指令,以下是一些常见的屏障类型:

  1. 全屏障(Full Barrier):也叫作 全内存屏障,会确保指令完全按顺序执行,通常会阻止所有的加载(Load)和存储(Store)操作的重排序。全屏障适用于需要完全同步内存访问的场景。

  2. 加载屏障(Load Barrier):用于控制加载指令(读取内存)的顺序,保证在屏障前的加载操作完成后,才能执行屏障后的加载操作。

  3. 存储屏障(Store Barrier):用于控制存储指令(写入内存)的顺序,确保在屏障前的写操作完成后,才能执行屏障后的写操作。

  4. 轻量级屏障(Light Barrier):有些现代 CPU 提供更细粒度的屏障,能够针对特定类型的指令(如仅仅是缓存一致性)进行同步。

典型的 CPU 屏障指令

1. x86 架构
  • MFENCE:这是 x86 架构中的一个全屏障指令,它确保所有的加载和存储指令在屏障前后都按顺序执行。
  • LFENCE:加载屏障,用于确保加载操作的顺序。
  • SFENCE:存储屏障,用于确保存储操作的顺序。
2. ARM 架构
  • DMB(Data Memory Barrier):用于确保数据内存操作的顺序。DMB 会阻止内存操作的重排序。
  • DSB(Data Synchronization Barrier):一个更强的同步屏障,通常会确保所有的内存操作完成,才会继续执行后续操作。
  • ISB(Instruction Synchronization Barrier):强制 CPU 刷新指令流水线,确保指令同步。
3. PowerPC 架构
  • sync:PowerPC 中的同步指令,强制执行内存访问的顺序。

CPU 屏障的应用场景

  1. 多线程编程和并发控制: 在多线程程序中,多个线程可能会同时访问共享内存。使用 CPU 屏障可以确保线程之间的内存操作顺序,从而避免出现数据不一致的情况。

  2. 内存模型: 在某些硬件平台上,特别是在不同架构(如 x86 和 ARM)之间进行程序移植时,CPU 屏障能够确保程序按照预期的内存顺序执行。

  3. 硬件编程: 对于直接操作硬件的低级编程(例如内存映射 I/O、嵌入式系统开发等),CPU 屏障能够确保 I/O 操作按顺序完成,避免因为缓存一致性问题导致硬件异常。

例子:在 x86 架构中的应用

假设有一个共享变量 shared_var,多个线程可能会修改它。如果不使用 CPU 屏障,线程 1 修改 shared_var 后,可能没有立即刷新到主内存,线程 2 可能会看到过时的缓存数据。

以下是一个简单的 C 代码示例:

#include <stdio.h>
#include <stdint.h>

volatile int shared_var = 0;

void thread1() {
    shared_var = 1;  // 修改共享变量
    __asm__ __volatile__ ("mfence" ::: "memory");  // 使用 MFENCE 全屏障,确保写操作完成
}

void thread2() {
    while (shared_var == 0) {
        // 等待 thread1 更新 shared_var
    }
    printf("shared_var updated to 1\n");
}

int main() {
    // 模拟两个线程
    thread1();
    thread2();
    return 0;
}

在这个示例中,thread1 在修改 shared_var 后使用了 mfence 指令来确保修改后的值及时写入主内存,并且被其他线程看到。thread2 会等待 shared_var 更新后继续执行。

总结

  • CPU 屏障 是硬件层面的机制,用来控制 CPU 内部指令执行顺序和缓存同步,确保内存操作按照特定顺序发生。
  • 它防止 CPU 对指令的重排序,从而避免多核环境中的缓存一致性问题。
  • 不同的 CPU 架构(如 x86、ARM)提供了不同类型的 CPU 屏障指令,如 MFENCE(x86)、DMB(ARM)等。

小结图示

   +-------------------------------+
   |           主内存(RAM)        |
   |                               |
   |  +-------------------------+  |
   |  | 共享变量(shared_var)   |  |
   |  +-------------------------+  |
   +-------------------------------+

            |                           |
            v                           v
      +----------------+         +----------------+
      |   核心 1       |         |   核心 2       |
      |   L1 缓存      |         |   L1 缓存      |
      |  存储变量值    |         |  存储变量值    |
      +----------------+         +----------------+
             |                          |
             v                          v
        CPU 屏障(如 MFENCE)   强制同步缓存与主内存

通过 CPU 屏障的使用,确保 核心 1shared_var 的修改能正确地同步到主内存,核心 2 可以看到最新的值。

五、使用内存屏障还需要使用CPU屏障吗?

对于大多数开发人员来说,内存屏障(Memory Barriers)通常是足够的,因为它们是软件级别的同步机制,而 CPU 屏障(CPU Barriers)是硬件级别的机制,两者的目标都是确保内存操作按预期顺序执行。内存屏障通过插入特定的指令来控制 CPU 的缓存一致性和内存操作顺序,通常通过编译器提供的原语(如 __sync_synchronizeatomic_thread_fencemfence 等)来实现。

1. 内存屏障 vs CPU 屏障

  • 内存屏障

    • 程序员显式插入,通常作为一条特殊的汇编指令或者编译器指令。
    • 它确保了程序中的某些内存操作顺序不会被优化或乱序执行。
    • 对于 程序员来说,内存屏障是一个高层的工具,在需要同步共享数据时,它是最常用的同步机制。
  • CPU 屏障

    • 硬件层面的同步机制,直接控制 CPU 内部的缓存、指令流水线和内存访问顺序。
    • 这些机制通常是针对 处理器架构(如 x86、ARM)的具体硬件指令,用于确保内存操作按顺序发生。
    • 程序员通常 不直接操作 CPU 屏障,而是通过内存屏障指令间接地影响硬件行为。

2. 内存屏障满足开发者需求

对于程序员而言,使用 内存屏障 就足够了,因为:

  • 内存屏障指令是跨平台的,开发人员不需要针对特定的 CPU 指令来编写代码。它们会依赖 编译器 来生成适合特定平台的机器代码。

  • 编译器和操作系统已经为我们处理了硬件的差异。程序员插入的内存屏障会触发适当的 CPU 屏障或其他低层次同步机制,具体取决于目标平台的 CPU 和架构。

  • 多核处理器 上,内存屏障确保一个线程对共享数据的修改能够及时写回主内存,并且通知其他线程访问这些数据时能看到最新的值。

3. 实际应用中的差异

3.1. 使用内存屏障
  • 内存屏障(如 __sync_synchronizeatomic_thread_fencemfence)通过插入到代码中来显式指定内存操作顺序,从而控制并发操作时的内存一致性问题。

    例如,在多线程编程中,你希望线程 A 在修改共享变量之后,线程 B 能立即看到该修改,可以在 A 中插入内存屏障:

    #include <atomic>
    
    std::atomic<int> shared_var = 0;
    
    void thread_a() {
        shared_var.store(1, std::memory_order_release);  // 写屏障
    }
    
    void thread_b() {
        while (shared_var.load(std::memory_order_acquire) == 0) {
            // 等待线程 A 更新 shared_var
        }
        // 现在可以安全地使用 shared_var
    }
    

    在此例中,std::memory_order_releasestd::memory_order_acquire 就是内存屏障的类型,它们保证了内存操作的顺序。

3.2. CPU 屏障
  • CPU 屏障是硬件级别的机制。程序员一般不会显式写入 CPU 屏障指令,除非进行非常低级的硬件编程或直接操作硬件。

    例如,在嵌入式系统开发或驱动开发中,可能会看到 mfence 或其他处理器指令:

    __asm__ __volatile__("mfence" ::: "memory");
    

    在这种情况下,程序员直接插入了硬件指令,显式控制 CPU 执行顺序。但是在 一般应用程序开发 中,通常并不需要手动插入这些指令,而是依赖编译器和标准库中的内存屏障。

4. 总结

对于大多数应用程序开发人员,内存屏障已经足够,不需要直接使用 CPU 屏障指令。编译器会根据目标硬件生成合适的机器代码,确保内存操作的顺序性和一致性。

  • 内存屏障:适用于大多数开发者,提供了一个高层次的同步工具,确保不同线程之间的内存操作顺序。
  • CPU 屏障:属于硬件层面的操作,通常程序员不需要直接处理,除非进行底层的硬件开发。
5. 应用场景
  1. 多线程同步:确保线程对共享数据的修改能够及时生效,避免缓存不一致导致的数据不一致问题。

  2. 优化:在多核处理器中,通过内存屏障确保正确的内存操作顺序,避免因指令重排和缓存不一致带来的问题。

6. 建议

a. 如果你在编写并发程序,内存屏障是足够的,尝试使用标准库中的原子操作和内存顺序。

b. 如果你进行低级别硬件编程(例如驱动开发、嵌入式系统),可能需要直接使用 CPU 屏障。


六、“原子操作/互斥锁” 与 “内存屏障” 

1. 原子操作与内存屏障的对比

原子操作(Atomic Operation)是对某个特定变量进行原子级别的访问(通常是加、减、交换等操作),它确保操作对这个变量的修改是不可分割的,即操作要么完全执行,要么完全不执行。

  • 原子操作的作用范围:原子操作是针对特定变量的,只会影响该变量的状态,它在进行操作时,也会隐含地执行内存屏障,确保对该变量的修改对其他线程可见。

  • 内存屏障的作用范围:内存屏障则更加广泛,它会影响到线程的所有共享变量,确保整个内存访问顺序符合预期。

简而言之,原子操作对某个特定变量的一种操作,保证它的正确性;而内存屏障控制的是线程的所有共享变量的访问顺序,确保不同线程之间的同步和一致性。


2. 互斥锁的读写屏障

互斥锁(Mutex)作为线程同步的一种机制,通常会涉及到内存屏障。因为互斥锁的使用会影响多个线程对共享数据的访问顺序,因此通常在加锁解锁时,操作系统会插入内存屏障,确保在锁被持有时,所有操作会遵循正确的顺序。

  • 加锁(线程持锁)时,需要确保该线程读取的共享数据是 最新的,因此需要通过 读屏障 来保证锁保护的变量被同步到主内存,确保读到的是最新的数据。
  • 解锁(释放锁)时,需要确保该线程修改的数据被 同步到主内存,并通知其他线程,这通常通过 写屏障 来实现,以保证其他线程能看到更新后的数据。

加锁/解锁时的内存屏障作用示意:

  • 加锁:会确保在加锁前,当前线程对共享数据的所有操作(尤其是写入)已经完成,并且数据对其他线程可见。这通常通过插入写屏障来实现。
  • 解锁:会确保在解锁后,其他线程能看到当前线程对共享数据的最新修改。这通常通过插入读屏障来实现。

3. 总结

  1. 内存屏障

    • 内存屏障的作用是保护共享数据的一致性,确保线程对共享变量的操作顺序正确。它影响的是所有共享变量,而不仅仅是某个特定的变量。
    • 它防止 CPU 对内存访问的乱序执行,确保内存操作按预期的顺序执行。
  2. 原子操作

    • 原子操作通常针对单个变量,确保操作是原子性的,即不可中断的,并且会隐式地执行内存屏障,保证操作对其他线程可见。
  3. 互斥锁和内存屏障

    • 互斥锁在加锁和解锁时会执行内存屏障。加锁时插入写屏障,确保当前线程的写操作完成;解锁时插入读屏障,确保其他线程能看到当前线程的修改。

4. 建议

a. 如果你编写多线程程序并使用互斥锁,确保你理解锁的加锁和解锁时的内存屏障作用,以避免数据不一致问题。

b. 了解并使用原子操作和内存屏障来控制线程间共享数据的顺序,特别是在性能要求较高的场合。

七、配合内存屏障来真正理解volatile

volatile 关键字在 C 和 C++ 中用于修饰变量,告诉编译器该变量的值可能会在程序的任何时刻发生变化,通常是由外部因素(如硬件、操作系统或其他线程)引起的。使用 volatile 可以防止编译器对该变量进行优化(什么样的优化?),从而确保每次访问时都从内存中获取变量的最新值,而不是从寄存器或缓存中读取过时的值。

优化?变量的优化就是CPU指令对此变量的操作一直在缓存中执行,增加运行速度。

防止优化?其实是防止读优化,即每次读操作都从主内存取,但是写操作不负责优化,可能写完大概率还在缓存中,其他同时在其他CPU核心运行的线程依然拿不到最新值。

等价于每次使用此变量之前都会触发一次“只针对这个变量的读屏障”(这是个比喻,读屏障不会只针对单独一个变量)

主要作用:

  1. 防止编译器优化
    • 编译器为了提高性能,通常会将变量存储在寄存器中,并减少对内存的访问。但是,如果一个变量是由外部因素改变的(比如硬件寄存器或其他线程修改),编译器可能不会及时从内存中读取该变量的最新值。
    • 使用 volatile 告诉编译器:“不要优化对这个变量的访问,每次都直接从内存中读取它。”
  2. 确保变量访问的正确性
    • 当一个变量的值可能被多个线程、信号处理程序或硬件设备(例如,I/O端口、硬件寄存器)所修改时,volatile 关键字确保每次读取时都会从内存中获取最新的值,而不是使用缓存或寄存器中的值。
  3. 不保证写操作同步到主内存:如果你修改了一个 volatile 变量,编译器会直接在内存中修改这个变量的值,但这并不意味着其他核心的 CPU 或线程会立即看到这个修改。它不会自动确保 这个修改会立即同步到其他线程或 CPU 核心的缓存中。
  4. volatile 的作用范围:它确保你每次访问时都能获取到变量的最新值,但并不会同步其他线程或 CPU 中的缓存。例如,在多核处理器环境下,CPU 核心 A 可能会缓存某个变量,而 CPU 核心 B 可能并不知道核心 A 修改了这个变量,volatile 不能解决这种缓存一致性问题。

适用场景:

  • 硬件寄存器:在嵌入式系统中,直接映射到硬件的内存地址经常会用 volatile,以确保每次访问时都读取硬件的最新值,而不是使用缓存。
  • 多线程共享变量:当多个线程共享一个变量时,某个线程对该变量的修改可能会被其他线程及时看到,volatile 可以确保每次访问该变量时,都能获取到最新的值。
  • 信号处理函数中的变量:在多线程或多进程的环境中,如果信号处理程序修改了某个变量,程序中的其他部分在访问这个变量时,需要确保获取到的是最新的值,因此也需要用 volatile

使用示例:

#include <stdio.h>

volatile int flag = 0;  // 声明为volatile,表示它的值可能被外部因素改变

void signal_handler() {
    flag = 1;  // 外部信号处理程序改变flag的值
}

void wait_for_flag() {
    while (flag == 0) {
        // 这里每次访问flag时,都会从内存中读取,而不是使用寄存器或缓存中的值
    }
    printf("Flag is set!\n");
}

int main() {
    wait_for_flag();  // 等待flag被信号处理程序修改
    return 0;
}

在这个例子中:

  • flag 变量声明为 volatile,表示它可能在程序的其他地方(如信号处理函数 signal_handler)被修改。
  • wait_for_flag 函数会在 flag 被修改之前一直等待。当 flag 被设置为 1 时,程序才会继续执行。
  • volatile 确保每次检查 flag 时都从内存读取,而不是从寄存器缓存中读取过时的值。

注意事项:

  1. volatile 不等同于原子性或线程同步
    • volatile 只确保了 每次都从内存中读取变量的值,它并不会提供线程安全或原子性。例如,如果多个线程同时修改一个 volatile 变量,它仍然会有竞态条件(race condition)问题,因此在这种情况下,还需要使用互斥锁或原子操作来保证同步。
  2. volatile 不会防止缓存一致性问题
    • 在多核 CPU 系统中,volatile 并不能解决不同核心之间的缓存一致性问题。缓存一致性通常是由硬件(如 MESI 协议)或内存屏障来处理的。
  3. volatile 主要是为了防止编译器优化
    • 在大多数情况下,volatile 只是告诉编译器不要优化对变量的访问,它并不改变该变量的实际行为或内存模型。

结论:

  • volatile 主要用于防止编译器优化,确保程序每次访问变量时都从内存中读取它的最新值,适用于那些可能被外部因素(硬件、其他线程、信号处理程序)改变的变量。
  • 不会 处理多线程之间的同步问题,如果需要同步,则应该配合 互斥锁(mutex)或其他线程同步机制。

相关建议

a. 在多线程编程中,除了 volatile,你还应该了解如何使用 原子操作互斥锁条件变量 来确保共享数据的一致性。

b. 如果涉及到硬件寄存器的访问,理解 volatile 的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。


0voice · GitHub


http://www.kler.cn/a/454008.html

相关文章:

  • RestTemplate关于https的使用详解
  • 【ES6复习笔记】集合Set(13)
  • json字符串或者json文件转换成相应的bean,报错“Unrecognized field xxx , not marked as ignorable”
  • 游戏引擎学习第62天
  • Linux:alias别名永久有效
  • 如何检查交叉编译器gcc工具链里是否有某个库(以zlib库和libpng库为例)
  • 3350、检测相邻递增子数组 Ⅱ
  • 中科岩创桥梁自动化监测解决方案
  • 对gPTP上PTP安全控制的评估
  • Tengine平替Nginx
  • 代码随想录day27 贪心1
  • 使用 Python 操作 Excel 表格
  • js垃圾回收机制详细讲解
  • IntelliJ Idea常用快捷键详解
  • 数据分析思维(五):分析方法——假设检验分析方法
  • R 语言 | 绘图的文字格式(绘制上标、下标、斜体、文字标注等)
  • 接口测试Day03-postman使用接口用例设计
  • vscode 插件一直提示正在安装的问题
  • Linux 的 Regmap API:简化设备寄存器访问
  • 新一代Web安全技术应用指南
  • 音视频入门知识(二)、图像篇
  • FPGA开发实战之“模块实例化中的端口映射陷阱”
  • HDLBits训练4
  • Flink RocksDB状态缩放加速:RocksDB原生DeleteRange原理简析
  • 云原生相关的 Go 语言工程师技术路线(含博客网址导航)
  • JAVAweb学习日记(三)Ajax