解锁Linux内存屏障:让程序运行更有序

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

在当今的计算机世界里,多核心处理器已成为主流,无论是日常办公的电脑,还是数据中心的超级服务器,它们内部的多个核心都在同时忙碌地工作着,力求高效地处理各种任务。在 Linux 系统中,这些核心共同协作,为无数的应用程序提供运行支撑。然而,这看似和谐的运行背后,实则隐藏着一个棘手的问题 —— 内存访问的混乱。

你或许想象不到,在处理器内部,为了提升性能,CPU 常常会对指令进行乱序执行。与此同时,每个核心都配备了自己的高速缓存,数据在缓存与主内存之间频繁穿梭,这就导致了不同核心对内存数据的访问顺序和时机变得难以捉摸。在单线程环境下,这种乱序执行或许不会引发明显问题,但一旦进入多线程或多处理器协同工作的场景,问题就会接踵而至,数据不一致、程序运行结果与预期相悖等情况屡见不鲜,严重影响了程序的正确性和稳定性。

而Linux内存屏障,正是为解决这一系列问题而生的关键技术。它宛如一位公正的秩序维护者,巧妙地介入 CPU 与内存之间,通过特殊的指令或编译器辅助手段,强制规定内存操作的先后顺序,让各个核心在访问内存时能够 “井然有序”,从而确保程序按照开发者预期的方式运行。接下来,就让我们一同深入探索 Linux 内存屏障的奥秘,揭开它为程序运行保驾护航的神秘面纱 。

一、内存屏障简介

1.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,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

1.2不同场景内存屏障

(1)java内存屏障

  • java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

(2)volatile语义中的内存屏障

  • volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
  • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
  • 由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

(3)final语义中的内存屏障

对于final域,编译器和CPU会遵循两个排序规则:

新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(意思就是先赋值引用,再调用final值)
  • 1.
  • 2.

总之上面规则的意思可以这样理解,必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:

  • 写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
  • 读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。
  • X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。

二、为什么需要内存屏障?

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

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

2.1内存屏障出现的背景(内存乱序是怎么出现的?)

早期的处理器为有序处理器(In-order processors),有序处理器处理指令通常有以下几步:

  • 指令获取
  • 如果指令的输入操作对象(input operands)可用(例如已经在寄存器中了),则将此指令分发到适当的功能单元中。如果一个或者多个操 作对象不可用(通常是由于需要从内存中获取),则处理器会等待直到它们可用
  • 指令被适当的功能单元执行
  • 功能单元将结果写回寄存器堆(Register file,一个 CPU 中的一组寄存器)

相比之下,乱序处理器(Out-of-order processors),处理指令通常有以下几步:

  • 指令获取
  • 指令被分发到指令队列(Invalidate Queues,后面会讲到)
  • 指令在指令队列中等待,直到输入操作对象可用(一旦输入操作对象可用,指令就可以离开队列,即便更早的指令未被执行)
  • 指令被分配到适当的功能单元并执行
  • 执行结果被放入队列(放入到store buffer中,而不是直接写到cache中,后面也会讲到)
  • 只有所有更早请求执行的指令的执行结果被写入cache后,指令执行的结果才被写入cache(执行结果重排序,让执行看起来是有序的)

已经了解了cache的同学应该可以知道,如果CPU需要读取的地址中的数据已经已经缓存在了cache line中,即使是cpu需要对这个地址重复进行读写,对CPU性能影响也不大,但是一旦发生了cache miss(对这个地址进行第一次写操作),如果是有序处理器,CPU在从其他CPU获取数据或者直接与主存进行数据交互的时候需要等待不可用的操作对象,这样就会非常慢,非常影响性能。举个例子:

如果CPU0发起一次对某个地址的写操作,但是其local cache中没有数据,这个数据存放在CPU1的local cache中。为了完成这次操作,CPU0会发出一个invalidate的信号,使其他CPU的cache数据无效(因为CPU0需要重新写这个地址中的值,说明这个地址中的值将被改变,如果不把其他CPU中存放的该地址的值无效,那么就有可能会出现数据不一致的问题)。只有当其他之前就已经存放了改地址数据的CPU中的值都无效了后,CPU0才能真正发起写操作。需要等待非常长的时间,这就导致了性能上的损耗。

