彻底搞懂内存屏障,让程序运行更有序

在多线程编程的世界中,内存访问就像是一场繁忙的交通。多个线程如同路上的车辆,它们频繁地读取和写入内存中的数据。在这种情况下,内存屏障就像是交通信号灯,起着至关重要的作用。它确保了不同线程对内存的访问是有序的,避免了数据不一致和其他潜在的问题。没有内存屏障,线程可能会以意想不到的顺序访问内存,就像没有信号灯的路口,车辆可能会发生碰撞和混乱。

而有了内存屏障,线程在访问内存时就有了明确的规则和顺序,就像车辆按照信号灯的指示有序通行,从而保证了多线程程序的正确性和稳定性。所以,内存屏障在多线程编程中,是保障程序正常运行的关键因素。

一、内存访问的 “乱序” 困境

1.1CPU 的 “小算盘”:乱序执行

为了提高执行效率,现代 CPU 采用了乱序执行技术。在传统的顺序执行中,CPU 按照指令在程序中的顺序依次执行,一条指令执行完成后才会执行下一条指令。然而,在实际运行中,很多指令之间并没有严格的依赖关系 ,比如下面这两条指令:

复制
int a = 5; int b = 3;1.2.

这两条指令之间没有数据依赖,它们的执行顺序并不影响最终的结果。如果按照顺序执行,当遇到一些长延迟操作,如内存访问时,CPU 就会处于空闲等待状态,这会浪费大量的时间。而乱序执行则打破了这种传统的顺序限制,允许 CPU 在遇到某些指令依赖未解决时,先执行其他不相关的指令。

乱序执行的实现依赖于复杂的硬件机制。CPU 内部有一个指令调度器,它会分析指令流,找出可以并行执行的指令,并重新排序以最大化资源利用率。同时,处理器还需要维护一个寄存器重命名机制,避免数据冲突和错误。此外,乱序执行还依赖于分支预测等辅助技术,以确保指令流的正确性。例如,在一个多任务环境下,当某个程序卡顿或等待 I/O 操作完成时,乱序执行能够动态调整各个进程之间的资源分配,确保其他程序仍可继续运行而不受影响,从而显著提升了计算机系统的响应速度。

1.2缓存惹的 “祸”:数据一致性问题

CPU 缓存的出现是为了解决 CPU 与内存之间速度不匹配的问题。由于 CPU 的运行速度远远快于内存的访问速度,如果 CPU 每次都直接从内存中读取数据,会大大降低系统的性能。因此,在 CPU 和内存之间引入了高速缓存(Cache),它作为一个高速的临时存储区域,存放着 CPU 近期可能会访问的数据。

CPU 缓存通常分为多级,如 L1、L2 和 L3 缓存,其中 L1 缓存速度最快但容量最小,L3 缓存容量最大但速度相对较慢。当 CPU 需要读取一个数据时,它会首先在缓存中查找,如果找到(称为缓存命中),则直接从缓存中读取数据,这样可以大大提高访问速度;如果没有找到(称为缓存未命中),则需要从内存中读取数据,并将该数据所在的数据块调入缓存中,以便后续访问。

在多核心 CPU 系统中,每个核心都有自己的缓存,这就可能引发数据一致性问题。当多个核心同时访问共享内存中的数据时,如果一个核心修改了其缓存中的数据,而其他核心的缓存中仍然保存着旧数据,就会导致数据不一致。例如,假设有两个线程分别在不同的核心上运行,它们都访问同一个共享变量 x。线程 1 读取 x 的值为 1,然后对其进行加 1 操作,得到 x 的值为 2,并将其写回缓存,但尚未写回内存。此时,线程 2 从自己的缓存中读取 x 的值,由于其缓存中的数据尚未更新,仍然读取到的值为 1,这就导致了数据不一致的问题。

为了解决缓存一致性问题,现代计算机系统采用了多种技术,如缓存一致性协议(如 MESI 协议)和总线嗅探机制。缓存一致性协议通过定义缓存状态和状态转换规则,确保各个核心的缓存数据始终保持一致;总线嗅探机制则通过让每个核心监听总线上的内存访问请求,及时更新自己缓存中的数据。

1.3编译器的 “优化陷阱”

编译器为了提高程序的执行效率,会对源代码进行各种优化,其中指令重排是一种常见的优化手段。指令重排是指编译器在不改变单线程程序语义的前提下,对指令的执行顺序进行重新排列,以充分利用 CPU 的资源和提高程序的性能。

考虑下面这段代码:

