在代码审查或冲刺计划(sprint planning)过程中,往往更加关注软件的功能实现,而经常会忽略掉软件性能,这在软件研发过程中是十分常见的。那些针对优化编写的代码在提交时往往会被打上“非必要”的标签,甚至被驳回,也可能因为一些常识性的错误或误解而被驳回。所以当你看到一些概括性的陈述时,需要更加谨慎,查明其真实意义从长远看可能会帮你节省大量的开发成本。
软件代码最重要的特性之一,就是需要具备良好的可读性。
代码具备良好的可读性比炫技更重要,调试和修改那些可读性差的代码会令人非常难受。此外,程序的过度优化与过度设计也会增加额外的风险。
——Brian W.Kernighan和P.J.Plauger, The Elements ofProgramming Style (McGraw-Hill,1978)
众所周知,优化程序运行效率的最直接方式是使用大量的位运算、字节填充、循环展开等,或者直接使用汇编代码进行部分功能实现。这些方式看起来十分炫酷,但实际上是很低级的实现(low-level optimization)。这些低级的实现会使代码变得难以阅读与维护,过度优化会明显增加程序复杂度与认知难度。所以一旦提及优化,软件工程师自然就会联想到可能会带来这种极端的复杂性,进而对效率优化产生抵触心理。因为在他们的认知当中,效率优化会对程序可读性带来负面影响。本节的目的是为读者展示软件运行效率与可读性共存的方法,使优化后的代码与之前一样清晰易懂。
同样,如果因为增加功能或其他因素而更改代码,也会面临同样的问题。例如,因为担心降低可读性而拒绝编写更高效的代码,等同于为避免复杂性而拒绝开发重要的功能。所以,诸多问题回归于合理性的范畴。我们可以考虑缩减功能范围,但应该首先评估合理性。这同样适用于提高软件的运行效率。在编写代码时,需要权衡代码的可读性和运行效率,以选择最合理的方案。
例如,如果想要对函数入参进行额外的验证,则可以直接在处理函数中粘贴一段长达50行的if-else代码。显然,这一定会令未来的维护者或几个月后回顾这段代码的你感到沮丧。相反,如果将新增内容封装到一个形如func validate(input string)error的函数中,只在原先的代码逻辑中增加轻微的复杂性,情况是不是会好很多呢?此外,为了避免对原先的代码逻辑造成冲击,可以将验证逻辑放在调用方或中间件中。另外,还可以重新思考系统设计,将验证复杂性的逻辑转移到另一个系统或组件中,从而避免实现这个功能。实现特定功能的方式有很多种。
代码效率优化和实现更多功能有什么不同?笔者认为它们本质上并没有区别。你可以像设计功能一样,在考虑可读性的同时设计效率优化。如果不考虑抽象,两者对阅读者来说都是完全透明的。
然而,代码效率优化常被视为可读性问题的主要来源。本节介绍的其他误区经常被用作忽视效率优化的借口。这通常会导致所谓的过早悲观(premature pessimization,是指在软件设计和开发过程中,过于关注可能出现的负面情况或问题,而忽略了积极乐观的方面和可能性),过度担忧可读性会让程序效率变得更加低下。
给自己留点余地,也给代码留点余地:在条件等同的情况下,尤其是代码的复杂性和可读性,高效的设计模式和编码习惯对于开发者来说应该是自然而然的,而且要比那些过度优化(即不必要的)的替代方案更容易编写。这并不是过早的优化,而是避免无谓的(不必要的)过度优化。
——H.Sutter和A.Alexandrescu, C++Coding Standards:101 Rules,Guidelines,and Best Practices (Addison-Wesley,2004)
代码可读性至关重要,笔者甚至认为,难以阅读的代码一般性能也比较差。随着软件的发展,我们很容易破坏前人精心设计的优化,因为我们不了解或误解了它的设计初衷。与错误和缺陷类问题相似,复杂的代码更加容易引发性能问题。在第10章中,你将会了解到如何在保证代码的可维护性和可读性的同时来做效率优化。
代码可读性很重要
优化可读代码比使高度优化的代码可读要容易得多。这对于可能试图优化代码的人和编译器来说都是如此。
由于在编写代码的过程中没有遵循一些针对效率的良好设计规范优化通常会导致代码可读性降低。如果从一开始就拒绝考虑效率问题,之后也很难做到在不影响可读性的同时实现对代码的优化。有些事情必须从一开始就考虑,后续所有的补救都为时已晚。在设计API和抽象之初,有太多的机会与时间来找到一种更优的实现方式。正如第3章将要了解到的,我们可以在系统的不同层面进行性能优化,而不仅仅是优化代码。也许我们可以选择一些运行效率更高的算法、更优的数据结构或设计更合理的系统架构。这会比在软件发布后再来做优化的效果更好。但在一些约束条件(比如向后兼容、集成性或严格的接口定义)下,提高性能的唯一方式只能在代码或系统中引入额外的复杂性。
优化后的代码可读性更强
令人惊讶的是,经过优化的代码反而可读性更好!下面来看几个Go代码示例。代码示例1-1是一个getter模式的代码片段,笔者在审查学生或初级开发人员的Go代码时已经看到过数百次。
代码示例1-1:一个计算错误比率的简单逻辑实现
❶ 这是一个简化的例子,在Go程序开发中有一种常用的模式,即通过函数或接口来获得操作所需的元素,而不是直接通过传参的方式。当元素被动态添加、缓存或从远程数据库中提取时,这种模式非常有用。
❷ 可能会执行多次Get函数来获取错误比率。
代码示例1-1在大多数情况下都能正常工作,它简单且易于阅读。但是,由于可能存在的效率和准确率问题,需要对代码示例1-1进行简单的修改,如代码示例1-2所示。
代码示例1-2:计算错误比率的更高效的逻辑实现
❶ 与代码示例1-1相比,代码示例1-2只执行一次Get函数,复用一个got临时变量来保存结果,这比在循环体内多次调用Get函数更加高效。
一些开发者可能会认为,像FailureRatio这种函数在实际应用中可能很少使用,它不是关键路径上的函数,而且当前的ReportGetter实现非常容易且快速。如果没有进行基准测试,实际上无法直接确定哪种方法更高效,这时候这种优化策略就会被称为“过早优化”。
然而,有编程经验的人都能看出来,代码示例1-2是一种正确的优化方向。这是一个典型的过早悲观的案例,虽然它并不会为程序的运行带来多少速度上的提升,但也没什么坏处。笔者仍然认为代码示例1-2在很多方面都性能优越:
在没有测试的情况下,代码示例1-2的效率更高
有接口就意味着可以替换实现方式,接口是开发者和实现方式之间的一种“契约”。从FailureRatio函数的内容来看,ReportGetter的实现方式基本已经定型,至少不能要求ReportGetter.Get的实现总是快速且简单的
,因为它可能会是非常耗费资源的I/O操作,例如,对文件系统的操作、使用互斥锁的实现或对远程数据库的访问
。
当然,也可以在以后使用适当的效率流进行迭代和优化(将在3.6节中讨论)。但如果这是一个常识性的优化点,在此处进行也无妨。
代码示例1-2更能保证协程安全
协程安全是一个潜在问题,它并不显而易见。如代码示例1-1所示,当有多个协程同时影响ReportGetter.Get的结果时,如果不进行竞态处理,就可能出现协程安全问题。在函数体内部最好避免竞态条件并确保一致性。竞态条件导致的错误往往是最难调试和检测的,为安全起见,最好避免竞态条件。
代码示例1-2的可读性更好
通过一个临时变量来表示Get函数的结果集,可以最大限度地减少潜在的风险,并且让代码示例1-2的可读性优于代码示例1-1。
并非所有的代码效率优化都是过早优化,但也不能将其当作拒绝或忘记做效率优化的借口。
另一个能提升代码清晰度的优化案例见代码示例1-3与代码示例1-4。
代码示例1-3:无任何优化的循环体
❶ 该函数返回一个变量名为slice的字符串切片,在函数调用开始的地方,底层默认会创建一个变量,该变量持有空字符串切片。
❷ 循环n次,每次往slice中添加7个字符串元素。
代码示例1-3展示了在Go语言中如何往切片中添加元素,代码表面上看上去并没有什么问题,能正常运行。但是,在使用循环进行元素追加时,如果知道将要追加到切片中的元素数量,就不应该按照这种“常规”的方式进行追加,因为在Go语言中,如果没有初始化切片容量,那么当切片的容量不足以存储新元素时,Go语言会自动为其扩充一块新的内存空间,这个操作是比较消耗资源的。如果预先知道将要追加到切片中的元素数量,则可以先对切片进行初始化,再通过循环添加元素。这样做的好处是可以减少不必要的迭代次数,以及省去在底层进行切片扩容所带来的消耗,从而提高代码的效率,具体写法参考代码示例1-4。
代码示例1-4:预分配优化的循环体会降低可读性吗
❶ 在函数开始的第一行,对slice变量进行初始化,为它分配n*7的内存空间。
❷ 循环n次,每次往slice中添加7个字符串元素。
在11.4节,我们还会结合在第4章中介绍的Go运行时知识来讨论代码示例1-2和代码示例1-4这种“尽可能预先分配”的性能优化方法。总的来说,这两种方式都可以减少程序运行的开销。在代码示例1-4中,由于对切片进行了预先分配,在追加元素时Go底层不需要再对切片进行扩容。下面我们来思考另一个问题:这段代码的可读性如何?
可读性的判定通常取决于人的主观意识,笔者认为代码示例1-4的可读性更好。虽然它在函数体中增加了一行,使函数体的复杂性提升了少许,但增加的这一行的表意是十分明确的。它不仅帮助Go运行时做更少的工作,也表明了这个循环的最终目的和期望的迭代次数。
如果你不知道Go语言的内置函数make的基本用法,你可能会觉得代码示例1-4可读性差。如果你知道具体的用法且能在编写代码时正确使用,它就会成为一种好习惯。此后当你看到没有初始化分配的切片时,潜意识就知道循环次数可能是不可预测的,因此你会变得更加谨慎。为了使这种习惯在Prometheus和Thanos代码库中得到充分应用,笔者的团队甚至在Thanos的Go编码风格指南中添加了一个相关条目( https://oreil.ly/Nq6tY )。
可读性并非一成不变,需要合理评估
我们编写代码的能力会随时间而变化,即使代码从未改变,一些新生概念的提出也会让我们的理解发生改变。了解一些关于编程的金科玉律,可以帮助我们更确切地理解一些复杂的代码实现。
可读性古今对比
开发人员经常会引用Knuth的那句“过早优化是万恶之源” [5] 来描述因为优化而带来的可读性问题。然而,这句话是很久以前说的。虽然我们可以从过去学到很多关于编程的知识,但自1974年以来,计算机科学在许多方面都有了巨大的进步。例如,那时流行在变量名称中添加有关变量类型的信息,如代码示例1-5所示 [6] 。
代码示例1-5:使用匈牙利表示法编写Go代码
在以前,匈牙利表示法很有用,因为编译器和集成开发环境(IDE)在那时还不是很成熟。但如今,在先进的IDE以及GitHub等代码托管网站上,将鼠标悬停在变量上,就能立即知道其类型。我们可以快速查看变量定义、阅读注释,并找到所有调用关系。随着20世纪90年代中期出现的代码提示、高亮显示和面向对象编程的流行,一些编程工具可以在不显著影响代码可读性的情况下完成功能和性能优化 [7] 。此外,可观测性和调试工具的可访问性和功能也已经大大提高,我们将在第6章中对此进行探讨,以更快地理解大型代码库。
总之,性能优化就像我们软件中的另一个不应忽视的功能。它可能会增加复杂性,但也有一些方法可以最大限度地减少理解代码所需的认知负荷(cognitive load) [8] 。
如何才能让优化后的代码可读性更高
· 移除或避免非必要的优化。
· 将复杂的逻辑封装在清晰的抽象(比如使用interface)当中。
· 将“热”代码(需要更高性能的关键部分)与“冷”代码(很少执行的部分)分开。
正如我们在本章中所了解到的,在某些情况下,经过优化的程序往往更加简单、清晰,解释性更强。
YAGNI(You Aren't Going to Need It)原则,字面意思为“你不会用上它”,是一个在软件研发工作中非常流行的原则。该原则的基本含义是,不应该去开发任何当前不会使用到的功能。
极限编程(Extreme Programming,XP)中最广为人知的原则之一是“You Aren't Going to Need It”,即YAGNI原则。YAGNI原则强调了在投资回报不确定的情况下推迟投资决策。在极限编程的背景下则表示需要暂缓实现那些模糊的特性和功能,直到其不确定性得到解决。
——Hakan Erdogmu和John Favaro,“Keep Your Options Open:Extreme Programming and the Economics of Flexibility”
YAGNI原则强调避免去做那些需求范围之外的工作。它有一个前提,即需求在快速变化,软件也需要快速迭代。
我们来看一个案例。高级软件工程师Katie接到一个开发任务,即创建一个简单的Web服务。该任务并无任何特别之处,只需创建一个HTTP服务,并暴露出一些REST接口。Katie是一位经验丰富的工程师,过去可能创建过100个类似的接口,她很快就开始编写程序并测试服务。任务完成之后,她决定添加额外的功能:一个简单的令牌授权层( https://oreil.ly/EuKD0 )。Katie知道这样的更改显然超出了当前的需求,但她编写过数百个REST接口,每个接口都有类似的授权层。经验告诉她,这样的需求可能很快就会出现,所以她要做好准备。你认为她做的这件事是有意义、且应该被接受的吗?
虽然Katie展现出了丰富的经验和职业素养,但我们应该避免去做这样的事,以保持这个Web服务的功能特性的纯粹和整体的开发成本效益。换句话说,她应该遵循YAGNI原则。这是为什么呢?因为在大多数情况下,功能特性都是难以预测的。严格遵循需求可以节省开发时间、降低代码复杂性。如果Katie编写的那个Web服务只是作为一个demo,永远不需要授权层,那就是画蛇添足,假如Web服务需要在专用授权代理后面运行,在这种情况下,Katie编写的额外代码即使不被使用也会带来高昂的成本,徒增认知负荷。此外,维护或重构这样的代码也会给工作增加难度。
现在,我们告诉Katie应该遵循YAGNI原则,她随即将那些授权代码删除。但她又决定在代码中增加一些指标来监控服务。这一决定是否也违反了YAGNI原则呢?
如果监控是需求的一部分,则不违反YAGNI原则;如果不是,在不了解需求背景的情况下,就不一定。关键监控应在需求中明确提及,但如果需求没有明确提及监控的范围,常识告诉我们,We b服务的可观测性很重要,是有必要做的。不然我们怎么知道它是否还在运行呢?在这种情况下,从技术上讲,Katie正在做一项能立即看到成效的工作,我们应该结合常识来判断其是否合理。
又过了一会儿,Katie决定在某个必要的计算逻辑中添加一个简单的缓存,以提高REST接口的性能。她编写并执行了一个快速基准测试,来验证接口的延迟和资源消耗是否得到改进。这是否违反YAGNI原则?
在软件研发领域有一个令人悲伤的事实,即需求提出方的需求描述中经常缺少对性能效率和响应时间的要求。应用程序的性能目标往往是“能用”和“速度能被接受”,至于细节则很少有特定的描述。本书将在3.3.2节中讨论如何定义实用的软件效率需求。例如,我们假设一种最坏的情况,即需求列表中没有任何关于性能的内容,那么能否说Katie违背了YAGNI原则呢?
同样,如果没有完整的需求背景,则很难判断。实现一个健壮且可用的缓存并非易事,我们需要考虑这些问题:加了缓存功能的新代码有多复杂?数据是否容易“被缓存” [9] ?该接口的QPS实际可以达到多少?是否所有接口都需要加缓存?我们知道,当某种特定的数据被频繁访问时,使用缓存才具备实际意义。
因此,和上一个改动一样,Katie也做了一项能立即看到成效的工作,但需要结合需求评估接口提供的性能保证。如果需要使用缓存才能实现性能,则没有违反YAGNI原则;否则,违反YAGNI原则。
最后,Katie为服务做了合理的性能优化,这种优化和代码示例1-4中的切片预分配优化类似。这是否违反YAGNI原则?
答案当然是违反。即使某些东西是普遍适用的,也需要确定是否应该去实现它。
笔者认为,即使在需求中没有明确提及,那些不会降低代码可读性(有些甚至会提高代码可读性)的编程习惯(比如一些考虑性能的编程习惯)应该被开发人员牢记,本书将在3.1.1节中进行介绍。同样,需求中也不会提及那些基本的最佳实践方法,比如代码版本控制、接口最小化原则,或者避免较大的依赖关系等。
本节主要介绍YAGNI原则,以希望帮助开发人员做出正确的决策,同时开发人员不能完全忽视代码性能。积土成山,积水成渊,不要因为单次只能优化一点性能就不去做,当优化汇聚起来时,应用程序的性能就会迎来质的提升。理想情况下,定义明确的需求有助于澄清软件的性能要求,但这也并不妨碍我们使用良好的编程习惯(优化意识)来处理程序细节。
当笔者开始从事编程工作时,那时的CPU处理速度非常慢,内存也十分有限,不像现在动辄几十上百GB。当时的内存是以KB为单位的。所以,在那个时代,要做软件研发就必须对内存的使用细节了如指掌,将内存的消耗优化到极致。
——Valentin Simonov,“Optimize for Readability First”( https://oreil.ly/I2NPk )
毫无疑问,目前的硬件绝对比以往任何时候都更便宜且性能更强大。硬件的发展几乎是以自然月为单位来衡量的。从1995年时钟频率为200MHz的单核奔腾CPU,到速度为3~4GHz的小型低功耗CPU。RAM大小从2000年的几十MB增加到20年后的64GB,且频率也越来越高。现在,机械硬盘(HDD)逐渐被替换成固态硬盘(SSD),诞生出了速度高达7GBps的NVME协议固态硬盘,存储空间也高达几十TB。目前的网络传输速度已经达到100GBps的吞吐量。而在远程存储方面,以前使用的软盘的空间只有1.44MB,发展到CD-ROM,容量可以达到553MB,再后来出现了蓝光光碟、具备读写能力的DVD,而现在容量达到TB级的SD卡和U盘也很容易买到。
现在,我们来看一个目前普遍流传的观点,即硬件的每小时使用成本要比开发人员时薪低得多。有了这个背景,大家就会认为,代码中的单个函数多占用1MB内存或者使用过多的磁盘空间都无关紧要,因为可以购买更大的服务器,算下来总费用还更低。那为什么不为软件产品开发更多的功能,反而去做性能优化呢?或者为什么要去培养具有性能优化意识的工程师呢?要回答这个问题并不是那么简单,下面我们来看看为什么这种观点对于软件研发来说是极其有害的。
首先,将投入性能优化的成本拿来买更多的硬件是一种目光短浅的行为。这就好比你的车坏了,马上卖掉,去买一辆新车,因为你不想把钱花在修车上。这合理吗?看起来没有浪费钱,还换了新车,但实际上如果每次车坏了都这样做,长期下来,成本是无法估量的。
假设一个软件研发人员的年薪在20万元左右(算上社保、福利等其他雇用员工的成本)( https://oreil.ly/AxI0Y ),公司每年需要花费25万元左右的成本,每月要支出2万元以上。在2024年,用2万元可以买到一台具有32GB DDR4内存、双路8核CPU、千兆网卡和12TB硬盘空间的服务器。暂且忽略电费与运维费用,假设软件每个月会增加这么多资源消耗,那看起来买一台这样的服务器比雇用一名开发人员要划算?很不幸,实际情况并非如此。
事实证明,如果不对应用程序进行优化,堆硬件的速度也赶不上程序占用资源的速度!图1-2展示了Thanos的单个副本服务(一共6个副本)的堆内存剖析部分截图( https://thanos.io ),其在单个集群中运行了5天。笔者将在第9章中介绍如何进行内存剖析。图1-2展示了服务进程启动了5天以来,其中某个Series函数分配的总内存。
虽然大部分内存已经过GC释放,但请注意,Thanos仅运行5天就占用了17.61TB的内存
。即使在桌面端应用程序开发领域,也可能会遇到类似的问题。以前面的例子为例,如果一个函数执行一次就多占用1MB的内存(不会释放),则可能看起来没多少,但当执行的次数多了,服务器拥有再大的内存也不够用。所以,你还会觉得堆硬件划算吗?
另外,增加服务器并不是购买完成后就能马上使用的,其中的成本还包括机架、网络、驱动程序、操作系统和基础软件等,以及监控、更新和操作服务器的运维成本。如果这一切还是没有问题,那新增的服务器是否也需要运维人员耗费精力来管理和维护呢?服务器足够多时,是不是还需要新增运维人员呢?如此发展下去,最后会成为一个恶性循环,并且后续很大一部分工作都是为了填补之前滥用资源埋下的坑,而这一切的根源仅仅是因为忽略性能优化,是典型的“占小便宜,吃大亏”。
图1-2:内存剖析显示,由于流量洪峰,应用不到5天就将系统内存耗尽
另一方面,随着硬件技术的进步,目前十分昂贵的10TB内存在未来也只是一个边际成本。难道这样我们就能忽略性能问题,将希望寄于服务器成本降低,或等待性能更好的手机和计算机的普及吗?虽然等待比调试棘手的性能问题更加容易,但这是“坐以待毙”。
因此,对软件做性能优化是一个不能省去的步骤,它至关重要,不要期望用硬件来弥补性能的缺陷。虽然硬件发展得很快,性能也在逐年提升,但它本质上仍是一种有限的资源,终会有耗尽的时候。下面来分析一下这种效应背后的三个主要原因。
软件终会耗尽可用内存
这种效应被称为帕金森定律(Parkinson's Law)
。帕金森定律指出,无论拥有多少资源,需求往往与供应刚好吻合。例如,帕金森定律在大学里随处可见,无论教授为作业或考试预留出多少时间,学生总是会使用所有的时间,而且学生很可能在最后时刻才会完成大部分的任务
[10]
。我们在软件研发中也可以看到类似的行为。
硬件发展速度跟不上软件过时速度
Niklaus Wirth提到过一个叫作“胖软件”(fat software)的术语,通常形容那些体积庞大、不够精简的软件。这类软件可能包含许多未使用的代码、复杂的逻辑结构、冗余的数据处理等,导致其体积庞大且运行效率低下。这个术语可以解释为什么软件对硬件的要求越来越高。
硬件厂商总是宣传升级硬件就能解决问题,但很多问题并非仅靠提升硬件就能解决,这些问题早已埋藏在软件当中,并且这类软件往往体量相当庞大。
——Niklaus Wirth,“A Plea for Lean Software”( https://oreil.ly/bctyb )
硬件性能越来越强大,但软件却变得越来越慢,这一切都是因为软件产品永远要迎合市场,有良好的用户体验才有盈利的可能。良好的用户体验包括更炫酷的操作系统、发光的图标、复杂的动画、网站上的高清视频,或者通过人脸识别技术模仿用户面部表情的花哨表情符号。这是一场客户与开发者之间永无止境的较量,其结果就是带来更多的复杂性,从而增加对硬件资源的消耗。
除此之外,由于能够更加方便地访问计算机、服务器、手机、物联网设备和任何其他类型的电子产品,软件的大众化普及得以快速实现。如Marc Andreessen所说:“软件正在吞噬世界。”(
https://oreil.ly/QUND4
)。2019年末暴发的新冠疫情进一步加速了数字化进程,互联网服务已然成为现代社会的关键支柱。世界每天都在增加计算力,但旺盛的需求仍然使其供不应求。笔者认为,造成“供不应求”的罪魁祸首可能就隐藏在某段函数代码中,研发时多考虑一些优化,也许负担就没有那么重。留意一下常用的软件就能知道,比如,日常使用的社交媒体,仅Facebook每天就能产生4PB(
https://oreil.ly/oowCN
)的数据
。搜索引擎的需求也很旺盛,这导致谷歌每天需要处理20PB的数据。当然,有人会说,拥有数十亿用户的应用软件是极少的,一般的开发人员也没有机会遇到这样的情况。然而,以笔者的经验,大多数的软件迟早都会遇到一些性能问题。例如:
· 一个使用React编写的Prometheus UI页面说不定在什么时候就会对数百万个度量名称进行搜索,或者试图获取数百MB的压缩数据,这会导致浏览器卡顿和客户端内存使用率过高。
· 即便使用率低,基础设施中的Kubernetes集群每天仍会生成0.5TB的日志并存储(大多数从未使用过),时间一长,日志存储组件也会告急。
· 笔者撰写此书时使用了语法检查工具,当输入的文本超过20000个单词时,它发起的网络连接数量就会多到使浏览器卡顿。
· 当Markdown文档体积很大的时候,用来格式化Markdown的脚本会耗费数分钟来处理所有元素。
· 做Go程序静态分析的任务使用了超过4GB的内存,导致CI平台崩溃。
· IDE需要20min来索引mono-repo(单一代码仓库)中的所有代码,尽管它是在最新的顶配笔记本计算机上进行的。
· 笔者还不能编辑GoPro的4K超宽视频,因为手里的软件太落后了。
笔者还能举更多类似的例子。事实上,我们正处于一个“数据量爆炸”的时代,因此,对应用软件进行优化刻不容缓。
在未来,数据还会呈几何级数增加,那时的软件和硬件必须具备处理这些目前看起来接近极端增速的数据的能力,5G通信(
https://oreil.ly/CWvFG
)已经可以达到每秒20GB的传输速度,市面上大量的商品嵌入了微型计算机,比如电视、自行车、洗衣机、冰箱、台灯,甚至除臭剂(
https://oreil.ly/DvZil
)!这个时代叫作“物联网”(IoT)时代。这些设备产生的海量数据预计将从2019年的18.3ZB增长到2025年的73.1ZB(
https://oreil.ly/J1o6D
)
。现代电子工业已经可以生产8K电视,分辨率为7680×4320,约有3300万像素。随之而来的是如何应对渲染这8K显示器的算力挑战,一般的GPU很难胜任。此外,加密货币和区块链算法也对硬件资源性能提出了挑战,例如,比特币在价值峰值期间的能源消耗约为130太瓦时(占全球电力消耗的0.6%)(
https://oreil.ly/NfnJ9
)。
技术限制
硬件发展速度不够快的最后一个原因是某些技术到了瓶颈阶段,很难突破,比如,CPU速度(时钟频率)或内存访问速度。本书将在第4章中介绍这种情况下的一些挑战,每个开发人员都应该了解这些硬件技术瓶颈。
讲到这里,就不得不提到摩尔定律(Moore's Law)。1965年,英特尔前首席执行官兼联合创始人戈登·E.摩尔(Gordon E.Moore)首次提出了这一定律。
集成电路上可容纳的晶体管数目约每隔18个月便会翻一番,性能也将提升一倍。当价格不变时,一美元所能买到的计算机性能将每隔18个月翻两番。
——Gordon E.Moore,“Cramming More Components onto Integrated Circuits”( https://oreil.ly/WhuWd ), Electronics 38(1965)
1974年,罗伯特·H.登纳德(Robert H.Dennard)主导的实验表明,晶体管尺寸(恒定功率密度)与能耗是成比例的 [11] ,这意味着晶体管越小,能效越高,后来这被称作“登纳德缩放比例定律”(Dennard Scaling)。正是基于这一理论基础,才有了摩尔定律。最终,这两个定律都保证了晶体管的能效比,对后世产生了深远的影响。随后,市场就不断研究和开发缩小MOSFET晶体管 [12] 尺寸的方法。如今,芯片制程已经可以达到3nm,在单位面积上可以分布更多的晶体管,带来更高的性能和更低的功耗。
在摩尔定律提出之后,摩尔的预测并不像他想象的那样只持续了10年,而是持续了几十年,虽然摩尔定律目前已经失效,但仍具有指导作用。而登纳德缩放比例定律则是在2006年
左右达到了物理极限,如图1-3所示。
图1-3:摩尔定律与登纳德定律(图片参考了Emery Berger的 Performance Matters )
虽然从技术上讲,更高密度的微型晶体管电路的功耗保持不变,但高密度的芯片很快就会发热。当时钟频率超过3~4GHz时,冷却晶体管以保持其运行会显著增加能耗和其他成本。除非永远在海底使用它们 [13] ,否则,就需要考虑散热。因此,在各种因素的制约下,多核CPU出现了。
性能越高,越节能
到目前为止,我们了解到了硬件速度越来越快,软件却越来越笨重,需要处理的数据和用户数量却持续增长。在开发软件时,我们往往会忘记一个重要的资源:电力。代码中的每一次计算都需要电力,这是手机、智能手表、物联网设备或笔记本计算机等许多平台性能受到制约的主要原因。非直观地说,能源效率、软件速度、软件性能之间存在很强的相关性。Chandler Carruth的演讲很好地解释了这种联系:
那些关于“节约用电,优化用电”的说明基本都是伪科学,毫无科学依据,实际上,最优的节能策略是在程序运行完成后,马上关闭电源。而且程序运行得越快,关闭电源就越频繁。
——Chandler Carruth,“Efficiency with Algorithms,Performance with Data Structures”( https://oreil.ly/9OftP ),CppCon 2014
总的来说,不要陷入一种常见的思维陷阱,即可以完全依赖硬件的性能来提升软件表现。开发人员一旦形成这种观念,就会掉入陷阱,并且会造成恶性循环,编写出的代码质量也会逐渐降低。
更廉价和更易获取的硬件会进一步吞噬我们的编码能力,让我们的程序毫无性能可言。虽然现在出现了一些具备划时代意义的硬件,例如,苹果公司的M系列芯片
[14]
、RISC-V标准
以及更多实用的量子计算设备,它们都使硬件性能提升到了另一个维度。然而不幸的是,截至2023年,硬件的更迭速度仍然比软件的性能需求慢得多。
软件效率提升有助于增强易用性和包容性
就目前而言,条件稍好的IT公司为开发人员配备的开发设备性能普遍较好,在这样的设备上开发的应用程序表面上看起来运行稳定,但实际情况是,许多组织和客户仍旧在使用较旧的设备与网络 [15] ,这时就不得不考虑将应用程序运行在这些旧设备上可能出现的问题。因此在开发过程中考虑效率可以提升软件的易用性和包容性。
在前面的章节中,我们知道我们的软件迟早会处理更多的数据。然而你的项目不太可能一开始就拥有数十亿的用户。切合实际地估计一个较低的目标用户数量、访问量或数据量,就能在开发初期避免巨大的软件复杂性和开发成本。这样简化需求是可行的,但在早期设计阶段大致预测性能要求也很重要,这就需要准确评估软件的中长期预期负载情况——即便流量增加,也能快速地进行水平扩展(横向扩展)。水平扩展是一种在设计阶段需要考虑的特性,如果在生产中才考虑水平扩展,会遇到很多问题,难度很大,成本高昂。
即使一个系统今天在可靠地运行,也不能保证将来一定能够可靠地运行。性能下降的一个常见原因是负载增加:可能是系统的并发用户数从1万增加到10万,或者从100万增加到1000万。也可能是因为处理的数据量比以前大得多。可扩展性是我们用来描述系统应对负载增加的能力的术语。
——Martin Kleppmann, Designing Data-Intensive Applications (O'Reilly,2017)
在讨论性能这个话题时,总是会不可避免地触及一些关于可扩展性的话题。然而,就本章的目的而言,我们可以将软件的可扩展性分为两种类型,如图1-4所示。
图1-4:垂直扩展和水平扩展
垂直扩展
扩展应用程序的第一种方法,也是最简单的方法,就是直接提升单机处理能力,即在性能更好的硬件上运行软件,这种方式叫作垂直扩展。例如,将那些支持并行处理的程序部署到多核CPU平台上,如果负载继续增加,再部署到更多核数的CPU平台。同理,如果我们的程序是内存密集型的,则可能会提高运行要求并增加硬件内存空间。其他资源(如磁盘、网络或电源)也可以进行类似的垂直扩展。这种简单、直接的方法虽然短期收效显著,但从长期角度来看,它的资源也是有限的,终有被耗尽或不能满足新需求的一天。
如果你的应用程序运行在云上,情况会稍微好一些,因为可以在云上直接买一台配置更高的服务器实例或虚拟机。截至2022年,在AWS平台上已经可以将硬件扩展到128核CPU、近4TB内存和14GBps带宽 [16] 。在极端情况下,还可以选择购买一台拥有190核CPU和40TB内存的IBM大型机( https://oreil.ly/P0auH ),当然,这需要不同的编程范式。
不幸的是,垂直扩展在许多方面都有其局限性。即使在云环境或数据中心,也无法无限扩大硬件配置规模。首先,大型机极其昂贵;其次,正如将在本书第4章中介绍的,大型机可能会遇到由许多隐藏的单点故障引起的复杂问题。比如内存总线、网络接口、NUMA节点和操作系统本身等部件,一旦出现问题就会引起一连串故障,而在大型机上部署的大规模应用程序则面临中断服务的风险
。
水平扩展
如果不考虑使用大型机,还有另一种方法扩展应用程序,即增加普通服务器,线性扩充系统性能:
· 如果要在一款消息类手机应用程序中搜索带有“home”一词的消息,则需要从数百万条历史消息中进行循环查找,并对每条消息运行正则匹配。我们可以设计一个API,并远程调用后端应用,后端应用可以将搜索拆分为100个作业来执行。
· 将不同的功能拆分到不同的组件,转向“微服务”架构,而不是构建“单体”应用软件。
· 相比使用PC运行大型游戏(需要使用昂贵的CPU和GPU),可以尝试云游戏。目前云游戏同样具备高分辨率的流媒体传输能力( https://oreil.ly/FKmTE )。
相比于垂直扩展,水平扩展的易用性更高,且限制较少、具备良好的弹性。例如,如果某款软件专供某家公司使用,那么晚上可能几乎没有用户,白天流量却很大。有了水平扩展,就可以很容易地实现基于流量的自动伸缩。
此外,在应用层实现水平扩展比直接进行垂直扩展难度更高。分布式系统、网络影响、服务无状态等是此类系统开发过程中需要解决的难题。因此,在某些情况下,坚持垂直扩展通常是最优的方案。
脑海中有了垂直扩展和水平扩展的概念之后,让我们来看一个特定的场景。目前许多数据库依靠压缩来高效地存储和查找数据。在此过程中,我们可以重用许多索引,去除重复数据,并将碎片收集到顺序数据流中,以实现更快的读取速度。在Thanos项目开始时,为了简单起见,我们决定使用一个非常幼稚的压缩算法,因为当时经过测算,理论上并不需要为单个数据块的压缩采用并行方式。要处理从单一数据源不断产生的100GB(或更多)的数据流,只需要单核CPU、少量内存和磁盘空间即可。这种方式最终被证实非常简单且未进行任何优化,它遵循了YAGNI原则并避免了过早地进行优化,这确实为我们减少了一些优化的工作。但不幸的是,该项目部署之后很快就遇到了压缩导致的问题:压缩速率太低,并且每次执行要消耗数百GB的内存,这显然不能被接受。还有一个大问题是,许多Thanos用户并未配置可以垂直扩展内存的设备。
乍一看,这个压缩问题似乎是一个可扩展性问题,压缩过程依赖于资源。由于用户希望快速得到解决方案,因此我们与社区一起开始集思广益,探讨水平扩展方案。其中讨论引入一个压缩调度器服务,将压缩作业分配给不同的机器,或使用gossip协议的P2P网络。不用说,使用这两种解决方案都会极大地增加复杂性,使开发和运行整个系统的复杂性增加一倍或两倍。幸运的是,几天之后,一些资深的开发人员重新设计了代码,实现了效率和性能的提升。使较新版本的Thanos能够以之前两倍的速度对数据进行压缩,并直接从磁盘流式传输数据,实现最小的峰值内存消耗。几年后,虽然Thanos项目已被数千名用户广泛使用,但仍然没有设计任何复杂的用于数据压缩的水平扩展功能(除了简单的分片),且仍在稳定运行。
现在回想起来,笔者很庆幸当时没有迫于客户和技术压力,去做针对水平扩展的分布式改造。也许这个改造的过程会很有趣,但同时也会面临许多难题。在未来某一天,我们也许会进行相关的改造,但要首先确保已经没有任何压缩优化策略可采用了。在做开发工作的时候,不要一来就为系统考虑水平扩展,而应该对其本体进行优化。在笔者的职业生涯中,类似的情况在各种项目中都遇到过不少,所以这是一个经验之谈。
过早扩展比过早优化更加险恶
在引入复杂的可扩展模式之前,请确保已经充分考虑算法和代码层面的优化。
Thanos这个关于压缩的例子其实给我们敲响了警钟,如果我们不关注软件的效率,就会被过早卷入水平扩展当中。这是一个巨大的陷阱,通过一些优化工作,其实可以完全避免掉入这个陷阱当中。换句话说,避免复杂性会带来更大的复杂性。在笔者看来,这是业界目前普遍存在的问题,也是笔者写这本书的主要原因之一。
复杂性存在于系统或组织的各个层面,它可能导致各种问题和困难。因此,为了应对复杂性,需要仔细考虑和管理各种因素,以确保系统的稳定性和性能( https://oreil.ly/0PcmN )。如果不想使代码复杂化,复杂性就会转移到其他方面,如果转移到系统层面,就会额外付出一些成本。水平扩展的复杂性较高,从设计上讲,它涉及网络操作。从CAP定理 [17] 中可以知道,一旦开始做负载均衡,就不可避免地会遇到可用性或一致性问题。处理水平扩展带来的问题要比再优化io.Reader接口困难百倍。
本节内容似乎只涉及基础设施,其实它还适用于软件的整个生命周期。例如,如果你编写了一个前端软件或动态网站,你可能会考虑将客户端计算转移到后端。其实只有在计算需要依赖硬件资源并且超出用户空间硬件能力的情况下才应该这样去做。过早地将其转移到服务器可能会增加额外的网络调用开销,需要处理更多的bug,并且可能遭受DoS攻击
。
另一个例子来自笔者的经验。笔者的硕士论文是关于“使用计算集群的粒子引擎”的,目标是在Unity引擎(
https://unity.com
)中为3D游戏添加粒子引擎。核心论点是粒子引擎不应该在客户端机器上运行,而是将“昂贵”的计算部分转移到笔者所在大学附近一台名为Tryton
的超级计算机上。结果却是,尽管它有超快的无限带宽网络
,但当笔者进行模拟的时候,超级计算机并没有在这个场景展现出预期的效果,也没有任何可靠性。直接在PC机上计算不仅不那么复杂,而且速度更快。
如果有人对你说,“不要优化程序,直接做水平扩展”,请保持怀疑的态度。一般来说,在将应用升级到可扩展性这个级别之前,做性能优化更简单、更经济。但当性能优化遇到瓶颈时,可扩展性应该是更好的选择,我们将在第3章中了解更多信息。
时间是宝贵的,对于一款软件来说尤其重要,这关乎它的命运。如果希望应用程序或系统拥有更多的功能,那么需要花费更多的时间来设计、实现、测试、安全防护和优化性能,但这样一来,公司或个人交付软件产品的时间就会变长,也就意味着软件的“上市时间”就越长,这可能会直接影响营收。
人们常说时间就是金钱,如今它比金钱更有价值。麦肯锡的一项研究报告表明,公司延迟六个月发货,平均会损失33%的税后利润。而在产品开发上超支50%,则会损失3.5%。
——Charles H.House和Raymond L.Price,“The Return Map:Tracking Product Teams”( https://oreil.ly/SmLFQ )
似乎很难衡量时间对产品的影响,但当你的产品晚于竞品上市时,你的产品便不再占有先机,从而可能会错过宝贵的机会。这就是为什么很多公司通过采用敏捷方法论(Agile Methodologies)或概念验证(POC)和最小可行产品(MVP)模式来降低这种风险。
虽然敏捷研发、POC、MVP模式对缩短产品上市周期有帮助,但为了实现更快的上市周期,公司也会尝试其他方法:扩大团队规模(雇用更多的人,调整组织架构)、简化产品、实现更多的自动化流程或建立合作伙伴关系。有时甚至试图降低产品质量来换取速度。例如,Facebook早期有一句口号是“Move fast and break things”(快速行动,打破传统) [18] ,在代码可维护性、可靠性和效率等方面合理降低软件质量,以拔得市场头筹。
这就是关于性能的最后一个误解,通过降低软件效率来更快地将产品投入市场在前期可能很有效,但终究不是银弹。如果始终坚持这样做,则要觉察风险与后果。
性能优化过程困难且昂贵。许多工程师认为,它会推迟产品进入市场的时间,降低利润。但这显然忽略了性能不佳的产品的后期成本(尤其是在存在残酷的市场竞争情况下)。
——Randall Hyde,“The Fallacy of Premature Optimization”( https://oreil.ly/mMjHb )
当产品bug、安全问题频发且性能低下时,公司的利润会受到较大影响。这并不是个例,波兰最大的游戏发行商CD Projekt在2020年底发布了一款游戏,《赛博朋克2077》( https://oreil.ly/ohJft ),它被公认为是一款质量上乘的作品。彼时销量不错,因为它由一家声誉良好的游戏出版商发行,尽管出现了延期,但世界各地激动的玩家还是献上了800万的预售订单。不幸的是,在2020年12月发布时,这款被认为绝对出色的游戏出现了巨大的性能问题。它在所有主机平台和大多数PC上都出现了bug、闪退和低帧率的现象。在一些较旧的主机平台(如PS4或Xbox One)上,游戏更是无法运行。当然,在接下来的几个月和几年时间里,开发人员做了大量的修复工作。
不幸的是,为时已晚。如此多的问题造成了巨大的影响。虽然这些问题在笔者看来都很常见,但它们足以对CD Projekt市值造成重创。游戏上架五天后,该公司股价下跌了三分之一,创始人为此损失了超过10亿美元( https://oreil.ly/x5Qd8 )。数以百万计的玩家要求退款,投资者被起诉( https://oreil.ly/CRKg4 ),游戏的主要开发人员引咎辞职( https://oreil.ly/XwcX9 ),虽然CD Projekt不至于因此倒闭,但已造成的声誉损失是无法估量的。
一些更有经验和成熟的公司非常清楚软件性能的价值所在,尤其那种toC的公司。例如,亚马逊发现,如果其网站加载速度慢1s,每年将损失16亿美元( https://oreil.ly/cHT2j )。亚马逊还报告称,每增加100ms的延迟,就会减少1%的利润( https://oreil.ly/Bod7k )。谷歌也发现,其搜索引擎响应速度从400ms减慢到900ms会导致流量下降20%( https://oreil.ly/hHmYJ )。对于一些初创企业来说,情况更糟。据估计,如果股票经纪人使用的交易平台比竞争对手慢5ms,就可能会损失1%的现金流,甚至更多。如果慢10ms,这个比例会增加到10%( https://oreil.ly/fK7mE )。
实际上,在大多数场景下,慢个几ms可能并不会引起什么。例如,假设开发一个将PDF转为DOCX的文件转换器,转换过程需要4s还是100ms有什么不一样吗?但在许多情况下,情况并非如此。在竞争激烈的商业环境,这会直接关系到客户体验问题,进而影响获客结果。所以性能现在已经成为一个绕不开的主题,即使是开源项目,也会将性能作为其核心能力之一。这虽然感觉像是一种廉价的营销技巧,但很有效,因为如果你面对两个有相同功能的开源项目,你大概率会选择性能好的那个。但是,性能并不仅仅体现在速度上——资源消耗问题同样重要。
在市场上,效率通常比功能更重要
在笔者担任基础设施系统顾问期间,很多客户选择了资源消耗更少的方案,即使方案的功能相对并不丰富
。
权衡效率优先还是功能优先其实很简单。如果想快速开拓市场,在核心功能具备的时候,一定要优先考虑效率优化。从另一方面来说,尽快将软件产品投入市场非常重要,因此在前期要为效率优化规划足够的时间和空间,一种方法是尽早设定非功能性目标(将在3.3.2节中讨论)。在本书中,我们将重点关注找到合理的平衡点,并减少效率优化带来的工作量(从而减少研发周期)。下面我们将从实用性的角度来看软件的性能。