但是乱序处理器山就不需要等待不可用的操作对象,直接把invalidate message放到invalidate queues中,然后继续干其他事情,提高了CPU的性能,但也带来了一个问题,就是程序执行过程中,可能会由于乱序处理器的处理方式导致内存乱序,程序运行结果不符合我们预期的问题。

2.2理解内存屏障

不少开发者并不理解一个事实 — 程序实际运行时很可能并不完全按照开发者编写的顺序访问内存。例如:

x = r;
y = 1;
  • 1.
  • 2.

这里,y = 1很可能先于x = r执行。这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。编译器和CPU都可能引起内存乱序访问:

  • 编译时,编译器优化进行指令重排而导致内存乱序访问;
  • 运行时,多CPU间交互引入内存乱序访问。

编译器和CPU引入内存乱序访问通常不会带来什么问题,但在一些特殊情况下(主要是多线程程序中),逻辑的正确性依赖于内存访问顺序,这时,内存乱序访问会带来逻辑上的错误,例如:

// thread 1
while(!ok);
do(x);
 
// thread 2
x = 42;
ok = 1;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

Ok初始化为0, 线程1等待ok被设置为1后执行do函数。假如,线程2对内存的写操作乱序执行,也就是x赋值晚于ok赋值完成,那么do函数接受的实参很有可能出乎开发者的意料,不为42。我们可以引入内存屏障来避免上述问题的出现。内存屏障能让CPU或者编译器在内存访问上有序。一个内存屏障之前的内存访问操作必定先于其之后的完成。

三、为什么要有内存屏障?

为了解决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重排序指令引起的。重排序问题无时无刻不在发生,主要源自以下几种场景:

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

3.1编译器优化

编译器在不改变单线程程序语义的前提下,也就是保证单线程程序执行结果正确的情况下,可以重新安排语句的执行顺序。编译器在优化的时候是不知道当前程序是在哪个线程中执行的,因此它只能保证单线程的正确性。

例如,有如下程序:

if (a)
    b = a;
else
    b = 42;
  • 1.
  • 2.
  • 3.
  • 4.

在经过编译器优化后可能变成:

b = 42;
if (a)
    b = a;
  • 1.
  • 2.
  • 3.

这种优化在单线程下是没有问题的,但是如果有另外一个线程要读取变量b的值时,有可能会有问题。前面的程序只有当变量a的值为0时,才会将b赋值42,后面的程序无论变量a的值是多少,都有一段时间会将b赋值为42。

造成这个问题的原因是,编译器优化的时候只注重“结果”,不注重“过程”。这种优化在单线程程序中没有问题,代码一直都是自己运行,只要结果对就可以了,但是在多线程程序下,代码执行过程中的某些状态可能会对别的线程产生影响,这个是编译器优化无法考虑到的。

3.2处理器执行时的多发射和乱序优化

现代处理器基本上都是支持多发射的,也就是在一个指令周期内可以同时执行多条指令。但是,处理器的资源就那么多,可能不能同时满足处理这些指令的要求。比如,处理器就只有一个加法器,如果同时有两条指令都需要算加法,那么有一条指令必须等待。如果这时候再下一条指令是读取指令,并且和前两条指令无关,那么这条指令将在前面某条加法指令之前完成。还有一种可能,就是前后指令之间具有相关性,比如对同一个地址先读取再写入,后面的写入操作必须等待前面的读取操作完成后才能执行。但是如果这时候第三条指令是写入一个无关的地址,那它可以在前面的写入操作之前被执行,执行顺序再次被打乱了。

所以,一般情况下指令乱序并不是CPU在执行指令之前刻意去调整顺序。CPU总是顺序的去内存里面取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终不是按照放入的顺序执行完成,在外边看起来仿佛是“乱序”一样,这就是所谓的“顺序流入,乱序流出”。

3.3读取和存储指令的优化

CPU有可能根据情况,将相临的两条读取或写入操作合并成一条。

例如,对于如下的两条读取操作:

X = *A; Y = *(A + 4);
  • 1.

可能被合并成一条读取操作:

{X, Y} = LOAD {*A, *(A + 4) };
  • 1.

同样的,对于如下两条写入操作:

*A = X; *(A + 4) = Y;
  • 1.

有可能会被合并成一条:

STORE {*A, *(A + 4) } = {X, Y};
  • 1.

以上这几种情况,由于编译器或CPU,出于“优化”的目的,按照某种规则将指令重新排序的行为称作“真”重排序。不同的是,编译器重排序是在编译程序时进行的,一旦编译成功后执行次序就定下来了。而后面几种是在CPU运行程序时实时进行的,CPU架构不同可能起到的效果完全不同。

编译器或CPU在执行各种优化的时候,都有一些必须的前提,就是至少在单一CPU上执行不能出现问题。有一些数据访问明显是相互依赖的,就不能打乱它们的执行顺序。比如:

1)在一个给定的CPU上,有依赖的内存访问:

比如如下两条指令:

A = Load B;
C = Load *A
  • 1.
  • 2.

第二条加载指令的地址是由第一条指令加载的,第二条指令要能正确执行,必须要等到第一条指令执行完成后才行,也就是说第二条指令依赖于第一条指令。这种情况下,无论如何处理器是不会打乱这两条指令的执行次序的。不过,有可能会在这两条指令间插入别的指令,但必须保证第二条指令在第一条指令执行完后才能执行。

2)在一个给定的CPU上,交叉的加载和存储操作,它们访问的内存地址有重叠:

例如,先存储后加载同一个内存地址上的内容:

Store *X = A;
B = Load *X;
  • 1.
  • 2.

或者先加载后读取同一个内存地址上的内容:

A = Load *X;
Store *X = B;
  • 1.
  • 2.

对同一个内存地址的存取,如果两条指令执行次序被打乱了,那肯定会发生错误。但是,如果是两条加载或两条存储指令(中间没有加载),哪怕是对同一个内存地址的操作,也可能由于优化产生变化。

有了上面两条限制,很容易想到,那如果所有加载或存储指令都没有相关性呢?这时候就要看CPU的心情了,可以以任何次序被执行,可以完全不按照它们在程序中出现的次序。

3.4缓存同步顺序

上面的几种情况都比较好理解,最诡异的是所谓的缓存同步顺序的问题。要把这个问题说清楚首先要说一下缓存是什么。

现代CPU的运算速度比现代内存系统的速度快得多,它们的速度差了几个数量级,那怎么办呢?硬件设计者想到了在内存和CPU之间加入一个速度足够快,但空间不是很大的存储空间,这个就是所谓的缓存。缓存的速度足够快,但是它一般是某个或某些CPU核独享的,而不像计算机的主存,一般认为是系统中所有CPU共享的。

图片图片

一旦引入了缓存,就会引入多个地方存放同一个数据的问题,就有可能出现数据不一致的问题。假设变量X所在内存同时被两个CPU都缓存了,但是这时候CPU0对变量X的值做出了修改,这之后CPU1如果试图读取变量X的值时,其实读到的是老的值。

这个时候就需要所谓的缓存一致性协议了,一般常用的是MESI协议。MESI代表“Modified”、“Exclusive”、“Shared”和“Invalid”四种状态的缩写,特定缓存行可以处在该协议采用的这四种状态上:

  1. 处于“Modified”状态的缓存行:当前CPU已经对缓存行的数据进行了修改,但是该缓存行的内容并没有在其它CPU的缓存中出现。因此,处于该状态的缓存行可以认为被当前CPU所“拥有”。这就是所谓的“脏”行,它的内容和内存中的内容不一样。由于只有当前CPU的缓存持有最新的数据,因此要么将“脏”数据写回到内存,要么将该数据“转移”给其它缓存。
  2. 处于“Exclusive”状态的缓存行:该状态非常类似于“Modified”状态,缓存的内容确保没有在其它CPU的缓存中出现。唯一的差别是,该缓存行还没有被当前的CPU修改,也就是说缓存行内容和内存中的是一样,是对内存数据的最新复制。但是,由于当前CPU能够在任何时刻将数据存储到该缓存行而不考虑其它CPU,因此处于“Exclusive”状态的缓存行也可以认为被当前CPU所“拥有”。
  3. 处于“Shared”状态的缓存行:表示缓存行的数据和主存中的一样,并且可能已经被复制到至少一个其它CPU的缓存中。但是,在没有得到其他CPU“许可”的情况下,任何CPU不能向该缓存行存储数据。与“Exclusive”状态相同,由于内存中的值是最新的,因此当需要丢弃该缓存行时,可以不用向内存回写。
  4. 处于“Invalid”状态的缓存行:表示该缓存行已经失效了,不能再被继续使用了。当有新数据进入缓存时,它可以直接放置到一个处于“Invalid”状态的缓存行上,不需要做其它的任何处理。

