并发编程学习笔记之可伸缩性(九)

很多改进性能的技术增加了复杂度,因此增加了安全和活跃度失败的可能性.

更糟糕的是,有些技术的目的是改善性能,事实上产生了相反的作用,带来了其他的性能问题.

数据的正确性永远是第一位的,保证程序是正确的,然后再让它更快.只有当你的性能需求和评估标准需要程序运行得更快时,才去进行改进.

在设计并发应用程序的时候,最大可能地改进性能,通常并不是最重要的事情.

性能的思考

当活动的运行因某个特定资源受阻时,我们称之为受限于该资源:受限于CPU,受限于数据库.

使用线程的目的是希望全面提升性能,但是与单线程相比,使用多线程会引入一些额外的开销.

如:

  • 协调线程相关的开销(加锁、信号、内存同步)
  • 增加的上下文切换
  • 线程的创建和消亡,以及调度的开销

当线程被过度使用后,这些开销会超过提高后的吞吐量响应性和计算能力带来的补偿.

一个没能经过良好并发设计的应用程序,甚至比相同功能的顺序的程序性能更差.

性能"遭遇"可伸缩性

可伸缩性指的是:当增加计算资源的时候(比如增加额外CPU数量、内存、存储器、I/O带宽),吞吐量和生产量能够相应地得以改进.

对性能的权衡进行评估

避免不成熟的优化,首先使程序正确,然后再加快----如果它运行得还不够快.

很多性能的优化会损害可读性或可维护性--代码越"聪明",越"晦涩",就越难理解和维护.

在多个方案之间进行选择的时候,先问自己一些问题:

  • 你所谓的更"快"指的是什么
  • 在什么样的条件下你的方案能够真正运行得更快?在轻负载还是重负载下?大数据集还是小数据集?是否支持你的测量标准答案?
  • 这些条件在你的环境中发生的频率?是否支持你的测量标准的答案?
  • 这些代码在其他环境的不同条件下被用到的可能性?
  • 你用什么样隐含的代价,比如增加的开发风险或维护性,换取了性能的提高?这个权衡的决定是否正确?

做出任何与性能相关的工程决定时,都应该考虑这些问题.

最好选择保守的优化方案,因为对性能的追求很可能是并发bug唯一最大的来源.通过减少同步来提高响应性,成了不遵守同步规定的常用的借口,但是因为并发bug是最难追踪和消除的,所以任何引入这类bug的行动风险都需要慎重进行.

优化改进后的代码,一定要进行压力测试.主观认为会提高性能的代码,在实际生产环境可能会出现问题.

测评,不要臆测

Amdahl 定律

Amdahl定律描述了在一个系统中,基于可并行化和串行化的组件各自所占的比重,程序通过获得额外的计算资源,理论上能够加速多少.

如果F是必须串行化执行的比重,那么Amdahl定律告诉我们,在一个N处理器的机器中,我们最多可以加速:

image

串行执行的比率越大,处理器越多,处理器的利用率越低:

image

线程引入的开销

调度和线程内部的协调都要付出性能的开销: 对于改进性能的线程来说,并行带来的性能优势必须超过并发所引入的开销.

切换上下文

如果可运行的线程大于CPU的数量,那么操作系统最终会强行换出正在执行的线程,从而使其他线程能够使用CPU,这回引起上下文切换,他会保存当前运行线程的执行上下文,并重建新调入线程的执行上下文.

切换上下文会有资源的损耗.

一个程序发生越多的阻塞(阻塞I/O,等待竞争锁,或者等待条件变量),与受限于CPU的程序相比,就会造成越多的上下文切换,这增加了调度的开销,并减少了吞吐量(无阻塞的算法可以减少上下文切换).

Unix系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数和内核占用的时间等信息.

阻塞

多个线程竞争加锁的方法的时候,失败的线程必然发生阻塞.

JVM在阻塞的时候有两种处理方式:

  • 自旋等待(spin-waiting,不断尝试获取锁,直到成功).
  • 挂起(suspending)这个阻塞的线程.

自旋等待适合短期的等待.挂起适合长期间等待.,有一些JVM基于过去等待时间的数据剖析来在这两者之间选择,但是大多数等待锁的线程都是被挂起的.

