编程基础:如何从CPU指令角度理解多线程并发编程的代码重排问题

2019-07-16   道以致远

编程基础

前面我们用了两篇文章专门说了计算机的内部存储系统,也就是我们常说的内存,本文继续说一下计算机的中央处理器CPU的相关基础知识。

因为我们编写的所有程序最终都是由它来完成调度执行的,所以要想学好编程,对于底层CPU的结构和特点有一个大致的了解,将非常有助于我们编写高效的应用程序。同时也能够促进我们理解各类编程语言中出现的那些概念和规则定义。

其实作为一个没接触过计算机和编程的人,我们对计算机和程序的猜想就是,一个黑盒子,将一堆数据输进去,它会经过它加工处理,输出结果来。

那么这个过程应该是里面有东西在接着我们给它的内容,然后交给能够处理的部分,然后将处理结果发还给我们。其实计算机的整体架构跟我们的猜测的一样。了解了其内部存储体系后,我们就可以说计算机其实就是我们有一个存储内容的箱子,加上一个处理内容的部分,就构成了计算机。

现在我们就详细说一下这个负责处理内容的部分,中央处理器单元,CPU。其实最开始的时候,我们的计算机不具备存储功能,只具备运算功能,通过卡片打孔来记录要处理的数据和处理的结果。这种情况现在想来肯定很扯,但计算机真的经历过这个阶段。

我们不得不承认,其实计算机就是一个内部存储系统加上一个负责对存储器里的数进行搬入搬出以及累计计算的中央处理器构成,很简单是吧!

我们还知道:

程序= 数据结构 + 算法

这个经典的概括描述,数据结构就是我们内存中保存数据的样式,算法就是我们操作这些数的逻辑顺序和行为,前面我们知道我们的CPU和内存系统中存储空间都是易失性存储,长期保存必须通过外部磁性设备。

也就是说这些数据结构和操作步骤行为必须先放入到内部存储中,然后由CPU读取完成运算然后给出结果,下一个运算开始之前同样执行这样的过程,也就意味着CPU和内存不会永久存储任何内容。

我们在内存中会专门开辟出空间对每一个程序的数据和逻辑指令进行存储,然后由CPU一条指令一条数据的读取运算返回结果的执行。CPU为此配备了数据寄存器和指令寄存器用于跟主内存交互。

学习

CPU操作指令分类

一类是哪些将值从内存加载到寄存器以及将值从寄存器存储到内存的函数。另一类是对寄存器中存储的值进行操作的指令。比如两个寄存器中值的加减乘除,执行按位逻辑操作,或者其他数学运算等。

分支

CPU除了从内存加载数据将数据存储会内存外,其另外一个重要的操作就是分支了。

在内部CPU会保存一个下一条要执行的指令的记录的指令指针,通常,指令指针按顺序递增以指向下一条指令;分支指令通常会检查特定寄存器是否为零,或者是否设置了标志,如果是,将修改指针到另一个地址。

由此,指令的执行将不再按照原来的顺序去执行下一条,而是要执行来自程序的其它不同部分的指令;这就是我们编程语言中设计的循环和决策语句的工作原理。

例如,if (x==0)这样的语句可以通过查找两个寄存器值来实现,一个寄存器保存x,另一个寄存器保存0; 如果x结果为零,则比较为真,并且语句的主体应该被接受,否则分支将替代主体代码。

循环

我们都熟悉计算机的速度,单位为兆赫或千兆赫,意味着每秒百万或成千上万个循环。这就是我们经常说的频率,或者说是系统时钟速度。

因为它是计算机内部时钟脉冲的速度。

这个脉冲在处理器内部用以保持内部同步。每一次滴答或脉冲都可以启动另一个操作;我们可以把时钟想象成我们划龙舟时前面坐着的那个敲鼓的人,他的作用就是让全舟的划手的划桨动作保持同步。

获取、解码、执行、存储

执行有一个事件周期构成的单一指令,获取,解码,执行和存储。