为了维护这个状态机,需要各个CPU之间进行通信,会引入下面几种类型的消息:

  1. 读消息:该消息包含要读取的缓存行的物理地址。
  2. 读响应消息:该消息包含较早前的读消息所请求的数据,这个读响应消息要么由物理内存提供,要么由某一个其它CPU上的缓存提供。例如,如果某一个CPU上的缓存拥有处于“Modified”状态的目标数据,那么该CPU上的缓存必须提供读响应消息。
  3. 使无效消息:该消息包含要使无效的缓存行的物理地址,所有其它CPU上的缓存必须移除相应的数据并且响应此消息。
  4. 使无效应答消息:一个接收到使无效消息的CPU必须在移除指定数据后响应一个使无效应答消息。
  5. 读使无效消息:该消息包含要被读取的缓存行的物理地址,同时指示其它CPU上的缓存移除对应的数据。因此,正如名字所示,它将读消息和使无效消息合并成了一条消息。读使无效消息同时需要一个读响应消息及一组使无效应答消息进行应答。
  6. 写回消息:该包含要回写到物理内存的地址和数据。这个消息允许缓存在必要时换出处于“Modified”状态的数据,以便为其它数据腾出空间。

通过上面的介绍可以看到,MESI缓存一致性协议可以保证系统中的各个CPU核上的缓存都是一致的。但是也带来了一个很大的问题,由于所有的操作都是“同步”的,必须要等待远端CPU完成指定操作后收到响应消息才能真正执行对应的存储或加载操作,这样会极大降低系统的性能。比如说,如果CPU0和CPU1上同时缓存了同一段数据,如果CPU0想对其进行更改,那么必须先发送使无效消息给CPU1,等到CPU1真的将该缓存的数据段标记成“Invalid”状态后,会向CPU0发送使无效应答消息,理论上只有CPU0收到这个消息后,才可以真的更改数据。但是,从要更改到真的能更改已经经过了好几个阶段了,这时CPU0只能等在那里。

鱼和熊掌都兼得是不可能的,想提高性能,只能稍微放松一下对缓存一致性的要求。

具体的,会引入如下两个模块:

存储缓冲:前面提到过,在写数据之前我们先要得到缓存段的独占权,如果当前CPU没有独占权,要先让系统中别的CPU上缓存的同一段数据都变成无效状态。为了提高性能,可以引入一个叫做存储缓冲(Store Buffer)的模块,将其放置在每个CPU和它的缓存之间。当前CPU发起写操作,如果发现没有独占权,可以先将要写入的数据放在存储缓冲中,并继续运行,仿佛独占权瞬间就得到了一样。当然,存储缓冲中的数据最后还是会被同步到缓存中的,但就相当于是异步执行了,不会让CPU等了。并且,当前CPU在读取数据的时候应该首先检查其是否存在于存储缓冲中。

无效队列:如果当前CPU上收到一条消息,要使某个缓存段失效,但是此时缓存正在处理其它事情,那这个消息可能无法在当前的指令周期中得到处理,而会将其放入所谓的无效队列(Invalidation Queue)中,同时立即发送使无效应答消息。那个待处理的使无效消息将保存在队列中,直到缓存有空为止。

图片图片

加入了这两个模块之后,CPU的性能是提高了,但缓存一致性就遭到了一定程度的破坏。假设变量X所在内存同时被两个CPU都缓存了,但是这时候CPU0对变量X的值做出了修改,这之后CPU1如果试图读取变量X的值时,有可能读到的是老的值,当然也有可能读到的是新的值。但是,在经过一段不确定的时间后,CPU1一定是可以读到变量X新的值,可以理解为满足所谓的最终一致性。

但这只是对单个变量来说的,如果程序中有多个变量,那么在其它CPU看来,它们之间的读写次序将完全无法得到保证。