复制
int a = 0; int b = 0; // 线程1执行 a = 1; b = 2; // 线程2执行 if (b == 2) { assert(a == 1); }1.2.3.4.5.6.7.8.9.

在单线程环境下,无论 a 和 b 的赋值顺序如何,都不会影响程序的正确性。因此,编译器可能会对这两条赋值指令进行重排,将其变为:

复制
b = 2; a = 1;1.2.

在多线程环境下,这种指令重排可能会导致问题。如果线程 2 在 b 被赋值为 2 之后,但 a 还未被赋值为 1 时执行if (b == 2)条件判断,那么assert(a == 1)就会失败,因为此时 a 的值仍然为 0。这就是编译器优化带来的多线程隐患,它破坏了程序在多线程环境下的正确性。

为了避免这种问题,程序员需要使用一些同步机制,如内存屏障(Memory Barrier)来告诉编译器哪些指令不能被重排,从而保证多线程程序的正确性。

二、Linux内存屏障详解

2.1内存屏障是什么?

内存屏障,也叫内存栅栏(Memory Fence),是一种在多处理器系统中,用于控制内存操作顺序的同步机制。它就像是一个 “关卡”,确保在它之前的内存读写操作,一定在它之后的内存读写操作之前完成 。

在单核单线程的程序里,我们通常不用担心指令执行顺序的问题,因为 CPU 会按照代码编写的顺序依次执行。但在多处理器或者多线程的环境下,情况就变得复杂起来。现代处理器为了提高性能,会采用诸如指令乱序执行、缓存等技术,这可能导致内存操作的顺序与程序代码中的顺序不一致。内存屏障的出现,就是为了解决这类问题,它能够阻止编译器和处理器对特定内存操作的重排序,保证内存操作的顺序性和数据的可见性。

从硬件层面来看,内存屏障可以被视为一种特殊的指令,它会影响处理器的流水线操作和缓存一致性协议。当处理器执行到内存屏障指令时,它会暂停流水线,直到之前的内存操作都完成,并且确保这些操作对其他处理器可见。从编译器层面来看,内存屏障则是一种告诉编译器不要对某些指令进行重排序的指示。通过这种方式,内存屏障确保了程序在多线程环境下的正确性和稳定性。

大多数处理器提供了内存屏障指令:

完全内存屏障(full memory barrier)保障了早于屏障的内存读写操作的结果提交到内存之后,再执行晚于屏障的读写操作。内存读屏障(read memory barrier)仅确保了内存读操作;内存写屏障(write memory barrier)仅保证了内存写操作。

内核代码里定义了这三种内存屏障,如x86平台:arch/x86/include/asm/barrier.h

复制
#define mb() asm volatile("mfence":::"memory") #define rmb() asm volatile("lfence":::"memory") #define wmb() asm volatile("sfence" ::: "memory")1.2.3.

个人理解:就类似于我们喝茶的时候需要先把水煮开(限定条件),然后再切茶,而这一整套流程都是限定特定环节的先后顺序(内存屏障),保障切出来的茶可以更香。

硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。

内存屏障有两个作用:

阻止屏障两侧的指令重排序;强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

2.2为什么会出现内存屏障?

由于现在计算机存在多级缓存且多核场景,为了保证读取到的数据一致性以及并行运行时所计算出来的结果一致,在硬件层面实现一些指令,从而来保证指定执行的指令的先后顺序。比如上图:双核cpu,每个核心都拥有独立的一二级缓存,而缓存与缓存之间需要保证数据的一致性所以这里才需要加添屏障来确保数据的一致性。三级缓存为各CPU共享,最后都是主内存,所以这些存在交互的CPU都需要通过屏障手段来保证数据的唯一性。

内存屏障存在的意义就是为了解决程序在运行过程中出现的内存乱序访问问题,内存乱序访问行为出现的理由是为了提高程序运行时的性能,Memory Bariier能够让CPU或编译器在内存访问上有序。

(1)运行时内存乱序访问

运行时,CPU本身是会乱序执行指令的。早期的处理器为有序处理器(in-order processors),总是按开发者编写的顺序执行指令, 如果指令的输入操作对象(input operands)不可用(通常由于需要从内存中获取), 那么处理器不会转而执行那些输入操作对象可用的指令,而是等待当前输入操作对象可用。