减少锁的竞争

串行化会损害可伸缩性,上下文切换会损害性能.竞争性的锁会同时导致这两种损失,所以减少锁的竞争能够改进性能和可伸缩性.

访问独占锁守护的资源是串行的--一次只能有一个线程访问它.使用锁可以避免过期数据,但是安全性是用很大的代价换来的,对锁长期的竞争会限制可伸缩性.

并发程序中,对可伸缩性首要的威胁是独占的资源锁.

有两个原因影响着锁的竞争性:

  • 锁被请求的频率
  • 每次持有锁的时间

如果这两者的乘积足够小,那么大多数请求锁的尝试都是非竞争的,这样竞争性的锁将不会成为可伸缩性巨大的障碍.

但是,如果这个锁的请求量很大,线程将会阻塞以等待锁.在极端的情况下,处理器将会闲置,即使仍有大量工作等待着完成.

有三种方式来减少锁的竞争:

  • 减少持有锁的时间;
  • 减少请求锁的频率;
  • 或者用协调机制取代独占锁,从而允许更强的并发性.

缩小锁的范围("快进快出")

减少竞争发生可能性的有效方式是尽可能缩短把持锁的时间.尽量缩小synchronized代码块,尤其是那些耗时的操作,以及那些潜在的阻塞操作(I/O).

减少锁的粒度

减少持有锁的时间比例的另一种方式是让线程减少调用它的频率(因此减少发生竞争的可能性).

可以通过使用分拆锁(lock splitting)和分离锁(lock striping)来实现,也就是采用相互独立的锁,守卫多个独立的状态变量,在改变之前,它们都是由一个锁守护的.这些技术减少了锁发生时的粒度,潜在实现了更好的可伸缩性---但是使用更多的锁同样会增加死锁的风险.

如果一个锁 守卫数量大于一、且相互独立的状态变量,你可能能通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性.结果是每个锁被请求的频率都减少 了.

使用相同的锁:

public class NewLock {
    //对象A
    private final Object objA = new Object();
    //队相比
    private final Object objB = new Object();

    public synchronized Object getObjA(){
            return objA;
    }

    public synchronized Object getObjB(){
            return objB;
    }

}

使用不同的锁(分拆锁),减少了锁的请求频率:

public class NewLock {
    //对象A
    private final Object objA = new Object();
    //队相比
    private final Object objB = new Object();

    public Object getObjA(){
        synchronized (objA){
            return objA;
        }
    }

    public Object getObjB(){
        synchronized (objB){
            return objB;
        }
    }

}

分拆锁对于竞争并不激烈的锁,能够在性能和吞吐量方面产生一些纯粹的改进,尽管这可能会在性能开始因为竞争而退化时增加负载的极限.

分拆锁对于中等竞争强度的锁,能够切实地把它们大部分转化成非竞争的锁,这个结果是性能和可伸缩性都期望得到的.

分离锁

分拆锁对性能的改进有一些局限性,不能大幅地提高多个处理器在同一系统中并发性的能力.

分拆锁有时候可以被扩展,分成可大可小加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁.

分离锁的一个负面作用是:对容器加锁,进行独占访问更加困难,并且更加昂贵了.

分拆锁和分离锁能够改进可伸缩性,因为它们能够使不同的线程操作不同的数据(或者相同数据结构的不同部分),而不会发生相互干扰.

能够从分拆锁收益的程序,通常是那些对锁的竞争普遍大于对锁守护数据竞争的程序.

例如: 一个锁守护两个独立变量X和Y,线程A想要访问X,而线程B想要访问Y,这两个线程没有竞争任何数据,然而它们竞争相同的锁.

独占锁的替代方法

用于减轻竞争锁带来的影响的第三种技术是提前使用独占锁,这有助于使用更友好的并发方式进行共享状态的管理.

这包括:

  • 使用并发容器
  • 读-写锁
  • 不可变对象
  • 原子变量

读写锁

读写锁实行了一个多读者-单写者(multiple-reader,single-write)加锁规则:只要没有改变,多个读者可以并发访问共享资源,但是写者必须独占获得锁.