假设有CPU0上要执行对三个变量的写操作:

Store A = 1;
Store B = 2;
Store C = 3;
  • 1.
  • 2.
  • 3.

但是,这三个变量在缓存中的状态不一样,假设A变量和B变量在CPU0和CPU1中的缓存都存在,也就是处于“Shared”状态,而C变量是CPU0独占的,也就是处于“Exclusive”状态。假设系统经历了如下几个步骤:

在对变量A和B赋值时,CPU0发现其实别的CPU也缓存了,因此会将它们临时放到存储缓冲中。

在对变量C赋值时,CPU0发现是独占的,那么可以直接修改缓存的值,该缓存行的状态被切换成了“Modified”。注意,这个时候,如果在CPU1上执行了读取变量C的操作,其实已经可以读到变量C的最新值了,CPU1发送读消息,CPU0发送读响应消息,包含最新的数据,同时将缓存行的状态都切换成“Shared”。但是,如果这个时候如果CPU1尝试读取变量A或者变量B的数据,将会获得老的数据,应为CPU1上对应变量A和B的缓存行的状态仍然是“Shared”。

CPU0开始处理对应变量A和B的存储缓冲,将它们更新进缓存,但之前必须要向CPU1发送使无效消息。这里再次假设变量A的缓存正忙,而变量B的可以立即处理。那么变量A的使无效消息将存放在CPU1的无效队列中,而变量B的缓存行已经失效。这时,如果CPU1尝试获得变量B,是可以获得最新的数据的,而变量A还是不行。

CPU1对应变量A的缓存已经空闲了,可以处理当前无效队列的请求,因此变量A对应的缓存行将失效。直到这时CPU1才可以真正的读到变量A的最新值。

通过以上的步骤可以看到,虽然在CPU0上是先对变量A赋值,接着对B赋值,最后对C赋值,但是在CPU1上“看到”的顺序刚好是相反的,先“看到”C,接着“看到”B,最后看到“C”。在CPU1上会产生一种错觉,方式CPU0是先对C赋值,再对B赋值,最后对A赋值一样。这种由于缓存同步顺序的问题,让程序看起来好像指令被重排序了的情况,称作“伪”重排序。

四、内存屏障的类型

在 Linux 系统中,根据其作用和功能,内存屏障主要分为以下三种类型:

4.1全屏障(Full Barrier)

全屏障,也称作强内存屏障 ,它的功能最为强大。全屏障可以阻止屏障两边的读写操作进行重排序,确保在屏障之前的所有读写操作,都在屏障之后的读写操作之前完成。在 x86 架构中,全屏障的实现指令是mfence 。当 CPU 执行到mfence指令时,会将之前所有的存储和加载操作都按顺序完成,才会继续执行后面的指令。例如:

// 线程1
x = 1;  // 写操作1
mfence();  // 全屏障
y = 2;  // 写操作2

