啃碎并发11内存模型之重排序

3次阅读

共计 2568 个字符,预计需要花费 7 分钟才能阅读完成。

前言


在很多状况下,拜访一个程序变量(对象实例字段,类动态字段和数组元素)可能会应用不同的程序执行,而不是程序语义所指定的程序执行。具体几种状况,如下:

例如,如果一个线程写入值到字段 a,而后写入值到字段 b,而且 b 的值不依赖于 a 的值,那么,处理器就可能自在的调整它们的执行程序,而且缓冲区可能在 a 之前刷新 b 的值到主内存。有许多潜在的重排序的起源,例如编译器,JIT 以及缓冲区

所以,从 Java 源码变成能够被机器(或虚拟机)辨认执行的程序,至多要通过编译期和运行期。在这两个期间,重排序分为两类:编译器重排序、处理器重排序(乱序执行),别离对应编译时和运行时环境。因为重排序的存在,指令理论的执行程序,并不是源码中看到的程序。

1 编译器重排序

编译器在不扭转单线程程序语义的前提下,能够重新安排语句的执行程序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充沛复用寄存器的存储值

假如第一条指令计算一个值赋给变量 A 并存放在寄存器中,第二条指令与 A 无关但须要占用寄存器(假如它将占用 A 所在的那个寄存器),第三条指令应用 A 的值且与第二条指令无关。那么如果依照程序一致性模型,A 在第一条指令执行过后被放入寄存器,在第二条指令执行时 A 不再存在,第三条指令执行时 A 从新被读入寄存器,而这个过程中,A 的值没有发生变化。通常编译器都会替换第二和第三条指令的地位,这样第一条指令完结时 A 存在于寄存器中,接下来能够间接从寄存器中读取 A 的值,升高了反复读取的开销

另一种编译器优化:在循环中读取变量的时候,为进步存取速度,编译器会先把变量读取到一个寄存器中 ;当前再取该变量值时,就间接从寄存器中取,不会再从内存中取值了。这样可能缩小不必要的拜访内存。然而提高效率的同时,也引入了新问题。 如果别的线程批改了内存中变量的值,那么因为寄存器中的变量值始终没有产生扭转,很有可能会导致循环不能完结。编译器进行代码优化,会进步程序的运行效率,然而也可能导致谬误的后果。所以程序员须要避免编译器进行谬误的优化。

2 处理器重排序

2.1 指令并行重排序

编译器和处理器可能会对操作做重排序,然而要恪守数据依赖关系,编译器和处理器不会扭转存在数据依赖关系的两个操作的执行程序 。如果两个操作拜访同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。 数据依赖分下列三种类型:

下面三种状况,只有重排序两个操作的执行程序,程序的执行后果将会被扭转。像这种有间接依赖关系的操作,是不会进行重排序的。特地留神:这里说的依赖关系仅仅是在单个线程内

举例:

** 因为操作 1 和 2 没有数据依赖关系,编译器和处理器能够对这两个操作重排序;操作 3 和操作 4 没有数据依赖关系,编译器和处理器也能够对这两个操作重排序。

当操作 1 和操作 2 重排序时,可能会产生什么成果?

当操作 1 和操作 2 重排序时

如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。因为条件判断为真,线程 B 将读取变量 a。此时,变量 a 还基本没有被线程 A 写入,在这里多线程程序的语义被重排序毁坏了!**
**

当操作 3 和操作 4 重排序时,可能会产生什么成果?(借助这个重排序,能够顺便阐明管制依赖性)

当操作 3 和操作 4 重排序时

在程序中,操作 3 和操作 4 存在管制依赖关系。当代码中存在管制依赖性时,会影响指令序列执行的并行度 。为此,编译器和处理器会采纳 猜想(Speculation)执行 来克服管制相关性对并行度的影响。以处理器的猜想执行为例:

从图中咱们能够看出,猜想执行 本质上对操作 3 和 4 做了重排序。重排序在这里毁坏了多线程程序的语义!

2.2 指令乱序重排序


当初的 CPU 个别采纳流水线来执行指令。一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段 。而后, 多条指令能够同时存在于流水线中,同时被执行 。指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。相同, 流水线是并行的,多个指令能够同时处于同一个阶段,只有 CPU 外部相应的处理部件未被占满即可。比方:CPU 有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于“执行”阶段,而两条加法指令在“执行”阶段就只能串行工作。

然而,这样一来,乱序可能就产生了。比方:一条加法指令本来呈现在一条除法指令的前面,然而因为除法的执行工夫很长,在它执行完之前,加法可能先执行完了。再比方两条访存指令,可能因为第二条指令命中了 cache 而导致它先于第一条指令实现。个别状况下,指令乱序并不是 CPU 在执行指令之前刻意去调整程序 CPU 总是程序的去内存外面取指令,而后将其程序的放入指令流水线。然而指令执行时的各种条件,指令与指令之间的相互影响,可能导致程序放入流水线的指令,最终乱序执行实现。 这就是所谓的“程序流入,乱序流出”

指令流水线除了在资源有余的状况下会卡住之外(如前所述的一个加法器应酬两条加法指令的状况),指令之间的相关性也是导致流水线阻塞的重要起因。CPU 的乱序执行并不是任意的乱序,而是以保障程序上下文因果关系为前提的。有了这个前提,CPU 执行的正确性才有保障。

比方:

因为 b =f(a)这条指令依赖于前一条指令 a ++ 的执行后果,所以 b =f(a)将在“执行”阶段之前被阻塞,直到 a ++ 的执行后果被生成进去;而 c – 跟后面没有依赖,它可能在 b =f(a)之前就能执行完。(留神,这里的 f(a)并不代表一个以 a 为参数的函数调用,而是代表以 a 为操作数的指令。C 语言的函数调用是须要若干条指令能力实现的,状况要更简单些)。

像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为期待前一条执行的后果,而在流水线中阻塞很久,占用流水线的资源。而编译器的重排序,作为编译优化的一种伎俩,则试图通过指令重排将这样的两条指令拉开距离 ,以至于后一条指令进入 CPU 的时候,前一条指令后果曾经失去了,那么也就不再须要阻塞期待了。 比方,将指令重排序为:

相比于 CPU 指令的乱序,编译器的乱序才是真正对指令程序做了调整。然而编译器的乱序也必须保障程序上下文的因果关系不产生扭转。

因为重排序和乱序执行的存在,如果在并发编程中,没有做好共享数据的同步,很容易呈现各种看似诡异的问题。

正文完
 0