11 min read

内存屏障

1、概述

参考:对优化说不 - Linux 中的 Barrier

1.1-load/store

在介绍内存屏障前,要先了解cpu中的load和store概念。正常来说,操作系统中的数据结构是存放在内存上的,而内存又分l1-l3 cache和主存,那么当CPU对数据结构进行读写操作的时候,就需要先将内存中的数据load到cache上,最后再load到cpu的寄存器内。

而想要修改数据时,则需要将特定寄存器内的值写入到指定的cache地址上,再在合适的实际将他写回主存中,这就是store操作。

  • Load:Load操作是将数据从计算机的内存中读取到CPU的寄存器中。这是一个读操作。
  • Store:Store操作是将数据从CPU的寄存器写入到计算机的内存中。这是一个写操作。

因此这里就出现了显而易见的问题:内存一致性。

在其他核心读写一个数据时,另外一个核心可能也在同步读写,并且可能已经将修改后的数据写入到了自己读书的cache中。因此,我们需要一种机制来保证内存一致性。

1.2-内存模型

我们了解了上述两种基础的命令操作作用,根据对load和store命令的执行顺序可以分两种模型,而恰好x86和arm64就分别是两种。

  • 强内存序模型(x86):在强内存序模型中,处理器保证了在单个处理器上看到的内存操作的顺序与程序中指定的顺序一致。这意味着处理器和编译器在优化时,不能改变在单个处理器上看到的内存操作的顺序。即load和store的命令不可以随机互换,只有store可以在必要的时候可以被load命令提前执行。
  • 宽松内存模型(arm64):在宽松(或弱)内存序模型中,处理器和编译器可以在一定程度上改变内存操作的顺序。例如,如果两个内存操作不依赖于彼此,那么处理器和编译器可以改变它们的执行顺序,以提高执行效率。即load和store的命令可以在有必要的时候任意互换执行顺序。

而对于上述中的必须情况就是是否存在依赖关系,即例如下列情况

x=1
y=x

而依赖关系又大致分为

  • 数据依赖:上述例子
  • 控制依赖:if判断
  • 资源依赖:指令使用相同的硬件资源

1.3-内存屏障

内核中很多代码都存在内存屏障的这个概念,内存屏障是一种同步原语,主要用于多处理器和多线程环境中对内存访问的顺序进行控制。内存屏障可以确保特定的内存操作按照预期的顺序执行,防止编译器和处理器对指令进行不符合预期的优化和重排序。

在多处理器和多线程环境中,编译器和处理器可能会对指令进行优化和重排序,以提高执行效率。然而,这种优化和重排序可能会导致内存操作的顺序与程序员原本预期的顺序不一致,从而引发竞态条件和其他同步问题。为了解决这个问题,内存屏障被引入,作为一种同步机制,确保内存操作按照预期的顺序执行。

内存屏障主要分为以下几类:

  1. Load(读)屏障:确保屏障之前的读操作在屏障之后的读操作之前完成。
  2. Store(写)屏障:确保屏障之前的写操作在屏障之后的写操作之前完成。
  3. Full(全)屏障:同时具有Load屏障和Store屏障的功能,确保屏障之前的读写操作在屏障之后的读写操作之前完成。

而内存屏障的实现与CPU结构相关,我们这里主要分析x86和arm两种。

2、barrier()编译屏障

2.1-原理

我们说到,其实我们关心的内存屏障更多是在多核系统中,但是在内核中提供了一组非smp架构的内存屏障接口

#else	/* !CONFIG_SMP */

#ifndef smp_mb
#define smp_mb()	barrier()
#endif

#ifndef smp_rmb
#define smp_rmb()	barrier()
#endif

#ifndef smp_wmb
#define smp_wmb()	barrier()
#endif

#endif	/* CONFIG_SMP */

可以看到,这里所有的读写屏障最终都被定义成了barrier()

# define barrier() __asm__ __volatile__("": : :"memory")

可以看到barrier的实现就是一条memory汇编,作用是编译器内存屏障,防止编译器对其前后的代码进行指令重排序优化。在编译程序时,为了提高程序的运行效率,编译器会尝试对代码进行优化,其中一种优化手段就是指令重排序。而memory汇编则会强制编译器按照C语言中代码写的顺序编译。

: : : "memory"这是内联汇编的输入、输出和汇编约束部分。这段代码没有输入和输出,但提供了一个汇编约束"memory"。"memory"约束告诉编译器,这个内联汇编指令可能会读写内存,因此编译器必须确保在这个指令之前的所有内存访问已经完成,同时在这个指令之后的所有内存访问还没有开始。这就创建了一个编译器屏障,阻止了编译器对前后代码进行重排序优化。

2.2-栗子

int flag = 0;
int data = 0;

void thread1() {
    data = 1;
    flag = 1;
}

void thread2() {
    if (flag == 1) {
        printf("%d\n", data);
    }
}

这段代码中我们来是期望data=1之后flag=1,那么此时证明只要通过flag == 1的时候打印的内容都是1

然而,由于编译器存在优化的可能,那么可能flag=1被调整到了data=1之前,那么此时打印的内容就会出现问题。因此我们在关键的节点中需要

void thread1() {
    data = 1;
    barrier();
    flag = 1;
}

这样,barrier()就确保了data = 1;这条语句在flag = 1;之前执行,防止了编译器的重排序。这样我们就可以确保,当thread2看到flag为1时,data已经被设置为1,从而打印出正确的值。

3、全局内存指令屏障

对于强制全局CPU中的内存指令的执行顺序,内核提供了这样一套通用接口

#ifdef __mb
#define mb()	do { kcsan_mb(); __mb(); } while (0)
#endif