相比之下,乱序处理器(out-of-order processors)会先处理那些有可用输入操作对象的指令(而非顺序执行) 从而避免了等待,提高了效率。现代计算机上,处理器运行的速度比内存快很多, 有序处理器花在等待可用数据的时间里已可处理大量指令了。即便现代处理器会乱序执行, 但在单个CPU上,指令能通过指令队列顺序获取并执行,结果利用队列顺序返回寄存器堆,这使得程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的, 因此内存屏障是没有必要使用的(前提是不考虑编译器优化的情况下)。

(2)SMP架构需要内存屏障的进一步解释:

从体系结构上来看,首先在SMP架构下,每个CPU与内存之间,都配有自己的高速缓存(Cache),以减少访问内存时的冲突采用高速缓存的写操作有两种模式:

复制
(1). 穿透(Write through)模式,每次写时,都直接将数据写回内存中,效率相对较低; (2). 回写(Write back)模式,写的时候先写回告诉缓存,然后由高速缓存的硬件再周转复用缓冲线(Cache Line)时自动将数据写回内存, 或者由软件主动地“冲刷”有关的缓冲线(Cache Line)。1.2.3.

出于性能的考虑,系统往往采用的是模式2来完成数据写入;正是由于存在高速缓存这一层,正是由于采用了Write back模式的数据写入,才导致在SMP架构下,对高速缓存的运用可能改变对内存操作的顺序。

已上面的一个简短代码为例:

复制
// thread 0 -- 在CPU0上运行 x = 42; ok = 1; // thread 1 – 在CPU1上运行 while(!ok); print(x);1.2.3.4.5.6.7.

假设,正好CPU0的高速缓存中有x,此时CPU0仅仅是将x=42写入到了高速缓存中,另外一个ok也在高速缓存中,但由于周转复用高速缓冲线(Cache Line)而导致将ok=1刷会到了内存中,此时CPU1首先执行对ok内存的读取操作,他读到了ok为1的结果,进而跳出循环,读取x的内容,而此时,由于实际写入的x(42)还只在CPU0的高速缓存中,导致CPU1读到的数据为x(17)。

程序中编排好的内存访问顺序(指令序:program ordering)是先写入x,再写入y。而实际上出现在该CPU外部,即系统总线上的次序(处理器序:processor ordering),却是先写入y,再写入x(这个例子中x还未写入)。

在SMP架构中,每个CPU都只知道自己何时会改变内存的内容,但是都不知道别的CPU会在什么时候改变内存的内容,也不知道自己本地的高速缓存中的内容是否与内存中的内容不一致。

反过来,每个CPU都可能因为改变了内存内容,而使得其他CPU的高速缓存变的不一致了。在SMP架构下,由于高速缓存的存在而导致的内存访问次序(读或写都有可能书序被改变)的改变很有可能影响到CPU间的同步与互斥。

因此需要有一种手段,使得在某些操作之前,把这种“欠下”的内存操作(本例中的x=42的内存写入)全都最终地、物理地完成,就好像把欠下的债都结清,然后再开始新的(通常是比较重要的)活动一样。这种手段就是内存屏障,其本质原理就是对系统总线加锁。

回过头来,我们再来看看为什么非SMP架构(UP架构)下,运行时内存乱序访问不存在。

在单处理器架构下,各个进程在宏观上是并行的,但是在微观上却是串行的,因为在同一时间点上,只有一个进程真正在运行(系统中只有一个处理器)。

在这种情况下,我们再来看看上面提到的例子:

线程0和线程1的指令都将在CPU0上按照指令序执行。thread0通过CPU0完成x=42的高速缓存写入后,再将ok=1写入内存,此后串行的将thread0换出,thread1换入,及时此时x=42并未写入内存,但由于thread1的执行仍然是在CPU0上执行,他仍然访问的是CPU0的高速缓存,因此,及时x=42还未写回到内存中,thread1势必还是先从高速缓存中读到x=42,再从内存中读到ok=1。

综上所述,在单CPU上,多线程执行不存在运行时内存乱序访问,我们从内核源码也可得到类似结论(代码不完全摘录)

复制
#define barrier() __asm__ __volatile__("": : :"memory") #define mb() alternative("lock; addl $0,0(%%esp)", "mfence", X86_FEATURE_XMM2) #define rmb() alternative("lock; addl $0,0(%%esp)", "lfence", X86_FEATURE_XMM2) #ifdef CONFIG_SMP #define smp_mb() mb() #define smp_rmb() rmb() #define smp_wmb() wmb() #define smp_read_barrier_depends() read_barrier_depends() #define set_mb(var, value) do { (void) xchg(&var, value); } while (0) #else #define smp_mb() barrier() #define smp_rmb() barrier() #define smp_wmb() barrier() #define smp_read_barrier_depends() do { } while(0) #define set_mb(var, value) do { var = value; barrier(); } while (0) #endif1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

