在有些问题中,如果可用资源越多,那么问题的解决速度就越快。例如,如果参与收割庄稼的工人越多,那么就能越快地完成收割工作。而有些任务本质上是串行的,例如,即使增加再多的工人也不可能增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效地使用这种潜在的并行能力。
大多数并发程序都与农业耕作有着许多相似之处,它们都是由一系列的并行工作和串行工作组成的。Amdahl 定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl 定律,在包含N个处理器的机器中,最高的加速比为:
当N趋近无穷大时,最大的加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2(而不管有多少个线程可用);如果在程序中有10%的计算需要串行执行,那么最高的加速比将接近10。Amdahl 定律还量化了串行化的效率开销。在拥有10个处理器的系统中,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2 (9%的使用率)。即使拥有无限多的CPU,加速比也不可能为10。
图11-1 给出了处理器利用率在不同串行比例以及处理器数量情况下的变化曲线。(利用率的定义为:加速比除以处理器的数量。)随着处理器数量的增加,可以很明显地看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源时能够提升的吞吐率。
第6章介绍了如何识别任务的逻辑边界并将应用程序分解为多个子任务。然而,要预测应用程序在某个多处理器系统中将实现多大的加速比,还需要找出任务中的串行部分。
假设应用程序中N个线程正在执行程序清单11-1中的doWork,这些线程从一个共享的工作队列中取出任务进行处理,而且这里的任务都不依赖于其他任务的执行结果或影响。暂时先不考虑任务是如何进入这个队列的,如果增加处理器,那么应用程序的性能是否会相应地发生变化?初看上去,这个程序似乎能完全并行化:各个任务之间不会相互等待,因此处理器越多,能够并发处理的任务也就越多。然而,在这个过程中包含了一个串行部分——从队列中获取任务。所有工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态,那么当一个线程从队列中取出任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。
程序清单11-1 对任务队列的串行访问
public class WorkerThread extends Thread {
private final BlockingQueue<Runnable>queue;
public WorkerThread(BlockingQueue<Runnable>queue){
this. queue =queue;
}
public void run(){
while (true){
try {
Runnable task =queue. take();
task. run();
}catch (InterruptedException e){
break;/* 允许线程退出 */
}
}
}
}
单个任务的处理时间不仅包括执行任务Runnable 的时间,也包括从共享队列中取出任务的时间。如果使用LinkedBlockingQueue作为工作队列,那么出列操作被阻塞的可能性将小于使用同步LinkedList时发生阻塞的可能性,因为LinkedBlockingQueue使用了一种可伸缩性更高的算法。然而,无论访问何种共享数据结构,基本上都会在程序中引入一个串行部分。
这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或者产生某种效应——如果不会,那么可以将它们作为“死亡代码”删除掉。由于Runnable 没有提供明确的结果处理过程,因此这些任务一定会产生某种效果,例如将它们的结果写入到日志或者保存到某个数据结构。通常,日志文件和结果容器都会由多个工作者线程共享,并且这也是一个串行部分。如果所有线程都将各自的计算结果保存到自行维护数据结构中,并且在所有任务都执行完成后再合并所有的结果,那么这种合并操作也是一个串行部分。
在所有并发程序中都包含一些串行部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细检查一遍。
示例:在各种框架中隐藏的串行部分
要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。图11-2给出了一个简单的应用程序,其中多个线程反复地从一个共享Queue 中取出元素进行处理,这与程序清单11-1很相似。处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么它将把一组新元素放入队列,因而其他线程在下一次访问时不会没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
对两个线程安全的Queue的吞吐率进行了比较:其中一个是采用synchroni-zedList 封装的LinkedList;另一个是ConcurrentLinkedQueue。这些测试在8路Sparc V880系
统上运行,操作系统为Solaris。尽管每次运行都表示相同的“工作量”,但我们可以看到,只需改变队列的实现方式,就能对可伸缩性产生明显的影响。
ConcurrentLinkedQueue的吞吐量不断提升,直到到达了处理器数量上限,之后将基本保持不变。另一方面,当线程数量小于3时,同步LinkedList的吞吐量也会有某种程度的提升,但是之后会由于同步开销的增加而下跌。当线程数量达到4个或5个时,竞争将非常激烈,甚至每次访问队列都会在锁上发生竞争,此时的吞吐量主要受到上下文切换的限制。
吞吐量的差异来源于两个队列中不同比例的串行部分。同步的LinkedList采用单个锁来保护整个队列的状态,并且在offer 和remove等方法的调用期间都将持有这个锁。ConcurrentLinkedQueue 使用了一种更复杂的非阻塞队列算法(请参见15.4.2节),该算法使用原子引用来更新各个链接指针。在第一个队列中,整个的插入或删除操作都将串行执行,而在第二个队列中,只有对指针的更新操作需要串行执行。
Amdahl 定律的应用
如果能准确估计出执行过程中串行部分所占的比例,那么Amdahl定律就能量化当有更多计算资源可用时的加速比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下Amdahl 定律仍然是有用的。
因为我们的思维通常会受到周围环境的影响,因此很多人都会习惯性地认为在多处理器系统中会包含2个或4个处理器,甚至更多(如果得到足够大的预算批准),因为这种技术在近年来被广泛使用。但随着多核CPU逐渐成为主流,系统可能拥有数百个甚至数千个处理器。一些在4路系统中看似具有可伸缩性的算法,却可能含有一些隐藏的可伸缩性瓶颈,只是还没有遇到而已。
在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。例如,在11.4.2节和11.4.3 节中介绍了两种降低锁粒度的技术:锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)。当通过Amdahl定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。)