对于多数操作都为读操作的数据结构,ReadWriteLock与独占的锁相比,可以提供更好的并发性.

对于只读的数据结构,不变性可以完全消除加锁的必要.

原子变量

原子变量类提供了针对整数或对象引用的非常精妙的原子操作,因此更具可伸缩性.

如果你的类只有少量热点域(例如:多个方法都在调用的计数操作,就是一个热点域),并且该类不参与其它变量的不变约束,那么使用原子变量替代它可能会提高可伸缩性.

检测CPU利用率

当我们测试可伸缩性的时候,我们的目标通常是保持处理器的充分利用.

Unix系统的vmstat和mpstat,或者Windows系统的perfmon都能够告诉你处理器有多忙碌.

如果所有的CPU都没有被均匀地利用(有时CPU很忙碌地运行,有时很清闲),那么你的首要目标应该是增强你程序的并行性.

不均匀的利用率表名,大多数计算都有很小的线程集完成,你的应用程序将不能够利用额外的处理器资源.

如果你的CPU没有完全利用,你需要找出原因.有以下几种:

  • 不充足的负载. 数据量不够多
  • I/O限制
  • 外部限制.可能你的应用程序取决于外部服务,比如数据库或者Web Service 那么瓶颈可能不在于你自己的代码.
  • 锁竞争. 使用Profiling工具能够告诉你,程序中存在多少个锁的竞争,哪些锁很"抢手".或者使用线程转储,如果线程因等待锁被阻塞,与线程转储的栈框架会声明"waiting to lock monitor...".非竞争的锁几乎不会出现在线程转储中:竞争激烈的锁几乎总会只要有一个线程在等待获得它,所以会频繁出现在线程转储中.

向"对象池"说"不"

不要使用对象池,对象池跟线程池差不多,为了减少创建和销毁对象的开销,能够重复使用对象,创建了一个对象池,但是现代的JVM对象的分配和垃圾回收已经非常快了.

如果使用对象池,那么线程从池中请求对象,协调访问池的数据结构的同步就成为必然了,这便产生了线程阻塞的可能性.

又因为由锁的竞争产生的阻塞,其代价比直接分配的代价多几百倍,即使是很小的池竞争都会造成可伸缩性的瓶颈(甚至是非竞争的同步,其代价也会比分配一个对象大很多).

所以使用对象池有点得不偿失了,反而效率更低.

比较Map的性能

单线程的时候ConcurrentHashMap的性能要比同步的HashMap的性能稍好一点,但是在并发应用中,这种作用就十分明显了.

ConcurrentHashMap对get操作做了一些优化,提供最好的性能和并发性.

同步的Map对所用的操作用的都是一个锁,所以同一时刻只有一个线程能够访问map.

而ConcurrentHashMap并没有对成功的读操作加锁,只对写操作和真正需要锁的读操作使用了分离锁的方法.因此多线程能够并发地访问Map而不被阻塞.

image

随着线程数的增加,并发的map吞吐量得到增长.看ConcurrentHashMap在线程数到达16的时候,它的吞吐量不在提高,因为它的内部使用的是16个分离锁的数组,可以支持16个线程同时写,当线程多余这个数量的时候,就得不到提升了(可以增加锁的数量,提高并行性)

再看同步容器,线程数越多,反而吞吐量降低.

在对锁的竞争小的境况下,每个操作花费的时间取决于真正工作的时间,吞吐量会因为线程数的增加而增加.

一旦竞争变得激烈,每个操作花费的时间就由上下文切换和调度延迟决定了,并且加入更多的线程不会对吞吐量有什么帮助.

总结

  • Amdahl定律告诉我们,程序的可伸缩性是由必须连续执行的代码比例决定的.
  • Java程序中串行化首要的来源是独占的资源锁,所以可伸缩性通常可以通过以下这些方式提升:
  1. 减少获取锁的时间
  2. 减少锁的粒度
  3. 减少锁的占用时间
  4. 用非独占或非阻塞锁来取代独占锁
posted @ 2018-10-29 10:26  lbr617  阅读(710)  评论(3编辑  收藏  举报