内存屏障和锁前缀指令之间的区别

difference between Memory Barriers and lock prefixed instruction

在这篇文章 Memory Barriers and JVM Concurrency! 中,我了解到 volatile 是由不同的内存屏障指令实现的,而 synchronized 和 atomic 是由 lock 前缀指令实现的。但是我在其他文章中得到了以下代码:

java代码:

volatile Singleton instance = new Singleton();

汇编指令(x86):

0x01a3de1d: movb [=11=]x0,0x1104800(%esi);

0x01a3de24: lock addl [=12=]x0,(%esp);

所以哪一个是正确的?在不考虑我糟糕的英语的情况下,Memory Barriers 和 lock 前缀指令有什么区别?

简答

锁定指令用于自动执行复杂的内存指令。
内存屏障用于排序(部分或全部)内存访问。

Java易变

Java volatile 关键字保证对 volatile 变量的更改可见 所有 个线程,因为它们被写在程序中。 volatile 的全部且唯一的一点是 对 volatile 变量的访问是完全有序的,所以如果你访问变量 X 然后 变量 Y,都是易变的,对 X 的访问先于对 Y 的访问 处理器!

这需要对内存访问进行排序,因此需要内存屏障。
IA32e 上的内存屏障可以使用“fences”指令(mfence, lfence, sfence)或 lock 指令来实现。但后一种选择只是一个方面 lock 的影响,而不是其主要用途。
锁定指令被序列化等 有总订单。这仅对内存访问进行排序是低效的,但有效并且被使用 在缺少“围栏”的旧处理器中。

所以你看到的锁实际上是一个屏障(Linux内核也使用了相同的指令)。

完整答案

上面的“复杂内存指令”是指读取-修改-写入指令(以英特尔命名),这些 是内部包含三个操作的指令: 从内存中获取值,更改它并存储回去。

如果在指令期间总线未保持锁定,则另一个处理器可以更改该值 after 它已从内存中读取但 before 它存储回来。

例子 x = 0

 CPU 1              CPU 2

 loop:              loop:    
    inc [X]            inc [x]
    j loop             j loop

如果每个 CPU 执行自己的循环 10 次,x 中将存储什么值?
你不能用确定性的方式来判断。伪指令inc [X]必须用三个微操作来实现 作为

  CPU 1              CPU 2

 loop:              loop:    
    mov r, [X]         mov r, [X]
    inc r              inc r
    mov [x], r         mov [x], r
    j loop             j loop

可能出现过这种情况:

CPU1: mov r, [X]    X is 0, CPU1 r is 0, CPU2 r is 0
CPU1: inc r         X is 0, CPU1 r is 1, CPU2 r is 0
CPU2: mov r, [X]    X is 0, CPU1 r is 1, CPU2 r is 0
CPU1: mov [X], r    X is 1, CPU1 r is 1, CPU2 r is 0
CPU1: mov r, [X]    X is 1, CPU1 r is 1, CPU2 r is 0
CPU1: inc r         X is 1, CPU1 r is 2, CPU2 r is 0
CPU1: mov [X], r    X is 2, CPU1 r is 2, CPU2 r is 0 
CPU2: inc r         X is 2, CPU1 r is 2, CPU2 r is 1 
CPU2: mov [X], r    X is 1, CPU1 r is 2, CPU2 r is 1

注意 X 是 1 而不是 3。
通过锁定 inc 指令,CPU 将系统总线锁定为 inc 开始,直到它退休。这会强制采用这样的模式(示例)

CPU1: mov r, [X]    X is 0, CPU1 r is 0, CPU2 r is 0, CPU2 cannot use bus
CPU1: inc r         X is 0, CPU1 r is 1, CPU2 r is 0, CPU2 cannot use bus
CPU1: mov [X], r    X is 1, CPU1 r is 1, CPU2 r is 0, CPU2 cannot use bus

CPU1: mov r, [X]    X is 1, CPU1 r is 1, CPU2 r is 0, CPU2 cannot use bus
CPU1: inc r         X is 1, CPU1 r is 2, CPU2 r is 0, CPU2 cannot use bus
CPU1: mov [X], r    X is 2, CPU1 r is 2, CPU2 r is 0, CPU2 cannot use bus

CPU2: mov r, [X]    X is 2, CPU1 r is 1, CPU2 r is 2, CPU1 cannot use bus
CPU2: inc r         X is 2, CPU1 r is 2, CPU2 r is 3, CPU1 cannot use bus
CPU2: mov [X], r    X is 3, CPU1 r is 2, CPU2 r is 3, CPU1 cannot use bus

改为使用内存屏障来排序内存访问。
处理器执行指令输出 顺序,这意味着即使您发送 CPU 指令 A B C 它也可以 执行 C A B.

但是,处理器需要遵守相关性和指令 仅当这不会改变程序行为时才会乱序执行。
要记住的一个非常重要的方面是指令执行和指令之间的区别 退役是因为处理器只对退役指令保持其架构状态(程序可以看到的状态)一致。 正常情况下,程序只在指令退出时才能看到指令的结果,即使它已经被执行了!但是对于内存访问,情况略有不同,因为它们具有修改主内存的全局可见副作用,并且无法撤消!

因此,从 CPU 上的程序来看,CPU 的所有内存访问都按程序顺序进行,但是处理器没有 努力保证 other 处理器以相同的顺序查看内存访问!他们看到 执行 顺序或最坏的传播顺序由于缓存层次结构和内存拓扑! 不同处理器的内存访问顺序不同。

因此 CPU 允许程序员控制内存访问的排序方式,屏障会阻止其他内存指令(在同一个 CPU 上)被执行,直到所有前一个指令 是 executed/retired/propagated(这取决于架构的屏障类型)。

例子

  x = 0, y = 0

  CPU 1            CPU2
  mov [x], 1       loop:
  mov [y], 1         mov r, [y]
                     jrz loop   ;Jump if r is 0
                   mov s, [x]

不需要锁。但是,如果没有障碍,程序后 CPU2 s 可能为 0。
这是因为 CPU1 的 mov [y], 1 写入可以重新排序和执行 在写入 x!
之前 从 CPU 1 的角度来看没有任何变化,但是对于 CPU 2 来说顺序已经改变了!

有障碍物

  x = 0, y = 0

  CPU 1            CPU2
  mov [x], 1       loop:
  sync               mov r, [y]
  mov [y], 1         jrz loop   ;Jump if r is 0
                   mov s, [x]

使用sync作为内存屏障伪指令。现在不能写入 y 重新排序并且必须等待 x 的写入对 CPU2.

可见

事情比我这张简单的图片复杂一点,不同的处理器 有不同种类的障碍和内存排序。不同的体系结构具有不同的 cache/memory 拓扑,需要特殊处理。 抽象这并不容易,Java 有一个简单的内存模型,它使生成的代码更 复杂,C++11 有一个更精细的内存模型,可以让你更好地探索内存的影响 障碍。

在阅读像 happens-before 这样的抽象符号之前,在 Google 上搜索是很有用的 常见架构(IA32e、IA64、ARM、SPARC、Power、Alpha)的内存排序问题,因此 你可以看到真正的问题是什么以及如何解决。

并且 IA32e 架构是一个糟糕的测试架构,因为它的松散内存顺序确实非常强大,并且大多数问题都不会在该架构上发生。如果你有一个多处理器 phone 你可以在 ARM 上测试。如果您喜欢一个极端的例子,请使用 Alpha 架构,其中甚至对依赖访问进行重新排序!