文章目录
声明:
本博客是本人在学习《实战 Java 高并发程序设计》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。
本博客已标明出处,如有侵权请告知,马上删除。
1.4 有关并行的两个重要定律
有关为什么要使用并行程序的问题在之前已经进行了简单的探讨。总的来说,最重要的应该是出于两个目的:
- 为了获得更好的性能。
- 由于业务模型的需要,确实需要多个执行实体。
在这里,我将更加关注于第一种情况,也就是有关性能的问题。将串行程序改造为并发,一般来说可以提供程序的整体性能,但是究竟能提高多少,甚至说究竟是否真的可以提高,还是一个需要研究的问题。
目前,主要有两个定律对这个问题进行解答:
- Amdahl 定律
- Gustafson 定律。
1.4.1 Amdahl 定律
Amdahl 定律是计算机科学中非常重要的定律。它定义了串行系统并行化后的加速比的计算公式和理论上限。
加速比定义:加速比=优化前系统耗时/优化后系统耗时
即,所谓加速比,就是优化前的耗时与优化后耗时的比值。加速比越高,表明优化效果越明显。
图 1.8 显示了 Amdahl 公式的推导过程,其中 n 表示处理器个数,T 表示时间,T1 表示优化前耗时(也就是只有 1 个处理器时的耗时),Tn 表示使用 n 个处理器优化后的耗时。F 是程序中只能串行执行的比例。
根据这个公式,如果 CPU 处理器数量趋于无穷,那么加速比与系统的串行化率成反比,如果系统中必须有 50% 的代码串行执行,那么系统的最大加速比为 2。
假设有一程序分为以下步骤执行,每个执行步骤花费 100 个时间单位。其中,只有步骤 2 和步骤 5 可以进行并行,步骤 1、3、4 必须串行,如图 1.9 所示。在全串行的情况下,系统合计耗时 500 个时间单位。
若将步骤 2 和步骤 5 并行化,假设在双核处理上,则有如图 1.10 所示的处理流程。在这种情况下,步骤 2 和步骤 5 的耗时将为 50 个时间单位。故系统整体耗时为 400 个时间单位。根据加速比的定义有:
或者根据前文中给出的加速比公式。由于 5 个步骤中,3 个步骤必须串行,因此其串行化比重为 3/5=0.6,即 F=0.6,且双核处理器的处理器个数 N 为 2。代入公式得:
在极端情况下,假设并行处理器个数为无穷大,则有如图 1.11 所示的处理过程。步骤 2 和步骤 5 的处理时间趋于 0。即使这样,系统整体耗时依然大于 300 个时间单位。即加速比的极限为 500/300=1.67。
使用加速比计算公式,N 趋于无穷大,有加速比=1/F,且 F=0.6,故有加速比=1.67。
由此可见,为了提高系统的速度,仅增加 CPU 处理器的数量并不一定能起到有效的作用。需要从根本上修改程序的串行行为,提高系统内可并行化的模块比重,在此基础上,合理增加并行处理器数量,才能以最小的投入,得到最大的加速比。
注意: 根据 Amdahl 定律,使用多核 CPU 对系统进行优化,优化的效果取决于 CPU 的数量以及系统中的串行化程序的比重。CPU 数量越多,串行化比重越低,则优化效果越好。仅提高 CPU 数量而不降低程序的串行化比重,也无法提高系统性能。
1.4.2 Gustafson 定律
Gustafson 定律也试图说明处理器个数、串行比例和加速比之间的关系,如图 1.12 所示,但是 Gustafson 定律和 Amdahl 定律的角度不同。同样,加速比都定义为优化前的系统耗时除以优化后的系统耗时。
可以看到,由于切入角度的不同,Gustafson 定律的公式和 Amdahl 定律的公式截然不同。从 Gustafson 定律中,我们可以更容易地发现,如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要你不断地累加处理器,就能获得更快的速度。
1.4.3 Amdahl 定律和 Gustafson 定律是否相互矛盾
由于 Amdahl 定律和 Gustafson 定律的结论不同,这是不是说明这两个理论之间有一个是错误的呢?其实不然,两者的差异其实是因为这两个定律对同一个客观事实从不同角度去审视后的结果,它们的偏重点有所不同。
举一个生活的例子,一辆汽车行驶在相聚 60 公里的城市。你花了一个小时,行驶了 30 公里。无论接下来开多快,你都不可能达到 90 公里/小时的时速。图 1.13 很好地说明了原因。
求解图 1.13 中的方程,你会发现如果你想达到 90 公里的时速,那么你从 AB 中点到达 B 点的时间会是一个负数,这显然不是一个合理的结论。实际上,如果前半程 30km 你使用了一小时,那么即使你从中点到 B 点使用光速,也只能把整体的平均时速维持在 60km/hour。
也就是说 Amdahl 强调:当串行比例一定时,加速比是有上限的,不管你堆叠多少个 CPU 参与计算,都不能突破这个上限!
而 Gustafson 定律的出发点与之不同,对 Gustafson 定律来说,不管你从 A 点出发的速度有多慢,只要给你足够的时间和距离,只要你后期的速度比期望值快那么一点点,你总是可以把平均速度调整到非常接近那个期望值的。比如,你想要达到均速 90km/hour,即使在前 30km 你的时速只有 30km/hour,你只要在很后面的速度达到 91km/hour,给你足够的时间和距离,你总有一天可以把均速提高到 90km/hour。
因此,Gustafson 定律关心的是:如果可被并行化的代码所占比重足够多,那么加速比就能随着 CPU 的数量线性增长。
所以,这两个定律并不矛盾。从极端角度来说,如果系统中没有可被串行化的代码(即 F=1),那么对于这两个定律,其加速比都是 1。反之,如果系统中可串行化代码比重达到 100%,那么这两个定律得到加速比都是 n(处理器个数)。
1.5 回到 Java:JMM
前面我已经介绍了有关并行程序的一些关键概念和定律。这些概念可以说是与语言无关的。无论你使用 Java 或者 C,或者其他任何一门语言编写并发程序,都有可能会涉及这些问题。但本书依然是一本面向 Java 程序员的书籍。因此,在本章最后,我们还是希望可以探讨一下有关 Java 的内存模型(JMM)。
由于并发程序要比串行程序复杂很多,其中一个重要原因是并发程序下数据访问的一致性和安全性将会受到严重挑战。如何保证一个线程可以看到正确的数据呢?这个问题看起来很白痴。对于串行程序来说,根本就是小菜一碟,如果你读取一个变量,这个变量的值是 1,那么你读到的一定是 1,就这么简单的问题在并行程序中居然变得复杂起来。事实上,如果不加控制地任由线程胡乱并行,即使原本是 1 的数值,你也有可能读到 2。因此,我们需要在深入了解并行机制的前提下,再定义一种规则,保证多个线程间可以有效地、正确地协同工作。而 JMM 也就是为此而生的。
JMM 的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。因此,我们首先必须了解这些概念。
1.5.1 原子性(Atomicity)
原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
比如,对于一个静态全局变量 int i,两个线程同时对它赋值,线程 A 给他赋值 1,线程 B 给它赋值为 -1。那么不管这 2 个线程以何种方式、何种步调工作,i 的值要么是 1,要么是 -1。线程 A 和线程 B 之间是没有干扰的。这就是原子性的一个特点,不可被中断。
但如果我们不使用 int 型而使用 long 型的话,可能就没有那么幸运了。对于 32 位系统来说,long 型数据的读写不是原子性的(因为 long 有 64 位)。也就是说,如果两个线程同时对 long 进行写入的话(或者读取),对线程之间的结果是有干扰的。
1.5.2 可见性(Visibility)
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量的值,一定是修改后的新值。
但是这个问题在并行程序中就不见得了。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。图 1.14 展示了发生可见性问题的一种可能。如果在 CPU1 和 CPU2 上各运行了一个线程,它们共享变量 t,由于编译器优化或者硬件优化的缘故,在 CPU1 上的线程将变量 t 进行了优化,将其缓存在 cache 中或者寄存器里。这种情况下,如果在 CPU2 上的某个线程修改了变量 t 的实际值,那么 CPU1 上的线程可能并无法意识到这个改动,依然会读取 cache 中或者寄存器里的数据。因此,就产生了可见性问题。外在表现为:变量 t 的值被修改,但是 CPU1 上的线程依然会读到一个旧值。可见性问题也是并行程序开发中需要重点关注的问题之一。
1.5.3 有序性(Ordering)
有序性问题可能是三个问题中最难理解的了。对于一个线程的执行代码而言,我们总是习惯地认为代码的执行是从先往后,依次执行的。这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。给人直观的感觉就是:写在前面的代码,会在后面执行。听起来有些不可思议,是吗?有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。
下面来看一个简单的例子:
假设线程 A 首先执行 writer() 方法,接着线程 B 执行 reader() 方法,如果发生指令重排,那么线程 B 在代码第 10 行时,不一定能看到 a 已经被赋值为 1 了。如图 1.15 所示,显示了两个线程的调用关系。
这确实是一个看起来很奇怪的问题,但是它确实可能存在。 注意:我这里说的是可能存在。因为如果指令没有重排,这个问题就不存在了,但是指令是否发生重排、如何重排,恐怕是我们无法预测的。因此,对于这类问题,我认为比较严谨的描述是:线程 A 的指令执行顺序在线程 B 看来是没有保证的。如果运气好的话,线程 B 也许真的可以看到和线程 A 一样的执行顺序。
不过这里还需要强调一点,对于一个线程来说,它看到的指令执行顺序一定是一致的(否则的话我们的应用根本无法正常工作,不是吗?)。也就是说指令重排是有一个基本前提的,就是保证串行语义的一致性。指令重排不会使串行的语义逻辑发生问题。因此,在串行代码中,大可不必担心。
注意: 指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。
那么,好奇的你可能马上就会在脑海里闪出一个疑问,为什么要指令重排呢?让他一步一步执行多好呀!也不会有那么多奇葩的问题。
之所以那么做,完全是因为性能考虑。指令重排对于提高 CPU 处理性能是十分必要的。虽然确实带来了乱序的问题,但是这点牺牲是完全值得的。
1.5.4 哪些指令不能重排:Happen-Before 规则
在前文已经介绍了指令重排,虽然 Java 虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则,这些原则是指令重排不可违背的。
- 程序顺序原则:一个线程内保证语义的串行性
- volatile 规则:volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
- 传递性:A 先于 B,B 先于 C,那么 A 必然先于 C
- 线程的 start() 方法先于它的每一个动作
- 线程的所有操作先于线程的终结(Thread.join())
- 线程的中断(interrupt())先于被中断线程的代码
- 对象的构造函数执行、结束先于 finalize() 方法
以程序顺序原则为例,重排后的指令绝对不能改变原有的串行语义。比如:
由于第 2 条语句依赖第一条的执行结果。如果冒然交换两条语句的执行顺序,那么程序的语义就会修改。因此这种情况是绝对不允许发生的。因此,这也是指令重排的一条基本原则。
此外,锁规则强调,unlock 操作必然发生在后续的对同一个锁的 lock 之前。也就是说,如果对一个锁解锁后,再加锁,那么加锁的动作绝对不能重排到解锁动作之前。很显然,如果这么做,加锁行为是无法获得这把锁的。
其他几条原则也是类似的,这些原则都是为了保证指令重排不会破坏原有的语义结构。