比如,要在CPU上面执行某条指令,必须执行经过如下步骤

  • 获取: 从内存冲将指令取出放到处理器的寄存器中
  • 解码: 内部解码
  • 执行:从寄存器中获取值,并执行操作
  • 存储:将结果存回另外一个寄存器。

当然,有时还有重试步骤。

编码类型

CPU内部如何操作内存

接下来我们看一下CPU内存到底发生了什么?

在CPU内部,有许多不同的子组件执行上述每个步骤,并且通常它们都可以彼此独立地发生。

这类似于我们工厂里的生产线,其中有许多工作环节分工,每个步骤都有特定的任务要执行。

一旦完成,它可以将结果传递到下一环节,并接受新的输入进行工作。

一般在CPU中,输入的指令首先被处理器解码,CPU有两种主要的寄存器类型,一种用于整数计算,另一种用于浮点计算

浮点数是一种以二进制形式表示小数点位数的数字的方法,在CPU中有各自不同的处理方。我们经常遇到的多媒体扩展(MMX)和单指令多数据流(SSE)或Altivec寄存器都类似于浮点寄存器。

这里有个名词叫寄存器文件,其实它就是CPU内寄存器的集合名称。我们说过,处理器要么将一个值加载到寄存器中,要么将一个值存储到内存中,要么对寄存器中的值执行一些操作。

算术逻辑单元(ALU)是CPU操作的核心。它接受寄存器中的值,并执行CPU能够执行的多种操作中的任何一种。

其实我们都知道,所有现代处理器都有数个算术逻辑单元,也就是我们的多核处理器,因此每个算术逻辑单元都可以独立工作。

前面我们说过速度快的内存更小,所以我们可以在CPU上安装更多内存,但它只能执行的智能是最常见的操作; 虽然速度慢的内存可以执行所有操作,但体积更大,靠近不了CPU。

所以现代计算机会将地址生成单元(AGU)处理与缓存和主内存的通信,将值放入寄存器中,以便算术逻辑单元操作,并将值从寄存器中取出返回到主内存。

指令管线

CPU指令管线

正如我们在上面看到的,当ALU将寄存器加在一起时,它与AGU是完全分开的,AGU将值写回内存,所以CPU没有理由不能同时做这两件事。

我们系统中还有多个算术逻辑单元,每个都可以处理单独的指令。结果是,CPU可以用它的浮点逻辑做一些浮点运算,同时整数指令也在运行。

这被称为指令管线,能够做到这一点的处理器称为超标量体系结构。所有现代处理器都是超标量的。

我们可以将另一类指令执行的方式想象成装满弹珠的软管,只不过弹珠是CPU的指令。理想情况下,我们将把弹珠放在一端,一个接一个地(每个时钟脉冲一个),填满管道。一旦弹珠满了,我们推进去的每一个弹珠指令都会移动到下一个位置,最后一个结果弹珠会掉出来。

然而,CPU的分支指令会破坏这个模型,因为它们可能会导致执行从不同的地方开始,也可能不会导致执行从不同的地方开始。

如果我们要使用管道操作,那么就必须去提前预测指令分支将走向,这样才知道将哪些指令压入管道。如果预测正确,一切都会很好,相反,如果处理器预测错误,则会浪费大量时间,并且必须清除管道并重新启动。这个过程通常被称为管道冲洗,类似于必须停止并清空软管中的所有弹珠!

分支预测包括管道刷新,预测发生,预测不发生,分支延迟插槽。

指令重排序

事实上,如果CPU是软管,它就可以自由地重新排列软管内的玻璃球,只要它们的末端和我们放入它们的顺序一样。

这个顺序我们称之为程序顺序,因为这是指令在计算机程序中指定的顺序。

举个例子,下面的代码定义顺序如下:

1: r3 = r1 * r2
2: r4 = r2 + r3
3: r7 = r5 * r6
4: r8 = r1 + r7

上面示例显示,指令2必须等待指令1完成,这意味着管道在等待计算值时必须停止。然而,指令2和指令3之间根本没有依赖关系; 也就是说它们可以在完全独立的寄存器上运行。