#ifdef __rmb
#define rmb()	do { kcsan_rmb(); __rmb(); } while (0)
#endif

#ifdef __wmb
#define wmb()	do { kcsan_wmb(); __wmb(); } while (0)
#endif

其中如果开启了KCSAN(KCSAN是一个用于检测内核中数据竞争的动态分析工具。)功能的操作系统,则会强制刷新存储器屏障,确保内存操作按照预期顺序执行。(配置CONFIG_KCSAN=y)

3.1-x86

对于x86-64来说,这种接口最终实现如下

#define __mb()	asm volatile("mfence":::"memory")
#define __rmb()	asm volatile("lfence":::"memory")
#define __wmb()	asm volatile("sfence" ::: "memory")
  1. MFENCE (Memory Fence):这是一个全内存屏障,它确保所有之前的读取(load)和写入(store)操作在MFENCE之后的读取和写入操作之前完成。换句话说,MFENCE保证了在所有处理器上看到的内存操作顺序与屏障之前的操作顺序一致。这个指令通常用于实现强内存顺序。
  2. LFENCE (Load Fence):这是一个读取(load)屏障,它确保所有之前的读取操作在LFENCE之后的读取操作之前完成。这个指令主要用于阻止读取操作的重排序。
  3. SFENCE (Store Fence):这是一个写入(store)屏障,它确保所有之前的写入操作在SFENCE之后的写入操作之前完成。这个指令主要用于阻止写入操作的重排序

3.2-arm64

而对于arm64来说,命令定义则是如下

#define __mb()		dsb(sy)
#define __rmb()		dsb(ld)
#define __wmb()		dsb(st)

#define dsb(opt)	asm volatile("dsb " #opt : : : "memory")

而对于arm64来说实现的方法则是使用dsb(Data Synchronization Barrier)指令。

其中sy指代systemld指代loadst指代store

  1. __mb():全内存屏障,参数是sy(system),表示所有类型的内存操作都不能重排序。这个宏确保所有之前的读取(load)和写入(store)操作在__mb()之后的读取和写入操作之前完成。
  2. __rmb():读取(load)屏障,参数是ld(load),表示读取操作不能重排序。这个宏确保所有之前的读取操作在__rmb()之后的读取操作之前完成。
  3. __wmb():写入(store)屏障,参数是st(store),表示写入操作不能重排序。这个宏确保所有之前的写入操作在__wmb()之后的写入操作之前完成。

4、多核内存指令屏障

include/asm-generic/barrier.h中定义了通用的smp结构的读写屏障

#ifdef CONFIG_SMP

#ifndef smp_mb
#define smp_mb()	do { kcsan_mb(); __smp_mb(); } while (0)
#endif

#ifndef smp_rmb
#define smp_rmb()	do { kcsan_rmb(); __smp_rmb(); } while (0)
#endif

#ifndef smp_wmb
#define smp_wmb()	do { kcsan_wmb(); __smp_wmb(); } while (0)
#endif

4.1-x86

#define __dma_rmb()	barrier()
#define __dma_wmb()	barrier()

#define __smp_mb()	asm volatile("lock; addl $0,-4(%%" _ASM_SP ")" ::: "memory", "cc")

#define __smp_rmb()	dma_rmb()
#define __smp_wmb()	barrier()

可以看到读写的屏障都是最终被定义成了barrier()编译屏障。

而全屏障这段汇编代码执行了一个lock前缀的addl指令,将0加到栈顶的上一个位置(-4(%esp)-4(%rsp))。lock前缀在x86架构中用于实现原子操作,它确保指令在多处理器环境中按顺序执行,并阻止其他处理器访问被操作的内存地址。在这段代码中,lock; addl $0,-4(%%" _ASM_SP ")"实际上是一个不会改变任何实质内容的原子操作,因为它只是将0加到栈顶的上一个位置。这个操作的主要目的是通过lock前缀实现内存屏障的功能。

4.2-arm64

#define __smp_mb()	dmb(ish)
#define __smp_rmb()	dmb(ishld)
#define __smp_wmb()	dmb(ishst)

#define dmb(opt)	asm volatile("dmb " #opt : : : "memory")

上述命令使用了ARM的dmb(Data Memory Barrier)指令,应用于同一内部共享域中的cpu之间的读\写操作

  1. __smp_mb():处理器间全内存屏障,参数是ish(Inner SHareable)。这个宏确保所有之前的读取(load)和写入(store)操作在__smp_mb()之后的读取和写入操作之前完成。
  2. __smp_rmb():处理器间读取(load)屏障,参数是ishld(Inner SHareable Load)。这个宏确保所有之前的读取操作在__smp_rmb()之后的读取操作之前完成。
  3. __smp_wmb():处理器间写入(store)屏障,参数是ishst(Inner SHareable Store)。这个宏确保所有之前的写入操作在__smp_wmb()之后的写入操作之前完成。

补充:

  1. DSB与DMB关系
  2. ARM共享域

5、全局与SMP__的区别

5.1-x86

这是一个存疑点,目前还没理解到在x86上带smp的内存屏蔽区别在哪里,smp的全屏蔽看起来只是使用了lock原子操作,压了一个空栈,并没有真正的执行内存屏障。并不是很清楚带不带smp的功能上具体区别。

5.2-arm64

主要的区别是使用dsb命令还是dmb命令。

二者的作用与不同,dsb是全局的,而dmb是共享域内的CPU。另外DSB指令要比DMB指令严格得多。DSB后面的任何指令必须满足下面两个条件才能开始执行。

  • DSB指令前面的所有数据访问指令(内存访问指令)必须执行完。
  • DSB指令前面的高速缓存、分支预测、TLB等维护指令也必须执行完。