Linux高级--3.3.2.6高并发编程之“内存屏障”“CPU屏障”“编译屏障”
一、内存屏障
在 Linux C 语言编程 中,内存屏障(Memory Barrier) 是一种用于控制内存访问顺序的技术。它主要用于多处理器系统中,确保某些操作按预期顺序执行,避免 CPU 和编译器对内存访问进行优化,从而影响程序的正确性。内存屏障的功能在多线程和并发编程中尤为重要。
什么是内存屏障?
内存屏障的障中文意思是保护和隔离的,也有阻止的意思,阻止的是CPU对变量的继续访问,停下来更新下变量,从而保护变量的一致性。 内存屏障是针对线程所有共享变量的,而原子操作仅针对当前原子变量。
内存屏障是一种指令,它的作用是禁止 CPU 重新排序特定的内存操作。它确保在屏障之前的所有读/写操作在屏障之后的操作之前完成。内存屏障一般被用来控制多处理器环境中的内存可见性问题,尤其是在进行原子操作、锁和同步时。
在多核处理器上,每个处理器都有自己的缓存,CPU 会将内存操作缓存到自己的本地缓存中。在不同的 CPU 之间,内存的可见性并非立刻同步,这就可能导致不同线程看到不同的内存值。通过内存屏障,可以确保特定的操作顺序,以避免此类问题。
内存屏障的类型
Linux C 中,内存屏障通常有以下几种类型,主要通过内核提供的原子操作或者内存屏障函数来实现。
-
全屏障(Full Barrier 或者 LFENCE、SFENCE):
- 作用:以下两种相加。
- 用途:确保所有的内存操作都在内存屏障前完成,通常用于同步和锁定操作。
- 内核函数:
mb()
(Memory Barrier)
-
读屏障(Read Barrier 或者 LFENCE):
- 作用:保证屏障之前的所有读操作在屏障之后的读操作之前完成。---》翻译过来的有歧义,难以理解,那个“完成”是缓存同步主内存的意思。
- 本质:作用是强制将 CPU核心 中的 L1/L2 缓存 中的共享变量值写回到 主内存。
- 用途:在执行并行读操作时确保读顺序。
- 内核函数:
rmb()
(Read Memory Barrier)
-
写屏障(Write Barrier 或者 SFENCE):
- 作用:保证屏障之前的所有写操作在屏障之后的写操作之前完成。--》翻译过来有歧义,难以理解,那个“完成”是主内存同步到缓存的意思。
- 本质:作用是强制使数据从主内存加载,而不是直接使用可能已经过时的缓存数据。
- 用途:用于确保写操作顺序。
- 内核函数:
wmb()
(Write Memory Barrier)
-
无序屏障(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;
}
为什么需要内存屏障?
-
避免重排序:编译器和 CPU 会对内存访问进行优化,尤其是在多处理器系统中,这可能导致指令执行顺序与预期不一致,进而导致错误的程序行为。
-
保证内存一致性:当一个线程或 CPU 修改共享变量时,其他线程或 CPU 可能会看到不同的内存值,内存屏障可以保证修改操作在其他线程中是可见的。
-
同步操作:在多线程或多处理器环境中,内存屏障确保执行顺序和同步的正确性,尤其是在没有锁或原子操作的情况下。
缓存一致性协议(例如 MESI)
为了保证多核处理器之间缓存的数据一致性,现代 CPU 会使用 缓存一致性协议(如 MESI 协议,即 Modified、Exclusive、Shared、Invalid)。这个协议的作用是确保一个核心的缓存修改在其他核心的缓存中得到更新,避免出现“脏数据”。
但即便如此,MESI 协议的具体实现仍然依赖于硬件,缓存之间的同步可能不会在每一次内存访问时都发生。尤其是在没有任何同步机制的情况下,一个线程修改的值可能会暂时不被另一个线程看到,直到某些缓存刷新或同步操作发生。
Linux 内核中的内存屏障
在 Linux 内核中,内存屏障主要是通过原子操作来实现的。例如,atomic_set
、atomic_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. 综述:内存模型和线程切换
在现代操作系统和多核处理器中,内存模型非常复杂。通过以下几个概念可以理解线程的内存操作:
- 每个线程有自己的栈,但共享堆和全局变量。
- 缓存一致性问题:线程修改的变量不会立刻对其他线程可见。
- 上下文切换:线程切换时,寄存器、栈等状态会保存,并由操作系统恢复。
- 内存屏障和同步机制:确保变量在多个线程之间同步和可见。
示例
假设有两个线程 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) 是一种用于控制编译器优化的机制,用来确保编译器在生成代码时不会对某些操作进行重排或优化,尤其是在多线程编程和硬件相关编程中非常重要。编译器屏障并不直接控制内存访问,而是用来防止编译器对指令的顺序进行不合适的重新排序,确保代码按照预期的顺序执行。
编译器屏障的作用
编译器屏障主要用于控制编译器如何处理代码中的指令顺序,尤其是:
- 防止指令重排序:编译器优化时可能会改变指令的顺序,重新排序内存访问或者其他指令。这可能导致多线程程序中某些预期的同步行为失败。编译器屏障防止这种行为。
- 确保指令的执行顺序:在多核处理器或并发编程中,编译器屏障确保某些关键操作(比如内存访问)在正确的顺序中执行,避免因编译器优化导致的不一致性。
编译器屏障与内存屏障的区别
- 编译器屏障(Compiler Barrier):
- 它的目的是确保编译器不会对代码进行不合理的优化或重排序,特别是在涉及并发或多核时。
- 编译器屏障不能强制 CPU 层面执行同步操作,仅仅是防止编译器重排代码。
- 内存屏障(Memory Barrier):
- 内存屏障则是用于确保在多核或多线程环境中,内存访问按照特定的顺序发生,它直接控制内存操作和硬件级别的缓存同步。
- 内存屏障不仅防止编译器重排,也会影响 CPU 对内存的读取和写入顺序。
编译器屏障的实际应用
编译器屏障通常用于那些需要确保内存或操作顺序的场景,尤其是在处理低级硬件和并发编程时。下面是一些编译器屏障的常见应用场景:
- 原子操作:在使用原子操作(比如
atomic_add
)时,编译器屏障可用于确保原子操作的顺序性。 - 多线程同步:当线程间存在共享数据时,编译器屏障可以防止编译器重排序线程的操作,以保证正确的同步。
- 硬件访问:在直接操作硬件时,编译器屏障可以确保 I/O 操作按预期顺序执行,而不被编译器优化掉。
编译器屏障的实现
不同的编译器提供不同的方式来实现编译器屏障。常见的做法包括:
-
volatile
关键字:在 C/C++ 中,volatile
可以告诉编译器不要优化对变量的访问,通常用于内存映射 I/O 或多线程共享变量。虽然它可以防止编译器优化,但它并不能防止 CPU 缓存重排序。 -
内联汇编:编译器屏障还可以通过内联汇编来实现。许多编译器(如 GCC)支持特定的内联汇编指令,用于告诉编译器不要重排某些指令。例如,GCC 提供了
asm volatile
来控制编译器优化。 -
编译器内置指令:某些编译器提供内置指令来实现编译器屏障。例如,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 = 1
和 shared_var
之间的读写操作时不会对它们进行优化或重排序。
编译器屏障的局限性
虽然编译器屏障可以阻止编译器对代码的优化,但它并不能保证在多核处理器上,缓存之间的同步或内存访问的正确顺序。要确保内存的一致性,尤其是跨多个 CPU 核心的同步,还需要使用 内存屏障 或 锁 等同步机制。
总结
- 编译器屏障 主要用于控制编译器优化,防止编译器对代码执行顺序进行重排序。
- 它不能控制内存访问的实际顺序,但可以防止编译器错误地优化掉重要的内存操作。
- 它通常与 内存屏障 一起使用,以确保在多线程或并发环境下的正确性。
四、CPU屏障
CPU 屏障,也常称为 处理器屏障,是一个硬件层面的同步机制,主要用于确保 CPU 在执行指令时按特定的顺序访问内存。它是为了处理 CPU 的 指令重排、内存缓存一致性 和 多核 CPU 系统中的缓存同步 等问题。
在多核系统中,每个 CPU 核心都有自己的 缓存(L1, L2, L3 缓存),这些缓存可能存储过时的内存值,导致不同核心之间的数据不一致。CPU 屏障通过硬件指令,确保 CPU 按照特定的顺序执行内存操作,从而解决缓存一致性和内存重排序的问题。
CPU 屏障的作用
-
防止指令重排:现代 CPU 在执行指令时,通常会对指令进行重排序(指令乱序执行)以提高性能。CPU 屏障确保特定的指令顺序不被改变,避免并发编程中的数据不一致性。
-
确保内存操作顺序:CPU 屏障通过禁止指令重排和缓存同步,确保内存操作按预期的顺序发生。这对多核处理器尤其重要,避免不同核心之间的数据不一致问题。
-
控制缓存一致性:当一个核心修改内存中的某个值时,CPU 屏障可以确保这个修改值被写回主内存,并通知其他核心从主内存读取最新的值。
与内存屏障的区别
- 内存屏障(Memory Barrier) 是一种在软件层面控制 CPU 内存操作顺序的机制。它可以是一个指令,告诉 CPU 按照特定顺序访问内存,避免乱序执行或缓存不一致。
- CPU 屏障 则是硬件级别的机制,它通过处理器的硬件指令实现类似的同步操作。CPU 屏障直接控制 CPU 内部的缓存管理和指令流水线,从而确保内存操作的顺序。
CPU 屏障的类型
不同的 CPU 和架构(如 x86、ARM、PowerPC)提供不同的屏障指令,以下是一些常见的屏障类型:
-
全屏障(Full Barrier):也叫作 全内存屏障,会确保指令完全按顺序执行,通常会阻止所有的加载(Load)和存储(Store)操作的重排序。全屏障适用于需要完全同步内存访问的场景。
-
加载屏障(Load Barrier):用于控制加载指令(读取内存)的顺序,保证在屏障前的加载操作完成后,才能执行屏障后的加载操作。
-
存储屏障(Store Barrier):用于控制存储指令(写入内存)的顺序,确保在屏障前的写操作完成后,才能执行屏障后的写操作。
-
轻量级屏障(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 屏障的应用场景
-
多线程编程和并发控制: 在多线程程序中,多个线程可能会同时访问共享内存。使用 CPU 屏障可以确保线程之间的内存操作顺序,从而避免出现数据不一致的情况。
-
内存模型: 在某些硬件平台上,特别是在不同架构(如 x86 和 ARM)之间进行程序移植时,CPU 屏障能够确保程序按照预期的内存顺序执行。
-
硬件编程: 对于直接操作硬件的低级编程(例如内存映射 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 vCPU 屏障(如 MFENCE) 强制同步缓存与主内存
通过 CPU 屏障的使用,确保 核心 1
对 shared_var
的修改能正确地同步到主内存,核心 2
可以看到最新的值。
五、使用内存屏障还需要使用CPU屏障吗?
对于大多数开发人员来说,内存屏障(Memory Barriers)通常是足够的,因为它们是软件级别的同步机制,而 CPU 屏障(CPU Barriers)是硬件级别的机制,两者的目标都是确保内存操作按预期顺序执行。内存屏障通过插入特定的指令来控制 CPU 的缓存一致性和内存操作顺序,通常通过编译器提供的原语(如 __sync_synchronize
、atomic_thread_fence
、mfence
等)来实现。
1. 内存屏障 vs CPU 屏障
-
内存屏障:
- 由 程序员显式插入,通常作为一条特殊的汇编指令或者编译器指令。
- 它确保了程序中的某些内存操作顺序不会被优化或乱序执行。
- 对于 程序员来说,内存屏障是一个高层的工具,在需要同步共享数据时,它是最常用的同步机制。
-
CPU 屏障:
- 是 硬件层面的同步机制,直接控制 CPU 内部的缓存、指令流水线和内存访问顺序。
- 这些机制通常是针对 处理器架构(如 x86、ARM)的具体硬件指令,用于确保内存操作按顺序发生。
- 程序员通常 不直接操作 CPU 屏障,而是通过内存屏障指令间接地影响硬件行为。
2. 内存屏障满足开发者需求
对于程序员而言,使用 内存屏障 就足够了,因为:
-
内存屏障指令是跨平台的,开发人员不需要针对特定的 CPU 指令来编写代码。它们会依赖 编译器 来生成适合特定平台的机器代码。
-
编译器和操作系统已经为我们处理了硬件的差异。程序员插入的内存屏障会触发适当的 CPU 屏障或其他低层次同步机制,具体取决于目标平台的 CPU 和架构。
-
在 多核处理器 上,内存屏障确保一个线程对共享数据的修改能够及时写回主内存,并且通知其他线程访问这些数据时能看到最新的值。
3. 实际应用中的差异
3.1. 使用内存屏障
-
内存屏障(如
__sync_synchronize
、atomic_thread_fence
、mfence
)通过插入到代码中来显式指定内存操作顺序,从而控制并发操作时的内存一致性问题。例如,在多线程编程中,你希望线程 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_release
和std::memory_order_acquire
就是内存屏障的类型,它们保证了内存操作的顺序。
3.2. CPU 屏障
-
CPU 屏障是硬件级别的机制。程序员一般不会显式写入 CPU 屏障指令,除非进行非常低级的硬件编程或直接操作硬件。
例如,在嵌入式系统开发或驱动开发中,可能会看到
mfence
或其他处理器指令:__asm__ __volatile__("mfence" ::: "memory");
在这种情况下,程序员直接插入了硬件指令,显式控制 CPU 执行顺序。但是在 一般应用程序开发 中,通常并不需要手动插入这些指令,而是依赖编译器和标准库中的内存屏障。
4. 总结
对于大多数应用程序开发人员,内存屏障已经足够,不需要直接使用 CPU 屏障指令。编译器会根据目标硬件生成合适的机器代码,确保内存操作的顺序性和一致性。
- 内存屏障:适用于大多数开发者,提供了一个高层次的同步工具,确保不同线程之间的内存操作顺序。
- CPU 屏障:属于硬件层面的操作,通常程序员不需要直接处理,除非进行底层的硬件开发。
5. 应用场景
-
多线程同步:确保线程对共享数据的修改能够及时生效,避免缓存不一致导致的数据不一致问题。
-
优化:在多核处理器中,通过内存屏障确保正确的内存操作顺序,避免因指令重排和缓存不一致带来的问题。
6. 建议
a. 如果你在编写并发程序,内存屏障是足够的,尝试使用标准库中的原子操作和内存顺序。
b. 如果你进行低级别硬件编程(例如驱动开发、嵌入式系统),可能需要直接使用 CPU 屏障。
六、“原子操作/互斥锁” 与 “内存屏障”
1. 原子操作与内存屏障的对比
原子操作(Atomic Operation)是对某个特定变量进行原子级别的访问(通常是加、减、交换等操作),它确保操作对这个变量的修改是不可分割的,即操作要么完全执行,要么完全不执行。
-
原子操作的作用范围:原子操作是针对特定变量的,只会影响该变量的状态,它在进行操作时,也会隐含地执行内存屏障,确保对该变量的修改对其他线程可见。
-
内存屏障的作用范围:内存屏障则更加广泛,它会影响到线程的所有共享变量,确保整个内存访问顺序符合预期。
简而言之,原子操作是对某个特定变量的一种操作,保证它的正确性;而内存屏障控制的是线程的所有共享变量的访问顺序,确保不同线程之间的同步和一致性。
2. 互斥锁的读写屏障
互斥锁(Mutex)作为线程同步的一种机制,通常会涉及到内存屏障。因为互斥锁的使用会影响多个线程对共享数据的访问顺序,因此通常在加锁和解锁时,操作系统会插入内存屏障,确保在锁被持有时,所有操作会遵循正确的顺序。
- 加锁(线程持锁)时,需要确保该线程读取的共享数据是 最新的,因此需要通过 读屏障 来保证锁保护的变量被同步到主内存,确保读到的是最新的数据。
- 解锁(释放锁)时,需要确保该线程修改的数据被 同步到主内存,并通知其他线程,这通常通过 写屏障 来实现,以保证其他线程能看到更新后的数据。
加锁/解锁时的内存屏障作用示意:
- 加锁:会确保在加锁前,当前线程对共享数据的所有操作(尤其是写入)已经完成,并且数据对其他线程可见。这通常通过插入写屏障来实现。
- 解锁:会确保在解锁后,其他线程能看到当前线程对共享数据的最新修改。这通常通过插入读屏障来实现。
3. 总结
-
内存屏障:
- 内存屏障的作用是保护共享数据的一致性,确保线程对共享变量的操作顺序正确。它影响的是所有共享变量,而不仅仅是某个特定的变量。
- 它防止 CPU 对内存访问的乱序执行,确保内存操作按预期的顺序执行。
-
原子操作:
- 原子操作通常针对单个变量,确保操作是原子性的,即不可中断的,并且会隐式地执行内存屏障,保证操作对其他线程可见。
-
互斥锁和内存屏障:
- 互斥锁在加锁和解锁时会执行内存屏障。加锁时插入写屏障,确保当前线程的写操作完成;解锁时插入读屏障,确保其他线程能看到当前线程的修改。
4. 建议
a. 如果你编写多线程程序并使用互斥锁,确保你理解锁的加锁和解锁时的内存屏障作用,以避免数据不一致问题。
b. 了解并使用原子操作和内存屏障来控制线程间共享数据的顺序,特别是在性能要求较高的场合。
七、配合内存屏障来真正理解volatile
volatile
关键字在 C 和 C++ 中用于修饰变量,告诉编译器该变量的值可能会在程序的任何时刻发生变化,通常是由外部因素(如硬件、操作系统或其他线程)引起的。使用 volatile
可以防止编译器对该变量进行优化(什么样的优化?),从而确保每次访问时都从内存中获取变量的最新值,而不是从寄存器或缓存中读取过时的值。
优化?变量的优化就是CPU指令对此变量的操作一直在缓存中执行,增加运行速度。
防止优化?其实是防止读优化,即每次读操作都从主内存取,但是写操作不负责优化,可能写完大概率还在缓存中,其他同时在其他CPU核心运行的线程依然拿不到最新值。
等价于每次使用此变量之前都会触发一次“只针对这个变量的读屏障”(这是个比喻,读屏障不会只针对单独一个变量)
主要作用:
- 防止编译器优化:
- 编译器为了提高性能,通常会将变量存储在寄存器中,并减少对内存的访问。但是,如果一个变量是由外部因素改变的(比如硬件寄存器或其他线程修改),编译器可能不会及时从内存中读取该变量的最新值。
- 使用
volatile
告诉编译器:“不要优化对这个变量的访问,每次都直接从内存中读取它。”
- 确保变量访问的正确性:
- 当一个变量的值可能被多个线程、信号处理程序或硬件设备(例如,I/O端口、硬件寄存器)所修改时,
volatile
关键字确保每次读取时都会从内存中获取最新的值,而不是使用缓存或寄存器中的值。
- 当一个变量的值可能被多个线程、信号处理程序或硬件设备(例如,I/O端口、硬件寄存器)所修改时,
- 不保证写操作同步到主内存:如果你修改了一个
volatile
变量,编译器会直接在内存中修改这个变量的值,但这并不意味着其他核心的 CPU 或线程会立即看到这个修改。它不会自动确保 这个修改会立即同步到其他线程或 CPU 核心的缓存中。 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
时都从内存读取,而不是从寄存器缓存中读取过时的值。
注意事项:
volatile
不等同于原子性或线程同步:volatile
只确保了 每次都从内存中读取变量的值,它并不会提供线程安全或原子性。例如,如果多个线程同时修改一个volatile
变量,它仍然会有竞态条件(race condition)问题,因此在这种情况下,还需要使用互斥锁或原子操作来保证同步。
volatile
不会防止缓存一致性问题:- 在多核 CPU 系统中,
volatile
并不能解决不同核心之间的缓存一致性问题。缓存一致性通常是由硬件(如 MESI 协议)或内存屏障来处理的。
- 在多核 CPU 系统中,
volatile
主要是为了防止编译器优化:- 在大多数情况下,
volatile
只是告诉编译器不要优化对变量的访问,它并不改变该变量的实际行为或内存模型。
- 在大多数情况下,
结论:
volatile
主要用于防止编译器优化,确保程序每次访问变量时都从内存中读取它的最新值,适用于那些可能被外部因素(硬件、其他线程、信号处理程序)改变的变量。- 它 不会 处理多线程之间的同步问题,如果需要同步,则应该配合 互斥锁(mutex)或其他线程同步机制。
相关建议
a. 在多线程编程中,除了 volatile
,你还应该了解如何使用 原子操作、互斥锁 或 条件变量 来确保共享数据的一致性。
b. 如果涉及到硬件寄存器的访问,理解 volatile
的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。
0voice · GitHub
相关文章:
Linux高级--3.3.2.6高并发编程之“内存屏障”“CPU屏障”“编译屏障”
一、内存屏障 在 Linux C 语言编程 中,内存屏障(Memory Barrier) 是一种用于控制内存访问顺序的技术。它主要用于多处理器系统中,确保某些操作按预期顺序执行,避免 CPU 和编译器对内存访问进行优化,从而影…...

【含开题报告+文档+PPT+源码】基于SpringBoot的智能安全与急救知识科普系统设计与实现
开题报告 在全球范围内,安全与急救知识的普及已成为提升公众安全素养、减少意外伤害发生率、提高突发事件应对能力的重要举措。尤其是在当今社会,人们面临的生活、工作环境日益复杂,交通事故、火灾、溺水、突发疾病等各种意外事件的发生概率…...

EMQX5.X版本性能配置调优参数
EMQX 主配置文件为 emqx.conf,根据安装方式其所在位置有所不同: 安装方式配置文件所在位置DEB 或 RPM 包安装/etc/emqx/emqx.confDocker 容器/opt/emqx/etc/emqx.conf解压缩包安装./etc/emqx.conf EMQ X 消息服务器默认占用的 TCP 端口包括: 端口 说明…...

电脑配置maven-3.6.1版本
不要使用太高的版本。 apache-maven-3.6.1-bin.zip 下载这个的maven压缩包 使用3.6.1版本。 解压缩放在本地软甲目录下面: 配置系统环境变量 在系统环境下面配置MAVEN_HOME 点击path 新增一条 在cmd中输入 mvn -v 检查maven的版本 配置阿里云镜像和本地的仓库 …...

水电站视频智能监控系统方案设计与技术应用方案
一、背景需求 水电站作为国家重要的能源基地,其安全运行对于保障能源供应和社会稳定具有重要意义。然而,传统的人工监控方式存在着诸多问题,如人力成本高、监控范围有限、反应不及时等。因此,水电站急需引进一种先进的视频智能监控…...
React 组件通信完整指南 以及 自定义事件发布订阅系统
React 组件通信完整指南 1. 父子组件通信 1.1 父组件向子组件传递数据 // 父组件 function ParentComponent() {const [data, setData] useState(Hello from parent);return <ChildComponent message{data} />; }// 子组件 function ChildComponent({ message }) {re…...

华为 AI Agent:企业内部管理的智能变革引擎(11/30)
一、华为 AI Agent 引领企业管理新潮流 在当今数字化飞速发展的时代,企业内部管理的高效性与智能化成为了决定企业竞争力的关键因素。华为,作为全球领先的科技巨头,其 AI Agent 技术在企业内部管理中的应用正掀起一场全新的变革浪潮。 AI Ag…...
【Pandas】pandas Series empty
Pandas2.2 Series Attributes 方法描述Series.index每个数据点的标签或索引Series.array对象底层的数据数组Series.values以NumPy数组的形式访问Series中的数据值Series.dtype用于获取 Pandas Series 中数据的类型(dtype)Series.shape用于获取 Pandas …...

Git如何设置和修改当前分支跟踪的上游分支
目录 前言 背景 设置当前分支跟踪的上游分支 当前分支已有关联,删除其关联,重新设置上游 常用的分支操作 参考资料 前言 仅做学习记录,侵删 背景 在项目开发过程中,从master新建分支时,会出现没有追踪的上游分…...

GitHub新手用法详解【适合新手入门-建议收藏!!!】
目录 什么是Github,为什么使用它? 一、GitHub账号的注册与登录 二、 gitbash安装详解 1.git bash的下载与安装 2.git常用命令 3. Git 和 GitHub 的绑定 1. 获取SSH keys 2.绑定ssh密钥 三、通过Git将代码提交到GitHub 1.克隆仓库 2.测试提交代码…...

游戏开发线性空间下PS工作流程
前言 使用基于物理的渲染,为了保证光照计算的准确,需要使用线性空间; 使用线性空间会带来一个问题,ui 在游戏引擎中的渲染结果与 PS 中的不一致: PS(颜色空间默认是sRGB伽马空间):…...
7-10 最长公共子序列
目录 题目描述 输入格式: 输出格式: 输入样例: 输出样例: 解题思路: 详细代码: 题目描述 给出 1~n 的两个排列 P1 和 P2,求它们的最长公共子序列。 n 在 5~1000 之间。 输入格式: 第一行是一个数 n 接下来两行,每行为 n 个数&…...
亚远景-ISO 21434标准下的汽车网络安全:风险评估与管理的关键实践
ISO 21434标准,全称为ISO/SAE 21434 "Road Vehicles - Cybersecurity Engineering",是国际标准化组织(ISO)发布的针对汽车领域的标准,旨在指导汽车制造商、供应商和相关利益相关方在汽车系统中应用适当的网络安全措施。在ISO 21434…...
C++ 的 source_location
1 __FILE__ 和 __LINE__ 你一定看过这样的代码: printf("Internal error at \"%s\" on line %d.\n", __FILE__, __LINE__); 这行代码的作用就是打印出 printf() 函数调用发生时所在的源代码文件名(包含路径)和这行代…...
[python SQLAlchemy数据库操作入门]-14.实时数据采集 记录股市动态
哈喽,大家好,我是木头左! 要使用 SQLAlchemy 进行实时数据采集,首先需要搭建相应的开发环境。以下是所需的主要步骤: 安装 Python:确保你的系统上已经安装了 Python,推荐使用 Python 3.x 版本。创建虚拟环境:为了隔离项目依赖,建议为每个项目创建一个虚拟环境。可以使…...

`we_chat_union_id IS NOT NULL` 和 `we_chat_union_id != ‘‘` 这两个条件之间的区别
文章目录 1、什么是空字符串?2、两个引号之间加上空格 好的,我们来详细解释一下 we_chat_union_id IS NOT NULL 和 we_chat_union_id ! 这两个条件之间的区别,以及它们在 SQL 查询中的作用: 1. we_chat_union_id IS NOT NULL 含…...

【和春笋一起学C++】文本输入与读取
前言:前面学习了while语句后,下面用while语句实现一个重要的功能,逐字符的读取键盘输入的字符序列,并输出到显示屏上。 准备知识: C的输入输出包含以下3方面的内容: 对系统指定的标准设备的输入和输出。即…...

D类音频应用EMI管理
1、前言 对于EMI,首先需要理解天线。频率和波长之间的关系,如下图所示。 作为有效天线所需的最短长度是λ/4。在空气中,介电常数是1,但是在FR4或玻璃环氧PCB的情况下,介电常数大约4.8。这种效应会导致信号在FR4材…...
第N8周:使用Word2vec实现文本分类
文章目录 一、数据预处理1.加载数据2.构建词典3.生成数据批次和迭代器 二、模型构建1.搭建模型2.初始化模型3.定义训练与评估函数 三、训练模型1.拆分数据集并运行模型 四、总结 🍨 本文为🔗365天深度学习训练营 中的学习记录博客🍖 原作者&a…...

100天精通Python(爬虫篇)——第113天:爬虫基础模块之urllib详细教程大全
文章目录 1. urllib概述2. urllib.request模块 1. urllib.request.urlopen()2. urllib.request.urlretrieve()3. urllib.request.Request()4. urllib.request.install_opener()5. urllib.request.build_opener()6. urllib.request.AbstractBasicAuthHandler7. urllib.request.…...
【磁盘】每天掌握一个Linux命令 - iostat
目录 【磁盘】每天掌握一个Linux命令 - iostat工具概述安装方式核心功能基础用法进阶操作实战案例面试题场景生产场景 注意事项 【磁盘】每天掌握一个Linux命令 - iostat 工具概述 iostat(I/O Statistics)是Linux系统下用于监视系统输入输出设备和CPU使…...

学习STC51单片机31(芯片为STC89C52RCRC)OLED显示屏1
每日一言 生活的美好,总是藏在那些你咬牙坚持的日子里。 硬件:OLED 以后要用到OLED的时候找到这个文件 OLED的设备地址 SSD1306"SSD" 是品牌缩写,"1306" 是产品编号。 驱动 OLED 屏幕的 IIC 总线数据传输格式 示意图 …...
python如何将word的doc另存为docx
将 DOCX 文件另存为 DOCX 格式(Python 实现) 在 Python 中,你可以使用 python-docx 库来操作 Word 文档。不过需要注意的是,.doc 是旧的 Word 格式,而 .docx 是新的基于 XML 的格式。python-docx 只能处理 .docx 格式…...
Linux C语言网络编程详细入门教程:如何一步步实现TCP服务端与客户端通信
文章目录 Linux C语言网络编程详细入门教程:如何一步步实现TCP服务端与客户端通信前言一、网络通信基础概念二、服务端与客户端的完整流程图解三、每一步的详细讲解和代码示例1. 创建Socket(服务端和客户端都要)2. 绑定本地地址和端口&#x…...
Java数值运算常见陷阱与规避方法
整数除法中的舍入问题 问题现象 当开发者预期进行浮点除法却误用整数除法时,会出现小数部分被截断的情况。典型错误模式如下: void process(int value) {double half = value / 2; // 整数除法导致截断// 使用half变量 }此时...

RSS 2025|从说明书学习复杂机器人操作任务:NUS邵林团队提出全新机器人装配技能学习框架Manual2Skill
视觉语言模型(Vision-Language Models, VLMs),为真实环境中的机器人操作任务提供了极具潜力的解决方案。 尽管 VLMs 取得了显著进展,机器人仍难以胜任复杂的长时程任务(如家具装配),主要受限于人…...
Redis:现代应用开发的高效内存数据存储利器
一、Redis的起源与发展 Redis最初由意大利程序员Salvatore Sanfilippo在2009年开发,其初衷是为了满足他自己的一个项目需求,即需要一个高性能的键值存储系统来解决传统数据库在高并发场景下的性能瓶颈。随着项目的开源,Redis凭借其简单易用、…...

STM32---外部32.768K晶振(LSE)无法起振问题
晶振是否起振主要就检查两个1、晶振与MCU是否兼容;2、晶振的负载电容是否匹配 目录 一、判断晶振与MCU是否兼容 二、判断负载电容是否匹配 1. 晶振负载电容(CL)与匹配电容(CL1、CL2)的关系 2. 如何选择 CL1 和 CL…...

华为OD机试-最短木板长度-二分法(A卷,100分)
此题是一个最大化最小值的典型例题, 因为搜索范围是有界的,上界最大木板长度补充的全部木料长度,下界最小木板长度; 即left0,right10^6; 我们可以设置一个候选值x(mid),将木板的长度全部都补充到x,如果成功…...
用鸿蒙HarmonyOS5实现中国象棋小游戏的过程
下面是一个基于鸿蒙OS (HarmonyOS) 的中国象棋小游戏的实现代码。这个实现使用Java语言和鸿蒙的Ability框架。 1. 项目结构 /src/main/java/com/example/chinesechess/├── MainAbilitySlice.java // 主界面逻辑├── ChessView.java // 游戏视图和逻辑├──…...