// 线程2
if (y == 2) {  // 读操作1
    assert(x == 1);  // 读操作2
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

在这个例子中,由于mfence全屏障的存在,线程 1 中x = 1的写操作一定会在线程 2 读取y的值之前完成,从而保证了线程 2 在读取y为 2 时,x的值也已经被正确地更新为 1,避免了由于指令重排序导致的数据不一致问题。全屏障在需要严格保证内存操作顺序的场景中非常有用,比如在实现一些关键的同步机制或者对共享资源的复杂操作时。

4.2读取屏障(Read/Load Barrier)

读取屏障的作用是确保在该屏障之前的所有读取操作,必须在该屏障之后的读取操作之前完成 。它主要用于控制读取操作的顺序,防止读取操作的重排序。在 x86 架构中,读取屏障对应的指令是lfence 。例如:

// 线程1
int a = shared_variable1;  // 读操作A
lfence();  // 读取屏障
int b = shared_variable2;  // 读操作B
  • 1.
  • 2.
  • 3.
  • 4.

在上述代码中,lfence读取屏障保证了读操作 A 一定会在读操作 B 之前完成。即使处理器可能有优化策略,也不能将读操作 B 提前到读操作 A 之前执行。读取屏障在多线程环境中,当读取操作的顺序对程序逻辑有重要影响时非常关键。比如在一些依赖于特定读取顺序的算法实现中,或者在读取共享状态变量时,为了确保获取到正确的状态信息,就需要使用读取屏障来保证读取操作的顺序性 。

4.3写入屏障(Write/Store Barrier)

一个写内存屏障可以提供这样的保证,站在系统中的其它组件的角度来看,在屏障之前的写操作看起来将在屏障后的写操作之前发生。

如果映射到上面的例子来说,首先,写内存屏障会对处理器指令重排序做出一些限制,也就是在写内存屏障之前的写入指令一定不会被重排序到写内存屏障之后的写入指令之后。其次,在执行写内存屏障之后的写入指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,将它们全部“提交”到缓存中。这样的话系统中的其它组件(包括别的CPU),就可以保证在看到写内存屏障之后的写入数据之前先看到写内存屏障之前的写入数据。

图片图片

写入屏障用于确保在该屏障之前的所有写入操作,必须在该屏障之后的写入操作之前完成 。它主要关注写入操作的顺序,防止写入操作的重排序。在 x86 架构中,写入屏障的指令是sfence 。例如:

// 线程1
shared_variable1 = 10;  // 写操作C
sfence();  // 写入屏障
shared_variable2 = 20;  // 写操作D
  • 1.
  • 2.
  • 3.
  • 4.

这里,sfence写入屏障确保了写操作 C 一定会在写操作 D 之前完成。无论编译器如何优化或者处理器如何执行指令,都不会改变这两个写操作的顺序。写入屏障在多线程同时修改共享数据时非常重要,它可以保证数据的更新按照预期的顺序进行,避免由于写入顺序混乱导致的数据不一致问题。比如在更新一些关联的共享变量时,使用写入屏障可以确保先更新的变量对其他线程可见后,再进行后续变量的更新 。

五、内存屏障的工作过程

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

// 定义共享变量
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 执行时:

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

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

六、内存屏障的实现原理

6.1存储器一致性模型

存储器一致性模型是处理器设计者定义的一种规则,用于描述处理器对内存操作的可见性和顺序性 。它分为强一致性模型和弱一致性模型,不同的模型对内存屏障的必要性和类型有着重要影响。

在强一致性模型下,处理器严格按照程序代码的指令次序来执行所有的存储(Store)与加载(Load)指令 ,并且从其他处理器和内存的角度来看,感知到的数据变化也完全是按照指令执行的次序。这就好比在一个非常有序的队列中,每个人都严格按照排队顺序依次进行操作,不存在插队或者提前操作的情况。在这种模型下,内存操作的顺序是非常明确的,程序不需要使用内存屏障来保证内存操作的正确性,因为处理器已经天然地保证了这一点。然而,这种模型虽然简单直观,但由于对处理器的限制较多,会在一定程度上影响处理器的性能。

弱一致性模型则相对宽松一些,它允许处理器对某些指令组合进行重排序 ,以提高处理器的执行效率。例如,在弱一致性模型中,可能会出现存储 - 加载(Store - Load)指令重排序的情况,即如果第一条指令是存储指令,第二条指令是加载指令,那么在程序执行时,加载指令可能会先于存储指令执行。这种重排序在单线程环境下通常不会产生问题,因为单线程环境下程序的执行顺序和结果是可预测的。但在多线程环境中,这种重排序可能会导致数据不一致的问题。

比如,一个线程对共享变量进行了修改(存储操作),但由于重排序,另一个线程可能在这个修改还未完成时就读取了这个变量(加载操作),从而获取到旧的数据。为了解决弱一致性模型下多线程环境中的数据一致性问题,就需要使用内存屏障。不同类型的内存屏障可以针对不同的指令重排序情况进行约束,确保内存操作的顺序性和数据的可见性。例如,在 x86 架构采用的完全存储定序(TSO)模型下,允许 Store - Load 指令重排序,为了保证程序执行的正确性,就需要在适当的位置插入内存屏障指令,如mfence、lfence、sfence等 ,来确保在加载操作之前,所有的存储操作都已经完成并对其他处理器可见。

6.2缓存一致性协议

在多核心处理器中,每个核心都有自己的高速缓存(如 L1、L2、L3 缓存) ,这些缓存可以大大提高处理器访问数据的速度。但也带来了缓存一致性的问题,即如何保证多个核心缓存中的数据与主内存以及其他核心缓存中的数据保持一致。MESI 协议是一种广泛应用的缓存一致性协议,它通过对缓存行(Cache Line,通常为 64 字节)的状态标记来实现缓存一致性。

MESI 协议中,缓存行有四种状态:

  1. 已修改(Modified,M):表示缓存行中的数据已经被修改,并且与主内存中的数据不一致 。此时,该缓存行只存在于当前核心的缓存中,其他核心的缓存中没有该缓存行的副本。在数据被写回主内存之前,其他核心如果需要读取该数据,会收到一个无效信号,然后从主内存中读取最新的数据。
  2. 独占(Exclusive,E):表示缓存行中的数据与主内存中的数据一致,并且只存在于当前核心的缓存中 ,其他核心的缓存中没有该缓存行的副本。在这种状态下,如果当前核心对缓存行中的数据进行修改,缓存行状态会变为已修改(M);如果其他核心请求读取该数据,缓存行状态会变为共享(S)。
  3. 共享(Shared,S):表示缓存行中的数据与主内存中的数据一致,并且存在于多个核心的缓存中 。当一个核心修改共享状态的缓存行时,会向总线上发送一个无效信号,通知其他核心将该缓存行的状态标记为无效(Invalid,I),然后自己将缓存行状态变为已修改(M)。这样可以保证在同一时刻,只有一个核心能够修改共享数据,从而维护数据的一致性。
  4. 无效(Invalid,I):表示缓存行中的数据已经无效,不能再被使用 。当一个核心收到其他核心发送的无效信号时,会将自己缓存中对应的缓存行状态标记为无效。

内存屏障在 MESI 协议中起着关键的作用。当处理器执行内存屏障指令时,它会强制完成所有内存写入操作,确保在屏障前的所有内存操作都能在屏障之后被其他执行上下文(线程或处理器)看到 。例如,在一个多核心处理器系统中,当一个核心执行写入屏障指令时,它会确保之前的所有写入操作都已经完成,并且将修改后的数据写回主内存,同时通过 MESI 协议通知其他核心更新它们的缓存,使其他核心缓存中的相应数据也变为最新状态。这样,当其他核心执行读取操作时,就能获取到最新的数据,从而保证了缓存一致性。

6.3指令序列

内存屏障通常通过特殊指令序列来实现,这些指令会强制CPU等待所有内存操作完成,从而确保内存操作的顺序性。以x86架构为例,常见的内存屏障指令有mfence(全屏障)、lfence(读取屏障)和sfence(写入屏障) 。

mfence指令是全屏障指令,它会阻止屏障两边的读写操作进行重排序 。当 CPU 执行到mfence指令时,会将之前所有的存储和加载操作都按顺序完成,才会继续执行后面的指令。例如:

// 线程1
x = 1;  // 写操作1
mfence();  // 全屏障
y = 2;  // 写操作2
  • 1.
  • 2.
  • 3.
  • 4.

在这个例子中,mfence指令保证了写操作 1(x = 1)一定会在写操作 2(y = 2)之前完成,即使处理器可能有优化策略,也不能改变这两个写操作的执行顺序。

lfence指令是读取屏障指令,它确保在该屏障之前的所有读取操作,必须在该屏障之后的读取操作之前完成 。例如:

// 线程1
int a = shared_variable1;  // 读操作A
lfence();  // 读取屏障
int b = shared_variable2;  // 读操作B
  • 1.
  • 2.
  • 3.
  • 4.

这里,lfence指令保证了读操作 A 一定会在读操作 B 之前完成,防止了读取操作的重排序。

sfence指令是写入屏障指令,它确保在该屏障之前的所有写入操作,必须在该屏障之后的写入操作之前完成 。例如:

// 线程1
shared_variable1 = 10;  // 写操作C
sfence();  // 写入屏障
shared_variable2 = 20;  // 写操作D
  • 1.
  • 2.
  • 3.
  • 4.

在这个例子中,sfence指令保证了写操作 C 一定会在写操作 D 之前完成,避免了写入操作的重排序。

这些内存屏障指令通过特殊的指令序列,利用 CPU 的硬件特性,实现了对内存操作顺序的控制,从而有效地解决了多线程和多处理器环境下的内存一致性问题 。

七、内存屏障的应用场景

7.1多线程编程

在多线程编程中,内存屏障起着至关重要的作用,它能够确保线程间数据的一致性和可见性。假设有两个线程共享变量x和y,初始值都为 0,如下所示:

// 共享变量
int x = 0;
int y = 0;

// 线程1
void* thread1(void* arg) {
    x = 1;  // 写操作1
    // 这里插入内存屏障,假设为全屏障mfence()
    y = 2;  // 写操作2
    return NULL;
}

// 线程2
void* thread2(void* arg) {
    if (y == 2) {  // 读操作1
        // 这里可以根据需要插入内存屏障,假设为读取屏障lfence()
        assert(x == 1);  // 读操作2
    }
    return NULL;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

在这个例子中,如果没有内存屏障,线程 1 中的写操作 1 和写操作 2 可能会被重排序,导致线程 2 在读取y为 2 时,x的值还未被更新为 1,从而使断言失败 。通过插入内存屏障,如线程 1 中的全屏障mfence(),可以确保写操作 1 先于写操作 2 完成,并且写操作 1 的结果对其他线程可见 。线程 2 中的读取屏障lfence()可以确保在读取x之前,先读取到y为 2 时,x的值已经被正确更新为 1,从而保证了线程间数据的一致性和可见性,避免了由于指令重排序导致的错误 。

7.2内存共享

在内存共享场景中,比如多个处理器同时访问共享内存,内存屏障能够确保各个处理器按照正确的顺序访问内存。以一个简单的生产者 - 消费者模型为例,假设有两个处理器,一个作为生产者,一个作为消费者,共享一个内存缓冲区和一个标志位flag :

// 共享内存缓冲区
int buffer = 0;
// 标志位,用于指示缓冲区是否有数据
int flag = 0;

// 生产者处理器执行的代码
void producer() {
    buffer = 10;  // 向缓冲区写入数据
    // 插入写入屏障sfence()
    flag = 1;  // 设置标志位,表示缓冲区有数据
}

// 消费者处理器执行的代码
void consumer() {
    if (flag == 1) {  // 检查标志位
        // 插入读取屏障lfence()
        assert(buffer == 10);  // 读取缓冲区数据并进行断言
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

在这个例子中,生产者处理器在向缓冲区写入数据后,通过插入写入屏障sfence(),确保写操作完成并对其他处理器可见后,再设置标志位 。消费者处理器在检查标志位后,通过插入读取屏障lfence(),确保在读取缓冲区数据之前,已经看到生产者设置的标志位,从而保证了缓冲区数据的一致性和正确访问顺序 。如果没有这些内存屏障,可能会出现消费者处理器在标志位被设置之前就读取缓冲区数据,或者生产者处理器设置标志位后,缓冲区数据还未被正确写入的情况 。

7.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 协议)协同工作,有效地解决了缓存不一致的问题,确保了多处理器系统中数据的正确性和可靠性 。

责任编辑:武晓燕 来源: 深度Linux
相关推荐

2017-09-15 11:53:48

厕所物联网虹桥站

2018-02-02 16:19:08

华为云

2017-09-04 15:15:48

Linux内核内存屏障

2009-03-10 17:15:07

Linux兼容内核Win程序

2019-12-10 14:51:00

CPU缓存内存

2010-09-15 21:14:48

IT管理网络构架Juniper Net

2020-04-03 21:36:54

数据科技

2020-04-21 22:18:20

MESI内存CPU

2025-02-10 04:00:00

Linux进程Python

2009-08-10 21:23:20

发布管理IT运维管理摩卡软件

2021-08-26 05:03:18

内存机制磁盘

2021-07-12 14:50:25

Linux命令文件

2019-10-30 09:56:56

内存屏障变量

2019-11-12 14:40:43

CPU缓存内存

2020-02-26 09:42:15

主存程序存储器

2016-04-07 14:53:28

中华网

2021-09-17 14:10:27

区块链购物技术

2020-09-10 17:59:01

存储

2010-04-27 09:17:23

内存屏障JVM
点赞
收藏

51CTO技术栈公众号