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

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

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

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

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

性能的思考

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

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

如:

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

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

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

性能"遭遇"可伸缩性

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

对性能的权衡进行评估

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

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

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

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

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

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

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

测评,不要臆测

Amdahl 定律

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

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

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

线程引入的开销

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

切换上下文

如果可运行的线程大于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而不被阻塞.

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

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

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

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

总结

Amdahl定律告诉我们,程序的可伸缩性是由必须连续执行的代码比例决定的.Java程序中串行化首要的来源是独占的资源锁,所以可伸缩性通常可以通过以下这些方式提升:
    减少获取锁的时间减少锁的粒度减少锁的占用时间用非独占或非阻塞锁来取代独占锁