这里可看到对内存屏障的定义,如果是SMP架构,smp_mb定义为mb(),mb()为CPU内存屏障(接下来要谈的),而非SMP架构时(也就是UP架构),直接使用编译器屏障,运行时内存乱序访问并不存在。

(3)为什么多CPU情况下会存在内存乱序访问?

我们知道每个CPU都存在Cache,当一个特定数据第一次被其他CPU获取时,此数据显然不在对应CPU的Cache中(这就是Cache Miss)。

这意味着CPU要从内存中获取数据(这个过程需要CPU等待数百个周期),此数据将被加载到CPU的Cache中,这样后续就能直接从Cache上快速访问。

当某个CPU进行写操作时,他必须确保其他CPU已将此数据从他们的Cache中移除(以便保证一致性),只有在移除操作完成后,此CPU才能安全地修改数据。

显然,存在多个Cache时,必须通过一个Cache一致性协议来避免数据不一致的问题,而这个通信的过程就可能导致乱序访问的出现,也就是运行时内存乱序访问。

受篇幅所限,这里不再深入讨论整个细节,有兴趣的读者可以研究《Memory Barriers: a Hardware View for Software Hackers》这篇文章,它详细地分析了整个过程。

现在通过一个例子来直观地说明多CPU下内存乱序访问的问题:

复制
volatile int x, y, r1, r2; //thread 1 void run1() { x = 1; r1 = y; } //thread 2 void run2 { y = 1; r2 = x; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.

变量x、y、r1、r2均被初始化为0,run1和run2运行在不同的线程中。

如果run1和run2在同一个cpu下执行完成,那么就如我们所料,r1和r2的值不会同时为0,而假如run1和run2在不同的CPU下执行完成后,由于存在内存乱序访问的可能,这时r1和r2可能同时为0。我们可以使用CPU内存屏障来避免运行时内存乱序访问(x86_64):