如果我们交换指令2和指令3,我们可以得到更好的管线顺序,因为处理器可以做有用的工作,而不是等待管线完成,以获得前一条指令的结果。

然而,当编写非常低级别的代码时,一些指令可能需要一些关于操作如何排序的安全性。我们称之为需求内存语义

如果我们需要获取语义,这意味着对于该指令,我们必须确保前面所有指令的结果都已完成。如果我们需要发布语义结果,那么意思是在此之后的所有指令都必须看到当前结果。

另一个更严格的语义是内存屏障内存围墙,它要求在指令操作继续之前将之前的操作提交到主内存中。

在某些体系结构上,这些语义由处理器为我们提供保证,而在另一些体系结构上,则需要我们显式地指定它们。

其实我们平时开发过程中大多时候根本不需要直接担心这些,尽管我们可能会遇到barrier,latch等此类的术语。

复杂指令集计算机(CISC)与 简化指令集计算机(RISC)

在指令集方面通常现在的计算机结构被划分为复杂指令集计算机和简化指令集计算机两种。

比如,我们显式地将值加载到寄存器中,执行某种算术运算操作并将结果值存储在另一个寄存器中,最后拷贝回到主内存中。这种操作就是典型的RISC计算方法,因为这个过程只对寄存器中的值执行操作,并显式地从内存加载数值并将值存储回内存。

如果换成CISC方法,可能只是一条从内存中提取值、在内部执行加工操作并将结果写回的指令。

这意味着这条指令可能需要经过许多环节来完成,但最终两种方法都实现了相同的目标。

其实所有现代的计算体系结构都被认为是RISC体系结构的,原因有很多:

虽然RISC使汇编编程变得越来越复杂,因为几乎现在所有程序员都使用高级语言,并将生成程序集代码的繁重工作留给了编译器,所以该计算指令集虽然复杂,但是都给了计算机自己搞定。

同时由于RISC处理器中的指令要简单得多,因此芯片内部有更多的空间用于数据寄存器。从前面我们说过的内存层次结构中知道,寄存器是最快的内存类型,不管什么操作最终所有指令都必须在寄存器中保存的值上执行,因此在其他条件相同的情况下,更多的寄存器会带来更高的性能。

由于所有指令都在同一时间执行,因此可以使用管线。我们知道管线需要不断地将指令流输入处理器,因此,如果一些指令花费很长时间,而另一些则不需要,那么管线就会变得非常复杂,难以发挥作用。

显式并行指令运算(EPIC)

我们已经讨论了超大规模处理器的管道是如何在处理器的不同部分同时运行许多指令的。

显然,为了使这一切正常工作,处理器应该按照能够充分利用CPU可用元素的顺序得到尽可能多的指令。

传统上,组织传入指令流一直是硬件的工作。指令由程序按顺序发出;处理器必须向前看,设法决定如何组织输入指令。

EPIC背后的理论是,在更高的级别上有更多的可用信息,可以比处理器更好地做出这些决策。

与当前处理器一样,分析汇编语言指令流会丢失许多程序员可能在原始源代码中提供的信息。

因此,排序指令的逻辑可以从处理器转移到编译器。这意味着编译器作者需要更聪明地尝试为处理器找到最佳的代码顺序。

处理器也得到了显著的简化,因为它的许多工作已经转移到编译器中。

这也是为什么像Java这类语言在编写程序时必须考虑编译器重排问题。

总结一下:

本文我们简单讲了一下CPU在执行运算时的大致分类和流程,主要讲了CPU在执行指令时的基本操作分类,从分支,循环到获取,解码,执行和存储。

以及指令的复杂度和管线模型。以及计算机的计算指令集分类,复杂指令集和简明指令集特点。由于我们现在编程大多使用高级语言,所以不用再在乎指令集的复杂度,因为高级语言的编译器可以帮我们搞定它,但是由于从CPU到编译器的这种指令优化和重排,造成了我们在用高级语言编写应用程序时必须去注意发生在编译阶段和执行阶段的代码重排问题。

相信理解了上面我说的内容后,你再回过头来看Java语言中JMM模型的那些规则和语法定义,应该就能找到一点思路了。