系统性能设计的10个反模式
从用户界面到应用程序,从驱动程序到操作系统的内核,几乎所有软件都存在着系统性能上的缺陷,许多看起来完全不同的性能问题实际上有着相同的根本原因。对于成功经验的抽象一般被称为软件模式或者设计模式,那么导致系统性能问题的行为方式和做法则可以称为性能设计的反模式。
有些反模式的根源在于硬件问题,有些是开发或管理实践不佳的结果,还有一些只是常见的错误。这里列出了10个影响系统性能的反模式, 它们产生的原因是什么?如何发现以及如何避免呢?
1. 项目结束时来修复性能
在软件项目的开发过程中,一个经常被忽视的领域就是性能的测量和评估。很多时候,团队都会争分夺秒地开发新特性并修复 bug,而性能工作则会被抛在脑后。人们常常无法制定性能目标或基准,而且开发人员第一次考虑性能则是在收到性能问题的报告之后。。
这种反模式看起来很明显,但是许多项目总是在重蹈覆辙。如果团队不费心去建模或测量软件性能,或者等到项目接近尾声才开始,不太可能得到好的结果,即便成功也是偶然的。
2.测量和比较的不当
选择一个基准和比较结果在软件项目开始阶段是一个简单的问题,但在持续的过程中会发生很多问题。例如,在软件系统基准测试上,性能指标比以前的版本降低不超过2% ,这就是一个典型的错误,这意味着系统性能会随着时间的推移而下降。
那么,如何选择度量的基准呢? 一个好基准的特点是:
可重复性,比较实验可以相对容易地进行,而且精确度合理。
可观察性,如果表现不佳,开发者有一个可以开始发现的地方。
可持续性,如果和竞品进行比较,维护以前版本的性能历史记录是一个有价值的帮助。
易于表达,让每个人都能在简短的演示中理解比较的结果。
真实性,使测量真实地反映了客户的体验。
可执行性,所有开发人员能够迅速确定其更改的效果。
并不是所有被选中的基准都能满足所有这些标准,但避免选择不能真正代表用户体验的基准,并抵制为基准进行优化的诱惑。
3. 对算法的厌恶
对于许多软件开发者来说,可能有着『算法恐惧症』。实际上,很多性能问题的根本原因是在于软件中所采用的算法,真正重大的改进都源于算法的改变,例如,性能提高1倍以上。算法选择的一个关键部分是手头有一个现实的基准或测量实践来支撑,而不是直觉或者所谓的最佳实践。这意味着执行性能最有效时间是在项目的早期阶段,这可能与通常发生的情况正好相反。在处理o (n2)算法时,所有的编译选项几乎是没啥用的。
为了弥补硬件差异,根据运行环境做出明显不同的选择。捕捉这类问题的方法之一是,原始开发人员既要记录影响代码性能的外部性的假设,又要提供某种程序化的方法来验证这些假设。例如,在判断哈希算法的时候,跟踪哈希表上的最大哈希链长度以及哈希表总数,这样就可以轻松识别哈希函数的优劣。另一种方式是当假设被违背时强制一个报错,这可能不适合某些应用程序。软件重用是一个很好的目标,但是要注意不要违背在其开发过程中所做的假设。
4. 递归的泛滥
如果你的应用程序正在做一些不需要或不值得欣赏的工作,比如多次重新绘制屏幕、过于频繁地计算统计数据等等,那么消除这种浪费就是性能提升的重要领域。对于应用程序来说,通常最重要的是程序的结束状态,而不是到达结束状态所需的一系列步骤。通常有一条捷径可以让我们更快地达到目标,这就像缩短赛道而不是加速赛车,除了正确的使用预测预取之外,软件中加快速度的唯一方法就是做得更少。
5. 过早地进行低级别的优化
过早的优化可能对实际基准测试的性能产生负面影响,低层次的周期调整不是在最初的代码开发阶段。即便如此,也应该仔细记录进行调优的条件,以帮助其他人以后评估这些条件是否仍然有效。
如前所述,最有效的软件性能工作侧重于算法,而不是低级别的细节,手工编码的汇编链表是愚蠢的事情。另外,这种低级别的优化不是一个好主意,对于需要在不同的系统或处理器集上良好运行的软件,这些技术往往需要每个平台的不同版本,从开发、可移植性和测试角度来看,这是一种痛苦而昂贵的方法。基于实际实验的结果,而不是直觉,直到确信这是一种提高性能的经济有效的方法。Donald Knuth 说过: “过早的优化是万恶之源。”
6.关注表象而不是真正到问题
人们经常要求确定应用程序性能不佳的原因,通常认为一定有某种潜在bug导致了性能问题的出现。尽管这有时确实如此,但在大多数情况下,问题实际上出在应用程序本身,而且往往出在用户使用应用程序的方式上。
应用程序顶层的每一行代码通常都会导致软件堆栈深处的大量工作,顶层的低效率会有一个很大的杠杆系数,放大了它们的影响。然而,由于缺乏观察应用程序行为的合适工具,工程师一般会尝试使用 trace 或诸如 iostat 等各种性能监视命令等低级工具来诊断性能问题,这常常会导致增加系统调用或 i/o 操作,而不是修改应用程序以减少正在进行的调用数量。例如,一个基于数据库的应用程序有性能问题,那么首先查看 SQL; 一旦调优了 SQL,数据库正确地编制了索引等,那么也许是时候查看磁盘利用率了。
7.线程数量过多
一旦程序员熟悉了线程或者多个协程,一个更常见的错误是决定对每个连接的工作单元使用一个线程(或进程)。无可否认,这是一个简单的编程模型,任务状态可以方便地保存在线程堆栈中。这在小型或本地测试环境中工作得很好,一旦这个应用程序被部署到成千上万个缓慢的连接上时,就会发现这些成千上万的线程并没有真正很好地执行,因为这台机器现在有大量的 TLB 和来自所有这些堆栈的缓存压力。
一个经验性的答案是将线程数量限制在一个更合理的数量(接近 cpu 数量) ,并使用工作堆模型和异步 i/o 针对要完成的任务多路传送。这些类型的应用程序架构更容易扩展,并且在重负载下表现得更优雅。毕竟,如果应用程序的净吞吐量开始下降,超过一定的负载水平,这种情况本身就是不稳定的。
8. 非对称硬件利用率
一些处理器设计使用三级缓存来隐藏内存访问的延迟,多级TLB现在也变得越来越普遍。这些缓存和 TLB使用不同程度的关联方式来跨缓存分散应用程序的负载,但是这种技术常常被其他性能优化意外地阻碍。一个简单的例子是一个性能工具,它将一个共享库中的函数重新排序,将最常用的函数放在库的开头,这是一个显然合理的策略,可以减少 ITLB的错误率和分页次数。然而,当应用于许多共享库时,这将导致每个共享库的段开头被访问的频率远远高于其他部分。如果是64kb 的对齐方式,这意味着那些属于64kb 边界的 TLB 条目更为常用,有效地将 ITLB 的大小减少了8倍。另一个例子发生在数据库上,如果该数据库代码以 L2缓存大小的倍数分配了大块共享内存,通过在每个大块中使用类似的访问模式,显著降低了 L2缓存的命中率。热点检测还可能发生在物理内存上,如果一个内存区域优于另一个区域,则可能导致平均内存访问时间显著增加。
检测和避免这一问题可能很困难,因为硬件计数器和工具往往缺乏直接观测这些影响的能力; 一旦检测到,如果没有应用程序可见的影响,则很难避免这种热点定位,通常需要有意识地将随机性注入分配模式、内存布局等。
9. CPU之间无需交换缓存
在多处理器上,精心设计的硬件协议确保系统中只有一个缓存包含修改版本的内存; 多个缓存可能包含未修改的内存副本。当一个 CPU 试图写入当前位于另一个 CPU 缓存中的内存时,会发生缓存到缓存的传输,以便在 CPU 之间移动该缓存的所有权。在大型多处理器上,这可能需要大量的时间和可用带宽; 最小化这些传输的数量可以提高可测量性。
一个经常看到的例子是一个简单的计数器,它受到读取锁的保护。锁被获取,计数器读取,锁被丢弃。这些锁除了毫无必要地降低可伸缩性之外,几乎什么都不做。经常影响性能的问题是锁的滥用。如果读取器锁的持有时间很短 ,那么我们最好只使用简单的互斥锁,当锁被长时间持有时,读写锁才可能是有意义的。
检测缓存到缓存的传输或错误共享可能非常困难。一种技术是查看在执行时间概要文件中引用的内存和语句; 如果大多数加载没有错过缓存,这种方法就可以很好地工作。对于开发工具来说,需要与编译器和运行时数据收集进行仔细的集成才能正确地解决这个问题。
10. 没有针对常见的情况进行优化
一般地,频繁的操作比不频繁的操作多出几个数量级,设计算法来利用这种不对称性可以产生显著的收益。一个简单的例子是使用哈希表锁,这提高了对表进行简单搜索、插入或删除的可伸缩性,同时监督需要访问整个表的操作,如调整大小。一个复杂的示例是锁定内核中的 CPU ,当前 CPU 的锁定实现利用了读访问和写访问的巨大优势,线程只需防止自己的抢占,这只需要一个本地内存的引用。
常见场景和用例才是性能优化的核心关注点,对于应用层的软件更是如此。
小结
这10个问题应该有助于我们研究系统的性能设计,至少能更快地认识到这些问题。尽管并非所有项目的性能都具有挑战性,但是避免这些反模式将使有限的资源更加有效。其核心思想是,在项目开始时在基准、算法和数据结构选择方面所做的性能工作将在以后带来巨大的好处。
【关联阅读】