0%

搞懂内存屏障-指令与JMM

前面讲了CPU的演进,提出了StoreBuffer和InvalidateQueue的设计,并且讲解了这两个设计会带来的问题。
解决这两个问题就是引入内存屏障:强制刷新StoreBuffer和InvalidateQueue。

这里详细讲讲x86机器上的内存屏障指令与其他隐式的含有内存屏障的指令。
然后再聊一聊JMM与内存屏障的对应关系。

x86与内存屏障

前面提到的StoreBuffer和InvalidateQueue并不是所有的CPU都会去实现。
其中x86的机器上,遵循的内存一致性协议叫TSO协议。
在这个协议中,有个叫WriteBuffer的东西,就是对应StoreBuffer。
但是并没有InvalidateQueue的存在。

内存屏障指令集

上文中,提到了三个内存屏障的指令:

  1. lfence():读屏障
  2. sfence():写屏障
  3. mfence():读写屏障

那么在代码中是怎么定义的呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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)
#define wmb() alternative("lock; addl $0,0(%%esp)", "sfence", X86_FEATURE_XMM)

#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)
#endif

首先来看barrirer()的定义,这个是禁止编译器进行重排序的。
具体的解释可以参考笔者的另外一个文章:volatile和内存屏障

然后我们看CONFIG_SMP,如果定义了这个,说明该机器上不止一个Core,否则就是单核心的机器。
在单核心的机器上,所有的CPU的内存屏障指令都是空指令,只有禁止编译器重排序的作用。
这个也好理解,就不多做解释了。

而在多核心的机器上,分别定义了:

  1. smp_mb():读写屏障
  2. smp_rmb():读屏障
  3. smp_wmb():写屏障

同时我们看具体的实现,也就是用到了我们上面提到了lfence,sfence,mfence。

但是我们再仔细看看这句话:
#define rmb() alternative("lock; addl $0,0(%%esp)", "lfence", X86_FEATURE_XMM2)

如果CPU没有lfence指令,那么就用lock; addl $0,0(%%esp)代替。
为什么?难道lock; addl $0,0(%%esp)也能有内存屏障的语义吗?

是的!
除了fence指令,还有很多的其他的指令也隐藏了内存屏障的语义。
下面笔者来总结一下:

常见的三种

x86/64系统架构提供了三种多核的内存屏障指令:(1) sfence; (2) lfence; (3) mfence

  1. sfence:在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
  2. lfence:在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
  3. mfence:在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。

其实总结起来就是读屏障,写屏障,读写屏障。

上述的是显式的会起到内存屏障作用的指令,但是还有许多指令带有异常的内存屏障的作用。

MMIO写屏障

Linux 内核有一个专门用于 MMIO 写的屏障:
mmiowb()
笔者也不熟悉这个的作用,后续再补上

隐藏的内存屏障

Linux 内核中一些锁或者调度函数暗含了内存屏障。

锁函数:

  • spin locks
  • R/W spin locks
  • mutexes
  • semaphores
  • R/W semaphores

中断禁止函数:
启动或禁止终端的函数的作用仅仅是作为编译器屏障,所以要使用内存或者 I/O 屏障 的场合,必须用别的函数。

SLEEP和WAKE-UP以及其它调度函数:
使用 SLEEP 和 WAKE-UP 函数时要改变 task 的状态标志,这需要使用合适的内存屏 障保证修改的顺序。

JMM

在JMM中,定义了4中内存可见性语义:

  1. LoadLoad
  2. LoadStore
  3. StoreStore
  4. StoreLoad

但是这些指令对应到x86的机器上,并不是都需要实现的。
因为x86的核心问题是有StoreBuffer,一个值被Core0写入了StoreBuffer,另外一个Core可能读不到最新的值,除非Flush StoreBuffer。所以StoreLoad语义需要内存屏障来维持。

例如以下的例子:

1
2
3
4
5
6
7
8
void foo(void) {
x=1; //S1
r1=y; //S2
}
void bar(void) {
y=1; //L1
r2=x;//L2
}

在这个例子中,如果没有内存屏障,Core0执行foo,Core1执行bar,则r1和r2可能出现同时为0的情况。

再具体的这个文章讲的很好:为什么在 x86 架构下只有 StoreLoad 屏障是有效指令?

更具体的例子

下面我们看看代码,经过JIT编译后的指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int a = 0;
static int b = 0;

public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
add();
}
}

public static void add() {
for (int i = 0; i < 100; i++) {
a++;
b += 2;
}
}

如果a没有被volatile修饰:
image-20200314152942448
可以看到a和b的操作分别对应:
inc %r9d
add $0x2, %r9d
中间没有任何内存屏障的指令

如果我们加上volatile修饰呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static volatile int a = 0;
static volatile int b = 0;

public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
add();
}
}

public static void add() {
for (int i = 0; i < 100; i++) {
a++;
b += 2;
}
}

image-20200314153303884
可以很明显的看到两个lock指令。