[Java Concurrency in Practice]第十一章 性能与可伸缩性

性能与可伸缩性

线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。

首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。

11.1 对性能的思考

提升性能意味着用更少的资源做更多地事情。“资源”的含义很广。对于一个给定的操作,通常会缺乏某种特定的资源,例如CPU时钟周期、内存、网络带宽、I/O带宽、数据库请求、磁盘空间以及其他资源。当操作性能由于某种特定的资源而受限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型、数据库密集型等。

尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度地使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。

要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。从性能监视的视角来看,CPU需要尽可能保持忙碌状态。(当然,这并不意味着将CPU时钟周期浪费在一些无用的计算上,而是执行一些有用的工作。)如果程序是计算密集型的,那么可以通过增加处理器来提高性能。因为如果程序无法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使得所有CPU都保持忙碌状态。

11.1.1 性能与可伸缩性

应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个特定的任务单元需要“多快“才能处理完成。另一些指标(生产量、吞吐量)用于程序的”处理能力“,即在计算资源一定的情况下,能完成”多少“工作。

可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力响应地增加。

在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用时间复杂度O(n2)算法来代替复杂度为O(nlogn)的算法。在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多地计算资源来完成更多的工作。

性能的这两个方面——”多块“和”多少“,是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个”流水线“子任务时。但是,大多数提高单线程程序性能的技术,往往都会破坏可伸缩性。

我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层和持久层是彼此独立的,并且可能由不同的系统来处理,这很好地说明了提高可伸缩性通常会造成性能损失的原因。如果把表现层、业务逻辑层和持久层都融合到单个应用程序中,那么在处理第一个工作单元时,其性能肯定要高于将应用程序分为多层并将不同层次分布到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此能减少许多开销(例如在任务排队、线程协调以及数据复制时存在的开销)。
然而,这种单一的系统到达自身的处理能力的极限时,会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。因此,我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。

对于服务器应用程序来说,”多少“这个方面——可伸缩性、吞吐量和生产量,往往比”多块“这个方面更受重视。(在交互式应用程序中,延迟或许更加重要,这样用户就不用等待进度条的指示,并奇怪程序究竟在执行哪些操作。)

11.1.2 评估各种性能权衡因素

在几乎所有的工程决策中都会设计某些形式的权衡。例如”快速排序“算法在大规模数据集上的执行效率非常高,但对于小规模的数据集来说,“冒泡排序“实际上更高效。如果要实现一个高效的排序算法,那么需要知道被处理数据集的大小,还有权衡优化的指标,包括:平均计算时间、最差时间、可预知性。然而,编写某个库中排序算法的开发人员通常无法知道这些需求信息。这就是为什么大多数优化措施都不成熟的原因之一:它们通常无法获得一组明确地需求。

避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它还运行得不够快。

当进行决策时,有时候会通过增加某种形式的成本来降低另一种形式的开销(例如,增加内存使用量以降低服务时间)。如果你无法找出其中的代价或风险,那么或许还没有对这些优化措施进行彻底的思考和分析。

在大多数性能决策中都包含有多个变量,并且非常依赖于运行环境。在使某个方案比其他方案”更快“之前,首先问自己一些问题:

  • ”更快“的含义是什么?
  • 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
  • 在其他不同条件的环境中能否使用这里的代码?
  • 在实现这种性能提升时需要付出哪些隐含地代价,例如增加开发风险或维护开销?这种权衡是否合适?

在进行任何与性能相关的决策时,都应该考虑这些问题。

在对性能的调优时,一定要有明确的性能需求(这样才能知道什么时候需要调优,以及什么时候应该停止),此外还需要一个测试程序以及真实地配置和负载等环境。在对性能调优后,你需要再次测量以验证是否到达了预期的性能提升目标。在许多优化措施中带来的安全安全性和可维护性等很风险非常高。如果不是必须的话,你通常不想付出这样的代价,如果无法从这些措施中获得性能提升,那么你肯定不希望付出这种代价。

以测试为基准,不要猜测。

在市场上有一些成熟的分析工具可以用于评估性能以及找出性能瓶颈,但你不需要花太多的资金来找出程序的功能。例如,免费得perfbar应用程序可以给出CPU的忙碌程度信息,而我们通常的目标就是使CPU保持忙碌状态,因此这个功能可以有效地评估是否需要进行性能调优或者已实现的调优效果如何。

11.2 Amdahl定律

在有些问题中,如果可用资源越多,那么问题的解决速度就越快。例如,如果参与收割庄稼的工人越多,那么就能越快地完成收割工作。而有些任务本质上是串行的,例如,即使增加再多的工人也不能增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效地使用这种潜在的并行能力。

大多数并发程序都与农业耕作有着许多相似之处,它们都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:

Speedup <= 1 / (F + (1 - F) / N)

当N趋近无穷大时,最大的加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2(而不管有多少个线程可用):如果在程序中有10%的计算需要串行执行,那么最高的加速比将接近10。Amdahl定律还量化了串行化的效率开销。在拥有19个处理器的系统中,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3%(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2(9%的使用率)。即使拥有无限多的CPU,加速比也不可能为10.

下图给出了处理器利用率在不同串行比例以及处理器数量情况下的变化曲线。(利用率的定义为:加速比除以处理器的数量。)随着处理器数量的增加,可以很明显地看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源时能够提升的吞吐率。

要预测应用程序在某个多处理器系统中将实现多大的加速比,还需要找出任务中的串行部分。

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; /* Allow thread to exit */
            }
        }
    }
}

在这个过程中包含了一个串行部分——从队列中获取任务。所有工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维护队列的完整性。如果通过加锁来保护队列的状态,那么当一个线程从队列中取出任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。

这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或者产生某种效应——如果不会,那么可以将它们作用”死亡代码“删除掉。由于Runnable没有提供明的结果处理过程,因此这些任务一定会产生某种效果,例如将它们的结果写入到日志或者保存到某个数据结构。通常,日志文

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值