概述
编写正确的程序很难,而编写正确的并发程序则难上加难。与串行程序相比,在并发程序中存在更多容易出错的地方。
那么,为什么还要编写并发程序/p>
线程是Java语言中不可或缺的重要功能,它们能使复杂的异步代码变得更简单,从而极大地简化了复杂系统的开发。此外,要想充分发挥多处理器系统的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用并发正变得越来越重要。
线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。
然而,许多提升性能的技术同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。更糟糕的是,虽然某些技术的初衷是提升性能,但事实上却与最初的目标背道而驰,或者又带来了其他新的性能问题。虽然我们希望获得更好的性能——提升性能总会令人满意,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。在设计并发的应用程序时,最重要的考虑因素通常并不是将程序的性能提升至极限。
软件的思考
软件或多或少的承载着人们这样那样的需求,如何去衡量软件的质量属性应该是软件人员一直都在思考的内容。
McCall质量属性模型将软件的质量属性划分为产品修正、产品运行、产品转移三个部分,其实更简单的划分,可以将其分为开发态质量属性与运行态质量属性。
1. 正确性
正确性是软件质量的基础,但仅能够满足正确的代码,不过是程序世界中的一堆垃圾
克劳士比说过:“质量是一组特性满足要求的程度”,满足“客户要求”、即正确性是所有软件质量的基础。
但是,往往并不是所有的要求都是明确的。没有客户有耐心详细的提出有哪些质量要求,往往只是提出“需要什么样的功能”,至于怎么实现,用什么实现从来是不关心的。所以, 一个仅能满足正确性的软件/代码只不过是计算机世界中的一堆垃圾。
2. 开发态质量属性
开发态质量属性狭义上可以理解为“代码的质量”,如 可读性 ,代码不仅是写给计算机运行的,更多的时候是写给人看的。写一份不需要说明文档的代码,让所有维护的人能够轻松的看懂就是成功。此外如 可扩展性 ,随需求的变更代码的改动情况,这里面设计模式的东西可以派上一些用场,Design For Change的思想也由此而来。再如 可移植性 ,写了一份代码,32位机器上可以跑,到64位机器上就出问题,或者在Linux上可以执行,到Unix上就需要大刀阔斧的改动,这样或多或少都是有些问题的。其它如 可测性 ,这里不写了。
3. 运行态质量属性
运行态质量属性指在程序运行期间的“满足要求”的表现,常见的如:
性能: 12306的购票系统就是典型的反面教材,在需要的时候顶不上,影响性能的如IO、数据库、内存操作使用是否恰当;
可靠性: 程序是否容易出问题,出问题能否及时恢复;
兼容性: 这个对于做平台或中间件层的软硬件要求尤为突出,没有用户愿意为底层的升级买单。不兼容的直接恶果是客户不愿意升级,最终导致版本无法收编,产生巨大的维护成本;
可维护性: 出了问题能否快速定位,快速分析,还是人海战术,全部成为救火队员。
4. 软件性能的可伸缩性
运行态的质量属性除了这些还有很多,如易用性、易升级等。
这里再提到的两点质量属性:一个是 软件性能的可伸缩性 ,其中一层含义可以理解为软件随外部压力增大所表现的性能表现,如100W用户在线时,系统的响应时长是1秒钟,1000W用户在线的时候,系统的响应时长是否是简单的1*10秒钟/p>
另外一层含义,可以理解成软件性能随硬件的扩充所产生的性能变化情况。
举例而言:在CPU是1G Hz的机器上,系统每秒钟可以处理1000个请求,如果CPU升级到2G Hz的处理速度,是否每秒钟就可以处理2*1000=2000个请求实情况下,这个伸缩性一定不是线性关系的,在前一层含义里面,可能还会出现拐点,也就是所谓的“雪崩”。
另外一个值得一提的应该是软件的开放与易集成 ,当前像微信、微博,都在构筑自己的软件平台,生态系统,如何能够打造一个开放的平台,当前也是一个思考和尝试的途径。
上面这些更多的是一些通用的质量属性,质量属性之间可能相互有矛盾的地方,产品实现时更多的是方方面面的平衡。也可以理解为:产品的质量属性决定了软件的架构。
另外,对于不同的产品而言,其关注的质量属性可能是不一样的,如电信产品更关注的可能是可靠性,而互联 产品可能更侧重于体验和快速响应。对于同一产品而言,不同时期关注的质量属性也可能随需求的变更发生或多或少的变化。
Amdahl 定律
1. 问题和资源的关系
在某些问题中,资源越多解决速度越快;而有些问题则相反:
在各种框架中隐藏的串行部分
要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。下图给出了一个简单的应用程序,其中多个线程反复地从一个共享Queue中取出元素进行处理,处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么它将把一组新元素放入队列,因而其他线程在下一次访问时不会没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
虽然每次运行都表现相同的”工作量“,但我们可以看到,只需改变队列的实现方式,就能对可伸缩性产生明显的影响。
Amdahl 定律的应用
如果能准确估计出执行过程中串行部分所占的比例,那么Amdahl定律就能量化当有更多计算资源可用时的加速比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下Amdahl定律仍然是有用的。
在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。例如,降低锁粒度的两种技术:锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)。当通过Amdahl定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。)
1. 对性能的思考
提升性能意味着用更少的资源做更多的事情。“资源”的含义很广。
对于一个给定的操作,通常会缺乏某种特定的资源,例如CPU时钟周期、内存、 络带宽、I/O带宽、数据库请求、磁盘空间以及其他资源。当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型、数据库密集型等。
尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引人一些额外的性能开销。
造成这些开销的操作包括:线程之间的协调(例如加锁、触发信 以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。
如果过度地使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。另一方面,一个并发设计很糟糕的应用程序,其性能甚至比实现相同功能的串行程序的性能还要差。
要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。
从性能监视的视角来看,CPU需要尽可能保持忙碌状态。(当然,这并不意味着将CPU时钟周期浪费在一些无用的计算上,而是执行一些有用的工作。)如果程序是计算密集型的,那么可以通过增加处理器来提高性能。因为如果程序无法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有CPU都保持忙碌态。
1.1 性能与可伸缩性
应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一定的情况下,能完成“多少”工作。
可伸缩性指的是:当增加计算机资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应地增加。
在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用时间复杂度为O(n2)算法来代替复杂度为O(nlogn)的算法。在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作。
性能的这两个方面 “多快”和“多少”,是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个“流水线”子任务时。具讽刺意味的是,大多数提高单线程程序性能的技术,往往都会破坏可伸缩性。
我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层和持久化层是彼此独立的,并且可能由不同的系统来处理,这很好地说明了提高可伸缩性通常会造成性能损失的原因。如果把表现层、业务逻辑层和持久化层都融合到单个应用程序中,那么在处理第一个工作单元时,其性能肯定要高于将应用程序分为多层并将不同层次分布到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的 络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此能减少许多开销(例如在任务排队、线程协调以及数据复制时存在的开销)。
然而,当这种单一的系统到达自身处理能力的极限时,会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。因此,我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。
对于服务器应用程序来说,“多少”这个方面一一可伸缩性、吞吐量和生产量,往往比“多快”这个方面更受重视。(在交互式应用程序中,延迟或许更加重要,这样用户就不用等待进度条的指定,并奇怪程序究竟在执行哪些操作。)
1.2 评估各种性能权衡因素:
-
避免不成熟地优化,首先使程序正确,然后再提高运行速度–如果它还运行得不够快。
-
以测试为基准,不要猜测。
2.线程引入的开销
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。
在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引人的线程来说,并行带来的性能提升必须超过并发导致的开销。
2.1 上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减小上下文切换。参见之前的文章非阻塞同步)
上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于5000、10000个时钟周期,也就是几微秒。
UNIX系统的vmstat命令和Windows系统的perfmon工具都能 告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由I/O或竞争锁导致的阻塞引起的。
2.2 内存同步
同步操作的性能开销包括多个方面。
在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏〔Memo Barrier)o内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。
synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的),而在编写本书时,一个“快速通道(Fast-Path) ”的非竞争同步将消耗20、250个时钟周期。虽然无竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且还会使你(或者后续开发人员)经历非常痛苦的除错过程。
jvm 会自动清空无用锁操作
现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。
逸出分析(EscapeAnalysis)
一些更完备的JVM能通过逸出分析(EscapeAnalysis)来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。
在代码getStoogeNames方法中,对List的唯一引用就是局部变量stooges,并且所有封闭在栈中的变量都会自动成为线程本地变量。在 getstoogeNames的执行过程中,至少会将vector上的锁获取/释放4次,每次调用add或 toString时都会执行1次。然而,一个智能的运行时编译器通常会分析这些调用,从而使 stooges及其内部状态不会逸出,因此可以去掉这4次对锁获取操作。
即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用同一个锁合并起来。
在 getStoogeNames 中,如杲JVM进行锁粒度粗化,那么可能会把3个add与1个toString调用合并为单个锁获取/释放操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而可能实现进一步的优化。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。
某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
2.3 阻塞
非竞争的同步可以完全在JVM中进行处理(Bacon等,1998),而竞争的同步可能需要操作系统的介人,从而增加开销。
当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-waiting,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。
当线程无法获取某个锁或者由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)
3.减少锁的竞争
我们已经看到,串行操作会降低可伸缩性,并且上下文切换也会降低性能。
在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对由某个独占锁保护的资源进行访问时,将采用串行方式一一每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的锁资源。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有3种方式可以降低锁的竞争程度:
-
减少锁的持有时间。
-
降低锁的请求频率。
-
使用带有协调机制的独占锁,这些机制允许更高的并发性。
3.1 缩小锁的范围(“快进快出”)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。
例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如l/O操作。
我们都知道,如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性。如果某个操作持有锁的时间超过2亳秒并且所有操作都需要这个锁,那么无论拥有多少个空闲处理器,吞吐量也不会超过每秒500个操作。如果将这个锁的持有时间降为1毫秒,那么能够将这个锁对应的吞吐量提高到每秒 1000 个操作。
下面给出了一个示例,其中锁被持有过长的时间。userLocationMatches方法在一个Map对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。
整个userLocationMatches方法都使用了synchronized来修饰,但只有Map.get这个方法才真正需要锁。
在下面的 usersLocaltionMatchLessScope() 中重新编写了AttributeStore,从而大大减少了锁的持有时间。
第一个步骤是构建Map中与用户位置相关联的键值,这是一个字符串,形式为users.name.location。
这个步骤包括实例化一个StringBuilder对象,向其添加几个字符串,并将结果实例化为一个string类型对象。在获得了位置后,就可以将正则表达式与位置字符串进行匹配。由于在构建键值字符串以及处理正则表达式等过程中都不需要访问共享状态,因此在执行时不需要持有锁。
通过在 usersLocaltionMatchLessScope() 中将这些步骤提取出来并放到同步代码块之外,从而减少了锁被持有的时间。
通过缩小userLocationMatches方法中锁的作用范围,能极大地减少在持有锁时需要执行的指令数量。
根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
由于在AttributeStore中只有一个状态变量attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能。
通过用线程安全的Map(Hashtable、 synchronizedMap或ConcurrentHashMap)来代替attributes,AttributeStore可以将确保线程安全性的任务委托给顶层的线程安全容器来实现。这样就无须在AttributeStore中采用显式的同步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险(例如在访问attributes之前忘记获得相应的锁)。
尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小。
一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。
此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。
在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。
3.2 减小锁的粒度
另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。
这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。
设想一下,如果在整个应用程序中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。
如果一个
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!