问题
并发编程的目的是为了让程序运行得更快,但是,并不是启动更多的线程就能让程序最大限度地并发执行。如果希望通过多线程执行任务让程序运行得更快,会面临非常多的挑战。
- 性能与可伸缩性:
- 上下文切换的问题、锁的竞争。
- 活跃性危险:
- 死锁、饥饿、糟糕的响应性、活锁。
- 受限于硬件和软件的资源限制问题。
概述
性能与可伸缩性
首先要保证程序能正常运行,然后仅当程序的性能需求和测试结果要求程序执行的更快时,才应该设法提高它的运行速度。
对性能的思考
提升性能意味着用更少的资源做更多的事情。资源指CPU时钟周期、内存、网络带宽、IO带宽、数据库请求、磁盘空间以及其他资源。
当操作性能由于某种特定的资源而受限时,通常称为资源密集型操作,例如CPU密集型、数据库密集型。
使用多个线程相比于单个线程总会引入一些额外的性能开销:
- 线程间的协调,例如加锁、触发信号以及内存同步等。
- 上下文切换、线程的创建和销毁。
- 线程的调度等。
因此想要通过并发获得更好的性能,就需要努力做好两件事情:更有效地利用现有处理资源、在出现新的处理资源时使程序尽可能利用这些新资源。
性能与可伸缩性
应用程序的性能可以使用多个指标来衡量:服务时间、延迟时间、吞吐率、效率、可伸缩性、容量等:
- 运行速度:某个指定的任务单元需要多块才能处理完成,用于衡量程序的运行速度。例如服务时间、等待时间。
- 处理能力:计算资源一定的情况下,能完成多少工作。例如生产量、吞吐量。
可伸缩性:当增加计算资源时(CPU、内存、存储或IO带宽),程序的吞吐量或者处理能力能相应地增加。
性能调优
通常目的时用更小的代价完成相同的工作,例如通过缓存重用之前的结果,或更换算法。
可伸缩性调优
目的是将问题的计算并行化,从而能利用更多的计算资源完成更多的工作。
对于多快与多少两个指标,是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个人物所要处理的工作量。
评估各种性能权衡因素
大多数优化措施不成熟的原因之一:它们通常无法获得一组明确的需求。
避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它还运行地不够快。
当进行决策时,有时候通过增加某种形式的成本来降低另一种形式的开销。大多数性能决策都包含多个变量,并且非常依赖于运行环境。在使得某个方案比其他方案更快前,首先要明确:
- 更快的含义时什么
- 该方法在什么条件下运行地更快?低负载还是高负载?大数据集还是小数据集?能否通过测试结果验证答案
- 这些条件在运行环境中的发生频率?能否通过测试结果验证答案
- 在其他不同条件的环境中能否使用这些代码
- 在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?
对于并发,开发人员对于哪些地方存在性能问题,哪种方法的运行速度更快,以及哪种方法的可伸缩性更高,往往会存在错误的直觉。因此在性能调优时一定要有明确的性能需求(什么时候需要调优,什么时候应该停止)。并且需要应该测试环境进行测试是否达到了预期目标。
以测试为基准,不要猜测。
Amdahl定律
明确并行部分的代码与串行部分的代码后再进行优化。
大多数并发程序都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假设F是必须被串行执行的部分,则在包含N个处理器的机器中,最高加速比为:
$$
Speedup<1/(F+(1-F)/N)
$$
即如果程序有50%的计算需要串行执行,那么最高加速比只能是2。该理论还量化了串行化的效率。
应用
当代多核CPU成为主流,系统可能拥有数百个处理器,一些在4路系统中看似具有可伸缩性的算法,可能存在一些隐含的可伸缩瓶颈。
线程引入的开销
上下文切换
如果可运行的线程大于CPU的数量,那么OS最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU,这将导致一次上下文切换。
上下文切换概念
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
损耗:
- 保存上一个任务状态、再加载下一个任务状态所消耗的时间。并且新的线程数据可能不在缓存中,将导致一些缓存丢失,使得线程在首次调度运行时更加缓慢。因此线程有一个最小执行时间。
- 应用程序、OS、JVM使用一组相同的CPU,在JVM和OS的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。
单核处理器实现多线程执行代码:
CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
多线程未必很快
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁还有受限于硬件和软件的资源闲置问题。
多线程就是几乎同时执行多个线程(一个处理器在某一个时间点上永远都只能是一个线程!即使这个处理器是多核的,除非有多个处理器才能实现多个线程同时运行)。CPU通过给每个线程分配CPU时间片来实现伪同时运行,因为CPU时间片一般很短很短,所以给人一种同时运行的感觉。
1 | public class ConcurrencyTest { |
测试上下文切换
- 使用Lmbench3(性能分析工具)可以测量上下文切换的时长。
- 一般相当于5000~10000个时钟周期,即几微秒。
- 使用vmstat可以测量上下文切换的次数。
- 一般一秒1000多次,如果内核占有率很高(10%以上),那么通常表示调度活动发生很频繁,可能由IO或竞争锁导致的。
如何减少上下文切换
上下文切换又分为2种:让步式上下文切换和抢占式上下文切换。前者是指执行线程主动释放CPU,与锁竞争严重程度成正比,可通过减少锁竞争和使用CAS算法来避免;后者是指线程因分配的时间片用尽而被迫放弃CPU或者被其他优先级更高的线程所抢占,一般由于线程数大于CPU可用核心数引起,可通过适当减少线程数和使用协程来避免。
如果线程频繁发生阻塞,它们将无法使用完整的调度时间片。在程序上发生越多的阻塞,与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,因此降低吞吐量。
方法
- 无锁并发编程:
- 避免使用锁,如将数据ID按照HASH算法取模分段,不同线程处理不同段的数据。
- CAS算法。
- 使用最少线程。
- 避免创造太多的线程,使得大量线程处于等待。
- 使用协程。
- 单线程当中实现多任务的调度,并在单个线程里维持多个任务的切换。
实战
通过减少大量waiting线程来减少上下文切换:
内存同步
内存操作的性能开销包括多个方面,例如volatile
提供的可见性会使用到一些特殊指令,即内存栅栏(Memory Barrier
)内存栅栏可以刷新缓存,使得缓存无效,刷新硬件写缓存,以及停止执行管道。并且会对性能带来间接影响,因为将抑制一些优化操作。
评估同步带来的性能影响,要区分有竞争的同步和无竞争的同步(volatile通常是非竞争的)。synchrnized对无竞争的同步进行了优化,非竞争的同步对整体性能影响微乎其微。
阻塞
竞争的同步可能需要OS介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会则是,JVM在实现阻塞行为时可以采用自旋等待,或OS挂起被阻塞的线程。具体的效率高低要取决于上下文切换的开销以及在成功获得锁之前等待的时间。
减少锁竞争
串行操作会降低并行性,上下文切换也会降低性能,在锁上发生竞争将同时导致这两种问题。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
减少锁发生竞争的可能性有:
- 锁的请求频率。
- 每次持有该锁的时间:
- 即缩小锁的范围。
- 降低线程请求锁的频率,可以通过锁分解和锁分段等技术实现,使得不同线程在不同的数据或一个数据的不同部分上操作。
- 锁分段一定要表现出在锁上的竞争频率高于在锁保护的数据上发生的频率。
- 也可以使用带有协调机制的独占锁,这些机制允许更高的并发性。
当每个请求都请求多个变量时,锁的粒度将很难降低。
监测CPU的利用率
当测试可伸缩性时,通常要确保处理器得到充分利用。UNIX上的vmstat可以查看CPU的忙碌状态。如果所有的CPU利用率并不均匀,那么首要目标就是进一步找出程序中的并行性,不均匀的利用率表明大多数计算都是由一小组线程完成的。通常有以下几种原因:
- 负载不充足。测试的程序中可能没有足够多的负载,因而可以在测试时增加负载,并检查利用率、响应时间、服务时间等指标的变化。
- IO密集。可以通过iostat或perfmon判断某个程序是否磁盘IO密集的,或通过监测网络的通信流量判断是否需要高带宽。
- 外部限制。如果应用程序依赖于外部服务,可能性能瓶颈是在外部服务中。
- 锁竞争。使用分析工具可以找到程序中存在何种程度的锁竞争,以及在哪些锁上存在激烈的竞争。如果因为激烈的竞争,则在线程转储中会频繁出现。
资源限制
概念
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。所以在进行并发编程时,要考虑这些资源的限制。
- 服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资源,下载速度不会变成10Mb/s。
- 硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。
- 软件资源限制有数据库的连接数和socket连接数等。
引发的问题
本来是并发编程,但由于资源限制,导致并行退化为串行。在此情况下,由于上下文切换与资源调度,导致执行速度更慢。
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程序使用多线程在办公网并发地下载和处理数据时,导致CPU利用率达到100%,几个小时都不能运行完成任务,后来修改成单线程,一个小时就执行完成了。
解决资源限制
- 对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。
- 对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。
在资源限制情况下进行并发编程
如何在资源限制的情况下,让程序执行得更快呢?
- 方法就是,根据不同的资源限制调整程序的并发度:
- 比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。
- 有数据库操作时,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接数大很多,则某些线程会被阻塞,等待数据库连接。
活跃性危险
在安全性与活跃性间通常存在某种制衡,我们使用加锁确保线程安全,但如果过度使用加锁,则可能导致锁顺序死锁。
如果使用线程池和信号量来限制对资源的使用,可能导致资源死锁。Java程序无法从死锁中恢复过来,因此设计时一定要排除可能导致死锁的条件。
死锁
是什么
死锁的必要条件:
- 互斥条件。
- 请求和保持条件。
- 不剥夺条件。
- 环路等待条件。
解决锁:
- 持有锁一定时间,依然在等待的话,则释放持有的所有锁。
死锁示例
A、B锁互相等待:
1 | public class DeadLockDemo { |
出现的场景
- t1拿到锁之后,因为一些异常情况没有释放锁(死循环)。
- t1拿到一个数据库锁,释放锁的时候抛出了异常,没释放掉。
锁顺序死锁
两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁就不会出现循环的加锁依赖性。
如果所有线程都以固定的顺序来获得锁,那么程序中就不会出现锁顺序死锁的问题。
动态的锁顺序死锁
有时候并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。
假设一个转账的场景,需要获得转账的账户A以及被转账的账户B的锁,那么假设同时发生A->B转账与B->A转账。则会产生死锁。
可以通过定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。
在协作对象间发生的死锁
在协作对象间,很容易出现锁的获取顺序不同的问题,因为作为两个不同的业务场景,它们最开始需要的锁很可能是不同的。
如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能或获取其他锁(可能产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
开放调用
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。
在程序中应尽量使用开放调用,与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。
资源死锁
在相同的资源集合上等待时也会出现死锁,例如等待数据库连接。
另一种形式就是线程饥饿死锁,例如单线程的线程池中一个任务提交另一个任务,则第一个任务永远等待,另一个任务永远在队列中等待。
死锁检测
在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些示例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。
利用dump线程查看到底哪个线程出现了问题,可见是42行、31行出现死锁:
死锁避免
当必须获取多个锁,那么在设计时必须考虑锁的顺序,尽量减少潜在的加锁交互数量,将获取锁需要遵守的协议写入文档。
常见方法:
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
- 超时后释放锁,并在一段时间后重新尝试。但是对于嵌套的多个锁效果很差。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
饥饿
当线程由于无法访问它所需要的资源而不能继续执行时就发生了饥饿。
最常见的资源时CPU时钟周期。例如线程优先级(OS相关,增加平台依赖性)使用不当,或者持有锁时进行一些无法结束的结构,也会导致饥饿。
丢失信号
糟糕的响应性
如果某个线程长时间占有一个锁,而其他想要访问整个容器的线程就需要等待很长时间。
活锁
活锁是另一种活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操,而且总会失败。
例如处理事务消息的程序,如果不能成功处理某个消息,则回滚整个事务,并将其重新放到队列的开头,如果消息处理器处理某种特定类型的消息存在错误并导致事务失败,那么由于消息在队列开头,处理器将反复调用并一直失败。
并发编程模型的两个关键问题
- 线程间如何通信。通信指线程间以何种机制交换信息。
- 线程间如何同步。
在命令式编程中,线程通信机制有两种:
- 共享内存。
- 线程间共享程序的公共状态,通过读写内存中的公共状态进行隐式通信。
- 同步是显式进行的,程序员必须显式指定某个方法或某段代码需要在线程间互斥执行。
- 消息传递。
- 线程间没有公共状态,必须通过发送消息来显式进行通信。
- 同步是隐式进行的,消息的发送必须在消息的接受前。
Java并发采用的是共享内存模型
Java线程间通信总是隐式进行,整个通信过程对程序员完全透明。
多线程开发良好的实践
- 给线程起个有意义的名字,这样可以方便找 Bug。
- 缩小同步范围,从而减少锁争用。例如对于synchronized,应该尽量使用同步块而不是同步方法。
- 多用同步工具少用
wait()
和notify()
。首先,CountDownLatch, CyclicBarrier, Semaphore 和 Exchanger这些同步类简化了编码操作,而用wait()
和notify()
很难实现复杂控制流;其次,这些同步类是由最好的企业编写和维护,在后续的JDK中还会不断优化和完善。 - 使用BlockingQueue实现生产者消费者问题。
- 多用并发集合少用同步集合,例如应该使用ConcurrentHashMap而不是Hashtable。
- 使用本地变量和不可变类来保证线程安全。
- 使用线程池而不是直接创建线程,这是因为创建线程代价很高,线程池可以有效地利用有限的线程来启动任务。
总结
建议多使用JDK并发包提供的并发容器和工具类来解决并发问题,因为这些类都已经通过了充分的测试和优化,均可解决了本章提到的几个挑战。