复制
void run1() { x = 1; //CPU内存屏障,保证x=1在r1=y之前执行 __asm__ __volatile__("mfence":::"memory"); r1 = y; } //thread 2 void run2 { y = 1; //CPU内存屏障,保证y = 1在r2 = x之前执行 __asm__ __volatile__("mfence":::"memory"); r2 = x; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

2.3为什么要有内存屏障?

为了解决cpu,高速缓存,主内存带来的的指令之间的可见性和重序性问题。

我们都知道计算机运算任务需要CPU和内存相互配合共同完成,其中CPU负责逻辑计算,内存负责数据存储。CPU要与内存进行交互,如读取运算数据、存储运算结果等。由于内存和CPU的计算速度有几个数量级的差距,为了提高CPU的利用率,现代处理器结构都加入了一层读写速度尽可能接近CPU运算速度的高速缓存来作为内存与CPU之间的缓冲:将运算需要使用

的数据复制到缓存中,让CPU运算可以快速进行,计算结束后再将计算结果从缓存同步到主内存中,这样处理器就无须等待缓慢的内存读写了。就像下面这样:

图片

每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取,但是这样的弊端也很明显:不能实时的和内存发生信息交换,会使得不同CPU执行的不同线程对同一个变量的缓存值不同。用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。

复制
volatile的有序性和可见性 volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;由于内存屏障的作用,避免了volatile变量和其它指令重排序、实现了线程之间通信,使得volatile表现出了锁的特性。 重排序:代码的执行顺序不按照书写的顺序,为了提升运行效率,在不影响结果的前提下,打乱代码运行 int a=1; int b=2; int c=a+b; int c=5; 这里的int c=5这个赋值操作可能发生在int a=1这个操作之前1.2.3.4.5.6.7.8.

内存屏障的引入,本质上是由于CPU重排序指令引起的。重排序问题无时无刻不在发生,主要源自以下几种场景:

编译器编译时的优化;处理器执行时的多发射和乱序优化;读取和存储指令的优化;缓存同步顺序(导致可见性问题)。

2.4内存屏障的分类与作用

在 Linux 内核中,常见的内存屏障操作分为读屏障(Read Barriers)、写屏障(Write Barriers)和全屏障(Full Barriers)。这些屏障操作通过插入特定的汇编指令,确保内存访问按预定顺序执行。

(1)读屏障(Load Barrier)

读屏障确保在屏障之后的所有读操作不会排在屏障之前的读操作之后执行。也就是说,屏障确保了它后面的所有读取操作在屏障之前的读取操作完成之后才开始执行。

假设我们有两个变量 a 和 b,以及两个线程 Thread1 和 Thread2,代码如下:

复制
// 全局变量 int a = 0; int b = 0; // Thread1执行 a = 1; rmb(); // 读屏障 b = 2; // Thread2执行 if (b == 2) { assert(a == 1); }1.2.3.4.5.6.7.8.9.10.11.12.13.

在这个例子中,读屏障rmb()保证了在b = 2之前,a = 1的读操作已经完成。因此,当 Thread2 执行if (b == 2)时,a的值一定是 1,assert(a == 1)不会失败。

(2)写屏障(Store Barrier)

写屏障保证在屏障之前的所有写操作会在屏障之后的写操作之前执行。具体来说,屏障确保了它前面所有的写入操作在屏障指令执行之前完成。通过写屏障,内核可以强制保证 “先写后读” 或 “先写再写” 的顺序。

还是以上面的代码为例,如果我们将读屏障换成写屏障:

复制
// 全局变量 int a = 0; int b = 0; // Thread1执行 a = 1; wmb(); // 写屏障 b = 2; // Thread2执行 if (b == 2) { assert(a == 1); }1.2.3.4.5.6.7.8.9.10.11.12.13.

写屏障wmb()保证了a = 1的写操作在b = 2的写操作之前完成,并且对其他线程可见。这样,当 Thread2 执行if (b == 2)时,它能看到a已经被赋值为 1,从而assert(a == 1)不会失败。

(3)全屏障(Full Barrier)

全屏障是一种同时包含读屏障和写屏障的屏障操作,确保所有在屏障之前的读写操作都会在屏障之后的读写操作之前执行。全屏障是最严格的屏障,它禁止乱序执行。

同样的代码,使用全屏障:

复制
// 全局变量 int a = 0; int b = 0; // Thread1执行 a = 1; mb(); // 全屏障 b = 2; // Thread2执行 if (b == 2) { assert(a == 1); }1.2.3.4.5.6.7.8.9.10.11.12.13.

全屏障mb()不仅保证了a = 1的写操作在b = 2的写操作之前完成,还保证了在b = 2之前,a = 1的读操作也已经完成。这意味着,无论是读操作还是写操作,都严格按照代码顺序执行,从而最大程度地保证了多线程环境下数据的一致性和程序的正确性。

三、内存屏障核心原理

3.1编译器优化与优化屏障

在程序编译阶段,编译器为了提高代码的执行效率,会对代码进行优化,其中指令重排是一种常见的优化手段。例如,对于下面的 C 代码:

复制
int a = 1; int b = 2;1.2.

在没有数据依赖的情况下,编译器可能会将其编译成汇编代码时,交换这两条指令的顺序,先执行b = 2,再执行a = 1。在单线程环境下,这种重排通常不会影响程序的最终结果。但在多线程环境中,当多个线程共享数据时,这种重排可能会导致数据一致性问题 。

为了禁止编译器对特定指令进行重排,Linux 内核提供了优化屏障机制。在 Linux 内核中,通过barrier()宏来实现优化屏障 。barrier()宏的定义如下:

复制
#define barrier() __asm__ __volatile__("" ::: "memory")1.

__asm__表示这是一段汇编代码,__volatile__告诉编译器不要对这段代码进行优化,即不要改变其前后代码块的顺序 。"memory"表示内存中的变量值可能会发生变化,编译器不能使用寄存器中的值来优化,而应该重新从内存中加载变量的值。这样,在barrier()宏之前的指令不会被移动到barrier()宏之后,之后的指令也不会被移动到之前,从而保证了编译器层面的指令顺序。

3.2CPU 执行优化与内存屏障

现代 CPU 为了提高执行效率,采用了超标量体系结构和乱序执行技术。CPU 在执行指令时,会按照程序顺序取出一批指令,分析找出没有依赖关系的指令,发给多个独立的执行单元并行执行,最后按照程序顺序提交执行结果,即 “顺序取指令,乱序执行,顺序提交执行结果” 。

例如,当 CPU 执行指令A需要从内存中读取数据,而这个读取操作需要花费较长时间时,CPU 不会等待指令A完成,而是会继续执行后续没有数据依赖的指令B、C等,直到指令A的数据读取完成,再继续执行指令A的后续操作 。

虽然 CPU 的乱序执行可以提高执行效率,但在某些情况下,这种乱序执行可能会导致问题。比如,在多处理器系统中,一个处理器修改数据后,可能不会把数据立即同步到自己的缓存或者其他处理器的缓存,导致其他处理器不能立即看到最新的数据。为了解决这个问题,需要使用内存屏障来保证 CPU 执行指令的顺序 。

内存屏障确保在屏障原语前的指令完成后,才会启动原语之后的指令操作。在不同的 CPU 架构中,有不同的指令来实现内存屏障的功能。例如,在 X86 系统中,以下这些汇编指令可以充当内存屏障:

所有操作 I/O 端口的指令;前缀lock的指令,如lock;addl $0,0(%esp),虽然这条指令本身没有实际意义(对栈顶保存的内存地址内的内容加上 0),但lock前缀对数据总线加锁,从而使该条指令成为内存屏障;所有写控制寄存器、系统寄存器或 debug 寄存器的指令(比如,cli和sti指令,可以改变eflags寄存器的IF标志);lfence、sfence和mfence汇编指令,分别用来实现读内存屏障、写内存屏障和读 / 写内存屏障;特殊的汇编指令,比如iret指令,可以终止中断或异常处理程序。

在 ARM 系统中,则使用ldrex和strex汇编指令实现内存屏障。这些内存屏障指令能够阻止 CPU 对指令的乱序执行,确保内存操作的顺序性和可见性,从而保证多线程环境下程序的正确执行。

3.3内存屏障的工作过程

内存屏障在工作时,就像是一个严格的 “栅栏”,对内存操作进行着有序的管控。以下通过一段简单的伪代码示例,来详细描述内存屏障的工作过程:

复制
// 定义共享变量 int shared_variable1 = 0; int shared_variable2 = 0; // 线程1执行的代码 void thread1() { shared_variable1 = 1; // 操作A:对共享变量1进行写入 memory_barrier(); // 插入内存屏障 shared_variable2 = 2; // 操作B:对共享变量2进行写入 } // 线程2执行的代码 void thread2() { if (shared_variable2 == 2) { // 操作C:读取共享变量2 assert(shared_variable1 == 1); // 操作D:读取共享变量1并进行断言 } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

在上述示例中,当线程 1 执行时:

屏障前的操作:首先执行shared_variable1 = 1(操作 A),这个写入操作会按照正常的流程进行,可能会被处理器优化执行,也可能会被暂时缓存在处理器的写缓冲区或者缓存中 。此时,操作 A 可以自由执行和重排,只要最终的结果正确即可。遇到屏障:当执行到memory_barrier()内存屏障指令时,处理器会暂停执行后续指令,直到操作 A 的写入操作被完全确认完成 。这意味着,操作 A 的数据必须被写入到主内存中,并且其他处理器的缓存也需要被更新(如果涉及到缓存一致性问题),以确保数据的可见性。只有在操作 A 的所有相关内存操作都完成之后,处理器才会继续执行内存屏障后面的指令。屏障后的操作:接着执行shared_variable2 = 2(操作 B),由于内存屏障的存在,操作 B 不能提前于操作 A 完成,它必须在操作 A 完全结束之后才能开始执行 。这样就保证了操作 A 和操作 B 的执行顺序是按照代码编写的顺序进行的。

当线程 2 执行时:

先执行if (shared_variable2 == 2)(操作 C),读取共享变量 2 的值。如果此时线程 1 已经执行完内存屏障以及后续的操作 B,那么线程 2 读取到的shared_variable2的值就会是 2 。接着执行assert(shared_variable1 == 1)(操作 D),读取共享变量 1 的值并进行断言。因为内存屏障保证了线程 1 中操作 A 先于操作 B 完成,并且操作 A 的结果对其他线程可见,所以当线程 2 读取到shared_variable2为 2 时,shared_variable1的值必然已经被更新为 1,从而断言不会失败 。

通过这个例子可以看出,内存屏障就像一个坚固的 “栅栏”,将内存操作有序地分隔开来,确保了内存操作的顺序性和数据的可见性,有效地避免了多线程环境下由于指令重排序和缓存不一致等问题导致的数据错误和程序逻辑混乱 。

四、内存屏障的应用实例

4.1多线程数据共享

在多线程编程中,数据共享是常见的场景。假设我们有一个多线程程序,其中一个线程负责写入数据,另一个线程负责读取数据。代码示例如下:

复制
#include <stdio.h> #include <pthread.h> #include <stdatomic.h> // 共享变量 atomic_int shared_variable = 0; // 写线程函数 void* writer(void* arg) { for (int i = 0; i < 1000000; ++i) { shared_variable = i; // 使用写屏障,确保数据写入主内存 atomic_thread_fence(memory_order_release); } return NULL; } // 读线程函数 void* reader(void* arg) { for (int i = 0; i < 1000000; ++i) { // 使用读屏障,确保从主内存读取最新数据 atomic_thread_fence(memory_order_acquire); int value = shared_variable; // 处理读取到的数据 } return NULL; } int main() { pthread_t writer_thread, reader_thread; // 创建写线程 pthread_create(&writer_thread, NULL, writer, NULL); // 创建读线程 pthread_create(&reader_thread, NULL, reader, NULL); // 等待写线程结束 pthread_join(writer_thread, NULL); // 等待读线程结束 pthread_join(reader_thread, NULL); return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.

在这个例子中,writer线程负责向shared_variable写入数据,reader线程负责读取数据。atomic_thread_fence(memory_order_release)是一个写屏障,它确保在屏障之前对shared_variable的写操作完成后,才允许其他线程进行读操作。atomic_thread_fence(memory_order_acquire)是一个读屏障,它确保在读取shared_variable之前,所有对shared_variable的写操作都已经完成并对当前线程可见。通过使用内存屏障,我们保证了多线程环境下数据共享的一致性和正确性。

4.2双重检查锁定(DCL)

双重检查锁定(Double-Checked Locking)是一种常见的设计模式,用于在多线程环境下实现延迟初始化。其基本思想是在获取实例时,先进行一次快速检查,判断实例是否已经创建,如果未创建,则进入同步块进行二次检查并创建实例。这样可以避免每次获取实例时都进行同步操作,从而提高性能。然而,在没有正确使用内存屏障的情况下,双重检查锁定可能会出现问题。

以 C++语言为例,以下是一个错误的双重检查锁定实现:

复制
#include <mutex> class Singleton { private: static Singleton* instance; // 缺少适当的内存可见性保证 static std::mutex mtx; Singleton() {} // 私有构造函数 public: static Singleton* getInstance() { if (instance == nullptr) { // 第一次检查(无锁) std::lock_guard<std::mutex> lock(mtx); // 加锁 if (instance == nullptr) { // 第二次检查(持有锁) instance = new Singleton(); // 问题所在 } } return instance; } }; // 静态成员初始化 Singleton* Singleton::instance = nullptr; std::mutex Singleton::mtx;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.

错误原因分析:

①指令重排序问题:instance = new Singleton() 可分解为三步:

分配内存构造对象(初始化)将指针指向指向内存地址

在缺乏内存屏障的情况下,编译器或 CPU 可能重排序后两步,导致其他线程在对象未完全构造时就看到 instance 非空并尝试使用,引发未定义行为。

②内存可见性问题:多个线程可能看不到其他线程对 instance 的修改,因为缺少强制内存同步的机制。

C++11 及以上的正确实现:需结合 std::atomic 确保内存可见性和禁止重排序

复制
#include <mutex> #include <atomic> class Singleton { private: static std::atomic<Singleton*> instance; // 原子变量保证可见性 static std::mutex mtx; Singleton() {} public: static Singleton* getInstance() { Singleton* temp = instance.load(std::memory_order_acquire); // 原子加载 if (temp == nullptr) { std::lock_guard<std::mutex> lock(mtx); temp = instance.load(std::memory_order_relaxed); if (temp == nullptr) { temp = new Singleton(); instance.store(temp, std::memory_order_release); // 原子存储 } } return temp; } }; // 静态成员初始化 std::atomic<Singleton*> Singleton::instance(nullptr); std::mutex Singleton::mtx;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.

std::atomic 的内存序(memory_order_acquire/release)确保了:

其他线程能看到 instance 的最新值禁止 new 操作的指令重排序,保证对象完全构造后才被其他线程可见

4.3缓存一致性

在缓存一致性场景中,内存屏障可以保证各处理器缓存数据的一致。在一个多处理器系统中,每个处理器都有自己的缓存,当多个处理器同时访问共享数据时,可能会出现缓存不一致的问题 。例如,处理器 A 修改了共享变量x的值,并将其缓存起来,此时处理器 B 的缓存中x的值还是旧的 。如果没有内存屏障的控制,处理器 B 在读取x时,可能会从自己的缓存中读取到旧值,而不是处理器 A 修改后的新值 。

复制
// 共享变量 int x = 0; // 处理器A执行的代码 void processorA() { x = 1; // 修改共享变量x的值 // 插入全屏障mfence(),确保缓存一致性 } // 处理器B执行的代码 void processorB() { // 插入全屏障mfence(),确保读取到最新数据 assert(x == 1); // 读取共享变量x的值并进行断言 }1.2.3.4.5.6.7.8.9.10.11.12.13.14.

在这个例子中,处理器 A 在修改共享变量x的值后,通过插入全屏障mfence(),将修改后的数据写回主内存,并通知其他处理器更新它们的缓存 。处理器 B 在读取x的值之前,也插入全屏障mfence(),确保从主内存中读取到最新的数据,从而保证了各处理器缓存数据的一致性 。内存屏障通过与缓存一致性协议(如 MESI 协议)协同工作,有效地解决了缓存不一致的问题,确保了多处理器系统中数据的正确性和可靠性 。

五、使用注意事项与性能考量

5.1避免过度使用

虽然内存屏障是解决多线程环境下内存一致性问题的有力工具,但过度使用会对系统性能产生负面影响 。内存屏障会阻止 CPU 和编译器对指令进行重排序,这在一定程度上限制了它们的优化能力,从而增加了指令执行的时间 。在一些不必要的场景中使用内存屏障,会导致性能下降 。

例如,在单线程环境中,由于不存在多线程并发访问共享数据的问题,使用内存屏障是完全没有必要的,这只会浪费系统资源 。在多线程环境中,如果共享数据的访问没有数据竞争问题,也不应随意使用内存屏障 。比如,在一个多线程程序中,多个线程只是读取共享数据,而不进行写操作,此时使用内存屏障并不能带来任何好处,反而会降低性能 。因此,在使用内存屏障时,需要仔细分析代码的执行逻辑和数据访问模式,确保只在必要的地方使用内存屏障,以避免不必要的性能损失 。

5.2选择合适的屏障类型

不同类型的内存屏障在功能和适用场景上有所不同,因此根据具体的场景选择合适的内存屏障类型至关重要 。如果只需要保证读操作的顺序,那么使用读内存屏障(rmb)即可;如果只需要保证写操作的顺序,使用写内存屏障(wmb)就足够了 。在一些复杂的场景中,可能需要同时保证读写操作的顺序,这时就需要使用通用内存屏障(mb)或读写内存屏障 。

例如,在一个多线程程序中,线程 A 需要先读取共享变量x,再读取共享变量y,并且要求这两个读操作按照顺序进行,此时就可以在读取x和y之间插入读内存屏障 。如果线程 A 需要先写入共享变量x,再写入共享变量y,并且要求其他线程能够按照这个顺序看到更新后的值,那么就应该在写入x和y之间插入写内存屏障 。在一些涉及复杂数据结构读写的场景中,可能需要使用通用内存屏障来保证读写操作的顺序 。

比如,在一个多线程程序中,线程 A 需要先写入数据到共享链表,然后读取链表中的其他部分,线程 B 则需要先读取线程 A 写入的数据,然后再写入新的数据,这种情况下就可以使用通用内存屏障来确保线程 A 和线程 B 的读写操作按照预期的顺序进行 。因此,在使用内存屏障时,需要根据具体的场景和需求,选择合适的内存屏障类型,以充分发挥内存屏障的作用,同时避免不必要的性能开销 。

5.3性能监测与优化

为了确保内存屏障的使用不会对系统性能造成过大的影响,使用工具监测内存屏障对性能的影响,并根据监测结果进行优化是很有必要的 。在 Linux 系统中,可以使用 perf 工具来监测内存屏障对性能的影响 。perf 是一个性能分析工具,它可以收集系统的性能数据,包括CPU使用率、内存访问次数等 。通过使用perf 工具,可以了解内存屏障的使用对系统性能的影响,从而找到性能瓶颈,并进行优化 。

例如,可以使用 perf record 命令来收集性能数据,然后使用 perf report 命令来查看性能报告 。在性能报告中,可以看到各个函数的 CPU 使用率、内存访问次数等信息,从而找到内存屏障使用较多的函数,并分析其对性能的影响 。如果发现某个函数中内存屏障的使用导致了性能下降,可以尝试优化该函数的代码,减少内存屏障的使用,或者选择更合适的内存屏障类型 。

除了使用 perf 工具外,还可以通过代码优化、算法改进等方式来提高系统性能 。例如,可以减少不必要的内存访问,优化数据结构,提高代码的并行性等 。通过综合使用这些方法,可以有效地提高系统性能,确保内存屏障的使用不会对系统性能造成过大的影响 。

THE END