并发编程之性能与可伸缩性

本篇文章介绍并发编程的性能与可伸缩性,内容皆总结摘抄自《Java并发编程实战》,仅作笔记。

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

然而,许多提升性能的技术同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。虽然不轮任何程序都想要获得更好的性能,但始终要把安全性放在第一位。首先要保证程序能正常运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。

性能与可伸缩性

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

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

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

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

我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层和持久化层是彼此独立的,并且可能由不同的系统来处理,这很好地说明了提高可伸缩性通常会造成性能损失的原因。如果把表现层、业务逻辑层和持久化层都融合到单个应用程序中,那么在处理第一个工作单元时,其性能肯定要高于将应用程序分为多层并将不同层次分不到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此能减少许多开销。

然而,当这种单一的系统到达自身处理能力的极限时,要进一步提升它的处理能力将非常困难。因此,我们通常会接收每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。

线程引入的开销

单线程程序即不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。在多个线程的调度和协调过程中都需要一定的性能开销:为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

上下文切换

如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。

切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM内部都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量。

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。

内存同步

同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊的指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化的操作。在内存栅栏中,大多数操作都是不能被重排序的。

现代的JVM能通过优化去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化去掉这个锁获取操作,因此另一个线程无法与当前线程在这个锁上发生同步。

某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。

阻塞

非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但大多数JVM在等待锁时都只是将线程挂起。

当线程无法获取某个锁或由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或其他资源可用时,又再次被切换回来。

减少锁的竞争

串行操作会降低可伸缩性,而上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。

在对由某个独占锁保护的资源进行访问时,将采用串行方式,即每次只有一个线程能访问它。如果在锁上持续发生竞争,将限制代码的可伸缩性。在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。

有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积较小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。

有三种方式可以降低锁的竞争性:

  1. 减少锁的持有时间;
  2. 降低锁的请求频率;
  3. 使用带有协调机制的独占锁,这些机制允许更高的并发性。

减少锁的范围

降低发生竞争可能性的一种方式就是尽可能地缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作。

尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小,一些需要采用原子方式执行的操作必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时,反而会对性能提升产生负面影响。

减小锁的粒度

另一种减小锁的持有时间的方式是降低线程请求锁的频率,从而减少发生竞争的可能性。这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,发生死锁的风险也就越高。

设想一下,如果整个应用程序中只有一个锁,而不是为每个对象都分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。

如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能,如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点值。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。

锁分段

把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。

在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第N mod 16个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。

锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需要获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段锁集合中的所有的锁。

 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值