Go-高效编程-全-

Go 高效编程(全)

原文:zh.annas-archive.org/md5/55d311da1d04b7a03267ba5252fa91c8

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

欢迎来到务实软件开发的世界,工程师们不会害怕雄心勃勃的性能目标。在这里,对需求变化或者意外的效率问题处理得无压力,代码优化基于数据进行战术性和有效性的提升,同时代码库保持简洁易读、易于维护和扩展。等等,这真的可能吗?

是的,我会告诉你如何做到!好消息是,如果你买了这本书,你已经走过了一半路——这意味着你意识到了问题,并且愿意学习更多!坏消息是,尽管我尝试将知识压缩到仅需的部分,但还有 11 章要阅读。我认为Efficient Go在这方面很独特,因为它不是一个快速教程,而是一本完整指南,讲述了在我职业生涯开始时我希望了解的所有高效而务实的软件编写方面。

在这本书中,你无疑会学到很多关于我最喜爱的编程语言 Go 以及如何优化它的内容。但不要让这本书的标题愚弄你。虽然我以 Go 语言作为示例语言展示优化思维和可观察性模式,但这本书的 11 章中有 8 章与编程语言无关。你可以使用相同的技术来改进任何其他语言编写的软件,如 Java、C#、Scala、Python、C++、Rust 或 Haskell。

最后,如果你期待看到一整套低级优化技巧的完整清单,那么这本书可能并不适合你。首先,优化并不是普适的。有人展开了循环或者在结构体字段中使用了指针并且获得了更好的效率,并不意味着我们这样做也会有帮助!我们会介绍一些优化技巧,但我更强调在实用软件开发中对效率的完整了解。

其次,“低级”危险的技巧通常是不需要的。在大多数情况下,只需意识到程序在哪些简单的地方浪费时间和资源,就足以实现你的效率和可伸缩性目标,而且是廉价和有效的。此外,你会了解到在大多数情况下,无需将程序重写为 C++、Rust 或汇编语言来获得高效的解决方案!

在我们开始之前,让我们梳理一下这本书背后的主要目标以及我为何认为有必要把时间集中在效率主题上。你还将学会如何在你的软件开发任务中最大化地利用这本书。

为什么写这本书

我花了大约 1200 小时写作Efficient Go,所以决定出版这样一本书并非一时冲动。在社交媒体、YouTube 和 TikTok 盛行的时代,书写和阅读可能显得过时,但依我看,现代媒体往往会过度简化主题。你必须将它们压缩到绝对的最低程度,以免失去观众和变现机会。这会导致错误的激励,这与我希望通过这本书实现的目标一般相悖。

我的使命很简单:我希望我使用或依赖的软件能更好!我希望软件项目的贡献者和维护者能理解他们代码的效率以及如何评估它。我希望他们能可靠地审查我的或他人的 pull request,并带来效率的改进。我希望周围的人们能够专业地处理性能问题,而不是制造紧张氛围。我希望用户和利益相关者能够谨慎对待我们在行业中看到的基准测试和廉价营销。最后,我希望领导、主管和产品经理们能成熟地对待软件效率主题,并意识到如何制定有助于工程师提供优秀产品的务实效率要求。

我还将这本书视为朝着更可持续软件的小贡献。每一次浪费的 CPU 时间和内存都会浪费你公司的大量资金。但同时也会浪费能源和硬件,这对环境有着严重的影响。因此,在学习这里的技能时,同时节省金钱和拯救地球,为你的业务创造更好的价值,并非是一个坏的结果。

我发现写书是实现这一目标的最佳途径。它比在我的日常工作、开源项目和会议中不断解释相同的微妙之处、工具和技术要容易得多!

我是如何获得这些知识的。

通过大量实践、错误、实验、隐性导师和研究,我积累了在效率主题和高质量软件开发方面的经验。

我在开始写这本书时 29 岁。这可能不算是很多经验,但我在 19 岁开始了全职专业软件开发职业生涯。我在 Intel 做软件定义基础设施(SDI)的工作时,也同时进行了全职的计算机科学学习。最初我在 OpenStack 项目 中使用 Python 进行编码,然后在 Mesos 项目 中贡献了一些代码,这是当时很受欢迎的项目,得益于来自 Mesosphere 和 Twitter 的优秀工程师的指导。最后,我转向了使用 Kubernetes 开发 Go 语言,并且深深地喜爱上了这门语言。

在 Intel 我花了不少时间在处理带有噪声邻居缓解的节点超额订阅功能上。通常情况下,超额订阅允许在单台机器上运行比原本可能的程序更多。这种做法是可行的,因为统计上,所有程序很少同时使用其保留资源的全部。从后来的视角来看,通常通过软件优化来节省金钱更容易更有效,而不是使用这样的复杂算法。

2016 年,我搬到伦敦加入一家游戏初创公司工作。我与 Google、Amazon、Microsoft 和 Facebook 的前员工合作开发和运营全球游戏平台。我们开发了大量基于 Go 的微服务,在全球数十个 Kubernetes 集群上运行。这是我学习分布式系统、站点可靠性工程和监控的重要时期。也许从那时起,我开始沉迷于关于观测性的惊人工具,这是实现实用效率的关键,也在《第六章》中有详细解释。

我对软件运行的良好可见性的热情转化为成为一位使用和开发流行的开源时间序列数据库用于监控的专家,该数据库称为Prometheus。最终,我成为了正式的维护者,并开始多个其他 Go 开源项目和库。最后,我与 Fabian Reinartz 共同创造了一个名为Thanos的大型分布式时间序列数据库的开源项目。也许我的一些代码正在您公司的基础设施中运行!

2019 年,我加入了红帽公司,在开源中全职从事可观测性系统的工作。这也是我更深入研究连续性分析解决方案的时候,你也将在本书中学到这些内容。

我还活跃于云原生计算基金会(CNCF)作为大使和可观察性技术咨询小组(TAG)的技术负责人。此外,我还共同组织会议和聚会。最后,通过 CNCF 的指导计划,我与 Prometheus 和 Thanos 项目团队每年指导多位工程师。

我为各种必须在生产环境运行、可靠且可扩展的软件编写或审查了数千行代码。到目前为止,我已经教导和指导了二十多位工程师。然而,或许最有洞察力的是开源工作。在这里,你与来自世界各地不同公司和地方、背景、目标和需求的多样化人群互动。

总体来看,我认为与我有幸合作的这些了不起的人们一起,我们取得了惊人的成就。我很幸运能够在重视高质量代码胜过减少代码审查延迟或减少处理风格问题花费时间的环境中工作。我们依靠良好的系统设计、代码可维护性和可读性蓬勃发展。我们尝试将这些价值观带到开源项目中,我认为我们在那里做得很好。然而,如果我有机会再次编写比如 Thanos 项目,有一件重要的事情我会改进:我会更注重我的代码和我们选择的算法的实用效率。我会从一开始就专注于更清晰的效率要求,并投入更多精力进行基准测试和性能分析。

不要误会,如今的 Thanos 系统比某些竞争对手更快,且使用的资源远少于他们,但这需要大量时间,并且我们仍然有大量硬件资源可以更节约地利用。我们仍然面临许多等待社区关注的瓶颈。然而,如果我能应用本书中你们将学到的知识、技巧和建议,我相信我们本可以将开发成本减少一半,甚至更多,从而让现在的 Thanos 处于我们今天拥有的状态(希望我的前老板支付这项工作的人不会读到这一段!)。

我的旅程向我展示了像这样的一本书是多么的必要。随着越来越多的人从事编程,尤其是没有计算机科学背景的人,错误和误解也就更加频繁,特别是关于软件效率的问题。很少有文献能够为我们提供实用的答案,解决我们关于效率或扩展问题的疑虑,尤其是针对 Go 语言。希望这本书能填补这一文献空白。

本书的读者对象

《高效 Go 语言》专注于提供必要的工具和知识,以便回答何时以及如何应用效率优化,这在很大程度上取决于具体情况和你的组织目标。因此,本书的主要受众是设计、创建或修改使用 Go 语言及其他现代语言编写的程序的软件开发人员。对于确保所创建软件在功能和效率要求内运行的专家来说,这应该是软件工程师的职责。理想情况下,在开始阅读本书时,你应具备一些基本的编程技能。

我相信这本书对那些主要操作其他人编写的软件的人也很有用,例如 DevOps 工程师、SRE、系统管理员和平台团队。优化设计有许多层次(如《优化设计层次》所讨论)。有时候在软件优化上投资是有意义的,而有时候我们可能需要在其他层次上解决这个问题!此外,为了实现可靠的效率,软件工程师必须在类似生产环境的环境中进行大量基准测试和实验(正如第六章中所解释的那样),这通常意味着与平台团队的紧密合作。最后,第六章中解释的可观察性实践是现代平台工程推荐的先进工具。我坚决主张在 SRE 中避免区分应用程序性能监控(APM)和可观察性。如果你听到这种区分,大多数情况下是来自希望你付更多费用或感觉他们具备更多功能的供应商。正如我将要解释的那样,我们可以在所有软件观察中重复使用相同的工具、仪器和信号。²总的来说,我们都是一队——我们希望构建更好的产品!

最后,我想向那些希望保持技术并了解如何确保团队不会因易于解决的效率问题而浪费数百万美元的经理、产品经理和领导推荐这本书!

本书的组织结构

本书分为 11 章。在第一章,我们讨论效率及其重要性。然后,在第二章,我简要介绍了 Go 语言并考虑了效率。然后,在第三章中,我们将讨论优化以及如何思考和处理它们。效率改进可能会花费大量时间,但系统化方法可以帮助您节省大量时间和精力。

在第四章和 5 章中,我将解释关于延迟、CPU 和内存资源的所有知识,以及 OS 和 Go 如何抽象它们。

然后我们将进入关于软件效率的数据驱动决策的含义。我们将从第六章开始。然后我们将讨论实验的可靠性和复杂性分析在第七章。最后,我将在第八章和 9 章中解释基准测试和性能分析技术。

最后但同样重要的是,我将在第十章中展示各种不同优化情况的示例。最后,在第十一章,我们将总结 Go 社区中看到的各种效率模式和技巧。

本书使用的约定

本书使用以下排版约定:

Italic

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

Constant width bold

显示应由用户逐字输入的命令或其他文本。

Constant width italic

显示应由用户提供值或由上下文确定值替换的文本。

此元素表示提示或建议。

此元素表示一般注意事项。

此元素指示警告或注意事项。

使用代码示例

本书包含代码示例,这些示例应该帮助您了解工具、技术和良好实践。所有这些都是使用 Go 编程语言编写的,并且与 Go 版本 1.18 及以上版本兼容。

您可以在可执行且经过测试的开源 GitHub 存储库efficientgo/examples中找到本书中的所有示例。欢迎您 fork 它,使用它,并玩弄我在本书中分享的示例。每个人的学习方式都不同。对于一些人来说,将一些示例导入到他们喜欢的 IDE 中,并通过修改、运行、测试或调试来玩弄它是有帮助的。找到适合您的方式,并随时通过GitHub 问题或拉取请求提出问题或提出改进建议!

请注意,本书中的代码示例被简化以便清晰查看和减小体积。特别地,以下规则适用:

  • 如果未指定Go包,请假定为package main

  • 如果未指定示例的文件名或扩展名,请假定文件具有.go扩展名。如果是功能测试或微基准测试,则文件名必须以_test.go结尾。

  • import语句并不总是提供。在这种情况下,请假设导入了标准库或先前引入的包。

  • 有时,我在import语句的注释中提供了导入(// import <URL>)。这是当我想解释单个非平凡导入时使用的。

  • 带有三个点(// ...)的注释指定删除了一些不相关的内容。这突出显示某些逻辑存在是为了函数能够合理运行。

  • 带有handle error语句(// handle error)的注释指示为了可读性已删除了错误处理。请始终在您的代码中处理错误!

本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需联系我们请求许可。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发 O’Reilly 书籍中的示例需要许可。通过引用本书并引用示例代码来回答问题不需要许可。但是,将本书中大量示例代码整合到产品文档中需要许可。

我们欣赏但通常不要求归属。归属通常包括标题、作者、出版商和 ISBN 号码。例如,“Efficient Go by Bartłomiej Płotka (O’Reilly)。版权所有 2023 Alloc Limited,978-1-098-10571-6。”

如果您觉得您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

致谢

正如他们所说,“伟大在于他人的作为”(oreil.ly/owETM)。本书也不例外。许多人在我编写Efficient Go书籍和我的职业生涯中直接或间接地帮助了我。

首先,我要感谢我的妻子卡西娅——没有她的支持,这一切都不可能。

特别感谢我的主要技术审阅人员,Michael Bang 和 Saswata Mukherjee,他们不知疲倦地详细检查了所有内容。感谢其他早期内容的部分查阅者,提供了出色的反馈:Matej Gera、Felix Geisendörfer、Giedrius Statkevičius、Björn Rabenstein、Lili Cosic、Johan Brandhorst-Satzkorn、Michael Hausenblas、Juraj Michalak、Kemal Akkoyun、Rick Rackow、Goutham Veeramachaneni 等等!

此外,感谢许多来自开源社区的才华横溢的人们,他们通过他们的公开内容分享了大量知识!他们可能没有意识到,但他们协助了这样的工作,包括我写这本书。您将在本书中看到一些他们的引用:Chandler Carruth、Brendan Gregg、Damian Gryski、Frederic Branczyk、Felix Geisendörfer、Dave Cheney、Bartosz Adamczewski、Dominik Honnef、William(Bill)Kennedy、Bryan Boreham、Halvar Flake、Cindy Sridharan、Tom Wilkie、Martin Kleppmann、Rob Pike、Russ Cox、Scott Mayers 等等。

最后,感谢 O’Reilly 团队,特别是 Melissa Potter、Zan McQuade 和 Clare Jensen,他们对推迟、移动截止日期以及将更多内容悄悄添加到本书中的理解和帮助! 😃

欢迎反馈意见!

如果您对关注我的工作或我所在团队的工作有兴趣,或者想在这一领域进一步学习,请关注我的Twitter查看我的博客

如果您对我的工作或我制作的内容有反馈,请毫不犹豫地与我联系。我随时乐意学习更多!

O’Reilly 在线学习

超过 40 年来,O’Reilly Media提供技术和商业培训、知识和洞察,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多个出版商的广泛的文本和视频集合。欲了解更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设有一个网页,列出勘误表、示例和任何额外信息。您可以访问此页面:https://oreil.ly/efficient-go

发送电子邮件至bookquestions@oreilly.com,对本书发表评论或提出技术问题。

关于我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://www.youtube.com/oreillymedia

¹ 如果你是新手软件开发者或开源社区的成员,请与我们联系,开始贡献并申请两个月的有偿指导。如果你希望在指导他人时有所乐趣,请告诉我!我们也需要优秀的导师——教导下一代开源项目的维护者是很重要的。

² 我已经收到一些经验丰富的人的反馈,他们不知道可以利用指标来提高效率和性能!这是可能的,你将在这里学习如何做到。

第一章:软件效率至关重要

软件工程师的主要任务是成本效益高、易维护且有用的软件开发。

Jon Louis Bentley,《编写高效程序》(Prentice Hall,1982)

即使经过 40 年,Jon 对开发的定义仍然非常准确。任何工程师的最终目标都是创建一个有用的产品,能够在产品生命周期内满足用户需求。不幸的是,如今并不是每个开发者都意识到软件成本的重要性。事实可能很残酷;声明开发过程可能昂贵可能是低估了。例如,Rockstar 花了 5 年时间和 250 名工程师开发了流行的《侠盗猎车手 5》视频游戏,估计成本达 1.375 亿美元。另一方面,为了创建一个可用的、商业化的操作系统,苹果在 2001 年 macOS 首次发布前已经花费了超过5 亿美元

由于软件生产成本高昂,我们需要把精力集中在最重要的事情上。理想情况下,我们不希望在不必要的行动上浪费工程时间和精力,例如花费数周进行并未客观减少代码复杂性的代码重构,或者深度微优化一个很少运行的函数。因此,该行业不断创新出新的模式来追求高效的开发过程。敏捷看板方法允许我们适应不断变化的需求,专门为移动平台开发的编程语言如 Kotlin,以及用于构建网站的框架如 React,仅仅是一些例子。工程师在这些领域进行创新,因为每一种低效都会增加成本。

更加困难的是,当下开发软件时,我们还应该考虑到未来的成本。一些来源甚至估计,运行和维护成本可能高于初始开发成本。为了保持竞争力,代码更改、故障修复、事故处理、安装以及最终的计算成本(包括消耗的电力)只是总软件拥有成本(TCO)的几个例子,我们必须考虑进去。敏捷方法有助于早期揭示这些成本,通过频繁发布软件并尽早获取反馈。

然而,如果我们从软件开发过程中剥离效率和速度优化,TCO 是否会更高呢?在许多情况下,等待应用程序执行多几秒钟应该不成问题。此外,硬件每个月变得更便宜更快。在 2022 年,购买一部具有数十 GB RAM 的智能手机并不困难。指尖大小的2 TB SSD 硬盘,可达每秒 7 GBps 的读写吞吐量已经可以购买到。即使是家庭 PC 工作站也达到了前所未见的性能分数。配备8 个或更多 CPU,每秒可以执行数十亿周期,以及 2 TB 的 RAM,我们可以快速计算事物。而且,我们总是可以稍后添加优化,对吧?

与人们相比,机器变得越来越便宜;任何未能考虑到这一点的计算机效率讨论都是短视的。“效率”包括整体成本的降低——不仅是程序生命周期内的机器时间,还包括程序员和程序使用者的时间。

Brian W. Kernighan 和 P. J. Plauger,《程序设计风格的要素》(McGraw-Hill, 1978)

毕竟,改进软件的运行时间或空间复杂性是一个复杂的话题。特别是当你是新手时,花费时间优化而没有显著的程序加速是很常见的。即使我们开始关注代码引入的延迟,像 Java 虚拟机或 Go 编译器也会应用它们的优化。在现代硬件上花费更多时间处理复杂的事情,比如效率,这可能会牺牲我们代码的可靠性和可维护性,听起来可能不是一个好主意。这些只是工程师通常将性能优化放在开发优先级列表的最低位置的几个原因。

不幸的是,就像每一种极端简化一样,这种性能降低也存在一定的风险。不过,别担心!在本书中,我不会试图说服你,现在应该测量每行代码引入的纳秒数量,或者在将其添加到你的软件之前分配的每个比特。你不应该这样做。我远非试图激励你将性能置于开发优先级列表的首位。

然而,有意推迟优化与犯傻错误、导致低效和减速是有区别的。正如俗话说的那样,“完美是好的敌人”,但我们必须首先找到那个平衡的好。因此,我想提出一个细微但至关重要的变化,来改变我们作为软件工程师应该如何思考应用程序性能的方式。它将允许你将小而有效的习惯带入你的编程和开发管理周期中。基于数据,并且在开发周期的尽早阶段,你将学会如何判断何时可以安全地忽略或推迟程序的低效性。最后,当你无法跳过性能优化时,如何在哪里以及如何有效地应用它们,以及何时停止。

在“性能背后”,我们将解析性能这个词,并学习它与本书标题中的效率的关系。然后在“常见效率误解”,我们将挑战关于效率和性能的五个严重误解,这些误解常常使开发者们不再关注此类工作。你将学会,思考效率并不仅仅适用于“高性能”软件。

一些章节,比如这一章,第三章,以及其他章节的部分,都是完全与语言无关的,因此对非 Go 开发者也应该是实用的!

最后,在“实用代码性能的关键”,我将教你为什么专注于效率将使我们能够有效地思考性能优化,而不会牺牲时间和其他软件质量。这一章可能感觉理论性很强,但请相信我,这些见解将训练你在其他部分中介绍的特定效率优化、算法和代码改进的关键编程判断能力。也许它还会帮助你说服你的产品经理或利益相关者,更高效地意识到你的项目可能会有益。

让我们从解析效率的定义开始。

性能背后

在讨论为什么软件效率或优化很重要之前,我们必须首先揭开被滥用的性能这个词的真相。在工程上,这个词在许多上下文中被使用,并且可以有不同的含义,因此让我们对其进行解析,以避免混淆。

当人们说“This application is performing poorly”时,他们通常意味着这个特定程序正在执行得很慢。¹ 然而,如果同样的人说“Bartek is not performing well at work”,他们可能并不意味着 Bartek 从电脑到会议室的步伐太慢。根据我的经验,许多软件开发人员认为performance这个词是speed的同义词。对于其他人来说,它意味着执行质量的总体,这是这个词的最初定义。² 这种现象有时被称为“语义扩散”,即当一个词开始被更大的群体使用时,它的含义可能与其最初的含义不同。

在计算机性能中,性能这个词意味着与其他情境中性能相同的东西,也就是说,“计算机在执行其预期工作时表现如何?”

Arnold O. Allen,《用 Mathematica 进行计算机性能分析导论》(Morgan Kaufmann,1994)

我认为 Arnold 的定义尽可能准确地描述了performance这个词,因此这可能是你从本书中可以采取的第一个可操作步骤。要具体。

当有人使用“性能”这个词时澄清

当阅读文档、代码、bug 跟踪器或参加会议时,当你听到performance这个词时要小心。提出跟进问题,确保了解作者的意思。

实际上,性能作为整体执行的质量可能包含比我们通常认为的更多。这可能看起来有点挑剔,但如果我们想提高软件开发的成本效益,我们必须清晰、高效和有效地沟通!

我建议避免使用performance这个词,除非我们能明确其含义。想象一下你在类似 GitHub Issues 这样的 bug 跟踪器中报告 bug。特别是在那里,不要只是提到“bad performance”,而是具体描述应用程序中出现的预期外行为。同样,在变更日志中描述软件发布的改进时,³ 不要只是说某个变更“改进了性能”。描述具体改进了什么。也许系统的某部分现在更不容易出现用户输入错误,使用的内存更少(如果是的话,在什么情况下?),或者执行某些工作负载更快(比以前快多少秒?)。具体描述将为您和您的用户节省时间。

我将在我的书中明确解释这个词。所以每当你看到描述软件的词语performance,请通过图 1-1 来想象这种可视化。

efgo 0101

图 1-1. 性能定义

原则上,软件性能意味着“软件运行得有多好”,并由你可以改进(或牺牲)的三个核心执行要素组成:

精确性

在完成任务所需的工作中所犯的错误数量。对于软件来说,可以通过应用程序产生的错误结果的数量来衡量。例如,在 Web 系统中,有多少请求以非 200 HTTP 状态代码完成。

Speed

完成任务所需的工作速度——执行的及时性。这可以通过操作延迟或吞吐量来观察。例如,我们可以估计内存中 1GB 数据的典型压缩通常需要大约 10 秒(延迟),从而实现大约100 MBps 的吞吐量

Efficiency

动态系统提供的有效能量与供给它的能量之比。更简单地说,这是衡量完成任务所需的额外资源、能量或工作量的指标。换句话说,我们浪费了多少努力。例如,如果我们从磁盘中获取 64 字节的宝贵数据的操作在 RAM 上分配了 420 字节,我们的内存效率将达到 15.23%。

这并不意味着我们的操作在绝对度量上效率为 15.23%。我们没有计算能量、CPU 时间、热量和其他效率指标。出于实际目的,我们倾向于指定我们考虑的效率是什么。在我们的例子中,这是内存空间。

总结一下,性能至少由这三个元素组成:

性能 = ( 精度 效率 速度 )

改进其中任何一个都可以提高正在运行的应用程序或系统的性能。它可以帮助提高可靠性、可用性、弹性、总延迟等。同样,忽视其中任何一个可能会使我们的软件变得不那么有用。⁴问题是,我们应该在什么时候停下来,并宣称它足够好?这三个元素可能看起来毫不相关,但实际上它们是相互关联的。例如,请注意,即使不减少错误的数量,我们仍然可以提高可靠性和可用性。例如,通过提高效率,减少内存消耗可以降低内存不足并导致应用程序或主机操作系统崩溃的风险。本书专注于知识、技术和方法,帮助您在不降低精度的情况下提高正在运行的代码的效率和速度。

我的书名为“高效的 Go”并非偶然。

我的目标是教会您实用的技能,使您能够以最小的努力产生高质量、准确、高效和快速的代码。为此,当我提到代码的整体效率时(而不是特定的资源),我指的是速度和效率,如图 1-1 所示。相信我,这将有助于我们有效地理解这个主题。您将在“实用代码性能的关键”中了解更多原因。

性能 这个词的误导性使用可能只是效率主题中误解冰山的一角。我们现在将详细讨论更多导致软件开发恶化的严重刻板印象和趋势。最好的情况下,它导致运行成本更高或价值不高的程序。最坏的情况下,它会引起严重的社会和财务组织问题。

常见的效率误解

我在代码审查或迭代计划中被要求“暂时忽略”软件效率的次数令人震惊。你可能也听说过这种情况!我也因同样的理由多次拒绝了别人的变更集。也许当时对我们的变更不予采纳是有充分理由的,特别是如果它们是不必要的微优化,增加了不必要的复杂性。

另一方面,也有一些情况是因为普遍的事实误解而被拒绝的。让我们试着揭示一些最具破坏性的误解。当你听到这些概括性的陈述时要谨慎。揭示它们可能有助于长期节省巨额开发成本。

优化后的代码并不一定可读

毫无疑问,软件代码最关键的品质之一是其可读性。

使代码目的不会被误解比展示技术高超更为重要... 模糊代码的问题在于调试和修改变得更加困难,而这已经是计算机编程中最难的方面。此外,太过聪明的程序可能会让你意想不到。

Brian W. Kernighan 和 P. J. Plauger,《编程风格的要素》(McGraw-Hill,1978)

当我们考虑超快速的代码时,有时候会想到那些巧妙的、低级别的实现,带有一堆字节移位、魔术字节填充和展开的循环。或者更糟糕的是,链接到您应用程序的纯汇编代码。

是的,像这样的低级别优化确实会使我们的代码显著变得不可读,但正如您将在本书中了解到的那样,这样的极端变化在实践中是罕见的。代码优化可能会带来额外的复杂性,增加认知负荷,并使我们的代码更难以维护。问题在于工程师们倾向于将优化与极端复杂性联系起来,并避免像火一样的效率优化。在他们的心目中,这意味着即时的负面可读性影响。本节的重点是向您展示,有方法可以使效率优化的代码清晰可见。效率和可读性可以共存。

类似地,如果我们添加其他任何功能或出于其他原因更改代码,也存在同样的风险。例如,因为害怕降低可读性而拒绝编写更高效的代码就像拒绝添加重要功能以避免复杂性一样。因此,再次,这是一个公正的问题,我们可以考虑放弃功能,但我们应该先评估后果。同样适用于效率更改。

例如,当您想为输入添加额外验证时,可以直接将复杂的 50 行代码if语句瀑布式地粘贴到处理函数中。这可能会让您的代码下一位读者哭泣(或者是您自己,当您几个月后重新查看这段代码时)。或者,您可以将所有内容封装到一个func validate(input string) error函数中,只添加轻微的复杂性。此外,为了避免修改处理代码块,您可以设计代码在调用者侧或中间件中验证它。我们还可以重新考虑系统设计,将验证复杂性转移到另一个系统或组件中,因此不实施此功能。组合特定功能时有很多方法,不会牺牲我们的目标。

我们的代码性能改进与额外功能有什么不同?我认为它们没有区别。您可以像处理功能一样,以可读性为目标设计效率优化。隐藏在抽象背后,二者可以对读者完全透明。⁵

然而,我们往往会将优化标记为可读性问题的主要来源。本章中其他错误观念的主要破坏性后果是,它们往往被用作完全忽略性能改进的借口。这往往导致所谓的过早悲观化,即使程序变得效率更低,反之亦然。

轻松对待自己,轻松对待代码:在其他一切相同的情况下,特别是代码复杂性和可读性,某些高效的设计模式和编码惯例应该从您的指尖自然流淌,并且比最优化的替代方案写起来更加容易。这不是过早优化;这是避免无谓的[不必要]悲观化。

H. Sutter 和 A. Alexandrescu,《C++ 编程规范:101 条规则、指南和最佳实践》(Addison-Wesley,2004 年)

可读性至关重要。我甚至认为不可读的代码在长期来看很少高效。当软件演化时,很容易破坏先前的过于巧妙的优化,因为我们误解或误解它。类似于错误和错误,很难在复杂代码中引入性能问题。您将在第十章中看到故意的效率更改示例,重点是可维护性和可读性。

可读性很重要!

优化易读的代码比将严重优化的代码变得易读更容易。这对于人类和试图优化您的代码的编译器来说都是真实的!

由于我们没有从一开始就设计软件的良好效率,优化通常会导致代码不易读。如果现在拒绝考虑效率,后期要优化代码可能会对可读性产生影响。在我们刚开始设计 API 和抽象的新模块中,找到引入更简单、更高效方法的途径要容易得多,正如您将在第三章中学到的那样,我们可以在许多不同的层面上进行性能优化,而不仅仅是通过挑剔和代码调整。也许我们可以选择更高效的算法、更快的数据结构或不同的系统权衡。这些将很可能导致更清洁、更易维护的代码和更好的性能,而不是在发布软件后再优化效率。在许多约束条件下,如向后兼容性、集成或严格接口,我们提高性能的唯一途径可能是引入额外的、通常是显著的代码或系统复杂性。

优化后的代码可能更易读

令人惊讶的是,优化后的代码可能更易读!让我们看几个 Go 代码示例。示例 1-1 是一种天真的获取器模式的使用方式,我在审核学生或初级开发者的 Go 代码时经常看到。

示例 1-1. 报告错误比例的简单计算
type ReportGetter interface {
   Get() []Report
}

func FailureRatio(reports ReportGetter) float64 { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   if len(reports.Get()) == 0 { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
      return 0
   }

   var sum float64
   for _, report := range reports.Get() { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
      if report.Error() != nil {
         sum++
      }
   }
   return sum / float64(len(reports.Get())) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
}

1

这只是一个简化的例子,但传递函数或接口以获取操作所需的元素的模式非常流行。当元素是动态添加的、缓存的或从远程数据库获取时,这种方式非常有用。

2

请注意,我们执行 Get 三次以检索报告。

我认为你会同意,示例 1-1 中的代码对大多数情况都适用。它简单而且相当易读。然而,由于可能存在效率和准确性问题,我可能不会接受这样的代码。我建议像示例 1-2 那样进行简单的修改。

示例 1-2. 更高效的计算报告错误比例的简单方法
func FailureRatio(reports ReportGetter) float64 {
   got := reports.Get() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   if len(got) == 0 {
      return 0
   }

   var sum float64
   for _, report := range got {
      if report.Error() != nil {
         sum++
      }
   }
   return sum / float64(len(got))
}

1

与示例 1-1 相比,我只调用Get一次,并通过变量got重复使用结果。

一些开发人员可能会争辩说,FailureRatio 函数可能很少使用;它不在关键路径上,而当前的 ReportGetter 实现非常便宜和快速。他们可能会认为,没有测量或基准测试,我们无法决定哪种更有效(这在大多数情况下是正确的!)。他们可能会称呼我的建议为“过早优化”。

然而,我认为这是一个非常普遍的过早悲观化案例。这是一个愚蠢的情况,拒绝更高效的代码,虽然现在并没有显著加快速度,但也没有造成任何损害。相反,我认为示例 1-2 在许多方面更为优越:

没有测量数据,示例 1-2 的代码更高效。

接口允许我们替换实现。它们代表用户和实现之间的某种契约。从FailureRatio函数的角度来看,我们不能假设超出该契约的任何内容。很可能,我们不能假设ReportGetter.Get代码将始终快速且廉价。⁶ 明天,有人可能会用针对文件系统的昂贵 I/O 操作,带有互斥锁的实现或对远程数据库的调用来替换Get代码。⁷

当然,我们可以稍后通过适当的效率流程进行迭代和优化,我们将在“效率感知开发流程”中讨论,但如果这是一个合理的改变,实际上还能改善其他事情,现在做也没有坏处。

示例 1-2 的代码更安全。

这在肉眼中可能看不见,但来自示例 1-1 的代码有引入竞态条件的相当大风险。如果ReportGetter实现与动态更改Get()结果的其他线程同步,我们可能会遇到问题。最好避免竞争,并确保函数体内的一致性。竞态错误是最难调试和检测的,因此宁愿安全也不要后悔。

示例 1-2 的代码更可读。

我们可能会添加一行额外的变量,但最终,在示例 1-2 中的代码明确告诉我们,我们希望在三个使用中使用相同的结果。通过用一个简单的变量替换三个Get()调用实例,我们还可以最小化潜在的副作用,使我们的FailureRatio函数纯函数化(除了第一行)。毫无疑问,从所有方面来看,示例 1-2 比示例 1-1 更可读。

这样的说法可能是准确的,但邪恶在于“过早”的部分。并非每种性能优化都是过早的。此外,这样的规则并不授权我们拒绝或忽视具有相当复杂性的更高效解决方案。

另一个优化代码清晰度的例子可以通过示例 1-3 和 1-4 中的代码可视化。

示例 1-3. 简单循环,没有优化
func createSlice(n int) (slice []string) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   for i := 0; i < n; i++ {
      slice = append(slice, "I", "am", "going", "to", "take", "some", "space") ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   }
   return slice
}

1

返回名为slice的命名参数将在函数调用开始时创建一个持有空string切片的变量。

2

我们向切片追加七个string项,并重复n次。

示例 1-3 展示了我们通常在 Go 中如何填充切片,您可能会认为这里没有任何问题。它只是有效地工作。然而,我认为如果我们事先知道我们将向切片附加多少元素,那么我们不应该在循环中这样写。在我看来,我们应该始终像 示例 1-4 中那样编写。

示例 1-4. 带有预分配优化的简单循环。这样的代码可读性更低吗?
func createSlice(n int) []string {
   slice := make([]string, 0, n*7) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   for i := 0; i < n; i++ {
      slice = append(slice, "I", "am", "going", "to", "take", "some", "space") ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   }
   return slice
}

1

我们正在创建一个包含字符串 slice 的变量。我们还为此切片分配了 n * 7 个字符串的空间(容量)。

2

我们向切片附加了七个 string 项目,并重复 n 次。

我们将讨论效率优化,如示例 1-2 和 1-4,以及来自“预先分配如果可能”的更深刻的 Go 运行时知识,与 第四章。原则上,这两者都使我们的程序工作量减少。在 示例 1-4 中,由于初始预分配,内部的 append 实现在内存中不需要逐步扩展切片大小。我们一开始就完成了。现在,我希望您关注以下问题:这段代码可读性更高还是更低?

可读性通常是主观的,但我认为来自 示例 1-4 的更有效率的代码更易理解。它增加了一行,因此我们可以说代码有点更复杂,但同时,它在消息上是显式且清晰的。它不仅帮助 Go 运行时执行更少的工作,还向读者提示了此循环的目的以及我们期望的迭代次数。

如果您从未见过在 Go 中内置的 make 函数的原始用法,您可能会认为这段代码可读性较低。这是公平的。然而,一旦您意识到其好处并在整个代码中始终如一地使用此模式,它就会成为一种良好的习惯。甚至更多,由于这一点,任何没有此类预分配的切片创建也告诉了您一些信息。例如,它可能表明迭代次数是不可预测的,因此您需要更加小心。您甚至在查看循环内容之前就知道了一件事!为了使这样的习惯在 Prometheus 和 Thanos 代码库中保持一致,我们甚至添加了相关的Thanos Go 编码风格指南条目

可读性不是一成不变的;它是动态的。

即使代码从未更改,理解特定软件代码的能力随时间可能会发生变化。随着语言社区尝试新事物,传统会来来去去。通过严格一致性,您可以通过引入新的清晰约定帮助读者理解甚至更复杂的程序片段。

现在与过去的可读性

通常情况下,开发者经常引用 Knuth 的“过早优化是所有邪恶之根源”这句话⁸ 来减少优化带来的可读性问题。 然而,这句话是很久以前说的。 虽然我们可以从过去的一般编程中学到很多东西,但我们已经在 1974 年后极大地改进了许多事物。 例如,那时流行的做法是向变量名称添加其类型信息,正如 示例 1-5 中展示的那样⁹。

示例 1-5。 应用于 Go 代码的系统匈牙利命名法示例
type structSystem struct {
   sliceU32Numbers []uint32
   bCharacter      byte
   f64Ratio        float64
}

匈牙利命名法曾经非常有用,因为当时编译器和集成开发环境(IDE)并不是很成熟。 但是现在,在我们的 IDE 或甚至像 GitHub 这样的仓库网站上,我们可以将鼠标悬停在变量上以立即知道其类型。 我们可以在毫秒内转到变量定义,阅读评论,并查找所有调用和变异。 随着智能代码建议、高级突出显示和在 1990 年代中期开发的面向对象编程的主导地位,我们手头的工具使我们能够在不显著影响实际可读性的情况下添加功能和效率优化(复杂性)。¹⁰ 此外,观察性和调试工具的可访问性和功能已经极大地增长,我们将在 第六章 中探讨这一点。 它仍然不允许巧妙的代码,但允许我们更快地理解更大的代码库。

总之,性能优化就像我们软件中的另一个功能,我们应该相应地对待它。 它可能会增加复杂性,但有方法可以减少理解我们代码所需的认知负荷。¹¹

如何使高效的代码更易读

  • 删除或避免不必要的优化。

  • 将复杂代码封装在清晰的抽象背后(例如接口)。

  • 将“热”代码(需要更好效率的关键部分)与“冷”代码(很少执行的代码)分开。

正如我们在本章中学到的,有时更高效的程序往往是简单、明确和可理解代码的副产品。

You Aren’t Going to Need It

You Aren’t Going to Need It (YAGNI) 是一条强大且流行的规则,在编写或审核任何软件时我经常使用。

XP [极限编程] 最广为人知的原则之一是 You Aren’t Going to Need It (YAGNI) 原则。 YAGNI 原则强调在面对投资回报不确定性时延迟投资决策的价值。 在 XP 的背景下,这意味着推迟实现模糊功能,直到确定其价值的不确定性得到解决。

Hakan Erdogmu 和 John Favaro,《保持选择的开放性:极限编程与灵活性经济学》

从原则上讲,它意味着避免做那些当前需求中并非绝对必要的额外工作。这基于需求经常变化的事实,我们必须接受在软件上快速迭代的观点。

让我们想象一种潜在的情况,凯蒂,一位资深软件工程师,被分配任务创建一个简单的 Web 服务器。没什么花哨的,只是一个暴露一些 REST 端点的 HTTP 服务器。凯蒂是一位经验丰富的开发者,在过去可能已经创建了数百个类似的端点。她前进,编写功能,并在很短时间内测试了服务器。还有一些时间,她决定添加额外的功能:一个简单的持有者令牌授权层。凯蒂知道这样的变更超出了当前的需求,但是她以前创建过数百个 REST 端点,每一个都有类似的授权。经验告诉她,这样的需求很可能很快也会出现,所以她想要做好准备。你认为这样的变更是否有意义,应该被接受?

尽管凯蒂表现出了良好的意图和扎实的经验,但为了保持 Web 服务器代码的质量和整体开发的成本效益,我们应该避免合并这样的变更。换句话说,我们应当遵循 YAGNI 规则。为什么呢?在大多数情况下,我们无法预测一个功能。坚持需求能够帮助我们节省时间和复杂性。有一种风险,即项目可能永远不需要授权层,例如,如果服务器在专用授权代理后运行。在这种情况下,即使不使用,凯蒂编写的额外代码也会带来很高的成本。这是额外的阅读代码,增加了认知负荷。此外,当需要时,更改或重构这样的代码会更加困难。

现在,让我们进入一个更加模糊的领域。我们向凯蒂解释了为什么需要拒绝授权代码。她同意了,并且决定通过在服务器上添加一些关键的监控来仪器化它,这些监控是一些关键指标。这种变更是否也违反了 YAGNI 规则?

如果监控是需求的一部分,它不违反 YAGNI 规则,应当被接受。如果不是,在不了解完整背景的情况下,很难说。关键监控应明确列入需求中。但即使没有,当我们在任何地方运行这样的代码时,Web 服务器的可观察性是首要需要的。否则,我们如何知道它是否正在运行?在这种情况下,凯蒂在技术上做了一些立即有用的重要事情。最终,我们应当运用常识和判断,在合并这种变更之前,向软件需求中添加或明确删除监控。

后来,在她的空闲时间里,Katie 决定为增强单独端点读取的性能添加一个简单的缓存。她甚至编写并执行了一个快速的基准测试,以验证端点的延迟和资源消耗的改进。这是否违反了 YAGNI 规则呢?

关于软件开发的悲哀之一是,性能效率和响应时间通常不在利益相关者的需求之列。应用程序的目标性能目标是“只要能工作”和“足够快”,但没有详细说明这意味着什么。我们将讨论如何在“资源感知效率需求”中定义实际的软件效率需求。在这个例子中,让我们假设最坏的情况。在需求列表中没有关于性能的内容。那么我们是否应该应用 YAGNI 规则并拒绝 Katie 的更改呢?

要了解清楚没有完整上下文是很难说的。实施一个健壮且可用的缓存并不是件小事,所以新代码有多复杂呢?我们正在处理的数据是否容易“可缓存”?¹² 我们知道这样的端点会被多频繁地使用吗(它是一个关键路径吗)?它应该扩展到多远?另一方面,为一个频繁使用的端点计算相同结果非常低效,所以缓存是一个很好的模式。

我建议 Katie 采取与她处理监控变更类似的方法:考虑与团队讨论,明确 Web 服务应该提供的性能保证。这将告诉我们,缓存现在是否需要或违反了 YAGNI 规则。

作为最后的更改,Katie 继续进行了一个合理的效率优化,就像你在示例 1-4 中学到的切片预分配改进一样。我们应该接受这样的改变吗?

我会在这里严格一点,答案是肯定的。我建议总是预先分配,就像在示例 1-4 中,当你预先知道元素的数量时。这是否违反了 YAGNI 规则背后的核心声明呢?即使某事通常适用,你也不应该在确定需要之前就去做吧?

我认为,小的效率习惯,如果不会降低代码可读性(甚至会改善),通常应该成为开发者工作的重要部分,即使在需求中没有明确提到。我们将把它们作为“合理优化”来讨论。同样,没有项目需求规定像代码版本控制、拥有小接口或避免大依赖等基本最佳实践。

这里的主要观点是,遵循 YAGNI 规则是有帮助的,但并不意味着开发人员完全可以忽略性能效率。通常成千上万的小事情造成应用程序的过多资源使用和延迟,而不仅仅是我们可以稍后修复的单个问题。理想情况下,清晰定义的需求有助于澄清软件的效率需求,但它们永远无法涵盖我们应该尝试应用的所有细节和最佳实践。

硬件正在变得更快更便宜

当我开始编程时,我们不仅拥有缓慢的处理器,还有非常有限的内存——有时甚至只有几千字节。因此,我们必须考虑内存并明智地优化内存消耗。

瓦伦丁·西蒙诺夫,《优先选择可读性》

毫无疑问,现在的硬件比以往任何时候都更强大、更便宜。我们几乎每年或每个月都能看到技术的进步。从 1995 年的单核奔腾 CPU,时钟速率为 200 MHz,到现在的小型、节能的 CPU,速度可达 3 到 4 GHz。内存大小从 2000 年的几十 MB 增加到 20 年后个人电脑的 64GB,访问速度更快。过去,小容量硬盘转向 SSD,然后是每秒 7GB 的快速 NVME SSD 硬盘,几 TB 的存储空间。网络接口实现了每秒 100Gb 的吞吐量。在远程存储方面,我记得有 1.44MB 空间的软盘,接着是容量高达 553MB 的只读 CD-ROM;接下来我们有蓝光、可读写的 DVD,现在可以轻松获取带有 TB 容量的 SD 卡。

现在让我们加入前述事实,普遍观点是典型硬件的摊销小时价值比开发人员的时间更便宜。考虑到这一切,有人会说,如果代码中的一个函数多占用了 1 MB 或进行了过多的磁盘读取,也无关紧要。为什么我们要推迟功能,培养或投资于性能意识的工程师,如果我们可以购买更大的服务器并总体支付更少呢?

可能你可以想象,事情并不是那么简单。让我们拆解这种非常有害的论点,将效率从软件开发的待办列表中剥离出来。

首先,声称在硬件上花更多的钱比在效率话题上投资昂贵的开发时间非常短视。这就像声称每次出了问题就买辆新车并卖掉旧车一样,因为修理不容易而且费用高昂。有时候这样做可能有效,但在大多数情况下,这并不是非常高效或可持续的。

假设软件开发人员的年薪大约在 10 万美元左右。考虑到其他就业成本,公司每年需要支付 12 万美元,即每月 1 万美元。2021 年以 1 万美元购买一台配备 1 TB DDR4 内存、两个高端 CPU、1 吉比特网络卡和 10 TB 硬盘空间的服务器。暂时忽略能源消耗成本。这样的交易意味着我们的软件每个月可以超额分配数 TB 的内存,而我们仍然比雇佣工程师来优化要好,对吧?不幸的是,事情并非如此。

这些领域的分配量往往比你想象的要多得多,不必等待整整一个月!图 1-2 展示了一个单副本(总共六个副本之一)在单个集群中运行五天的堆内存概况的屏幕截图。我们将在第九章讨论如何阅读和使用这些概况,但图 1-2 展示了自进程重启五天前某个Series函数的总分配内存。

efgo 0102

图 1-2. 显示高流量服务在五天内的所有内存分配片段的内存概况

大部分内存已经被释放,但请注意,来自 Thanos 项目的这款软件在仅运行五天的情况下总共使用了 17.61 TB。¹³ 如果您写桌面应用程序或工具,迟早会遇到类似的规模问题。根据先前的例子,如果一个函数超额分配 1 MB,那么仅仅通过 100 个桌面用户的一次运行,该函数就可以在我们的应用程序中进行 100 次关键操作,总计浪费掉 10 TB。这不是一个月的时间,而是由 100 个用户一次运行造成的。因此,轻微的低效率很快就会导致过多的硬件资源。

还有更多。要负担得起 10 TB 的超额分配,购买具有如此多内存的服务器并支付能源消耗是不够的。分摊成本,除其他外,还必须包括编写、购买或至少维护固件、驱动程序、操作系统以及用于监视、更新和操作服务器的软件。由于额外硬件需要额外软件,按定义,这需要花钱雇佣工程师,所以我们回到了起点。通过避免专注于性能优化来节省工程成本,我们可能会在维护过度使用的资源所需的其他工程师身上花更多钱,或者支付一个已经在云使用账单中计算了额外成本及利润的云服务提供商。

另一方面,今天 10 TB 的内存成本很高,但明天由于技术进步可能只是边际成本。如果我们忽视性能问题,并等待服务器成本降低或更多用户用更快的设备替换他们的笔记本电脑或手机,会怎么样?等待比调试棘手的性能问题更容易!

不幸的是,我们不能忽视软件开发效率,期望硬件进步来缓解需求和性能问题。硬件确实变得更快更强大,是的。但不幸的是,速度还不够快。让我们来看看这种非直观效应背后的三个主要原因。

软件会扩展以填充可用的内存

这种效应被称为帕金森法则。¹⁴ 它指出,无论我们拥有多少资源,需求总是倾向于匹配供给。例如,帕金森法则在大学中明显可见。无论教授给予作业或考试准备多少时间,学生总是会用光所有时间,可能大部分都是在最后一刻完成的。¹⁵ 我们在软件开发中也可以看到类似的行为。

软件比硬件变得更快地变慢

Niklaus Wirth 提到了一个“肥软件”的术语,解释了为什么总是会有更多对更多硬件需求的需求。

增加的硬件性能无疑是厂商处理更复杂问题的主要动力......但应该担心的不是固有复杂性,而是自我造成的复杂性。有许多问题早已解决,但对于同样的问题,我们现在提供的解决方案包装在更庞大的软件中。

Niklaus Wirth, “精简软件的呼吁”

软件变得比硬件变得更快更强大更快,因为产品必须投资于更好的用户体验才能获得利润。这些包括更漂亮的操作系统,发光图标,复杂的动画,网站上的高清视频或模仿您面部表情的花哨表情符号,感谢面部识别技术。这是客户的永恒战斗,带来了更多的复杂性,因此增加了计算需求。

此外,由于更好地访问计算机、服务器、手机、物联网设备和任何其他类型的电子设备,软件的快速民主化也在发生。正如 Marc Andreessen 所说,“软件正在吞噬世界”。自 2019 年末开始的 COVID-19 大流行加速了数字化进程,远程基于互联网的服务成为现代社会的重要支柱。每天我们可能有更多的计算能力可用,但更多的功能和用户交互都会耗尽所有资源,甚至需要更多。最后,我认为我们在前述单一功能中过度使用的 1 MB 可能很快就会成为一个重要的瓶颈。

如果这仍然感觉非常假设性,只需看看你周围的软件。我们使用社交媒体,其中仅 Facebook 每天生成 4PB¹⁶的数据。我们在网上搜索,导致谷歌每天处理 20PB 的数据。然而,有人可能会说这些都是罕见的、全球规模的系统,拥有数十亿用户。典型的开发人员没有这样的问题,对吧?当我看到大多数共同创造或使用的软件时,他们迟早会遇到与大数据使用相关的性能问题。

  • 一个用 React 编写的 Prometheus UI 页面在数百万个度量名称上执行搜索,或尝试获取数百兆字节的压缩样本,导致浏览器延迟和爆炸性内存使用。

  • 在低使用率下,我们基础设施的一个 Kubernetes 集群每天生成 0.5TB 的日志(其中大部分从未被使用)。

  • 我用来撰写本书的优秀语法检查工具在文本超过 20,000 字时进行了过多的网络调用,严重拖慢了我的浏览器。

  • 我们用 Markdown 格式化和链接检查的简单脚本,花了几分钟处理所有元素。

  • 我们的 Go 静态分析作业和 linting 超过了 4GB 内存,导致我们的 CI 作业崩溃。

  • 我的 IDE 曾经需要 20 分钟才能索引我们的单体库中的所有代码,尽管我使用的是顶级笔记本电脑。

  • 我仍未编辑我的 GoPro 4K 超广角视频,因为软件太卡了。

我可以举例说个没完没了,但关键是我们生活在一个真正的“大数据”世界中。因此,我们必须明智地优化内存和其他资源。

未来情况将会更糟。我们的软件和硬件必须处理数据以极快速度增长,这比任何硬件发展都要快。我们正处于引入每秒传输高达 20 吉比特的 5G 网络的边缘。我们几乎在每一件购买的物品中引入了迷你计算机,如电视、自行车、洗衣机、冰柜、台灯,甚至除臭剂! 我们称这种运动为“物联网”(IoT)。预计从这些设备中获取的数据将从 2019 年的 18.3 ZB 增长到 2025 年的 73.1 ZB[¹⁷]。该行业可以生产 8K 电视,分辨率为 7,680 × 4,320,约 3300 万像素。如果您编写过电脑游戏,您可能很了解这个问题——在高度逼真的游戏中,以 60 帧以上的速度渲染这么多像素需要大量的有效工作。现代加密货币和区块链算法也对计算能效提出了挑战;例如,比特币在价值高峰期间的能源消耗达到了约 130 太瓦时(全球电力消耗的 0.6%)

技术限制

最后一个原因,但同样重要的是,导致硬件进展不够快的背后原因之一是,硬件的进步在某些方面停滞不前,比如 CPU 速度(时钟频率)或内存访问速度。我们将在第四章中讨论这种情况的一些挑战,但我认为每个开发者都应该意识到我们当前所面临的基本技术限制。

在现代的效率书籍中读到不提及摩尔定律会感到奇怪,对吧?你可能在某处已经听说过它。这一定律是由英特尔的前首席执行官和联合创始人戈登·摩尔于 1965 年首次提出的。

最小组件成本的复杂性(每个芯片的晶体管数量,具有最低制造成本)每年以约两倍的速度增加……从长远来看,增长率更加不确定,尽管没有理由认为它在至少 10 年内不会保持几乎恒定的状态。这意味着到 1975 年,为了最低成本,集成电路每个组件的数量将达到 65,000 个。

戈登·E·摩尔,《“在集成电路上装更多的元件”》(https://oreil.ly/WhuWd),《电子》38(1965 年)

摩尔的观察对半导体行业产生了巨大影响。但如果不是因为罗伯特·H·丹纳德及其团队,减小晶体管尺寸可能不会那么有利。在 1974 年,他们的实验揭示,功耗与晶体管尺寸成正比(恒定功率密度)。¹⁸这意味着更小的晶体管更加节能。最终,这两条法则都承诺了晶体管每瓦性能的指数增长。这激励投资者不断研究和开发减小金属氧化物半导体场效应晶体管的尺寸的方法。我们还可以在更小、更密集的微芯片上安装更多的晶体管,从而降低制造成本。该行业不断减少了安装相同计算能力所需的空间,改善了从 CPU、RAM 和闪存到 GPS 接收器和高清摄像头传感器的任何芯片的性能。

在实践中,摩尔的预测没有像他想的那样持续 10 年,而是接近 60 年,至今仍然有效。我们继续发明更小的微小晶体管,目前大约在约 70 纳米左右波动。可能我们可以使它们更小。不幸的是,正如我们可以从图 1-3 看到的那样,我们在 2006 年左右达到了丹纳德的比例缩放的物理极限。²⁰

efgo 0103

图 1-3. 受“性能至上”(Emery Berger 著)启发的图像:摩尔定律与丹纳德规则

尽管技术上,高密度小晶体管的功耗保持恒定,但这些密集的芯片很快就会发热。超过 3-4 GHz 的时钟速度后,为了保持其运行,冷却晶体管将需要显著更多的电力和其他成本。因此,除非你计划在海底底部运行软件²¹,否则短期内不会有更快的指令执行 CPU。我们只能拥有更多的核心。

更快的执行更节能。

到目前为止,我们学到了什么?硬件速度受到限制,软件变得越来越臃肿,而我们必须处理数据和用户的持续增长。不幸的是,这还不是结束。在开发软件时,我们经常忽略的一个重要资源是电力。我们的每一个计算过程都需要电力,而在诸如手机、智能手表、物联网设备或笔记本电脑等平台上,电力资源是严重受限的。令人意外的是,能效与软件速度和效率之间存在着强烈的关联。我喜欢 Chandler Carruth 的演讲,他很好地解释了这种令人惊讶的关系:

如果你读过“节能指令”或“优化电力使用”的内容,你应该变得非常怀疑……这基本上是一种完全没有科学根据的东西。关于如何节省电池寿命的头号理论是:尽快运行程序,然后进入休眠状态。你的软件运行得越快,消耗的电力就越少……今天的每一个通用微处理器,在节省电力方面的方式都是通过尽可能快速且频繁地关闭自己。

Chandler Carruth,《算法效率,数据结构性能》,CppCon 2014

总结一下,要避免常见的陷阱,即把硬件想象成一个持续变快和变便宜的资源,这样就能避免优化我们的代码。这是一个陷阱。这种破碎的循环使工程师逐渐降低了他们的性能编码标准,并且要求更多、更快的硬件。更便宜、更易得的硬件又会创造出更多的心理空间来跳过效率等等。有像苹果的 M1 硅片²²,RISC-V 标准²³ 等令人惊叹的创新,还有更实际的量子计算设备,它们承诺了很多。不幸的是,截至 2022 年,硬件的增长速度远远落后于软件效率的需求。

效率提高了可访问性和包容性。

软件开发人员通常对我们使用的高端笔记本电脑或移动设备的机器性能“宠坏”并与典型人类现实脱节。工程师们通常在高级、高端的笔记本电脑或移动设备上创建和测试软件。我们需要意识到,许多人和组织正在使用较老的硬件或更差的互联网连接。²⁴ 人们可能不得不在较慢的计算机上运行您的应用程序。考虑到我们的开发过程中的效率,以改善软件的总体可访问性和包容性可能是值得的。

我们可以选择水平扩展

正如我们在前面的部分中学到的,我们预计我们的软件迟早会处理更多的数据。但是你的项目从一开始就不太可能有数十亿的用户。我们可以通过在开发周期初期选择更低的目标用户数、操作数或数据大小来避免巨大的软件复杂性和开发成本。例如,在移动笔记应用程序中,我们通常简化初始编程周期,假设笔记数目较少,正在构建的代理每秒请求较少,或者团队正在处理的数据转换工具中的文件较小。简化事情是可以的。在早期设计阶段大致预测性能要求也很重要。

同样,找到软件部署中中长期预期负载和使用情况至关重要。即使在交通量增加的情况下,也要确保软件设计可以保证相似的性能水平是可伸缩的。一般来说,可伸缩性在实践中非常难以实现且昂贵。

即使系统今天能够可靠地工作,这并不意味着它将来一定会可靠地工作。系统性能下降的一个常见原因是负载增加:也许系统从一开始的 1 万并发用户增长到 10 万并发用户,或者从 100 万增长到 1000 万。也许它正在处理比以前更大量的数据。可伸缩性是我们用来描述系统处理增加负载能力的术语。

Martin Kleppmann,《设计数据密集型应用》(O'Reilly,2017)

在谈论效率的同时,我们可能会在本书中涉及一些可伸缩性的话题。然而,对于本章的目的,我们可以将软件的可伸缩性区分为两种类型,如图 1-4 所示。

efgo 0104

图 1-4. 垂直与水平可伸缩性比较

垂直可伸缩性

扩展我们应用程序的第一种,有时最简单的方法是在具有更多资源的硬件上运行软件——“垂直”扩展。例如,我们可以为软件引入并行性,不是使用一个而是三个 CPU 核心。如果负载增加,我们提供更多的 CPU 核心。同样,如果我们的过程需要大量内存,我们可能需要增加运行要求,并请求更大的 RAM 空间。像磁盘、网络或电源这样的其他资源也是如此。显然,这并不是没有后果的。在最好的情况下,您在目标机器上有这个空间。潜在地,您可以通过将其他进程重新调度到不同的机器(例如,在云中运行时)或暂时关闭它们(在笔记本电脑或智能手机上运行时有用)。最坏的情况是,您可能需要购买更大的计算机,或者更有能力的智能手机或笔记本电脑。后一种选择通常非常有限,特别是如果您提供软件供客户在他们的非云场地上运行。最终,只能垂直扩展的资源消耗型应用程序或网站的可用性要低得多。

如果您或您的客户在云中运行您的软件,则情况略有改善。您可以“只需”购买一个更大的服务器。截至 2022 年,在 AWS 平台上,您可以将您的软件扩展到 128 个 CPU 核心,几乎 4 TB 的 RAM 和 14 GBps 的带宽。²⁵ 在极端情况下,您还可以购买一台IBM 主机,具有 190 个核心和 40 TB 的内存,需要不同的编程范例。

不幸的是,垂直扩展在许多方面都有其局限性。即使在云端或数据中心,我们也不能无限地扩展硬件。首先,大型机器是稀缺且昂贵的。其次,正如我们将在第四章中学到的,更大的机器会遇到由许多隐藏的单点故障引起的复杂问题。例如内存总线、网络接口、NUMA 节点和操作系统本身可能会过载和过慢。²⁶

横向扩展

而不是一个更大的机器,我们可以尝试在多个远程、更小、更简单和更便宜的设备之间进行计算卸载和共享。例如:

  • 在移动消息应用程序中搜索包含“home”一词的消息,我们可以获取数百万条过去的消息(或者在第一时间将它们存储在本地),并对每条运行正则表达式匹配。相反,我们可以设计一个 API,并远程调用一个后端系统,将搜索分成 100 个匹配数据集的 1/100 的作业。

  • 而不是构建“单块”软件,我们可以将不同功能分布到单独的组件中,并转向“微服务”设计。

  • 而不是在个人电脑或游戏主机上运行需要昂贵 CPU 和 GPU 的游戏,我们可以在云中运行,并且以高分辨率流式传输输入和输出

水平扩展更容易使用,因为它有较少的限制,并且通常允许更大的动态性。例如,如果软件仅在某个公司使用,夜间可能几乎没有用户,而白天流量很大。通过水平扩展,可以轻松实现根据需求进行自动扩展和缩减的自动化功能,响应速度快。

另一方面,软件方面的水平扩展要困难得多。分布式系统、网络影响以及无法分片的困难问题是开发此类系统时的许多复杂性之一。因此,在某些情况下,通常更好地坚持垂直扩展。

考虑到水平和垂直扩展,让我们看看过去的一个具体场景。许多现代数据库依赖于压缩来高效存储和查找数据。在此过程中,我们可以重复使用许多索引,去重相同数据,并将碎片化的数据片段聚集到顺序数据流中,以加快读取速度。在 Thanos 项目初期,我们决定为简单起见重用一个非常朴素的压缩算法。理论上,我们计算出在一个数据块内不需要并行进行压缩过程。给定来自单一源的稳定的、最终压缩后的 100 GB(或更多)数据流,我们可以依赖于单个 CPU、少量内存和一些磁盘空间。最初的实现非常朴素且未经优化,遵循了 YAGNI 原则,避免了过早优化的模式。我们希望避免优化项目的可靠性和功能特性的复杂性和努力。结果,部署我们项目的用户很快遇到了压缩问题:处理传入数据过慢或每次操作消耗数百 GB 内存。成本是第一个问题,但不是最紧迫的问题。更大的问题是,许多 Thanos 用户在其数据中心没有更大的机器来垂直扩展内存。

乍一看,压缩问题看起来像是一个可扩展性问题。压缩过程依赖于我们无法无限添加的资源。由于用户想要快速解决方案,我们与社区一起开始集思广益,探讨潜在的水平扩展技术。我们讨论了引入一个压缩调度服务,该服务将把压缩作业分配给不同的机器,或者使用一种八卦协议的智能对等网络。不详细讨论,这两种解决方案都会增加巨大的复杂性,可能会使整个系统的开发和运行的复杂性翻倍或翻三倍。幸运的是,经过几天勇敢和经验丰富的开发人员的时间重新设计代码以提高效率和性能。这使得新版本的灭霸能够将压缩速度提高一倍,并直接从磁盘流式传输数据,从而使内存消耗达到最低。几年后,灭霸项目仍然没有任何复杂的水平扩展用于压缩,除了简单的分片,即使有成千上万的成功用户在其中运行数十亿的指标。

现在可能感觉有趣,但在某种程度上,这个故事相当可怕。我们几乎要引入基于社会和客户压力的巨大分布式系统级复杂性。这可能很有趣,但也可能会冒着项目被抛弃的风险。也许我们以后会添加,但首先我们会确保没有其他效率优化可以压缩。在我的职业生涯中,无论是在开源还是闭源的小型和大型项目中,类似的情况都反复出现。

过早的可扩展性比过早的效率优化更糟糕!

在引入复杂的可扩展模式之前,请确保考虑在算法和代码层面提高效率。

正如“幸运”的灭霸压缩情况所展示的那样,如果我们不专注于软件的效率,我们很快就会被迫引入过早的水平扩展。这是一个巨大的陷阱,因为通过一些优化工作,我们可能完全可以避免陷入扩展方法的复杂性。换句话说,避免复杂性可能会带来更大的复杂性。这对我来说是一个未被注意但却是行业中的一个关键问题。这也是我写这本书的主要原因之一。

复杂性源于“复杂性必须存在”的事实。我们不想使代码复杂化,因此我们必须使系统复杂化,如果从低效组件构建,会浪费资源和大量开发者或运维人员的时间。横向扩展尤其复杂。从设计上来说,它涉及网络操作。正如我们可能从 CAP 定理中了解的那样,²⁷ 一旦我们开始分布我们的流程,我们不可避免地遇到可用性或一致性问题。相信我,缓解这些基本约束,处理竞争条件,并理解网络延迟和不可预测性的世界比添加小的效率优化要困难一百倍,比如隐藏在io.Reader接口后面。

这一部分可能让你觉得只涉及基础设施系统。这是不正确的。它适用于所有软件。例如,如果你编写前端软件或动态网站,你可能会被诱惑将小客户端计算移到后端。我们可能只有在计算依赖负载并且超出用户空间硬件能力时才应该这样做。过早地将其移到服务器上可能会增加复杂性,因为需要额外的网络调用,更多的错误处理情况以及导致服务器饱和的拒绝服务(DoS)问题。²⁸

另一个例子来自我的经验。我的硕士论文是关于“使用计算集群的粒子引擎”。原则上,目标是在Unity 引擎中添加一个粒子引擎到 3D 游戏中。诀窍在于,粒子引擎不应在客户端机器上运行,而是将“昂贵”的计算卸载到我大学附近的超级计算机“Tryton”上。²⁹ 你猜怎么着?尽管使用了超快的 InfiniBand 网络,³⁰ 我尝试模拟的所有粒子(比如真实的雨和人群)在转移到我们的超级计算机后速度更慢,可靠性更低。不仅更简单,而且在客户端机器上计算所有内容速度也更快。

总结一下,当有人说,“不要优化,我们可以横向扩展”,应该非常怀疑。通常,从效率改进开始比从扩展性水平上升更简单更便宜。另一方面,判断应该告诉你,当优化变得过于复杂时,扩展性可能是一个更好的选择。关于这一点,你会在第三章中了解更多。

市场时间更为重要

时间是昂贵的。其中一个方面是软件开发人员的时间和专业知识成本高昂。你希望应用程序或系统拥有的功能越多,设计、实施、测试、保障和优化解决方案所需的时间就越多。第二个方面是,公司或个人花费的时间越长,产品或服务的“上市时间”就越长,这可能会影响财务结果。

曾经时间就是金钱。现在它比金钱更有价值。麦肯锡的一项研究报告称,平均而言,公司如果将产品延迟六个月交付,税后利润将损失 33%,相比之下,如果产品开发超支 50%,损失只有 3.5%。

查尔斯·H·豪斯和雷蒙德·L·普赖斯,《产品团队追踪回报图

很难衡量这种影响,但当你“迟到”市场时,你的产品可能不再是领先的。你可能会错过宝贵的机会,或者对竞争对手的新产品反应过晚。因此,公司通过采用敏捷方法论或概念验证(POC)和最小可行产品(MVP)模式来减少这种风险。

敏捷和较小的迭代有所帮助,但最终,为了实现更快的开发周期,公司也会尝试其他方法:扩展他们的团队(招聘更多人,重新设计团队)、简化产品、增加自动化,或者进行合作伙伴关系。有时他们试图降低产品质量。正如 Facebook 的自豪初衷是“快速行动,打破常规”,公司常常在诸如代码可维护性、可靠性和效率等方面减少软件质量,以“击败”市场。

这正是我们最后一个误解所涉及的内容。为了更快地进入市场,减少软件的效率并不总是最佳选择。了解这种决定的后果是很好的。首先要了解风险。

优化是一个困难且昂贵的过程。许多工程师认为这个过程会延迟进入市场并降低利润。这可能是真的,但它忽略了与产品性能不佳相关的成本(尤其是在市场竞争激烈时)。

兰德尔·海德,《过早优化的谬误

Bugs、安全问题和性能不佳时有发生,但它们可能会损害公司。毋庸置疑,我们可以看看波兰最大的游戏发行商 CD Projekt 在 2020 年底发布的一款游戏。赛博朋克 2077被认为是一款非常雄心勃勃、开放世界、庞大且高质量的作品。尽管市场营销做得很好,来自声誉良好的发行商,尽管推迟了,全球的玩家们还是购买了 800 万份预购。不幸的是,2020 年 12 月发布时,本来是优秀游戏却存在严重的性能问题。在所有游戏主机和大多数 PC 配置上都有 bug、崩溃和低帧率。在像 PS4 或 Xbox One 这样的老旧游戏主机上,据说这款游戏几乎无法游玩。当然,在接下来的几个月和几年里,有许多更新,大量的修复和显著的改进。

不幸的是,一切都为时已晚。损害已经造成。对我来说,这些问题似乎有些小,但已足以动摇 CD Projekt 的财务前景。在发布后五天,公司股价损失了三分之一,造成创始人损失超过 10 亿美元。数百万玩家要求退款。投资者因游戏问题起诉CD Projekt,著名的主导开发者离开了公司。也许这家发行商会幸存并恢复。但我们只能想象一个破损声誉对未来制作的影响。

更有经验和成熟的组织深知软件性能的关键价值,特别是那些面向客户的组织。亚马逊发现,如果其网站加载慢了一秒钟,将会导致每年损失 16 亿美元。亚马逊还报告称,每增加 100 毫秒的延迟就会损失 1%的利润。谷歌意识到,将其网页搜索速度从400 毫秒降低到 900 毫秒会导致流量下降 20%。对一些企业来说,情况甚至更糟。据估计,如果经纪人的电子交易平台比竞争对手慢 5 毫秒,可能会损失 1%甚至更多的现金流。如果慢 10 毫秒,这个数字将增长到收入减少 10%

从现实角度来看,毫秒级的慢速在大多数软件情况下可能并不重要。例如,假设我们想要实现从 PDF 到 DOCX 的文件转换器。整个体验是否持续 4 秒或 100 毫秒是否重要?在许多情况下,并不重要。然而,当有人把这个作为市场价值,并且竞争对手的产品延迟为 200 毫秒时,代码的效率和速度突然成为赢得或失去客户的问题。如果在物理上可能实现如此快的文件转换,竞争对手迟早会试图实现它。这也是为什么许多项目,甚至是开源项目,非常强调它们的性能结果的原因。虽然有时感觉像是廉价的营销手段,但这确实有效,因为如果你有两个功能集和其他特征相似的解决方案,你会选择速度最快的那个。不过,不仅仅是速度,资源消耗也很重要。

在市场上,效率通常比功能更重要!

在我作为基础设施系统顾问的经验中,我见过很多情况,客户选择远离需要更多 RAM 或磁盘存储的解决方案,即使这意味着在功能上有所损失。³²

对我来说,结论很简单。如果你想在市场上获胜,忽视软件中的效率可能不是最好的选择。不要等到最后一刻再进行优化。另一方面,市场时间至关重要,因此在软件开发过程中平衡足够的效率工作至关重要。其中一种方法是早期设定非功能性目标(在“资源感知效率需求”中讨论)。在本书中,我们将重点放在找到健康平衡,并减少改进软件效率所需的工作量(因此时间)上。现在让我们看看如何实用地思考软件性能。

实用代码性能的关键

在“性能背后”,我们了解到性能可以分为准确性、速度和效率。我提到在本书中,当我使用“效率”这个词时,它自然指的是资源的高效利用,但也包括我们代码的速度(延迟)。一个实用的建议隐藏在我们如何思考代码在生产中表现的决策中。

这里的秘密在于不要严格关注我们代码的速度和延迟。一般来说,对于非专用软件,速度仅在边缘地方有所影响;浪费和不必要的资源消耗才会导致减慢。而且,用低效率达到高速度总会带来更多问题而不是好处。因此,我们通常应该关注效率。可悲的是,这通常被忽视。

假设你想从城市 A 快速到达城市 B,穿过河流。你可以选择一辆快车,驶过附近的桥梁,快速到达城市 B。但是,如果你跳入水中慢慢游过河流,你也能更快到达城市 B。当有效率地完成时,较慢的行动仍然可以更快。例如,选择一条更短的路径。可以说,为了提高旅行效率并超过游泳者,我们可以选择更快的汽车,改善道路表面以减少阻力,甚至加装火箭引擎。我们可能会打败游泳者,但是这些剧变可能比简化工作并租用一艘小船更昂贵。

在软件中也存在类似的模式。假设我们的算法在磁盘上进行某些单词的搜索功能并且执行缓慢。鉴于我们操作持久数据,通常最慢的操作通常是数据访问,特别是如果我们的算法广泛执行此操作。非常诱人的做法是不去考虑效率,而是找到一种方法说服用户使用 SSD 而不是 HDD 存储。这样,我们可以将延迟降低到最多 10 倍。这将通过增加等式中的速度元素来提高性能。相反,如果我们能找到一种方法来改进当前算法,只需少数几次读取数据而不是百万次,我们可以实现更低的延迟。这意味着我们可以通过保持成本低来达到相同甚至更好的效果。

我建议将我们的努力集中在效率而不是纯粹的执行速度上。这也是为什么本书的标题是Efficient Go,而不是像Ultra Performance GoFastest Go Implementations这样更一般和引人注目的标题³³。

速度并非毫不重要。它很重要,正如你将在第三章中学到的那样,你可以编写更高效但更慢的代码,反之亦然。有时这是你需要做出的权衡。速度和效率都是至关重要的。它们彼此之间会相互影响。在实践中,当程序在关键路径上执行的工作较少时,通常会具有较低的延迟。在 HDD 与 SSD 的例子中,更换更快的硬盘可能允许您删除一些缓存逻辑,从而提高效率:减少内存和 CPU 时间的使用。有时候反过来也可以——正如我们在“硬件变得更快更便宜”中所学到的,进程越快,消耗的能量就越少,提高了电池的效率。

我认为,在优化性能时,我们通常应该首先专注于提高效率,而不是速度。正如你将在“优化延迟”中看到的那样,仅通过改善效率,我就能够仅用一个 CPU 核心将延迟降低七倍。有时候,改善效率后,你可能会惊讶地发现已经达到了期望的延迟!让我们进一步探讨为何效率可能更为重要的一些原因:

制作高效软件变慢要困难得多。

这与可读性代码更易优化的事实类似。但正如我之前提到的,由于需要做的工作较少,高效代码通常表现更好。在实践中,这也意味着慢速软件通常是低效的。

速度更易受影响。

正如你将在“实验的可靠性”中学到的,软件流程的延迟取决于大量外部因素。一个人可以优化代码以在专用和隔离环境中快速执行,但在长时间运行后可能会慢得多。在某些时候,由于服务器的热问题,CPU 可能会被限制频率。其他进程(例如定期备份)可能会意外地减慢您的主要软件。网络可能会被限制。在编程时,要考虑到大量隐藏的未知因素。这就是为什么效率通常是我们作为程序员能够最好控制的因素。

速度不够便携。

如果我们仅优化速度,那么当将应用程序从开发者机器移至服务器或不同客户端设备时,不能保证其能够同样运行。不同的硬件、环境和操作系统可能会完全改变应用程序的延迟。因此,设计高效软件至关重要。首先,受影响的因素较少。其次,如果在开发者机器上对数据库进行两次调用,那么无论是将其部署到空间站的物联网设备还是基于 ARM 的大型机,调用次数可能都是相同的。

一般来说,效率是我们在可读性之后或与可读性同时要考虑的事情。我们应该从软件设计的最开始就开始考虑效率问题。健康的效率意识,如果不过度强调,会导致健壮的开发习惯。它使我们能够避免那些在后期开发阶段难以改进的低效性能错误。做更少的工作通常还能减少代码的总体复杂性,提高代码的可维护性和可扩展性。

总结

我认为,开发者通常在开始开发过程时心存妥协。我们经常坐下来时带着必须从一开始就妥协某些软件品质的态度。我们经常被教导要牺牲软件的质量,如效率、可读性、可测试性等,以完成我们的目标。

在本章中,我希望鼓励你在软件质量上更有野心、更贪婪一些。坚持下去,并且尽量不要在不必要之前牺牲任何质量——直到证明无法实现所有目标为止。不要从默认的妥协立场开始你的谈判。有些问题在没有简化和妥协的情况下确实很难,但许多问题在一些努力和适当的工具下是可以解决的。

希望到此为止,你已经意识到我们必须从早期开发阶段开始考虑效率的问题。我们了解了性能的组成。此外,我们了解到许多误解在适当时是值得挑战的。我们需要意识到过早的悲观和过早的可扩展性,就像我们需要考虑避免过早的优化一样重要。

最后,我们了解到性能方程中的效率可能会给我们带来优势。通过先提高效率来改善性能是比较容易的。这在有效地处理性能优化主题上多次帮助了我的学生和我。

在下一章中,我们将快速介绍一下 Go 语言。知识是提高效率的关键,但如果我们对所使用的编程语言的基础不熟练,这将变得非常困难。

¹ 我甚至在 Twitter 上做了一个小的 实验,证明了这一点。

² 英国剑桥词典 定义 名词性能为“一个人、机器等完成工作或活动的能力有多强”。

³ 我甚至建议,与您的变更日志一起,遵循像这里所示的 通用标准格式。这些材料还包含了关于清晰发布说明的宝贵提示。

⁴ 我们可以在这个句子中使用“less performant”吗?不能,因为performant这个词在英语词汇中并不存在。也许这表明我们的软件不能够“高效”——总有改进的余地。在实际意义上,我们的软件能力有其极限。1962 年,H. J. Bremermann 提出 存在一个计算物理上的极限,这取决于系统的质量。我们可以估计,一台质量极轻的笔记本电脑每秒能处理 ~10⁵⁰ 位,而地球质量的计算机最多每秒能处理 10⁷⁵ 位。尽管这些数字看起来巨大,但即使是这样一台大型计算机也需要很长时间才能强制执行所有的象棋走法(估计为 10¹²⁰ 复杂度)。这些数字在密码学中有着实际应用,用来评估破解特定加密算法的难度。

⁵ 值得一提的是,有时隐藏功能或优化可能会导致可读性降低。有时明确性更好,可以避免意外。

⁶ 作为接口“契约”的一部分,可能会有一条注释说明实现应该缓存结果。因此,调用者可以放心多次调用它。但我认为,最好避免依赖于类型系统未保证的东西,以防意外。

⁷ 所有三个Get实现的示例可能被认为是昂贵的调用。与内存中读取或写入相比,针对文件系统的输入输出(I/O)操作要慢得多。涉及互斥锁意味着在访问之前可能需要等待其他线程。调用数据库通常涉及所有这些,并且可能涉及网络通信。

⁸ 这句著名的引用用来阻止某人花费时间在优化上。通常被滥用,它来自唐纳德·库努斯的“带有 goto 语句的结构化编程”(1974)。

⁹ 这种类型的风格通常被称为匈牙利命名法,它在微软公司被广泛使用。这种记法也分为两种:App 和 Systems。文献表明,Apps Hungarian 仍然可以带来许多好处

¹⁰ 值得强调的是,如今建议以与 IDE 功能轻松兼容的方式编写代码;例如,你的代码结构应该是“连通”的图。这意味着你连接函数的方式应该是 IDE 可以帮助的。任何动态分派、代码注入和延迟加载会禁用这些功能,除非绝对必要。

¹¹ 认知负荷是指“大脑处理和记忆”的量,一个人必须用来理解代码或函数

¹² 可缓存性通常被定义为可以被缓存的能力。可以缓存(保存)任何信息以便以后检索,速度更快。但是,数据可能仅在短时间内有效,或者仅适用于少量请求。如果数据依赖于外部因素(例如用户或输入)并且频繁更改,则其缓存效果不佳。

¹³ 当然,这只是一个简化。该过程可能使用了更多的内存。配置文件不显示现代应用程序运行所需的内存映射、堆栈和许多其他缓存。我们将在第四章中学到更多相关内容。

¹⁴ Cyril Northcote Parkinson 是一位英国历史学家,阐述了现在被称为帕金森法则的管理现象。这一法则称为“工作会膨胀以填满其完成所需的时间”,最初是指高度与决策机构中官员数量相关联的政府办公室效率。

¹⁵ 至少这就是我学习的样子。这种现象也被称为“学生综合症”

¹⁶ PB意味着皮字节。一皮字节等于 1,000 TB。如果我们假设一个平均两小时长的 4K 电影占用 100 GB,那么用 1 PB 可以存储 10,000 部电影,相当于大约两到三年的持续观看时间。

¹⁷ 1 赫赫字节等于 1 百万 PB,一百亿 TB。我甚至不敢尝试想象这么多的数据量。 😃

¹⁸ Robert H. Dennard 等人,《“非常小物理尺寸的离子注入 MOSFET 设计”》IEEE 固态电路杂志,9 卷 5 期(1974 年 10 月):256–268 页。

¹⁹ MOSFET代表“金属氧化物半导体场效应晶体管”,简单来说,它是一个允许切换电子信号的绝缘门。这种特定技术驱动了 1960 年至今大多数存储芯片和微处理器的背后。它已被证明具有高度可扩展性和微小化能力。它是历史上生产数量最多的器件,1960 年至 2018 年间生产了 13 兆兆个器件。

²⁰ 有趣的是,出于营销原因,公司们通过将 CPU 世代命名约定从晶体管门长度转换为工艺尺寸来隐藏有效减少晶体管大小的能力。14 纳米世代的 CPU 仍然使用 70 纳米的晶体管,类似于 10、7 和 5 纳米工艺。

²¹ 我不是在开玩笑。微软已经证明,将服务器放在水下 40 米是一个极好的主意,可以提高能效。

²² M1 芯片是一个有趣权衡的优秀示例:选择速度和能效性能的灵活性,而不是硬件扩展性。

²³ RISC-V 是一种开放标准的指令集架构,可以更容易地制造兼容的“精简指令集计算机”芯片。这种集合比一般用途的 CPU 更简单,并且允许更优化和专门化的硬件。

²⁴ 为了确保开发者理解并与网络连接较慢的用户产生共鸣,Facebook 引入了“2G Tuesdays”,在 Facebook 应用上开启模拟的 2G 网络模式。

²⁵ 这个选项并不像我们想象的那么昂贵。实例类型 x1e.32xlarge 每小时费用为 $26.60,所以一个月“只有” $19,418。

²⁶ 即使是对于具有极大硬件的机器,硬件管理也必须有所不同。这就是为什么 Linux 内核有专门的hugemem类型内核,可以管理多达四倍的内存和大约八倍的 x86 系统逻辑核心。

²⁷ CAP 是核心系统设计原则。它的首字母缩写来自一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)。它定义了一个简单的规则,即这三者中只能同时实现两个。

²⁸ 拒绝服务是系统处于无响应状态,通常是由于恶意攻击引起的。它也可能因意外的大负载而“意外”触发。

²⁹ 大约在 2015 年,这是波兰最快的超级计算机,提供 1.41 PFlop/s 的性能和超过 1,600 个节点,其中大多数节点配备了专用的 GPU。

³⁰ InfiniBand 是一种高性能网络通信标准,特别是在光纤发明之前非常流行。

³¹ 有趣的是,Mark Zuckerberg 在 2014 年的 F8 大会上宣布了将著名的座右铭更改为“Move fast with stable infra”

³² 在云原生世界中,我经常看到的一个例子是将日志堆栈从 Elasticsearch 移动到像 Loki 这样更简单的解决方案。尽管缺乏可配置的索引功能,Loki 项目能够以更少的资源提供更好的日志读取性能。

³³ 还有另一个原因。 “Efficient Go” 这个名称与你可能在 Go 编程语言中找到的最好的文档之一——“Effective Go”非常相似!这可能也是我读过的关于 Go 的第一篇文章。它具体、可操作,如果你还没有阅读过,我推荐你阅读一下。

第二章:高效入门 Go 语言

Go 是高效、可扩展且高生产力的。一些程序员觉得在其中工作很有趣;另一些人则认为它缺乏想象力,甚至乏味。……这些观点并不矛盾。Go 的设计是为了解决谷歌在软件开发中遇到的问题,这导致了一种不是突破性研究语言,但却是大型软件项目工程的优秀工具。

罗布·派克,《谷歌的 Go:服务于软件工程的语言设计》(来源:oreil.ly/3EItq)

我是 Go 编程语言的铁杆粉丝。全球开发者用 Go 取得的成就令人印象深刻。连续几年,Go 都在人们喜欢或想学习的前五种语言列表上。它在许多企业中都有使用,包括像苹果、美国运通、Cloudflare、戴尔、谷歌、Netflix、红帽、Twitch 和其他公司。当然,像所有事物一样,没有完美的东西。如果你在半夜叫醒我,让我快速编写可靠的后端代码,我会选择用 Go。命令行界面?也用 Go。快速、可靠的脚本?同样也是用 Go。作为初级程序员学习的第一门语言?Go。用于物联网、机器人和微处理器的代码?答案同样是 Go。¹ 基础设施配置?截至 2022 年,我认为没有比 Go 更好的工具用于强大的模板化了。²

请不要误会,有些语言具有专门的能力或生态系统,这些方面可能比 Go 更优秀。例如,想想图形用户界面(GUI)、游戏行业的高级渲染部分或在浏览器中运行的代码。³ 然而,一旦你意识到 Go 语言的诸多优点,再回到其他语言就会感觉相当痛苦。

在第一章中,我们花了一些时间来建立我们软件的效率意识。结果,我们学到了我们的目标是用最少的开发工作和成本编写高效的代码。本章将解释为什么 Go 编程语言可以成为实现性能和其他软件质量平衡的可靠选择。

我们将从 “Go 基础知识” 开始,然后继续学习 “高级语言元素”。这两个部分列出了关于 Go 的简短但关键的信息,这是我在 2014 年开始学习 Go 时希望自己早些知道的内容。这些部分将涵盖远不止基本的效率信息,并可以作为学习 Go 的入门。然而,如果你完全是这门语言的新手,我仍然建议先阅读这些部分,然后再查看摘要中提到的其他资源,也许在 Go 中写下你的第一个程序,然后再回到这本书。另一方面,如果你认为自己是一个更高级的用户或专家,我建议不要跳过这一章节。我会解释关于 Go 的一些较少人知的事实,可能会引起你的兴趣或争议(没关系,每个人都可以有自己的观点!)。

最后但同样重要的是,我们将回答一个关于总体 Go 效率能力的棘手问题,在 “Go 是否‘快速’?” 中,与其他语言相比。

Go 基础知识

Go 是由谷歌维护的开源项目,在一个名为“Go team”的分布式团队内进行。该项目包括编程语言规范、编译器、工具、文档和标准库。

让我们快速浏览一些关于 Go 基础知识和特性的事实和最佳实践。尽管这里的一些建议可能带有个人意见,但这是基于我自 2014 年以来与 Go 工作的经验——一个充满事件、过去错误和艰难汲取的背景。我在这里分享它们,希望你不需要再犯这些错误。

命令式、编译型和静态类型语言

Go 项目的核心部分是同名的通用编程语言,主要设计用于系统编程。正如你在 Example 2-1 中会注意到的,Go 是一种命令式语言,因此我们对执行过程有(某些)控制权。此外,它是静态类型和编译型的,这意味着编译器可以在程序运行之前执行许多优化和检查。这些特性本身已经足够使 Go 适合于可靠和高效的程序。

Example 2-1. 打印“Hello World”并退出的简单程序
package main

import "fmt"

func main() {
   fmt.Println("Hello World!")
}

项目和语言都称为“Go”,但有时候你也可以称它们为“Golang”。

Go 与 Golang

作为一个经验法则,我们应该在任何地方都使用“Go”这个名称,除非它与英语单词 go 或一种古老的游戏“围棋”冲突。 “Golang” 来自于域名选择(https://golang.org),因为作者无法获取“go”这个域名。因此,在网络上搜索与这种编程语言相关的资源时,请使用“Golang”。

Go 还有自己的吉祥物,称为“Go 地鼠”。我们可以在各种形式、情况和组合中看到这只可爱的地鼠,例如会议演讲、博客文章或项目标志。有时 Go 开发人员也被称为“地鼠”!

设计用于改进严肃的代码库

一切始于三位 Google 经验丰富的程序员在 2007 年勾勒出 Go 语言的想法:

Rob Pike

UTF-8 和 Plan 9 操作系统的共同创作者。在 Go 之前,他与 Limbo 一起编写用于编写分布式系统和 Newsqueak 用于编写图形用户界面中并发应用程序。这两者都受到了 Hoare 的通信顺序处理(CSP)的启发。⁴

Robert Griesemer

除了其他工作,Griesemer 开发了Sawzall 语言,并在 Niklaus Wirth 指导下完成了博士学位。同一位 Niklaus 撰写的“关于精简软件的呼吁”被引用在“软件变慢比硬件变快更迅速”一书中。

Ken Thompson

第一个 Unix 系统的原始作者之一。 grep 命令行实用程序的唯一创建者。Ken 与 Rob Pike 共同创造了 UTF-8 和 Plan 9。他还撰写了几种语言,例如 Bon 和 B 编程语言。

这三位旨在创建一种新的编程语言,旨在改进当时由 C++、Java 和 Python 主导的主流编程。一年后,它成为一个全职项目,Ian Taylor 和 Russ Cox 于 2008 年加入,这些人后来被称为Go 团队。 Go 团队在 2009 年宣布了公开的 Go 项目,并于 2012 年 3 月发布了 1.0 版本。

与 C++相关的主要挫折⁵在 Go 设计中提到:

  • 复杂性,多种做同样事情的方式,太多的功能

  • 特别是对于更大的代码库,编译时间特别长

  • 大型项目中更新和重构的成本

  • 不易使用且内存模型容易出错

这些元素是 Go 诞生的原因,源于对现有解决方案的挫折和允许更少实现更多的雄心。其指导原则是制造一种不以牺牲安全性换取少重复的语言,但允许更简单的代码。它不为了更快的编译或解释而牺牲执行效率,但确保构建时间足够快。Go 试图尽可能快地编译,例如通过显式导入。特别是默认启用缓存,只有更改的代码才会被编译,因此构建时间很少超过一分钟。

你可以把 Go 代码当作脚本来使用!

虽然 Go 在技术上是一种编译语言,但你可以像运行 JavaScript、Shell 或 Python 一样运行它。只需调用go run <executable package> <flags>即可。这很方便,因为编译速度非常快。你可以像使用脚本语言一样使用它,同时保持编译的优势。

在语法方面,Go 的设计初衷是简单、关键字少且熟悉。语法基于 C,支持类型推导(自动类型检测,类似于 C++ 的 auto),没有前置声明,没有头文件。概念保持正交性,这使得它们更容易组合和推理。元素的正交性意味着,例如,我们可以为任何类型或数据定义添加方法(方法添加与类型创建分开)。接口对类型也是正交的。

由 Google 管理,同时又是开源的。

自从宣布 Go 以来,所有开发都在开源进行,使用公共邮件列表和 Bug 跟踪器。更改提交到公共权威源代码,采用BSD 风格许可证。Go 团队审核所有贡献。无论变更或想法来自 Google 还是其他方,流程都是一样的。项目路线图和提案也是公开开发的。

不幸的是,悲伤的事实是有许多开源项目,但有些项目比其他项目更不开放。Google 仍然是 Go 的唯一公司监护人,并对其拥有最终决策权。即使任何人都可以修改、使用和贡献,由单一供应商协调的项目存在着风险,如重新许可或阻止某些功能。虽然有些颇具争议的情况使得 Go 团队的决定让社区感到惊讶,⁶ 总体而言,该项目管理得非常合理。无数变更来自 Google 外部,并且 Go 2.0 草案提案过程得到了良好的尊重和社区驱动。最终,我认为来自 Go 团队的一致决策和监护也带来了许多好处。冲突和不同看法是不可避免的,即使不完美,有一个一致的视角可能比没有决策或多种做法更好。

到目前为止,这种项目设置已被证明对采用和语言稳定性非常有效。对于我们的软件效率目标,这种对齐也是再好不过了。我们有一个大公司投入其中,确保每个发布版本都不会带来性能退化。一些内部 Google 软件依赖于 Go,例如,Google Cloud Platform。而许多人依赖于 Google Cloud Platform 的可靠性。另一方面,我们有庞大的 Go 社区提供反馈,发现错误并贡献想法和优化。而且如果这还不够,我们有开源代码,让我们这些凡人开发者能够深入了解实际的 Go 库、运行时(参见“Go Runtime”)等,以理解特定代码的性能特征。

简单性、安全性和可读性至关重要。

罗伯特·格里塞默在GopherCon 2015 提到过,在他们最初开始构建 Go 语言时,首先知道了哪些事情不应该做。主要的指导原则是简单性、安全性和可读性。换句话说,Go 语言遵循“少即是多”的模式。这是一个贯穿多个领域的强有力的习语。在 Go 语言中,只有一种习惯用法的编码风格⁷,并且有一个名为gofmt的工具可以确保大部分都符合这种风格。特别是代码格式化(紧随命名之后)很少有程序员能达成一致。我们花时间争论它,并根据特定的需求和信念调整它。多亏了工具强制执行单一风格,我们节省了大量时间。正如一个Go 谚语所说,“gofmt 的风格不是任何人的最爱,但 gofmt 是每个人的最爱。”总体来说,Go 语言的作者们设计这门语言尽可能简洁,以便在编写程序时几乎只有一种写法。这在你编写程序时大大减少了决策的负担。处理错误的方式、编写对象的方式、以及并发运行事物的方式,都是唯一的。

Go 语言可能会“缺失”大量特性,然而可以说它比 C 或 C++更具表现力。这种简约主义允许保持 Go 代码的简洁性和可读性,从而提高软件的可靠性、安全性和整体更高的应用速度。

我的代码是否符合习惯用法?

在 Go 社区中,“习惯用法”一词被大量使用。通常指的是经常使用的 Go 模式。由于 Go 的普及程度大大增加,人们用许多创造性的方式改进了最初的“习惯用法”风格。如今,什么是习惯用法,什么不是,并不总是清晰的。

就像《曼达洛人》系列中的“这是正确的方式”一样。当我们说“这段代码符合习惯用法”时,这让我们感到更加自信。因此,总结起来,使用这个词要谨慎,并且除非你能详细阐述为什么某种模式更好,否则最好避免使用它

有趣的是,“少即是多”的习语可以帮助我们为本书的目的努力提高效率。正如我们在第一章中所学到的,如果在运行时做得越少,通常意味着更快、更精简的执行和更少复杂的代码。在本书中,我们将尝试保持这一方面,同时提高我们的代码性能。

打包与模块

Go 源代码按照表示包或模块的目录进行组织。包是位于同一目录中的源文件集合(具有.go后缀)。包名在每个源文件顶部的package语句中指定,如示例 2-1 所示。同一目录中的所有文件必须具有相同的包名⁸(包名可以与目录名不同)。多个包可以成为单个 Go 模块的一部分。模块是一个具有go.mod文件的目录,该文件列出了构建 Go 应用程序所需的所有依赖模块及其版本。然后,依赖管理工具Go Modules使用此文件。模块中的每个源文件都可以导入来自同一或外部模块的包。某些包也可以是“可执行的”。例如,如果一个包名为main并且在某个文件中具有func main(),则可以执行它。有时这样的包会放在 cmd 目录下以便更容易地发现。请注意,你不能导入可执行包。你只能构建或运行它。

在包内部,你可以决定哪些函数、类型、接口和方法对包用户可见,哪些仅在包范围内可访问。这很重要,因为为了可读性、可重用性和可靠性,最好只导出最少量的 API。Go 语言没有任何privatepublic关键字来实现这一点。而是采取了稍微新的方法。如示例 2-2 所示,如果构造名称以大写字母开头,任何包外的代码都可以使用它。如果元素名称以小写字母开头,则它是私有的。值得注意的是,这种模式对所有构造都同样适用,例如函数、类型、接口、变量等(正交性)。

示例 2-2. 使用命名案例构建可访问性控制
package main

const privateConst = 1
const PublicConst = 2

var privateVar int
var PublicVar int

func privateFunc() {}
func PublicFunc()  {}

type privateStruct struct {
   privateField int
   PublicField  int ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
}

func (privateStruct) privateMethod() {}
func (privateStruct) PublicMethod()  {} ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

type PublicStruct struct {
   privateField int
   PublicField  int
}

func (PublicStruct) privateMethod() {}
func (PublicStruct) PublicMethod()  {}

type privateInterface interface {
   privateMethod()
   PublicMethod() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
}

type PublicInterface interface {
   privateMethod()
   PublicMethod()
}

1

细心的读者可能会注意到私有类型或接口上导出字段或方法的棘手情况。如果结构体或接口是私有的,包外的人能否使用它们呢?这很少见,但答案是肯定的,你可以在公共函数中返回一个私有的接口或类型,例如,func New() privateStruct { return privateStruct{}}。尽管privateStruct是私有的,但其所有公共字段和方法对包用户都是可访问的。

内部包

你可以随意命名和结构化代码目录以形成包,但有一个目录名具有特殊含义。如果你想确保只有给定的包可以导入其他包,可以创建一个名为internal的包子目录。任何在internal目录下的包都不能被祖先之外的任何包导入(及internal下的其他包)。

默认透明依赖关系

根据我的经验,通常导入预编译库,例如在 C++、C# 或 Java 中,并使用一些头文件中定义的导出函数和类。但导入编译代码也有一些好处:

  • 它减轻了工程师编译特定代码的工作量,即查找和下载正确版本的依赖项、特殊编译工具或额外资源。

  • 可能更容易销售这样一个预编译库,而无需暴露源代码并担心客户复制业务价值提供的代码。⁹

原则上,这应该是有效的。库的开发者维护特定的程序化契约(API),这些库的用户无需担心实现复杂性。

不幸的是,在实践中,这很少是完美的。实现可能有问题或效率低下,接口可能误导,并且可能缺少文档。在这种情况下,访问源代码是非常宝贵的,允许我们更深入地理解实现。我们可以根据具体的源代码找到问题,而不是靠猜测。我们甚至可以提出对库的修复建议或分叉该包并立即使用它。我们可以提取所需的部分并用它们构建其他东西。

Go 假设这种不完美,要求每个库的部分(在 Go 中称为模块的包)都必须使用称为“导入路径”的包 URI 明确导入。这种导入也是严格控制的,即未使用的导入或循环依赖会导致编译错误。让我们看看在 示例 2-3 中声明这些导入的不同方式。

示例 2-3. 来自 github.com/prometheus/​prome⁠theus 模块的 import 语句部分,main.go 文件
import (
   "context" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   "net/http"
   _ "net/http/pprof" ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

   "github.com/oklog/run" ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
   "github.com/prometheus/common/version"
   "go.uber.org/atomic"

   "github.com/prometheus/prometheus/config" ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
   promruntime "github.com/prometheus/prometheus/pkg/runtime"
   "github.com/prometheus/prometheus/scrape"
   "github.com/prometheus/prometheus/storage"
   "github.com/prometheus/prometheus/storage/remote"
   "github.com/prometheus/prometheus/tsdb"
   "github.com/prometheus/prometheus/util/strutil"
   "github.com/prometheus/prometheus/web"
)

1

如果导入声明没有域和路径结构,这意味着从“标准”库导入包。这种特定的导入允许我们使用来自 $(go env GOROOT)/src/context/ 目录的代码,并使用 context 引用,例如 context.Background()

2

可以显式导入包而不带任何标识符。我们不希望引用该包的任何构造,但我们希望初始化一些全局变量。在这种情况下,pprof 包将在全局 HTTP 服务器路由器中添加调试端点。虽然允许,但实际上我们应避免重用全局的可修改变量。

3

非标准软件包可以通过互联网域名形式的导入路径引入,以及特定模块中软件包的可选路径。例如,Go 工具链很好地集成了https://github.com,因此如果您在 Git 仓库中托管 Go 代码,它会找到指定的软件包。在这种情况下,是https://github.com/oklog/run Git 仓库中的run软件包在github.com/oklog/run模块中。

4

如果软件包来自当前模块(在本例中,我们的模块是github.com/prometheus/prometheus),软件包将从您的本地目录解析。在我们的例子中,是<module root>/config

这个模型专注于开放和明确定义的依赖关系。它与开源分发模型非常契合,社区可以在公共的 Git 仓库上协作开发稳健的软件包。当然,模块或软件包也可以使用标准的版本控制认证协议进行隐藏。此外,官方工具链不支持以二进制形式分发软件包,因此强烈鼓励依赖源码以便于编译。

软件依赖的挑战并不容易解决。Go 语言吸取了 C++等语言的经验教训,采用谨慎的方法来避免长时间的编译,并避免所谓的“依赖地狱”问题的影响。

通过标准库的设计,花费了大量精力来控制依赖关系。有时候复制少量代码比引入一个大型库来完成一个功能更好。(如果出现新的核心依赖关系,系统构建中的一个测试会报警。)依赖卫生优先于代码复用。在实践中的一个例子是,低级别的 net 包有自己的十进制转换例程,避免依赖更大、依赖重的格式化 I/O 包。另一个例子是 strconv 字符串转换包具有“可打印”字符定义的私有实现,而不是引入大型 Unicode 字符类表;strconv 通过包的测试来验证其符合 Unicode 标准。

Rob Pike,《Google 的 Go 语言:在软件工程服务中的语言设计》(“Go at Google: Language Design in the Service of Software Engineering”),链接

再次强调效率,依赖的最小化和透明度带来了巨大的价值。减少未知因素意味着我们可以快速检测主要的瓶颈,并优先关注最重要的价值优化。如果我们注意到依赖项中存在优化的潜力空间,我们无需绕过它。相反,我们通常欢迎直接向上游贡献修复,这有助于双方!

一致的工具链

从一开始,Go 语言作为其命令行接口工具的一部分拥有强大且一致的工具集,称为go。让我们列举一些实用工具:

  • go bug 打开一个新的浏览器标签,指向可以提交官方错误报告的正确位置(Go 仓库在 GitHub 上)。

  • go build -o <输出路径> <包> 编译给定的 Go 包。

  • go env 显示当前终端会话中设置的所有与 Go 相关的环境变量。

  • go fmt <文件、包或目录> 格式化给定的文件到所需的风格,清理空白字符,修复错误的缩进等。请注意,源代码甚至不需要是有效和可编译的 Go 代码。你也可以安装一个扩展的官方格式化工具。

  • goimports 还会清理和格式化你的 import 语句。

为了获得最佳体验,请设置你的编程 IDE 每次在文件上运行 goimports -w $FILE,以免再担心手动缩进问题!

  • go get <包@版本> 允许你安装所需的依赖项与期望的版本。使用 @latest 后缀获取最新版本或 @none 以卸载该依赖项。

  • go help <命令/主题> 打印关于给定命令或主题的文档。例如,go help environment 告诉你所有关于 Go 使用的可能环境变量。

  • go install <包> 类似于 go get,如果给定的包是“可执行”的,则安装二进制文件。

  • go list 列出 Go 包和模块。它允许使用 Go 模板(稍后解释)进行灵活的输出格式化,例如 go list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all 列出所有直接非可执行依赖模块。

  • go mod 允许管理依赖模块。

  • go test 允许运行单元测试、模糊测试和基准测试。我们将在 第八章 中详细讨论后者。

  • go tool 提供了数十个更高级的命令行工具。我们将特别关注 “pprof Format” 中的 go tool pprof,用于性能优化。

  • go vet 执行基本的静态分析检查。

在大多数情况下,Go CLI 是进行有效的 Go 编程所需的全部工具。¹¹

单一的错误处理方式

错误是每个运行软件不可避免的一部分。特别是在分布式系统中,它们是设计上预期的,具有处理不同类型失败的先进研究和算法。¹² 尽管需要错误处理,大多数编程语言并不推荐或强制执行特定的错误处理方式。例如,在 C++ 中,你会看到程序员使用各种手段从函数中返回错误:

  • 异常

  • 整数返回码(如果返回值非零,则表示错误)

  • 隐式状态码¹³

  • 其他哨兵值(如果返回值是 null,则表示错误)

  • 通过参数返回潜在的错误

  • 自定义错误类

  • 单子¹⁴

每种选择都有其利弊,但仅仅有这么多处理错误的方式就可能导致严重问题。它通过潜在地隐藏某些语句可能会返回错误而引起意外,引入复杂性,因此使我们的软件不可靠。

毫无疑问,有这么多选择的初衷是好的。它给开发者提供了选择。也许您创建的软件是非关键的,或者是第一个迭代版本,因此您希望“快乐路径”非常清晰。在这种情况下,掩盖一些“坏路径”似乎是一个不错的短期想法,对吗?不幸的是,就像许多捷径一样,它带来了许多危险。软件复杂性和功能需求导致代码永远无法脱离“第一次迭代”,并且非关键代码很快就会变成关键部分的依赖。这是导致软件不可靠或难以调试的最重要原因之一。

Go 采用了一条独特的路径,将错误视为第一类语言特性。它假设我们想要编写可靠的软件,使错误处理变得显式、简单且在库和接口之间统一。让我们在示例 2-4 中看一些例子。

示例 2-4. 具有不同返回参数的多个函数签名
func noErrCanHappen() int { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   // ...
   return 204
}

func doOrErr() error { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   // ...
   if shouldFail() {
      return errors.New("ups, XYZ failed")
   }
   return nil
}

func intOrErr() (int, error) { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
   // ...
   if shouldFail() {
      return 0, errors.New("ups, XYZ2 failed")
   }
   return noErrCanHappen(), nil
}

1

这里的关键是函数和方法将错误流定义为其签名的一部分。在这种情况下,noErrCanHappen函数声明在其调用期间不可能发生任何错误。

2

通过查看doOrErr函数的签名,我们知道可能会发生一些错误。我们还不知道错误的类型,但我们只知道它实现了内置的error接口。如果错误为 nil,则表示没有错误发生。

3

Go 函数可以返回多个参数的事实在计算“快乐路径”中得到了充分利用。如果可能发生错误,它应该是最后一个返回参数(始终如此)。从调用者的角度来看,只有在错误为 nil 的情况下,我们才应该处理结果。

值得注意的是,Go 语言有一种称为panics的异常机制,可以使用内置函数recover()进行恢复。虽然在某些情况下(例如初始化)它们是有用或必要的,但在实际的生产代码中,你不应该将panics用于传统的错误处理。它们效率较低,隐藏了失败,并且总体上让程序员感到惊讶。将错误作为调用的一部分允许编译器和程序员在正常执行路径中准备处理错误情况。示例 2-5 展示了如果在函数执行路径中发生错误,我们如何处理它们。

示例 2-5. 检查和处理错误
import "github.com/efficientgo/core/errors" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

func main() {
   ret := noErrCanHappen()
   if err := nestedDoOrErr(); err != nil { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
      // handle error
   }
   ret2, err := intOrErr()
   if err != nil {
      // handle error
   }
   // ...
}

func nestedDoOrErr() error {
   // ...
   if err := doOrErr(); err != nil {
      return errors.Wrap(err, "do") ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
   }
   return nil
}

1

请注意,我们没有导入内置的errors包,而是使用了开源的替代品github.com/efficientgo/core/errors。这是我推荐的替代errors包和流行但已存档的github.com/pkg/errors,允许更高级的逻辑,如您将在第三步看到的错误包装。

2

要判断是否发生错误,我们需要检查err变量是否为 nil。然后,如果发生错误,我们可以进行错误处理。通常意味着记录日志,退出程序,增加度量标准,甚至明确地忽略它。

3

有时,将错误处理委托给调用者是合适的。例如,如果函数可能因多个错误而失败,考虑使用errors.Wrap函数对其进行包装,以添加简短的错误上下文。例如,使用github.com/efficientgo/core/errors,我们将拥有上下文和堆栈跟踪,稍后使用%+v时将呈现这些信息。

如何处理错误?

请注意,我推荐使用errors.Wrap(或errors.Wrapf)而不是内置的错误包装方式。Go 语言为fmt.Errors类型的函数定义了%w标识符,允许传递错误。目前,我不推荐使用%w,因为它不是类型安全的,也不像Wrap那样显式,过去曾导致一些非常规的错误。

定义错误并处理它们的方式是 Go 语言的最佳特性之一。有趣的是,由于冗长和特定的样板,这也是语言的缺点之一。有时可能会感觉重复,但工具可以帮助您减少样板代码。

一些 Go IDE 定义了代码模板。例如,在 JetBrains 的 GoLand 产品中,输入err并按 Tab 键将生成一个有效的if err != nil语句。您还可以折叠或展开以提高可读性的错误处理块。

另一个常见的抱怨是,编写 Go 代码可能会感觉非常“悲观”,因为那些可能永远不会发生的错误一目了然。程序员必须在每一步决定如何处理这些错误,这需要精力和时间。然而,根据我的经验,这是值得的工作,可以使程序更可预测,更易于调试。

决不忽视错误!

由于错误处理的冗长,很容易跳过err != nil的检查。除非您知道函数永远不会返回错误(甚至在未来版本中也是如此!),否则请不要这样做。如果不知道如何处理错误,请默认将其传递给调用者。如果必须忽略错误,请考虑使用_ =语法明确地进行。此外,请始终使用检查器,它将警告您有些部分未经检查的错误。

一般的 Go 代码运行时效率是否会受到错误处理的影响?是的!不幸的是,这比开发者通常预期的要显著得多。根据我的经验,错误路径通常比快乐路径执行速度慢一个数量级,而且执行起来更昂贵。其中一个原因是我们在监控或基准测试步骤中往往不会忽略错误流(在“效率感知开发流程”中提到)。

另一个常见原因是,错误的构建通常涉及大量的字符串操作,用于创建可读性强的消息。因此,特别是在本书后面涉及的长调试标签,这可能会成本高昂。理解这些影响并确保一致且高效的错误处理对任何软件都至关重要,我们将在接下来的章节中详细讨论这一点。

强大的生态系统

Go 普遍提到的一个优点是,尽管是一门“年轻”的语言,其生态系统对于坚实的编程方言来说非常成熟。虽然本节列出的项目对于稳固的编程方言并非必要,但它们提升了整个开发体验。这也是为什么 Go 社区如此庞大且不断发展的原因。

首先,Go 允许程序员专注于业务逻辑,而不必重新实现或导入第三方库来进行基本功能,如 YAML 解码或加密哈希算法。Go 标准库质量高,健壮,向后兼容性强,并且功能丰富。它们经过良好的基准测试,具有坚实的 API 和良好的文档。因此,您可以在不导入外部包的情况下完成大多数任务。例如,运行 HTTP 服务器非常简单,如在示例 2-6 中所示。

示例 2-6. 为提供 HTTP 请求而编写的最简代码^(15)
package main

import  "net/http"

func handle(w http.ResponseWriter, _ *http.Request) {
   w.Write([]byte("It kind of works!"))
}

func main() {
   http.ListenAndServe(":8080", http.HandlerFunc(handle))
}

在大多数情况下,标准库的效率足够好甚至比第三方替代方案更好。例如,特别是包的低级元素,如net/http用于 HTTP 客户端和服务器代码,或者cryptomathsort等部分(还有更多!),都有大量的优化来满足大多数用例。这使得开发人员可以在其上构建更复杂的代码,而不必担心像排序性能这样的基础问题。然而,并非总是如此。有些库是为特定用途而设计的,错误使用它们可能导致显著的资源浪费。我们将在第十一章中探讨所有需要注意的事项。

成熟的生态系统的另一个亮点是一个基本的官方 Go 在线编辑器,称为 Go Playground。如果您想快速测试或共享交互式代码示例,这是一个很棒的工具。它也很容易扩展,因此社区经常发布 Go Playground 的变体,以尝试和分享先前的实验性语言特性,如 泛型(现在已成为主要语言的一部分,并在 “泛型” 中解释)。

最后但同样重要的是,Go 项目定义了其模板语言,称为 Go templates。在某种程度上,它类似于 Python 的 Jinja2 语言。尽管听起来像是 Go 的一个附带功能,但在任何动态文本或 HTML 生成中都是非常有用的。它也经常用于流行工具如 HelmHugo

未使用的导入或变量导致构建错误

如果在 Go 中定义了一个变量但从未读取其值或不将其传递给另一个函数,则编译将失败。同样地,如果在 import 语句中添加了一个包但在文件中未使用该包,则也会失败。

我看到 Go 开发者已经习惯了这个功能并且喜欢它,但对新手来说可能会感到惊讶。对未使用的结构进行编译失败可能会令人沮丧,如果你想快速尝试语言,例如为调试目的创建一些未使用的变量。

然而,有办法可以明确处理这些情况!您可以在 示例 2-7 中看到处理这些使用检查的几个例子。

示例 2-7. 多个未使用和已使用的变量示例
package main

func use(_ int) {}

func main() {
   var a int // error: a declared but not used ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

   b := 1 // error: b declared but not used ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

   var c int
   d := c // error: d declared but not used ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

   e := 1
   use(e) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

   f := 1
   _ = f ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
}

1

变量 abc 没有使用,因此会导致编译错误。

2

变量 e 被使用。

3

变量 f 技术上用于明确的无标识符 (_)。如果您明确希望告诉读者(和编译器)您要忽略该值,这种方法非常有用。

同样地,未使用的导入会导致编译失败,因此像 goimports(在 “一致的工具” 中提到)会自动删除未使用的导入。对未使用的变量和导入的编译失败有效地确保代码保持清晰和相关。请注意,只有内部函数变量会受到检查,诸如未使用的 struct 字段、方法或类型不会被检查。

单元测试和表测试

测试是每个应用程序的强制部分,无论大小。在 Go 语言中,测试是开发过程的自然组成部分——易于编写,并专注于简单性和可读性。如果我们想要谈论高效的代码,我们需要在位于包内的代码中引入一个带有_test.go后缀的文件来引入一个单元测试。您可以在该文件中编写任何不会从生产代码中访问的 Go 代码。但是,您可以添加四种类型的函数,这些函数将被调用以用于不同的测试部分。特定的签名区分这些类型,特别是函数名前缀:TestFuzzExampleBenchmark,以及具体的参数。

让我们来看一下示例 2-8 中的单元测试类型。为了使其更有趣,这是一个表格测试。示例和基准测试解释在“代码文档作为第一公民”和“微基准测试”中。

示例 2-8. 示例单元表格测试
package max

import (
   "math"
   "testing"

   "github.com/efficientgo/core/testutil"
)

func TestMax(t *testing.T) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   for _, tcase := range []struct { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
      a, b     int
      expected int
   }{
      {a: 0, b: 0, expected: 0},
      {a: -1, b: 0, expected: 0},
      {a: 1, b: 0, expected: 1},
      {a: 0, b: -1, expected: 0},
      {a: 0, b: 1, expected: 1},
      {a: math.MinInt64, b: math.MaxInt64, expected: math.MaxInt64},
   } {
      t.Run("", func(t *testing.T) { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
         testutil.Equals(t, tcase.expected, max(tcase.a, tcase.b)) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
      })
   }
}

1

如果_test.go文件中的函数命名以Test开头,并且接收恰好是t *testing.T,那么它被视为“单元测试”。你可以通过go test命令来运行它们。

2

通常,我们希望使用多个测试用例(通常是边界情况),定义不同的输入和预期输出来测试特定函数。这就是我建议使用表格测试的地方。首先,定义你的输入和输出,然后在一个易于阅读的循环中运行同一个函数。

3

可选地,您可以调用t.Run,它允许您指定一个子测试。在像表格测试这样的动态测试用例上定义它们是一个很好的实践。这将使您能够快速导航到失败的案例。

4

Go 语言的testing.T类型提供了诸如FailFatal之类的有用方法,以中止和失败单元测试,或者Error以继续运行和检查其他潜在的错误。在我们的示例中,我建议使用一个简单的辅助工具,称为来自我们开源核心库testutil.Equals,这将为您提供一个漂亮的差异。¹⁶

经常编写测试。也许会让你惊讶,但是为关键部分及早编写单元测试将有助于更快地实现所需的功能。这就是为什么我建议遵循一些合理的测试驱动开发形式,详细介绍在“高效开发流程”中。

这些信息应该为您提供了在进入更高级特性之前对语言目标、优势和特性的良好概述。

高级语言元素

现在让我们讨论 Go 的更高级功能。与前一节提到的基础知识类似,重要的是在讨论效率改进之前概述核心语言功能。

作为第一公民的代码文档

每个项目在某个时候都需要扎实的 API 文档。对于库类型项目,程序化 API 是主要的入口点。具有良好描述的强大接口允许开发人员隐藏复杂性,带来价值,并避免意外。应用程序也需要代码接口概述,这对于快速理解代码库非常重要。在其他项目中重用应用程序的 Go 包也并不罕见。

Go 项目开发了一个名为godoc的工具,从一开始就不依赖社区创建可能分散和不兼容的解决方案。它的行为类似于 Python 的Docstring和 Java 的Javadocgodoc可以直接从代码及其注释生成一致的文档 HTML 网站。

令人惊奇的是,您不需要许多特殊的约定来直接降低代码注释的可读性。要有效使用这个工具,您需要记住五件事。我们通过示例 2-9 和 2-10 来详细说明它们。当调用godoc时,生成的 HTML 页面可以在图 2-1 中看到。

例 2-9. godoc 兼容文档的 block.go 文件示例代码片段
// Package block contains common functionality for interacting with TSDB blocks
// in the context of Thanos.
package block ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

import ...

const (
   // MetaFilename is the known JSON filename for meta information. ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   MetaFilename = "meta.json"
)

// Download the downloads directory... ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
// BUG(bwplotka): No known bugs, but if there was one, it would be outlined here. ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
func Download(ctx context.Context, id ulid.ULID, dst string) error {
// ...

// cleanUp cleans the partially uploaded files. ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
func cleanUp(ctx context.Context, id ulid.ULID) error {
// ...

1

规则 1: 可选的包级描述必须放在 package 条目的顶部,没有空行,并以 Package <name> 前缀开始。如果任何源文件有这些条目,godoc 将收集它们。如果有许多文件,通常会在 doc.go 文件中只放置包级别的文档、包语句和没有其他代码。

2

规则 2: 任何公共结构都应该有一个完整的句子评论,以结构的名称(这很重要!)开始,紧接着是其定义。

3

规则 3: 已知的错误可以用 // BUG(who) 语句提及。

4

私有结构可以有注释,但由于它们是私有的,永远不会在文档中公开。为了可读性,请一致地以结构名称开头。

例 2-10. godoc 兼容文档的 block_test.go 文件示例代码片段
package block_test

import ...

func ExampleDownload() { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    // ...

    // Output: ... ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
}

1

规则 4:如果您在测试文件(例如 block_test.go)中编写了一个名为 Example<ConstructName> 的函数,godoc 将生成一个交互式代码块,其中包含所需的示例。请注意,包名也必须有一个 _test 后缀,表示一个本地测试包,该包在没有访问私有字段的情况下测试包。由于示例是单元测试的一部分,它们将被积极运行和编译。

2

规则 5:如果示例的最后一个注释以 // Output: 开头,那么在示例之后将会与标准输出进行断言,使得示例保持可靠。

efgo 0201

图 2-1. godoc 输出示例 2-9 和 2-10

我强烈建议遵循这五个简单的规则。不仅因为您可以手动运行 godoc 并生成您的文档网页,而且另一个好处是,这些规则使您的 Go 代码注释结构化和一致。每个人都知道如何阅读它们以及在哪里找到它们。

我建议在所有注释中使用完整的英文句子,即使它们不会出现在 godoc 中。这将帮助您保持代码注释的自解释性和明确性。毕竟,注释是供人类阅读的。

此外,Go 团队维护着一个公共文档网站,它会免费爬取所有请求的公共存储库。因此,如果您的公共代码存储库与 godoc 兼容,它将被正确地渲染,用户可以阅读每个模块或包版本的自动生成文档。

向后兼容性与可移植性

Go 对向后兼容性保证非常重视。这意味着核心 API、库和语言规范不应该破坏为 Go 1.0 创建的旧代码。这一点已被证明执行得很好。在将 Go 升级到最新的小版本或补丁版本时,人们非常信任。升级在大多数情况下是平稳的,没有重大的错误和意外。

关于效率兼容性,很难讨论任何保证。通常情况下,并不能保证现在执行两次内存分配的函数在 Go 项目和任何库的下一个版本中不会使用数百次。在效率和速度特性方面,版本之间确实存在一些惊喜。社区正在努力改进编译和语言运行时(更多信息请参见“Go 运行时” 和 第四章)。由于硬件和操作系统也在不断发展,Go 团队正在尝试不同的优化和功能,以使每个人都能更有效地执行。当然,我们并不讨论主要性能回归问题,因为通常会在发布候选期间注意到并修复。然而,如果我们希望我们的软件有意快速和高效,我们需要更加警惕和了解 Go 引入的变化。

源代码编译为针对每个平台的二进制代码。然而,Go 工具允许跨平台编译,因此您可以构建几乎所有架构和操作系统的二进制文件。

当您执行为不同操作系统(OS)或架构编译的 Go 二进制文件时,可能会返回神秘的错误消息。例如,当您尝试在 Linux 上运行为 Darwin(macOS)编译的二进制文件时,常见的错误是 Exec 格式错误。如果出现这种情况,您必须重新编译代码源以适应正确的架构和操作系统。

关于可移植性,我们不能不提 Go 运行时及其特性。

Go 运行时

许多语言决定通过使用虚拟机来解决跨不同硬件和操作系统的可移植性问题。典型的例子是Java 虚拟机(JVM),适用于 Java 字节码兼容语言(例如 Java 或 Scala),以及公共语言运行时(CLR),适用于 .NET 代码,例如 C#。这样的虚拟机允许构建语言而无需担心复杂的内存管理逻辑(分配和释放)、硬件和操作系统之间的差异等。JVM 或 CLR 解释中间字节码并将程序指令传递到主机。然而,尽管这使得创建编程语言更容易,它们也引入了一些开销和许多未知因素。¹⁷ 为了减少开销,虚拟机通常使用复杂的优化技术,如即时(JIT)编译,以在运行时将特定虚拟机字节码块处理为机器码。

Go 不需要任何“虚拟机”。我们的代码和所用的库在编译时完全编译为机器码。由于标准库支持大型操作系统和硬件,我们的代码在针对特定架构编译后,在那里运行没有问题。

当我们的程序启动时,后台会同时运行一些内容。这是Go 运行时的逻辑之一,除了 Go 的其他较小特性外,它还负责内存和并发管理。

面向对象编程

无疑,面向对象编程(OOP)在过去几十年中获得了巨大的推广。它由 Alan Kay 于大约 1967 年发明,至今仍然是编程中最流行的范式。¹⁸ OOP 允许我们利用高级概念,如封装、抽象、多态和继承。原则上,它允许我们将代码视为具有属性(在 Go 中为字段)和行为(方法)的对象,彼此告知如何操作。大多数 OOP 示例讨论高级抽象,如具有Walk()方法的动物或允许Ride()的汽车,但实际上,对象通常不那么抽象,仍然有助于封装,并由类描述。在 Go 中没有类,但有struct类型的等效物。示例 2-11 展示了我们如何在 Go 中编写 OOP 代码,将多个块对象压缩成一个。

示例 2-11. 在 Go 中使用Group作为Block的示例
type Block struct { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    id         uuid.UUID
    start, end time.Time
    // ...
}

func (b Block) Duration() time.Duration { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    return b.end.Sub(b.start)
}

type Group struct {
    Block ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

    children []uuid.UUID
}

func (g *Group) Merge(b Block) { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    if g.end.IsZero() || g.end.Before(b.end) {
        g.end = b.end
    }
    if g.start.IsZero() || g.start.After(b.start) {
        g.start = b.start
    }
    g.children = append(g.children, b.id)
}

func Compact(blocks ...Block) Block {
    sort.Sort(sortable(blocks)) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

    g := &Group{}
    g.id = uuid.New()
    for _, b := range blocks {
        g.Merge(b)
    }
    return g.Block ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
}

1

在 Go 中,没有像 C++中那样的结构和类的分离。在 Go 中,除了基本类型如integerstring等外,还有一个可以拥有方法(行为)和字段(属性)的struct类型。我们可以使用结构体作为class的等效物,封装更复杂的逻辑在更简单的接口下。例如,在Block上的Duration()方法告诉我们覆盖的时间范围的持续时间。

2

如果我们将一些结构体,例如Block,添加到另一个结构体,例如Group,而没有任何名称,这样的Block结构体被认为是嵌入的,而不是字段。嵌入允许 Go 开发人员获取继承的最有价值部分,借用嵌入结构的字段和方法。在这种情况下,Group将拥有Block的字段和Duration方法。这样,我们可以在生产代码库中重复使用大量代码。

3

在 Go 中,有两种方法可以定义:使用“值接收器”(例如,像Duration()方法中的方式)或使用“指针接收器”(使用*)。所谓的接收器是func之后的变量,表示我们要为其添加方法的类型,在我们的案例中是Group。我们将在“值、指针和内存块”中提到这一点,但关于使用哪种方法的规则很简单:

  • 如果你的方法不修改 Group 的状态,请使用值接收器(不要 func (g Group) SomeMethod())。对于值接收器,每次调用时 g 将创建 Group 对象的本地副本。这等效于 func SomeMethod(g Group)

  • 如果你的方法旨在修改本地接收器状态或者任何其他方法也这样做,请使用指针接收器(例如 func (g *Group) SomeMethod())。对于指针接收器,每次调用时都会修改 g 的本地接收器状态。这等效于 func SomeMethod(g *Group)。在我们的示例中,如果 Group.Merge() 方法是值接收器,我们将无法持久化 g.children 的更改,或者潜在地注入 g.startg.end 的值。另外,为了保持一致性,建议始终具有所有指针接收器方法的类型,如果至少有一个方法要求指针接收器。

4

为了将多个块紧凑地放在一起,我们的算法需要一个排好序的块列表。我们可以使用标准库中的 sort.Sort,它期望实现 sort.Interface 接口。[]Block 切片没有实现此接口,因此我们将其转换为我们临时的 sortable 类型,详见 示例 2-13。

5

这是真正继承的唯一缺失要素。Go 不允许将特定类型强制转换为另一种类型,除非它是一个别名或者严格的单结构嵌入(如 示例 2-13 所示)。之后,你只能将接口转换为某些类型。这就是为什么我们需要明确指定嵌入的 structBlock。因此,Go 通常被认为是一种不支持完全继承的语言。

示例 2-11 给了我们什么?首先,Group 类型可以重用 Block 的功能,如果正确处理,我们可以像处理任何其他 Block 一样处理 Group

嵌入多种类型

你可以在一个 struct 中嵌入任意多个唯一结构。

这些没有优先级——如果编译器不能确定使用哪个方法,编译将失败,因为两个嵌入类型具有相同的 SomeMethod() 方法。在这种情况下,请使用类型名称显式告诉编译器应该使用哪一个。

正如 示例 2-11 中所述,Go 也允许定义接口,告诉 struct 必须实现哪些方法来匹配它。注意,不需要像 Java 等其他语言那样显式标记实现特定接口的特定 struct,只需实现所需的方法即可。让我们看一个标准库中公开的排序接口的示例,详见 示例 2-12。

示例 2-12. 标准 sort Go 库中的排序接口
// A type, typically a collection, that satisfies sort.Interface can be
// sorted by the routines in this package. The methods require that the
// elements of the collection be enumerated by an integer index.
type Interface interface {
    // Len is the number of elements in the collection.
    Len() int
    // Less reports whether the element with
    // index i should sort before the element with index j.
    Less(i, j int) bool
    // Swap swaps the elements with indexes i and j.
    Swap(i, j int)
}

要在 sort.Sort 函数中使用我们的类型,它必须实现所有 sort.Interface 方法。示例 2-13 展示了 sortable 类型如何实现这些方法。

示例 2-13. 可以使用 sort.Slice 进行排序的类型示例
type sortable []Block ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

func (s sortable) Len() int           { return len(s) }
func (s sortable) Less(i, j int) bool { return s[i].start.Before(s[j].start) } ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
func (s sortable) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }

var _ sort.Interface = sortable{} ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

1

我们可以将另一种类型(例如Block元素的切片)作为我们的sortable结构中的唯一内容嵌入。这允许我们在示例 2-11 中使用的Compact方法中进行简单(但显式)的强制转换。

2

我们可以通过time.Time.Before(...)方法按增加的start时间进行排序。

3

我们可以使用这一行语句来断言我们的sortable类型实现了sort.Interface,否则将无法通过编译。我建议在未来要确保你的类型与特定接口兼容时使用这种语句!

总结一下,结构体的方法、字段和接口是编写过程化可组合和面向对象代码的一个出色而简单的方式。根据我的经验,在我们软件开发期间最终能够满足低级和高级编程需求。虽然 Go 不支持所有继承方面的特性(类型到类型转换),但它提供了足够满足几乎所有面向对象编程案例的内容。

通用(Generics)

自 Go 版本 1.18 起,支持通用,这是社区最期待的功能之一。通用,又称参数多态,允许我们对不同类型重用的功能进行类型安全的实现。

对于 Go 中的通用需求引发了 Go 团队和社区的广泛讨论,因为它带来了两个主要问题:

同样的事情可以有两种做法

从一开始,Go 就已经通过接口支持了类型安全的可重用代码。你可以在之前的面向对象示例中看到这一点——sort.Sort可以被所有实现sort.Interface的类型重用,如示例 2-12 中所示。我们可以通过在示例 2-13 中实现这些方法来对我们的自定义Block类型进行排序。添加通用意味着在许多情况下我们有两种做一件事的方法

然而,接口对我们代码的用户可能更为麻烦,并且有时由于某些运行时开销而变慢。

开销(Overhead)

实现通用可能会对语言产生许多负面影响。根据实现方式,它可能会影响不同的事物。例如:

  • 我们可以像在 C 中那样简单跳过它们,这会减慢程序员的速度。

  • 我们可以使用单态化,这实质上是为每种将被使用的类型复制代码。这会影响编译时间和二进制文件大小。

  • 我们可以像在 Java 中那样使用装箱,这与 Go 接口的实现非常相似。在这种情况下,我们可能会影响执行时间或内存使用。

通用困境就在于:你想要缓慢的程序员、缓慢的编译器和臃肿的二进制文件,还是缓慢的执行时间?

Russ Cox, “通用困境”

在许多提案和辩论之后,最终(非常详细!)设计得到了接受。最初,我非常怀疑,但接受的泛型使用结果清晰而合理。到目前为止,社区也没有像担心的那样跳过并滥用这些机制。我们倾向于很少看到泛型的使用——只有在需要时,因为这使得代码更复杂以维护。

例如,我们可以为所有基本类型(如intfloat64或甚至strings)编写一个泛型排序,如示例 2-14 中所示。

示例 2-14. 基本类型的泛型排序示例实现
// import "golang.org/x/exp/constraints" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

type genericSortableBasic[T constraints.Ordered] []T ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

func (s genericSortableBasic[T]) Len() int           { return len(s) }
func (s genericSortableBasic[T]) Less(i, j int) bool { return s[i] < s[j] } ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
func (s genericSortableBasic[T]) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }

func genericSortBasicT constraints.Ordered { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    sort.Sort(genericSortableBasicT)
}

func Example() {
    toSort := []int{-20, 1, 10, 20}
   sort.Ints(toSort) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

   toSort2 := []int{-20, 1, 10, 20}
   genericSortBasicint ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    // ...
}

1

多亏了泛型(也称为类型参数),我们可以实现一个单一类型,该类型将为所有基本类型实现sort.Interface(参见示例 2-13)。我们可以提供类似接口的自定义约束,以限制可以用作类型参数的类型。在这里,我们使用表示Integer | Float | ~string约束的类型,因此任何支持比较操作符的类型都可以使用。我们可以放置任何其他接口,例如any来匹配所有类型。我们还可以使用特殊的comparable关键字,允许我们将T comparable对象用作map键。

2

现在预期s切片的任何元素都是带有Ordered约束的T类型,因此编译器将允许我们为它们比较Less功能。

3

现在我们可以为任何基本类型实现一个排序函数,该函数将利用sort.Sort的实现。

4

我们不需要像sort.Ints那样实现特定于类型的函数。只要切片是可以排序的类型,我们就可以执行genericSortBasic<type>

这很棒,但它只适用于基本类型。不幸的是,在 Go 语言中我们不能(还)重写像<这样的操作符,因此为了实现更复杂类型的泛型排序,我们必须做更多的工作。例如,我们可以设计我们的排序以期望每种类型实现func <typeA> Compare(<typeA>) int方法。¹⁹ 如果我们在示例 2-11 的Block中添加此方法,我们就可以轻松地对其进行排序,如示例 2-15 中所示。

示例 2-15. 某些对象的泛型排序示例实现
type Comparable[T any] interface { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    Compare(T) int
}

type genericSortable[T Comparable[T]] []T ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

func (s genericSortable[T]) Len() int           { return len(s) }
func (s genericSortable[T]) Less(i, j int) bool { return s[i].Compare(s[j]) > 0 } ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
func (s genericSortable[T]) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }

func genericSort[T Comparable[T]](slice []T) {
    sort.Sort(genericSortableT)
}

func (b Block) Compare(other Block) int { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    // ...
}

func Example() {
    toSort := []Block{ /* ... */ }
    sort.Sort(sortable(toSort)) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

    toSort2 := []Block{ /* ... */ }
    genericSortBlock ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
}

1

让我们设计我们的约束。我们期望每种类型都有一个接受相同类型的Compare方法。因为约束和接口也可以有类型参数,我们可以实现这样的要求。

2

现在我们可以为这种类型的对象提供一个实现sort.Interface接口的类型。请注意Comparable[T]中的嵌套T,因为我们的接口也是泛型的!

3

现在我们可以为我们的Block类型实现Compare方法。

4

由于这一点,我们无需为每种想要排序的自定义类型实现sortable类型。只要类型有Compare方法,我们就可以使用genericSort

接受的设计在用户界面独立操作复杂的情况下显示出明显优势。但是对于泛型困境问题呢?设计允许任何实现,那么最终选择了什么权衡?在本书中我们不会深入讨论细节,但 Go 语言使用了字典和模板化算法,介于单态化和装箱之间。²⁰

泛型代码会更快吗?

Go 语言中泛型的具体实现(这可能会随时间变化)意味着泛型实现理论上应该比接口更快,但比手动为特定类型实现某些功能更慢。然而,在实践中,这种潜在差异在大多数情况下可以忽略不计,因此首先使用最可读和易于维护的选项。

根据我的经验,在效率关键的代码中,这种差异可能很重要,但结果并不总是符合理论。例如,有时泛型实现更快,有时使用接口可能更有效](https://oreil.ly/tiOhS)。结论?始终执行基准测试(第八章)以确保!

总结一下,这些事实是我在教授他人如何使用 Go 编程时认为至关重要的事实,这些都基于我个人对该语言的经验。此外,当在本书的后续部分深入研究 Go 语言运行时性能时,这些将会非常有帮助。

然而,如果您以前从未编写过 Go 程序,最好在进入本书的后续部分和章节之前查看其他材料,例如Go 语言之旅。确保尝试编写自己的基本 Go 程序,编写单元测试,并使用循环,开关以及通道和协程等并发机制。学习常见类型和标准库抽象。作为接触新语言的人,您需要在确保程序返回有效结果之前,确保其执行快速和高效。

我们学习了 Go 语言的一些基本和高级特性,现在是时候揭开语言的效率方面了。在 Go 语言中编写足够好或高性能的代码有多容易呢?

Go 语言是“快速”的吗?

最近,许多公司已经将他们的产品(例如从 Ruby、Python 和 Java)重写为 Go。²¹ 转向 Go 或者在 Go 中启动新项目的两个重要原因是可读性和出色的性能。可读性来源于简单和一致性(例如,你还记得从“处理错误的单一方式”),这也是 Go 的优势所在,但是性能如何呢?与 Python、Java 或 C++等其他语言相比,Go 有多快呢?

在我看来,这个问题本质上是不恰当的。在时间和复杂性允许的情况下,任何语言都可以与您的计算机和操作系统允许的一样快。这是因为,最终,我们编写的代码会被编译成使用精确 CPU 指令的机器代码。此外,大多数语言允许将执行委托给其他进程,例如,使用优化的汇编语言编写。不幸的是,有时我们仅仅依靠原始的、半优化的短程序基准测试来判断一种语言是否“快”,而这些测试仅仅告诉了我们一些东西,但实际上并未显示出实际的方面,例如,编写高效率的编程有多复杂。²²

相反,我们应该从编写高效代码(而不仅仅是快速代码)的难度和实用性以及这一过程牺牲了多少可读性和可靠性的角度来看待一种编程语言。我认为 Go 语言在这些元素之间保持了优越的平衡,同时保持了快速和编写基本、功能性代码的简易性。

更容易编写高效代码的一个原因是封闭的编译阶段、Go 运行时中相对较少的未知因素(参见“Go 运行时”)、易于使用的并发框架以及调试、基准测试和性能分析工具的成熟性(在第八章和第九章讨论)。这些 Go 的特性并非凭空出现。不多的人知道,Go 是站在巨人的肩膀上设计出来的:C、Pascal 和 CSP。

1960 年,来自美国和欧洲的语言专家联手创建了 Algol 60。1970 年,Algol 树分裂成了 C 和 Pascal 分支。大约 40 年后,这两个分支在 Go 中再次合二为一。

罗伯特·格里塞默,《Go 的演变》(The Evolution of Go),oreil.ly/a4V1e

正如我们在图 2-2 中看到的那样,许多在第一章中提到的名字都是 Go 的奠基人。由霍尔爵士创造的伟大并发语言 CSP,由沃思创造的 Pascal 声明和包以及 C 基本语法,所有这些都对 Go 今天的面貌产生了影响。

efgo 0202

图 2-2. Go 的系谱

但并非一切都能完美无缺。在效率方面,Go 也有其自身的软肋。正如您将在“Go 内存管理”中了解的那样,内存使用有时可能难以控制。在我们的程序中,分配可能会令人惊讶(特别是对于新用户),而垃圾收集的自动内存释放过程也有一些开销和最终行为。特别是对于数据密集型应用程序,确保内存或 CPU 效率需要付出努力,类似于严格限制 RAM 容量的机器(例如 IoT)。

然而,自动化此过程的决定非常有利,使程序员无需担心内存清理,这已被证明甚至更糟糕,有时甚至是灾难性的(例如,释放内存两次)。其他语言使用的替代机制的绝佳例子是 Rust。它实现了一种独特的内存所有权模型,替代了自动全局垃圾回收。不幸的是,尽管更高效,但编写 Rust 代码比 Go 更加复杂。这就是为什么我们看到 Go 的更高采用率。这反映了 Go 团队在这一元素上易用性的权衡。

幸运的是,有办法减轻 Go 中垃圾回收机制的负面性能影响,并保持我们的软件精简和高效。我们将在接下来的章节中讨论这些内容。

总结

在我看来,Go 是一种非常优雅和一致的语言。此外,它提供了许多现代和创新的特性,使编程更加高效和可靠。而且,代码本身设计上就具有可读性和可维护性。

这是我们稍后将讨论的效率改进的关键基础。像任何其他功能一样,优化总是增加复杂性,因此修改简单代码比复杂化已经复杂的代码更容易。简单性、安全性和可读性至关重要,即使对于高效的代码也是如此。确保您知道如何在不考虑效率的情况下实现这一点!

许多资源详细介绍了我只能用一个子章节来介绍的元素。如果您有兴趣了解更多信息,没有比实践更好的了。如果您在进入优化之前需要更多的 Go 经验,这里有一个优秀资源的简短列表:

Go 的优化、基准测试和效率实践的真正力量,是在实际应用中发挥作用,即在日常编程中。因此,我希望能够让你将效率与可靠性或者实用抽象等其他良好技术结合起来。尽管有时候对于关键路径可能需要完全定制的逻辑(如你将在第十章中看到的),但基本的、通常足够的效率来自于理解简单规则和语言能力。这就是为什么我在本章节专注于为你提供关于 Go 及其特性的更好概述。有了这些知识,我们现在可以继续到第三章,在那里我们将学习如何开始提高程序执行时的效率和整体性能。

¹ 新的用于在小设备上编写 Go 的框架和工具正在涌现,例如GoBotTinyGo

² 这是一个颇具争议的话题。在基础设施行业中,关于配置即代码的最佳语言存在激烈的竞争。例如,在 HCL、Terraform、Go 模板(Helm)、Jsonnet、Starlark 和 Cue 之间。2018 年,我们甚至开源了一个名为“mimic”的工具,用于在 Go 语言中编写配置。可以说,反对在 Go 中编写配置的最大声音是它感觉太像“编程”,需要系统管理员的编程技能。

³ WebAssembly 的意图是改变这一切,不过不会很快

⁴ CSP 是一种形式语言,允许描述并发系统中的交互。由 C.A.R. Hoare 在《ACM 通讯》(1978 年)中引入,它是 Go 语言并发系统的灵感来源。

⁵ 类似的挫折促使 Google 的另一部分创建了另一种语言——Carbon,于 2022 年发布。Carbon 看起来非常有前景,但它的目标与 Go 不同。它更注重效率和与 C++概念的熟悉度及互操作性。所以让我们看看 Carbon 的采用情况将如何发展!

⁶ 一个著名的例子是依赖管理工作背后的争议

⁷ 当然,偶尔会有一些不一致之处;这就是为什么社区创建了更多严格的格式化程序代码检查工具,或者风格指南。然而,标准工具已经足够让你在每个 Go 代码库中感到舒适。

⁸ 有一个例外:单元测试文件必须以 _test.go 结尾。这些文件可以具有相同的包名或 <package_name>_test 名称,允许模仿包的外部用户。

⁹ 在实践中,您可以从编译后的二进制文件快速获取 C++ 或 Go 代码(即使混淆了),特别是如果您没有从调试符号中剥离二进制文件。

¹⁰ 标准库意味着与 Go 语言工具和运行时代码一起提供的包。通常只提供成熟和核心功能,因为 Go 有强大的兼容性保证。Go 还维护着一个实验性的 golang.org/x/exp 模块,其中包含必须证明能够毕业进入标准库的有用代码。

¹¹ 虽然 Go 每天都在改进,有时您可以添加更高级的工具如 goimportsbingo 来进一步改善开发体验。在某些领域,Go 不能提出具体意见,而是受到稳定性保证的限制。

¹² CAP 定理 提到了一个优秀的例子,严肃地对待失败。它指出,在三个系统特性(一致性、可用性和分区容错性)中,你只能选择两个。一旦你分布你的系统,你必须处理网络分区(通信失败)。作为一个错误处理机制,你可以设计你的系统等待(失去可用性)或者操作部分数据(失去一致性)。

¹³ bash 有许多处理错误的方法,但默认方法是隐式的。程序员可以选择打印或检查 ${?},它保存了在给定行之前执行的最后一个命令的退出代码。退出代码为 0 表示命令执行没有任何问题。

¹⁴ 原则上,一个单子(monad)是一个可选地持有某些值的对象,例如一些带有 Get()IsEmpty() 方法的对象 Option<Type>。此外,“错误单子”是一个 Option 对象,如果值未设置,则持有一个错误(有时称为 Result<Type>)。

¹⁵ 这样的代码不建议用于生产,但唯一需要改变的是避免使用全局变量和检查所有错误。

¹⁶ 这种断言模式在其他第三方库中也很典型,如流行的 testify。但是,我不喜欢 testify 包,因为有太多做同一件事情的方式。

¹⁷ 由于程序(例如 Java)编译成 Java 字节码,许多事情发生在代码被翻译成实际可理解的机器代码之前。这个过程的复杂性对于一个普通人来说太大,因此机器学习“AI”工具被创建用于自动调整 JVM。

¹⁸ 2020 年的一项调查显示,在使用最多的前 10 种编程语言中,有 2 种要求面向对象编程(Java,C#),6 种鼓励它,而有 2 种则不实现 OOP。对于那些必须在数据结构或函数之间保持某些较大上下文的算法,我个人几乎总是偏爱面向对象编程。

¹⁹ 我更喜欢函数胜过方法,因为它们在大多数情况下更容易使用。

²⁰ 在PlanetScale博客文章中,总结得非常清楚。

²¹ 举几个公共变更的例子,我们看到了Salesforce 案例AppsFlyer,和Stream

²² 例如,当我们查看一些基准测试时,我们发现 Go 有时比 Java 更快,有时比 Java 更慢。然而,如果我们查看 CPU 负载,每次 Go 或 Java 更快时,它只是因为例如实现允许减少了在内存访问上浪费的 CPU 周期。你可以在任何编程语言中实现这一点。问题在于,实现这一点有多难?我们通常不测量在每种特定语言中优化代码所花费的时间,以及在优化后阅读或扩展这样的代码有多容易等。只有这些指标可能告诉我们哪种编程语言更“快速”。

第三章:征服效率

是时候行动了!在第一章中,我们了解到软件效率很重要。在第二章中,我们学习了 Go 编程语言——它的基础和高级特性。接下来,我们讨论了 Go 语言易读易写的能力。最后,我们提到它还可以是编写高效代码的有效语言。

毫无疑问,提高程序效率并非易事。在某些情况下,你试图改进的功能已经经过良好优化,因此在不重新设计系统的情况下,进一步优化可能需要大量时间,并且只能产生边际差异。然而,也许还有其他情况,当前的实现非常低效。消除浪费的工作实例可以在几小时的开发时间内显著提高程序的效率。作为工程师真正的技能在于,在短时间的研究后,最好知道你目前处于哪种情况:

  • 你是否需要在性能方面做出改进?

  • 如果是的话,是否有可能去除浪费周期?

  • 需要多少工作来减少函数 X 的延迟?

  • 是否存在可疑的过度分配?

  • 应该停止过度使用网络带宽而牺牲内存空间吗?

本章将教会你工具和方法,帮助你有效地回答这些问题。

如果你在这些技能上有困难,不要担心!这是正常的。效率话题并不简单。尽管需求大,但许多人仍未掌握,甚至一些主要的软件开发者有时也会做出糟糕的决策。令人惊讶的是,看起来高质量的软件经常在没有明显效率问题的情况下发布。例如,2021 年初,一位用户优化了流行游戏《侠盗猎车手在线》的加载时间,从六分钟缩短到两分钟,而没有访问源代码!正如在第一章中提到的,该游戏耗资惊人的约 1.4 亿美元,并花费了几年时间进行开发。然而,它却存在明显的效率瓶颈,其简单的 JSON 解析算法和重复数据删除逻辑大大降低了游戏加载时间,影响了游戏体验。这个人的工作非常出色,但他们使用的是你即将学习的相同技术。唯一的区别在于,我们的工作可能稍微容易一些——希望你不需要在路上逆向工程用 C++编写的二进制代码!

在前面的例子中,游戏背后的公司错过了影响游戏加载性能的明显计算浪费。公司不可能没有资源找专家来优化这部分,而是基于特定的权衡决定,优化不值得投资,因为可能有更高优先级的开发任务。最终,可以说这样的低效并没有阻止游戏的成功。它确实完成了任务,但是例如,我和我的朋友们从来不是游戏的粉丝,因为加载时间太长。我认为,如果没有这种愚蠢的“浪费”,成功可能会更大。

懒惰还是故意的效率降低?

还有其他有趣的例子,说明在特定情况下,软件效率的某些方面可能会被降低。例如,有有趣的关于导弹软件开发者的故事,他们决定接受某些内存泄漏,因为导弹在应用程序运行结束时将被销毁。同样,我们听说过关于低延迟交易软件中的“故意”内存泄漏的故事,预计只会运行很短的时间。

可以说,避免效率工作的例子,并没有发生什么悲剧性的事情,都是务实的方法。最终,避免了额外的知识和工作来修复泄漏或减慢速度。潜在地,是的,但是如果这些决策不是数据驱动的呢?我们不知道,但是这些决策可能是出于懒惰和无知,没有任何有效的数据点表明修复确实需要太多的努力。如果每个例子中的开发人员不完全理解所需的小努力呢?如果他们不知道如何优化软件的问题部分呢?否则,他们会做出更好的决策吗?减少风险吗?我认为会的。

在本章中,我将介绍优化的主题,首先解释定义并从“超越浪费,优化是一个零和游戏”的初步方法开始。在接下来的一节中,“优化挑战”,我们将总结在试图提高软件效率时必须克服的挑战。

在“理解你的目标”中,我们将尝试通过设定明确的效率目标来驯服软件倾向和诱惑以最大化优化工作。我们只需要足够快或足够高效。这就是为什么从一开始就设定正确的性能要求如此重要。接下来,在“资源感知效率要求”,我将提出一个任何人都可以遵循的模板和实用的过程。最后,这些效率要求将在“遇到效率问题了?保持冷静!”中发挥作用,我将教你如何处理你或其他人报告的性能问题的专业流程。你会了解到,优化过程可能是你的最后一招。

在“优化设计层次”中,我将解释如何分割和隔离你的优化工作,以便更容易地征服。最后,在“效率感知开发流程”,我们将把所有的片段合并成一个我始终使用并希望推荐给你的统一优化流程:可靠的流程,适用于任何软件或设计层次。

我们有很多学习的内容要开始理解优化的含义。

超越浪费,优化是一个零和游戏。

没有秘密,我们在克服效率问题时的武器之一就是被称为“优化”的努力。但是,“优化”到底意味着什么?如何最好地思考它并掌握它呢?

优化不仅仅局限于软件效率主题。我们在生活中也倾向于优化许多事物,有时是无意识的。例如,如果我们经常烹饪,我们可能会把盐放在一个易于取用的地方。如果我们的目标是增重,我们会摄入更多的卡路里。如果我们早晨出行,我们会在前一天晚上打包和准备好。如果我们通勤,我们倾向于利用那段时间听有声书。如果通勤到办公室令人痛苦,我们会考虑搬到靠近更好的交通系统的地方。所有这些都是旨在朝着特定目标改善我们生活的优化技术。有时我们需要进行重大改变。另一方面,通过重复获得的小幅增量改进通常足以产生更大的影响。

在工程领域,“优化”一词源于数学,意味着在一组规则约束下,从所有可能的解决方案中找到最佳解决方案。然而,在计算机科学中,我们通常使用“优化”来描述改进系统或程序执行特定方面的行为。例如,我们可以优化我们的程序以更快地加载文件,或者在 Web 服务器上服务请求时减少内存利用率的峰值。

我们可以为任何事情进行优化。

一般来说,优化不一定需要改进我们程序的效率特性,如果这不是我们的目标的话。例如,如果我们的目标是提高安全性、可维护性或代码大小,我们也可以为此进行优化。然而,在本书中,当我们谈论优化时,它们将是在效率背景下的(改进资源消耗或速度)。

效率优化的目标应该是修改代码(通常不改变其功能^(1)),使其执行要么整体更高效,要么至少在我们关心的类别中更高效(在其他方面更差)。

重要的部分是,从高层次的视角来看,我们可以通过以下两种方式(或两者兼有)来进行优化:

  • 我们可以消除“浪费”的资源消耗。

  • 我们可以用一种资源消耗来换取另一种,或者故意牺牲其他软件质量(所谓的权衡)。

让我通过描述第一种变化——减少所谓的浪费来解释这两者之间的区别。

合理的优化

我们的程序由代码组成——一组操作数据并在我们的机器上使用各种资源(CPU、内存、磁盘、电源等)的指令。我们编写这些代码以便我们的程序能够执行请求的功能。但是,涉及到的一切很少是完美的(或完美集成):我们编写的代码、编译器、操作系统,甚至硬件。因此,我们有时会引入“浪费”。资源消耗的浪费代表了我们程序中相对不必要的操作,这些操作占用了宝贵的时间、内存或 CPU 时间等。这种浪费可能是出于故意简化、偶然、技术债务、疏忽或对更好方法的无知。例如:

  • 我们可能不小心留下了一些调试代码,导致在频繁使用的函数中引入了巨大的延迟(例如,fmt.Println语句)。

  • 我们进行了一个不必要的昂贵检查,因为调用者已经验证了输入。

  • 我们忘记停止某些不再需要但仍在运行的 goroutines(一种我们将在“Go 运行时调度器”中详细解释的并发范式),这浪费了我们的内存和 CPU 时间。²

  • 当存在一个更快的优化函数时,我们使用了第三方库中的非优化函数,而这个函数在另一个不同的、维护良好的库中存在。

  • 我们在磁盘上多次保存了相同的数据,而实际上可以只重复使用并存储一次。

  • 当我们的算法在处理时可能进行了过多的检查,而实际上可以免费减少(例如,在有序数据上进行朴素搜索与二分搜索的比较)。

如果我们的程序执行或者消耗特定资源的操作是一种“浪费”,那么如果通过消除它,我们不会牺牲其他任何东西,那么这种消除就是合理的。这里的“任何东西”指的是我们特别关心的任何东西,比如额外的 CPU 时间,其他资源消耗,或者与可读性、灵活性或可移植性无关的特性。这样的消除会使我们的软件整体上更加高效。仔细观察,你会惊讶地发现每个程序都存在多少浪费。它只是等待我们注意到并消除它!

我们的程序通过减少“浪费”进行优化是一种简单而有效的技术。在本书中,我们将其称为合理的优化,并建议每次发现这种浪费时都这样做,即使之后没有时间来进行基准测试。是的,你没听错。这应该是编码卫生的一部分。请注意,要将其视为“合理”的优化,必须显而易见。作为开发者,你需要确信:

  • 这种优化消除了程序的一些额外工作。

  • 它不会牺牲任何其他有意义的软件质量或功能,特别是可读性。

寻找那些可能“显然”不必要的东西。消除这种不必要的工作是很容易做到的,并且不会有任何害处(否则就不是浪费)。

要注意可读性

任何代码修改通常首先受到可读性的影响。如果显而易见的减少浪费会显著降低可读性,或者你需要花几个小时来尝试可读的抽象,那么这就不是一个合理的优化。

没问题。我们可以稍后处理这个问题,并且我们会在“深思熟虑的优化”中详细讨论它。如果影响了可读性,我们需要数据证明这样做是值得的。

削减“浪费”也是一种有效的思维模式。就像那些因为变得聪明懒惰而受到奖励的人类一样,我们也希望在最少的运行时间内最大化程序所带来的价值。

有人会说,合理的优化是被称为“过早优化”的反模式的一个例子,许多人都受到过警告。我不得不同意减少这种显而易见的浪费确实是一种过早优化,因为我们没有评估和测量其影响。但我认为,如果我们确信这种过早优化不会带来任何害处,除了多做一点工作外,让我们承认它是过早优化,但仍然是合理的,我们继续进行并前进。

如果我们回到通勤上班的例子,如果我们注意到鞋里有几块石头,当然我们会把它们拿出来,这样我们就可以不再感到疼痛了。我们不需要评估、测量或比较去确认移除石头是否提高了我们的通勤时间。去掉石头会在某种程度上帮助我们,这样做也没有害处(我们不需要每次出门都带石头)!😃

如果你正在处理的是噪音,你不应立即处理,因为投入时间和精力的回报非常小。但是,如果你在浏览你的代码库时注意到一个显著改进的机会(比如 10%或 12%),当然可以弯腰捡起来。

Scott Meyers,《重要的事情

起初,当您刚接触编程或某种特定语言时,您可能不知道哪些操作是不必要的浪费,或者消除潜在浪费是否会损害您的程序。这没关系。这种“显而易见”的能力来自实践,所以不要猜测。如果你在猜测,这意味着优化并不明显。通过经验,您将学会什么是合理的,我们将在第十章和第十一章中一起练习这一点。

合理的优化会带来一致的性能改进,并经常简化或使我们的代码更可读。然而,对于更大的效率影响,我们可能希望采取更为刻意的方法,结果可能不那么明显,正如下一节所解释的那样。

刻意优化

除了浪费,我们还有对功能至关重要的操作。在这种情况下,我们可以说我们处于一个零和游戏的情况³。这意味着我们有一个情况,我们不能消除使用资源 A(例如内存)的某个操作,而不使用更多的资源 B(例如 CPU 时间)或其他质量(例如可读性、可移植性或正确性)。

那些不明显或需要我们做出某种权衡的优化可以被称为刻意⁴,因为我们需要在其上花费更多的时间。我们可以理解这种权衡,对其进行测量或评估,并决定保留还是放弃它。

刻意优化在任何方面都不差。相反,它们通常会显著影响您希望减少的延迟或资源消耗。例如,如果我们的网络服务器请求速度太慢,我们可以考虑通过引入缓存来优化延迟。缓存将允许我们保存从昂贵计算中的结果,以便为请求相同数据的情况提供响应。此外,它节省了 CPU 时间,避免了引入复杂的并行逻辑。然而,在服务器的生命周期内,我们将牺牲内存或磁盘使用,并可能引入一些代码复杂性。因此,刻意优化可能不会提高程序的整体效率,但它可以提高我们当前关心的特定资源使用的效率。根据情况,这种牺牲可能是值得的。

然而,有些牺牲意味着我们必须在一个与功能分离的开发阶段中执行这种优化,正如在“效率感知开发流程”中所解释的那样。这样做的原因很简单。首先,我们必须确保我们理解我们所牺牲的内容及其影响是否不大。不幸的是,人类在估计这类影响时通常表现得很差。

例如,在发送或存储数据之前,常见的网络带宽和磁盘使用优化方法是对数据进行压缩。然而,同时在接收或读取数据时需要进行解压(解码)。我们软件引入压缩前后资源使用的潜在平衡可以在图 3-1 中看到。

efgo 0301

图 3-1. 如果在发送到网络和保存到磁盘之前压缩数据,可能对延迟和资源使用造成潜在影响

具体的数字可能会有所不同,但在增加压缩后,CPU 资源的使用可能会增加。我们不再进行简单的数据写入操作,而是必须逐字节压缩它们。即使对于最佳的无损压缩算法(例如snappygzip),这也需要一些时间。但发送到网络和磁盘的消息数量减少,可能会改善这种操作的总延迟。所有的压缩算法都需要一些额外的缓冲区,因此也会增加内存使用。

总之,对于合理和有意义地分类优化有着深远的影响。如果我们看到潜在的效率提升,必须意识到其意外后果。有些情况下,进行优化可能是合理且容易获得的,例如我们可以免费剥离一些不必要的程序操作。但更多时候,要在每个方面使我们的软件高效是不可能的,或者我们会影响其他软件质量。这时候我们陷入了一个零和博弈,必须认真审视这些问题。在本书和实践中,你将学习到你所处的情况及如何预测这些后果。

在我们将这两种优化类型引入开发流程之前,让我们讨论一下我们必须意识到的效率优化挑战。我们将在下一节中重点介绍其中最重要的挑战。

优化挑战

如果优化我们的软件很容易,我就不需要写这本书了。但事实并非如此。这个过程可能耗时且容易出错。这就是为什么许多开发人员倾向于忽略这个主题或在他们职业生涯的后期学习它。但不要感到沮丧!每个人经过一些实践后都可以成为一个有效且务实的效率感知开发者。了解优化障碍应该给我们一个很好的指示,告诉我们应该专注于改进什么。让我们来看看一些基本问题:

程序员很难估计哪一部分是性能问题的罪魁祸首。

我们很难猜测程序的哪一部分消耗了最多的资源以及具体的数量。然而,找出这些问题至关重要,因为通常情况下,适用于帕累托原则。该原则指出,我们程序消耗的时间或资源的 80% 只来自其执行的 20% 操作。由于任何优化都是耗时的,我们希望专注于那关键的 20% 操作,而不是一些噪音。幸运的是,有工具和方法可以估算这些问题,我们将在第 9 章中详细介绍。

程序员在估计确切的资源消耗方面声名狼藉。

同样地,我们经常对某些优化是否有帮助作出错误假设。通过经验,我们的猜测会变得更准确(希望在阅读本书后如此)。然而,最好不要轻信自己的判断,始终在深思熟虑进行优化后,测量和验证所有数据(在第 7 章中详细讨论)。软件执行过程中有太多层和许多未知因素和变量。

长期保持效率是困难的。

之前提到的复杂软件执行层(操作系统的新版本、硬件、固件等)是不断变化的,更不用说程序的演变以及未来可能接触您代码的开发人员。我们可能花了几周时间优化一个部分,但如果我们不防范回归,这些优化可能就变得无关紧要了。有多种方法可以自动化或者至少结构化我们程序的效率基准测试和验证过程,因为事情每天都在变化,如第 6 章中所述。

可靠地验证当前性能非常困难。

正如我们将在“效率感知开发流程”中了解到的那样,解决上述挑战的方法是对效率进行基准测试、测量和验证。不幸的是,这些操作难以执行且容易出错。有很多原因:无法足够接近模拟生产环境、外部因素如噪声邻居、缺乏预热阶段、错误的数据集或微基准测试中的意外编译器优化。这就是为什么我们将在“实验可靠性”(第 7 章中的“Reliability of Experiments”)中花费一些时间讨论这个主题。

优化很容易影响其他软件质量。

坚实的软件在许多方面都非常出色:功能性、兼容性、可用性、可靠性、安全性、可维护性、可移植性和效率。每一种特性都不容易做到完美,所以它们都会给开发过程带来一些成本。每种特性的重要性可能因您的用例而异。然而,每种软件质量都有其安全的最低标准,以保证程序的实用性。在添加更多功能和优化时,这可能会带来挑战。

具体来说,在 Go 中,我们无法严格控制内存管理。

正如我们在《Go 运行时》中学到的,Go 是一种垃圾收集语言。虽然这对我们的代码简洁性、内存安全性和开发速度至关重要,但在追求内存效率时也会显现出一些不足。有方法可以改进我们的 Go 代码以减少内存使用,但事情可能会变得复杂,因为内存释放模型是最终的。通常,解决方案就是简单地减少分配。我们将详细讨论《我们是否存在内存问题?》中的内存管理。

我们的程序何时足够高效?

最终,所有的优化都不是完全免费的。它们需要开发人员付出不同大小的努力。合理和有意识的优化需要先前的知识和时间用于实现、实验、测试和基准测试。鉴于此,我们需要找到这种努力的正当理由。否则,我们可以把这些时间花在别的地方。我们应该消除这种浪费吗?我们应该将资源 X 的消耗交换为资源 Y 吗?这种转换对我们有用吗?答案可能是“不”。如果是“是”,那么效率提升到什么程度才够?

关于最后一点,这就是为什么了解你的目标非常重要。在开发过程中,你(或你的老板)关心什么事物、资源和品质可能会有所不同。在下一节中,我将提出一种明确软件性能要求的务实方法。

理解你的目标

在你朝着这些崇高目标(程序效率优化)迈进之前,你应该审视自己的原因。优化是软件工程中许多可取的目标之一,但它经常与其他重要目标(如稳定性、可维护性和可移植性)相对立。在其最表面的层次上(高效的实现、清晰的非冗余接口),优化是有益的,应该始终应用。但在其最具侵入性的层次上(内联汇编、预编译/自修改代码、循环展开、位字段化、超标量和向量化),它可能是一个耗时的实现和错误追踪的不竭源泉。对于优化代码的成本要谨慎和警惕。

保罗·谢(Paul Hsieh),《编程优化》

根据我们的定义,效率优化改善了我们程序的资源消耗或延迟。挑战自己并探索我们的程序可以有多快是非常令人着迷的⁵。然而,首先我们需要明白优化的目标并不是让我们的程序完全高效或“最优”的(因为这可能是不可能或不可行的),而是足够地次优。但是对于我们来说,“足够”意味着什么?什么时候停止?如果甚至没有必要开始优化呢?

当利益相关者(或用户)要求我们开发的软件更高效时,一个答案是进行优化,直到他们满意。但不幸的是,由于几个原因,这通常非常困难:

XY 问题

利益相关者经常要求更高的效率,而更好的解决方案可能在其他地方。例如,许多人抱怨使用度量系统时的内存使用量过大,如果他们尝试监控唯一事件。相反,解决方案可能是使用日志记录或跟踪系统来处理此类数据,而不是使度量系统更快。⁶ 因此,我们不能总是信任初期用户的请求,特别是关于效率的请求。

效率不是零和游戏。

理想情况下,我们需要全面了解所有效率目标的大局。正如我们在“刻意优化”中学到的,为了降低延迟而进行的一项优化可能会导致更多的内存使用或影响其他资源,因此我们不能仅凭反应到每一个关于效率的用户投诉。当然,软件通常越精简和高效越好,但很可能我们无法制作出一款既满足需要低延迟实时事件捕获解决方案的用户,又满足在此操作期间需要极低内存使用的用户的单一软件。

利益相关者可能不理解优化成本。

一切都有成本,特别是优化工作和维护高度优化的代码。从技术上讲,只有物理定律限制了我们的软件可以优化到多么高的程度。⁷ 然而,在某个时候,优化带来的好处与寻找和开发这种优化的成本相比是不划算的。让我们深入探讨一下最后一点。

图 3-2 展示了软件效率与不同成本之间的典型相关性。

efgo 0302

图 3-2. 超出“甜点”之后,提高效率的成本可能非常高。

图 3-2 解释了为什么在某些“甜点”点上,投入更多时间和资源来提高软件效率可能是不可行的。超过某一点后,优化和开发优化代码的成本可能会迅速超过我们从更轻量的软件中获得的好处,例如计算成本和机会。我们可能需要投入更多昂贵的开发人员时间,需要引入巧妙的、不可移植的技巧,专用的机器码,专用的操作系统,甚至专用的硬件。

在许多情况下,超越甜点之外的优化并不值得,可能更好的做法是设计一个不同的系统或使用其他流程来避免这样的工作。不幸的是,甜点在哪里并没有一个单一的答案。通常情况下,计划软件寿命越长,部署越大,投资价值就越大。另一方面,如果您计划仅几次使用您的程序,您的甜点可能在此图表的开始处,效率非常低。

问题在于用户和利益相关者将不会意识到这一点。虽然理想情况下,产品所有者帮助我们找出这一点,但往往是开发者的角色使用我们将在第 6 和 7 章中学习的工具来建议这些不同成本的水平。

但是,无论我们同意多少数字,解决“什么时候足够”的最佳方法并确定明确的效率要求是将它们写下来。在下一节中,我将解释为什么。在“资源感知效率要求”中,我将介绍其轻量级公式。然后在“获取和评估效率目标”中,我们将讨论如何获取和评估这些效率要求。

效率要求应该被形式化

正如您可能已经知道的那样,每个软件开发都始于功能需求收集阶段(FR 阶段)。架构师、产品经理或您本人必须通过潜在的利益相关者进行访谈,收集用例,并理想情况下将它们记录在某些功能需求文档中。然后开发团队和利益相关者会在该文档中审查和协商功能细节。FR 文档描述了您的程序应接受的输入,用户期望的行为和输出。它还提到了先决条件,例如应用程序预期运行的操作系统是什么。理想情况下,您会对 FR 文档获得正式批准,并成为双方之间的“合同”。特别是在您因构建软件而获得补偿时,这一点非常重要:

  • FR 告诉开发人员他们应该专注于什么。它告诉您输入应该是有效的以及用户可以配置哪些内容。它规定了您应该专注的内容。您是否在为利益相关者支付的内容而花费时间?

  • 与明确的FR(功能需求)结合软件更容易。例如,利益相关者可能希望设计或订购进一步与您的软件兼容的系统部件。他们甚至可以在您的软件完成之前开始做这些!

  • FR 强化了明确的沟通。理想情况下,FR 应该是书面和正式的。这是有帮助的,因为人们往往会忘记事情,很容易发生误解。这就是为什么您要把所有事情写下来并要求利益相关者审查的原因。也许您听错了什么?

对于更大的系统和功能,您需要形式化的功能需求。对于较小的软件部件,您往往将其写入您的待办事项中,例如 GitHub 或 GitLab 的问题,然后进行文档化。即使是微小的脚本或小程序,也要设定一些目标和先决条件,例如特定的环境(例如 Python 版本)和一些依赖项(机器上的 GPU)。当您希望其他人有效地使用它时,您必须提及您的软件的功能需求和目标。

定义和达成功能需求在软件行业中已被广泛接受。即使有些官僚主义,开发人员倾向于喜欢这些规范,因为这使得需求更加稳定和具体。

也许你知道我要说什么。令人惊讶的是,我们经常忽视定义类似要求,这些要求集中在我们预期构建的软件更多的非功能方面,例如描述所需功能的效率和速度。⁸

这样的效率要求通常是非功能性需求(NFR)文档或规范的一部分。其收集过程理想情况下应该类似于 FR 过程,但对于所有其他请求的质量,软件应具备:可移植性、可维护性、可扩展性、可访问性、可操作性、容错性和可靠性、合规性、文档、执行效率等等。列举如此之多。

NFR 的名字可能在某种程度上具有误导性,因为许多品质,包括效率,对我们的软件功能有着巨大的影响。正如我们在第一章中学到的,效率和速度对用户体验至关重要。

实际上,在软件开发过程中,根据我的经验和研究,NFR 并不常用。我找到了多个原因:

  • 传统的 NFR(非功能性需求)规范被认为是官僚主义的,并且充满样板文件。特别是如果提到的品质无法量化并且不具体,那么每个软件的 NFR 看起来显而易见且多少相似。当然,所有软件都应该是可读的、可维护的,尽可能快速地使用最少的资源,并且可用。这并不起作用。

  • 没有易于使用的、开放的、和可访问的标准来进行这一过程。最流行的ISO/IEC 25010:2011 标准阅读起来需要大约$200。它有令人震惊的 34 页,并且自 2017 年最后一次修订以来没有改变。

  • NFR 通常过于复杂,以至于无法在实践中应用。例如,之前提到的 ISO/IEC 25010 标准指定了总共 13 个产品特征,42 个子特征。理解它并花费大量时间进行收集和审查非常困难。

  • 正如我们将在“优化设计级别”中了解到的那样,我们的软件速度和执行效率取决于比我们的代码更多的因素。典型的开发者通常可以通过优化算法、代码和编译器来影响效率。然后由操作员或管理员来安装该软件,将其适应更大的系统,配置它,并为该工作负载提供操作系统和硬件。当开发人员不在“生产”环境中运行其软件的领域时,很难谈论运行时效率。

    SRE 领域

    站点可靠性工程(SRE)由 Google 引入,专注于将软件开发与运营/管理结合起来。这些工程师具有在大规模上运行和构建软件的经验。有了更多的实践经验,更容易讨论效率要求。

  • 最后但同样重要的是,我们都是人类,充满了情感。因为预估我们的软件效率特别是提前是困难的,所以在设定效率或速度目标时有时会感到羞辱。这就是为什么我们有时会不自觉地避免同意可量化的性能目标。这是正常的,感到不舒服也是正常的。

好吧,抛开那个,我们不去那里。我们需要更加务实和易于操作的东西。需要阐明我们的软件效率和速度的大致目标,并成为消费者和开发团队之间一些合同的起点。在功能性要求之上提前设定这些效率要求是非常有帮助的,因为:

我们确切知道我们的软件必须有多快或资源高效。

例如,假设我们同意某个操作应使用 1 GB 内存、2 CPU 秒,并最多花费 2 分钟。如果我们的测试显示它需要 2 GB 内存和 1 CPU 秒 1 分钟,那么优化延迟就没有意义了。

我们知道我们是否有权进行权衡。

在前面的例子中,我们可以预先计算或压缩一些内容以提高内存效率。我们仍然有 1 CPU 秒可以用,我们可以慢 1 分钟。

没有官方要求,用户将隐含地假设一些效率期望。

例如,也许我们的程序对某个输入意外地运行非常快。用户可以假设这是设计意图,并且将来会依赖这一事实,或者用于系统的其他部分。这可能导致用户体验不佳和意外情况。⁹

在更大的系统中使用您的软件更容易。

更多时候,您的软件将成为另一软件的依赖,并形成更大的系统。即使是基本的效率要求文档也可以告诉系统架构师从组件中期望什么。这可以极大地帮助进一步的系统性能评估和容量规划任务。

提供运行支持更容易。

当用户不知道您的软件可以期望什么样的性能时,随着时间的推移,您将很难支持它。用户会就什么样的效率是可以接受的、什么样的不是进行多次来回。相反,有了明确的效率要求,更容易判断您的软件是否被充分利用,结果问题可能在用户一侧。

让我们总结一下我们的情况。我们知道效率要求可能非常有用。另一方面,我们也知道它们可能很繁琐且充满样板文件。所以让我们探索一些选项,看看是否能在需求收集工作和带来的价值之间找到平衡。

资源感知效率要求

没有人定义一个良好的标准流程来创建效率要求,所以让我们尝试 定义一个!当然,我们希望它尽可能轻量化,但让我们从理想情况开始。有什么是某个资源感知效率要求(RAER)文档中可以放入的完美信息集?比“我希望这个程序运行得相当快速”的更具体和可操作性的信息。

在 Example 3-1 中,您可以看到某个软件中单个操作的数据驱动、最小 RAER 的示例。

示例 3-1. 示例 RAER 条目
Program: "The Ruler"
Operation: "Fetching alerting rules for one tenant from the storage using HTTP."
Dataset: "100 tenants having 1000 alerting rules each."

Maximum Latency: "2s  for 90th percentile"
CPU Cores Limit: "2"
Memory Limit: "500 MB"
Disk Space Limit: "1 GB"
...

理想情况下,这个 RAER 是一组记录,其中包含某些操作的效率要求。原则上,单个记录应该包含如下信息:

  • 它所涉及的操作、API、方法或函数。

  • 我们操作的数据集的大小和形状,例如输入或存储的数据(如果有)。

  • 操作的最大延迟。

  • 在该数据集上,这个操作的资源消耗预算,例如内存、磁盘、网络带宽等。

现在,有坏消息和好消息。坏消息是,严格来说,这样的记录对于所有小操作来说是不现实的。这是因为:

  • 在软件执行期间,可能有数百种不同的操作。

  • 几乎有无数种数据集的形状和大小(例如,想象一个 SQL 查询作为输入,存储的 SQL 数据作为数据集:我们有近乎无限的选项排列)。

  • 现代硬件和操作系统在执行软件时可以“消耗”的成千上万个元素。总体而言,CPU 秒和内存是常见的,但 CPU 缓存、内存总线带宽、使用的 TCP 套接字数量、使用的文件描述符等成千上万的其他元素怎么样呢?我们需要指定所有可以使用的吗?

好消息是,我们不需要提供所有的细节。这类似于我们处理功能要求的方式。我们是否关注所有可能的用户故事和细节?不,只关注最重要的那些。我们是否定义所有可能的有效输入和预期输出的排列组合?不,我们只定义了几个围绕边界的基本特性(例如,信息必须是正整数)。让我们看看如何简化 RAER 条目的详细级别:

  • 首先专注于我们软件中最常用和最昂贵的操作。这些将最大程度地影响软件资源的使用。我们将在本书后面讨论有助于您的基准测试和分析性能的内容。

  • 我们不需要概述可能被消耗的所有微小资源的要求。从对资源使用影响最大且最重要的那些开始。通常,这意味着特定于 CPU 时间、内存空间和存储(例如,磁盘空间)的要求。从这里开始迭代并添加其他将来可能重要的资源。也许我们的软件需要一些独特、昂贵和难以找到的资源,值得一提(例如,GPU)。也许某种消耗对整体可伸缩性构成限制,例如,如果我们的操作使用更少的 TCP 套接字或磁盘 IOPS,我们可以在单台机器上放置更多的进程。只有当它们重要时才添加它们。

  • 类似于我们在单元测试中验证功能时所做的,我们可以只关注重要的输入类别和数据集。如果我们选择边界案例,我们有很高的概率为最坏和最佳情况的数据集提供资源需求。这已经是一个巨大的胜利。

  • 或者,还有一种方法可以定义输入(或数据集)与允许的资源消耗之间的关系。然后,我们可以以数学函数的形式描述这种关系,通常称为复杂性(在“渐近复杂性与大 O 标记”中讨论)。即使有些近似,这也是一种非常有效的方法。我们的操作/rules的 RAER 可以在示例 3-1 中看到,如示例 3-2 所示。

示例 3-2. 以复杂性或吞吐量而不是绝对数字的示例 RAER 条目
Program: "The Ruler"
Operation: "Fetching alerting rules for one tenant from the storage using HTTP."
Dataset: "X tenants having Y alerting rules each."

Maximum Latency: "2*Y ms for 90th percentile"
CPU Cores Limit: "2"
Memory Limit: "X + 0.4 * Y MB"
Disk Space Limit: "0.1 * X GB"
...

总体而言,我甚至建议在先前提到的功能需求(FR)文档中包括 RAER。将其放在另一部分称为“效率要求”。毕竟,如果没有合理的速度和效率,我们的软件怎么能被称为完全功能的呢?

总之,在本节中,我们定义了资源感知效率要求(RAER)规范,该规范为我们提供了关于软件效率需求和期望性能的近似值。这对我们在进一步开发和优化技术中学到的内容将极为有帮助。因此,我鼓励你在开始开发软件并优化或添加更多功能之前,了解你所追求的性能。

让我们解释一下如何为我们打算提供的系统、应用程序或功能拥有或创建这样的 RAER。

获取和评估效率目标

理想情况下,当你参与任何软件项目时,你应该像已经规定了 RAER 一样。在较大的组织中,你可能会有专门的人员,如项目经理或产品经理,他们会收集这些效率需求,除了功能需求之外。他们还应确保这些需求是可以实现的。如果他们没有收集 RAER,不要犹豫要求他们提供这样的信息。通常他们的职责就是这样。

不幸的是,在大多数情况下,特别是在较小的公司、社区驱动的项目或显然是你的个人项目中,往往没有明确的效率需求。在这些情况下,我们需要自己获取效率目标。那么我们该如何开始呢?

这项任务与功能目标类似。我们需要为用户带来价值,因此理想情况下,我们需要向他们询问他们在速度和运行成本方面的需求。因此,我们去找利益相关者或客户,询问他们在效率和速度方面的需求,他们愿意支付多少,以及他们这方面的限制是什么(例如,集群只有四台服务器或 GPU 只有 512MB 内存)。同样地,对于功能,优秀的产品经理和开发人员会努力将用户性能需求转化为效率目标,如果利益相关者不来自工程领域,则这并不是件容易的事情。例如,“我希望这个应用程序运行快速”的声明必须被具体化。

如果利益相关者无法提供软件的潜在延迟数字,只需选择一个数字。起始值可以设置得较高,这对你很有利,但以后会使你的工作更轻松。也许这会引发利益相关者讨论该数字带来的影响。

很多时候,系统用户会有多种角色。例如,让我们想象我们的公司将为客户提供软件服务,并且该服务已经定义了价格。在这种情况下,用户关心速度和正确性,而我们公司关心软件的效率,因为这直接影响运行服务的净利润(或者如果运行我们的软件的计算成本过大,则是损失)。在这种典型的软件即服务(SaaS)示例中,我们对我们的 RAER 有了不只一个,而是两个来源的输入。

Dogfooding

对于较小的编码库、工具以及我们的基础设施软件,我们往往既是开发者也是用户。在这种情况下,从用户的角度设置 RAER 会更容易。这只是使用你自己创建的软件的一个良好实践的原因之一。这种方法通常被称为“吃自己的狗食”(dogfooding)。

不幸的是,即使用户愿意定义 RAER,现实却并非如此完美。这里就涉及到难点。我们能确定从用户角度提出的建议是否在预期的时间内可行吗?我们了解需求,但我们必须通过团队技能、技术可能性和所需时间来验证它。通常情况下,即使有了一些 RAER,我们也需要进行自己的尽职调查,并从实现可能性的角度定义或评估 RAER。本书将教会您完成这项任务所需的一切。

同时,让我们通过一个 RAER 定义过程的例子来了解一下。

定义 RAER 的示例

定义和评估复杂的 RAER 可能会变得复杂。然而,如果你必须从零开始,从潜在的琐碎但明确的要求开始是合理的。

设置这些要求归结为用户的观点。我们需要找到使得你的软件在其环境中具有价值的最低要求。例如,假设我们需要创建一个在一组 JPEG 格式图像上应用图像增强的软件。在 RAER 中,我们现在可以将这样的图像转换视为一个操作,而图像文件集和选择的增强效果则是我们的输入

我们 RAER 中的第二项是我们操作的延迟时间。从用户角度来看,尽可能快地完成是更好的。然而,我们的经验告诉我们,在多张图片(特别是大尺寸和多张)上应用增强效果时,我们可以施加的速度有其限制。但是我们如何找到一个合理的延迟时间要求,既适用于潜在用户,又使得我们的软件能够实现呢?

在我们新接触效率世界时,很难达成一个共识的单一数字。例如,我们可能猜测单张图像处理需要 2 小时可能太长,而 20 纳秒则不可实现,但在这里找到中间地带却很困难。然而正如在“效率要求应当被形式化”中所提到的,我建议您尝试定义一个数字,因为这将使得您的软件更容易评估!

定义效率要求就像谈判薪水一样

Agreeing to someone’s compensation for their work is similar to finding the requirement sweet spot for our program’s latency or resource usage. The candidate wants the salary to be the highest possible. As an employer, you don’t want to overpay. It’s also hard to assess the value the person will be providing and how to set meaningful goals for such work. What works in salary negotiating works when defining RAER: don’t set too high expectations, look at other competitors, negotiate, and have trial periods!

One way to define RAER details like latency or resource consumption is to check the competition. Competitors are already stuck in some kind of limits and framework for stating their efficiency guarantees. You don’t need to set those as your numbers, but they can give you some clue of what’s possible or what customers want.

While useful, checking competition is often not enough. Eventually, we have to estimate what’s roughly possible with the system and algorithm we have in mind and the modern hardware. We can start by defining the initial naive algorithm. We can assume our first algorithm won’t be the most efficient, but it will give us a good start on what’s achievable with little effort. For example, let’s assume for our problem that we want to read an image in JPEG format from disk (SSD), decode it to memory, apply enhancement, encode it back, and write it to disk.

With the algorithm, we can start discussing its potential efficiency. However, as you will learn in “优化设计级别” and “实验可靠性”, efficiency depends on many factors! It’s tough to measure it on an existing system, not to mention forecasting it just from the unimplemented algorithm.

This is where the complexity analysis with napkin math comes into play!

Napkin Math

Sometimes referred to as back-of-the-envelope calculation, napkin math is a technique of making rough calculations and estimations based on simple, theoretical assumptions. For example, we could assume latency for certain operations in computers, e.g., a sequential read of 8 KB from SSD is taking approximately 10 μs while writing 1 ms.¹⁰ With that, we could calculate how long it takes to read and write 4 MB of sequential data. Then we can go from there and calculate overall latency if we make a few reads in our system, etc.

Napkin math is only an estimate, so we need to treat it with a grain of salt. Sometimes it can be intimidating to do since it all feels abstract. Yet such quick calculation is always a fantastic test on whether our guesses and initial system ideas are correct. It gives early feedback worth our time, especially around common efficiency requirements like latency, memory, or CPU usage.

我们将在“复杂性分析”中详细讨论复杂性分析和餐巾数学,但让我们快速定义我们示例 JPEG 增强问题空间的初始 RAER。

复杂性允许我们将效率表示为与输入的延迟(或资源使用)的函数。对于 RAER 讨论,我们的输入是什么?首先假设最坏情况。找出系统中最慢的部分以及可以触发该部分的输入。在我们的示例中,我们可以想象我们允许的最大图像(例如,8K 分辨率)是处理最慢的。处理一组图像的需求使事情有些复杂。现在,我们可以假设最坏情况,并开始从那里进行协商。最坏情况是图像不同,并且我们不使用并发。这意味着我们的延迟可能是 x * N 的函数,其中 x 是最大图像的延迟,N 是集合中图像的数量。

考虑到 JPEG 格式的 8K 图像的最坏情况输入,我们可以尝试估算其复杂性。输入的大小取决于唯一颜色的数量,但我找到的大多数图像大小约为 4 MB,所以让我们将这个数字作为我们的平均输入大小。使用附录 A 中的数据,我们可以计算出这样的输入至少需要 5 ms 读取和 0.5 s 保存到磁盘上。类似地,从 JPEG 格式编码和解码很可能至少意味着循环并在内存中分配多达 7680 × 4320 像素(约 33 百万)。查看image/jpeg标准 Go 库,每个像素由三个uint8表示以在YCbCr 格式中表示颜色。这意味着大约 1 亿个无符号 8 字节整数。因此,我们可以了解到潜在的运行时和空间复杂性:

运行时

我们需要两次从内存中提取每个元素(从 RAM 顺序读取约 5 ns),一次用于解码,一次用于编码,这意味着 2 * 1 亿 * 5 ns,因此 1 秒。因此,通过这种快速的数学计算,我们现在知道,在不应用任何增强或更复杂的算法的情况下,单个图像的此类操作至少需要 1s + 0.5s,即 1.5 秒。

由于餐巾数学只是一个估算,而且我们没有考虑实际的增强操作,可以安全地假设我们的错误率高达三倍。这意味着我们可以将单个图像的初始延迟要求设置为 5 * N 秒,其中 N 是图像数量。

空间

对于读取整个图像到内存的朴素算法来说,存储该图像可能是分配最多内存的操作。每个像素使用三个uint8数,所以最多使用 33 百万 * 3 * 8 字节,因此最多使用 755 MB 内存。

我们假设了典型情况和未优化的算法,因此我们期望能够改进这些初始数字。但如果用户等待 10 张图像需要 50 秒,并且每张图像使用 1 GB 内存可能也是可以接受的。知道这些数字可以在可能的情况下减少效率工作的范围!

为了更加自信地进行我们的计算,或者如果您在草稿计算中遇到困难,我们可以对系统中关键、最慢的操作进行快速基准测试¹¹。因此,我编写了一个单一的基准测试,使用标准的 Go jpeg 库来读取、解码、编码和保存 8K 图像。示例 3-3 显示了基准测试结果的总结。

示例 3-3. 对读取、解码、编码和保存一张 8K JPEG 图像的微基准测试结果
name       time/op
DecEnc-12  1.56s ±2%
name       alloc/op
DecEnc-12  226MB ± 0%
name       allocs/op
DecEnc-12   18.8 ±3%

结果表明我们的运行时计算相当准确。平均执行一个 8K 图像的基本操作需要 1.56 秒!然而,分配的内存比我们预想的要好三倍多。仔细检查YCbCr 结构的注释揭示了这种类型每像素存储一个 Y 样本,但每个 CbCr 样本可能跨越一个或多个像素,这也许可以解释差异。

虽然获取和评估 RAER 看起来复杂,但我建议在进行任何严肃开发之前进行这项练习并获得这些数据。然后,通过基准测试和草稿计算,我们可以快速判断我们心目中的初步算法是否能够实现 RAER。同样的过程也可以用来判断是否有更多易于实现的优化空间,正如在“优化设计级别”中所述。

通过获取、定义和评估您的 RAER 的能力,我们最终可以尝试解决一些效率问题!在下一节中,我们将讨论我建议的处理这类有时会造成压力的情况的步骤。

遇到效率问题?保持冷静!

首先,不要惊慌!我们都曾经历过这种情况。我们编写了一段代码,并在自己的机器上测试通过,效果很好。然后,为此感到自豪地发布给其他人使用,但立刻有人报告性能问题。也许它在其他人的机器上运行不够快。或者它在其他用户数据集上使用了意想不到的内存量。

在我们建立、管理或负责的程序面临效率问题时,我们有几种选择。但在做出任何决定之前,有一件至关重要的事情你必须做。当问题发生时,请摆脱对自己或与你共事的团队的负面情绪。责怪自己或别人犯错是非常常见的。当有人抱怨你的工作时,感到一种不舒服的内疚感是很自然的。然而,每个人(包括我们在内)都必须理解,效率问题是具有挑战性的话题。此外,即使是经验丰富的开发人员,每天也会遇到效率低下或有 bug 的代码。因此,犯错误是没有羞耻的。

为什么我在一本编程书中谈论情感?因为心理安全是开发者在代码效率方面采取错误方法的一个重要原因。拖延、感觉被困、害怕尝试新事物或者批评错误想法只是一些负面后果。根据我的经验,如果我们开始责怪自己或他人,我们不会解决任何问题。相反,我们会扼杀创新和生产力,引入焦虑、毒性和压力。这些感受进一步会阻止你在处理报告的效率问题或其他任何问题时做出专业合理的决定。

无过失文化至关重要

在“事后分析”过程中,强调无过失的态度尤为重要,这是可靠性工程师在事故发生后进行的一项工作。例如,有时昂贵的错误是由一个人触发的。虽然我们不希望打击这个人或惩罚他们,但理解事故的原因以防止再次发生至关重要。此外,无过失的方法使我们能够在尊重他人的前提下诚实地讨论事实,从而使每个人都能安全地升级问题而无需担心。

我们不应过于担心,而应以清晰的头脑按照系统化的几乎是机械化的过程(是的,理想情况下,所有这些都会有一天自动化!)。让我们面对现实,实际上,并不是每个性能问题都必须跟随优化。我建议开发人员的潜在流程如图 3-3 所示。请注意,优化步骤尚未列入清单!

efgo 0303

图 3-3. 推荐的效率问题排查流程

在这里,我们概述了报告效率问题时应采取的六个步骤:

第 1 步:我们的错误跟踪器上报告了一个效率问题。

当有人报告我们负责的软件的效率问题时,整个流程就开始了。如果报告了多个问题,请始终为每个问题按图 3-3 所示的过程进行处理(分而治之)。

请注意,进行这一过程并将事物记录在错误跟踪器中应成为你的习惯,即使是对小型个人项目也是如此。否则,你怎么能详细记住所有想要改进的事物呢?

步骤 2:检查重复项。

这可能是微不足道的,但请尽量保持有序。将多个问题合并成一次专注的对话。节省时间。不幸的是,我们还没有到可以可靠地为我们找到重复问题的自动化(例如人工智能)的阶段。

步骤 3:根据功能要求验证情况。

在这一步骤中,我们必须确保效率问题报告者使用了支持的功能。我们为功能要求中定义的特定用例设计软件。由于解决各种独特但有时相似的用例需求的高需求,用户经常尝试“滥用”我们的软件来做它本不应该做的事情。有时候他们会走运,事情能够正常工作。有时候则以崩溃、意外的资源使用或者减速结束。¹²

同样地,如果未达到约定的先决条件,我们也应该采取同样的措施。例如,发送了不支持的、格式错误的请求,或者在没有必需 GPU 资源的机器上部署了软件。

步骤 4:根据 RAERs 验证情况。

某些关于速度和效率的期望可能无法或者不需要满足。这就是“资源感知效率要求”(“Resource-Aware Efficiency Requirements”)中讨论的正式效率要求规范非常宝贵的地方。如果报告的观察结果(例如有效请求的响应延迟)仍在约定的软件性能数值范围内,我们应该传达这一事实并继续进行。¹³

同样地,当问题作者在需要 SSD 的情况下使用了 HDD 磁盘部署我们的软件,或者程序在核心数低于正式协议中规定的机器上运行时,我们应该礼貌地关闭这样的错误报告。

功能或效率要求可能会改变!

也许还有一些情况,功能或效率规范没有预测到某些边缘情况。因此,规范可能需要修订以符合现实。需求和需求在演变,性能规格和期望也应该如此。

步骤 5:确认问题,记录优先级并继续。

是的,你没看错。在检查影响和所有先前步骤之后,通常可以(甚至建议!)当前时刻对报告的问题几乎不采取任何措施。可能有更重要的事情需要我们的关注——也许是一个重要的、过期的特性或者代码不同部分的另一个效率问题。

世界并非完美。我们不能解决一切。行使你的断言权。注意,这与忽视问题并非同一回事。我们仍然必须承认存在问题,并提出后续问题,帮助找到瓶颈并在以后的某个日期优化它。确保询问他们正在运行的确切软件版本。尝试提供解决方法或提示发生了什么,以便用户帮助找到根本原因。讨论可能出错的想法。把它们都写在问题中。这将帮助你或其他开发者以后有一个很好的起点。明确地传达你将在下一个优先级会议上与团队优先处理此问题。

第六步:完成,问题已分级。

恭喜,问题已处理。它可能已关闭或打开。如果在所有这些步骤之后仍然打开,我们现在可以考虑其紧急性,并与团队讨论下一步。一旦计划解决特定问题,“效率感知开发流程”中的效率流程将告诉你如何有效地做到这一点。别害怕。这可能比你想象的要容易!

此流程适用于 SaaS 和外部安装软件

相同的流程适用于用户在他们的笔记本电脑、智能手机或服务器上安装和执行的软件(有时称为“本地”安装),以及当它由我们公司作为服务管理时(软件即服务——SaaS)。我们开发者仍应尝试系统地处理所有问题。

我们将优化分为合理和谨慎的。让我们不要犹豫,进行下一个分割。为了简化和隔离软件效率优化问题,我们可以将其分成几个层次,然后在独立的层次中设计和优化。我们将在下一节讨论这些内容。

优化设计层次

让我们以之前的现实生活例子为例,每天通勤时间很长(在本章中我们将多次使用这个例子!)。如果这样的通勤让你感到不快,因为需要付出相当大的努力并且时间太长,那么优化它可能是有意义的。然而,我们可以从多个层面上进行这样的优化:

  • 我们可以从小处着手,比如为步行距离购买更舒适的鞋子。

  • 如果有帮助,我们可以购买电动滑板车或汽车。

  • 我们可以计划旅程,使其花费更少的时间或距离。

  • 我们可以购买电子书阅读器,并投资于阅读书籍的爱好,以免浪费时间。

  • 最后,我们可以靠近工作场所,甚至换工作。

我们可以在这些单独的“层次”中进行这样的优化,或者全部进行,但每个优化都需要一些投资、权衡(购买汽车需要花钱)和努力。理想情况下,我们希望在最小化努力的同时最大化价值并产生影响。

这些级别的另一个关键方面是:如果我们在更高的级别进行优化,可能会影响或贬值从一个级别到另一个级别的优化。例如,假设我们在一个级别上对通勤进行了多次优化。我们买了一辆更好的车,组织了拼车以节省燃料费,改变了工作时间以避开交通拥堵等等。现在想象一下,我们决定在更高的级别进行优化:搬到工作地点步行可达的公寓。在这种情况下,之前所有优化的努力和投资现在都变得不那么有价值(如果不是完全浪费)。在工程领域也是如此。我们应该意识到我们在哪里投入了优化的努力,以及何时投入。

在学习计算机科学时,学生们对优化的第一次接触之一是学习有关算法和数据结构的理论。他们探索如何使用具有更好时间或空间复杂性的不同算法来优化程序(在“渐近复杂性与大 O 符号”中有解释)。虽然改变我们在代码中使用的算法是一种重要的优化技术,但我们还有许多其他可以优化的领域和变量,以提高软件的效率。要适当地讨论性能,软件依赖于更多级别。

图 3-4 展示了参与软件执行的主要级别。这个级别列表是受 Jon Louis Bentley 在 1982 年制作的列表的启发,¹⁴至今仍然非常准确。

efgo 0304

图 3-4. 参与软件执行的级别。我们可以分别在每个级别提供优化。

本书概述了五个优化设计级别,每个级别都有其优化方法和验证策略。所以让我们从最高级到最低级深入挖掘它们:

系统级别

在大多数情况下,我们的软件是某个更大系统的一部分。也许它是许多分布式进程中的一个,或者是更大单体应用程序中的一个线程。在所有情况下,系统都是围绕多个模块结构化的。模块是一个小的软件组件,通过方法、接口或其他 API(例如网络 API 或文件格式)封装某些功能,以便更轻松地进行交换和修改。

每个 Go 应用程序,即使是最小的,也是一个可执行模块,导入其他模块的代码。因此,您的软件依赖于其他组件。在系统级别进行优化意味着改变使用哪些模块,它们如何链接在一起,谁调用哪个组件以及多频繁。我们可以说,我们正在设计跨模块和 API 的算法,这些算法是我们的数据结构。

这是一项复杂的工作,需要多团队的努力和良好的架构设计。但另一方面,它通常带来了巨大的效率改进。

模块内算法和数据结构级别

面对要解决的问题、其输入数据和期望输出,模块开发人员通常首先设计该过程的两个主要元素。首先是算法,一系列计算机指令,操作数据并能解决我们的问题(例如产生正确的输出)。你可能已经听说过许多流行的算法:二分查找、快速排序、归并排序、映射-减少等等,但是你的程序执行的任何一组自定义步骤都可以称为算法。

第二个要素是数据结构,通常由选择的算法隐含。它们允许我们在计算机上存储数据,例如输入、输出或临时数据。在这里也有无限的选择:数组、哈希映射、链表、栈、队列、其他数据结构、混合结构或自定义结构。在你的模块中选择合适的算法非常重要。它们必须根据你的具体目标(例如请求延迟)和输入特性进行调整。

实现(code)级别

模块中的算法在编写成可编译为机器码的代码之前是不存在的。开发人员在这里有很大的控制权。我们可以高效地实现低效的算法,以满足我们的 RAERs。另一方面,我们也可以糟糕地实现高效的算法,导致意外的系统减速。在代码级别进行优化意味着接受用高级语言(例如 Go 语言)编写的程序,实现特定算法,并生成更高效的程序,在任何我们希望的方面(例如延迟)使用相同的算法并产生相同的正确输出。

通常,我们同时在算法和代码级别进行优化。在其他情况下,只选定一个算法并专注于代码优化更为简单。你将在第十章和第十一章看到这两种方法。

有些先前的材料将编译步骤视为一个独立的层次。我认为代码级优化技术必须包含编译器级的优化技术。你的实现和编译器如何将其转换为机器码之间存在深刻的协同作用。作为开发人员,我们必须理解这种关系。我们将在“理解 Go 编译器”中更深入地探讨 Go 编译器的影响。

操作系统级别

这些天,我们的软件从未直接在机器硬件上执行,也不再单独运行。相反,我们运行操作系统,将每个软件执行分成进程(然后是线程),在 CPU 核心上调度它们,并提供其他必要的服务,如内存和 IO 管理,设备访问等等。此外,我们还有额外的虚拟化层(虚拟机、容器),可以放入操作系统桶中,特别是在云原生环境中。

所有这些层级都带来了一些开销,可以通过控制操作系统开发和配置的人员进行优化。在本书中,我假设 Go 开发人员很少能够影响这个层级。然而,通过理解挑战和使用模式,我们可以在其他更高层级上实现效率提升。我们将在第 Chapter 4 章中详细讨论它们,主要集中在 Unix 操作系统和流行的虚拟化技术上。在本书中,我假设设备驱动程序和固件也属于这个类别。

硬件层级

最后,在某个时刻,从我们的代码翻译出来的一组指令会由计算机 CPU 单元执行,这些单元与主板上的其他重要部件连接,如 RAM、本地磁盘、网络接口、输入输出设备等等。通常情况下,作为开发者或运营商,我们可以从这种复杂性中抽象出来(这种复杂性在硬件产品中也有所不同),这要归功于前面提到的操作系统级别。然而,我们应用程序的性能受到硬件限制的限制。其中一些可能会让人惊讶。例如,您是否知道多核机器的 NUMA 节点的存在及其如何影响我们的性能?您是否知道 CPU 和内存节点之间的内存总线带宽有限?这是一个广泛的主题,可能会影响我们的软件效率优化过程。我们将在第 4 和 5 章中简要探讨这个话题,以及 Go 语言在解决这些问题时采用的机制。

将我们的问题空间分成层级的实际好处是什么?首先,研究¹⁵表明,在应用程序速度方面,通常可以在任何提到的层级上实现 10 到 20 倍的加速,甚至更多。这也与我的经验类似。

这个好消息是,这意味着我们可以将优化的重点集中在一个层级上,以达到所需的系统效率提升。¹⁶ 但是,假设您在一个层级上将您的实现优化了 10 到 20 倍。在没有显著牺牲开发时间、可读性和可维护性的情况下,进一步优化这个层级可能会很困难(参见我们从图 3-2 找到的甜蜜点)。因此,您可能需要查看另一个层级以获得更多的优势。

坏消息是,您可能无法更改某些层级。例如,作为程序员,我们通常无法轻松更改编译器、操作系统或硬件。同样,系统管理员也无法更改软件正在使用的算法。相反,他们可以更换系统并进行配置或调整。

警惕优化偏见!

有时候(令人有趣且可怕!)一个公司内部不同工程组对同一效率问题提出高度不同的解决方案,这有时令人感到有些好笑。

如果团队中有更多的系统管理员或 DevOps 工程师,解决方案通常是切换到另一个系统、软件或操作系统,或者尝试“调整”它们。相反,软件工程组将主要在同一个代码库上进行迭代,优化系统、算法或代码层次。

这种偏见来自于每个层级变更的经验,但可能会产生负面影响。例如,完全切换系统,比如从RabbitMQ切换到Kafka,需要付出相当大的努力。如果你只是因为 RabbitMQ“感觉慢”,而没有试图做出贡献,也许简单的代码级优化可能就过度了。反过来,试图在代码级别优化为不同目的设计的系统的效率可能不够。

我们讨论了优化的概念,提到了如何设定性能目标,处理效率问题以及我们操作的设计级别。现在是将所有知识结合到完整开发周期中的时候了。

高效开发流程

程序员在程序生命周期的早期阶段的主要关注点应该是编程项目的整体组织和产生正确且可维护的代码。此外,在许多情况下,清晰设计的程序通常对于手头的应用来说已经足够高效了。

乔恩·路易斯·本特利,《编写高效程序》

希望到目前为止,你已经意识到我们必须从早期开发阶段就考虑性能问题。但是也存在风险——我们开发代码不仅仅是为了高效。我们编写程序是为了满足我们设定或从利益相关者那里得到的功能需求。我们的工作是有效地完成这项工作,因此需要一种务实的方法。从高层次来看,开发一个工作但高效的代码会是什么样子呢?

我们可以将开发过程简化为九个步骤,如图 3-5 所示。暂且称之为TFBO流程——测试、修复、基准测试和优化。

efgo 0305

图 3-5. 高效开发流程

这个过程是系统化且高度迭代的。需求、依赖关系和环境都在变化,因此我们也必须分块进行工作。TFBO 过程可能有点严格,但相信我,有意识和有效的软件开发需要一些纪律。它适用于从头开始创建新软件、添加功能或更改代码的情况。TFBO 应该适用于任何语言编写的软件,不仅仅是 Go。它也适用于“优化设计级别”中提到的所有级别。让我们一起来看看九个 TFBO 步骤。

功能性阶段

制作正确的程序比制作快速的程序正确要容易得多。

H. Sutter 和 A. Alexandrescu,《C++编码标准:101 条规则、指南和最佳实践》(Addison-Wesley,2004)

总是先从功能开始。 无论我们是要开始一个新的程序,添加新功能,还是只是优化现有程序,我们应该始终从设计或功能的实现开始。 使其工作,使其简单,易读,易维护,安全等等,根据我们设定的目标,最好是以书面形式。 尤其是当您作为软件工程师开始您的旅程时,请专注于一件事。 通过实践,我们可以在早期添加更合理的优化。

1. 首先测试功能

对于一些人来说,这可能感觉反直觉,但几乎总是应该从期望功能的验证框架开始。 自动化程度越高越好。 当您有一个空白页并开始开发新程序时,这也适用。 这种开发范式称为测试驱动开发(TDD)。 它主要集中在代码可靠性和功能交付速度效率上。 以严格的形式,在代码水平上,它强制执行特定流程:

  1. 编写一个测试(或扩展现有测试),期望实施该功能。

  2. 确保运行所有测试并查看新测试因预期原因而失败。 如果您没有看到失败或其他失败,请先修复这些测试。

  3. 迭代,直到所有测试通过并且代码干净。

TDD 消除了许多未知数。 想象一下,如果我们不遵循 TDD。 例如,我们添加了一个功能,并编写了一个测试。 即使没有我们的功能,也很容易犯一个错误。 同样,让我们说我们在实施后添加了测试,这是通过的,但是其他先前添加的测试失败了。 很可能我们在实施之前没有运行测试,因此我们不知道之前是否一切正常。 TDD 确保您不会在工作结束时遇到这些问题,极大地提高了可靠性。 它还减少了实施时间,允许安全的代码修改并及早给出反馈。

此外,如果我们想要实施的功能已经完成而我们没有注意到怎么办? 首先编写测试会快速揭示这一点,为我们节省时间。 剧透警告:稍后在步骤 4 中我们将使用相同的原则进行基准驱动优化!

TDD 可以很容易地理解为代码级实践,但是如果您设计或优化算法和系统怎么办? 答案是流程保持不变,但我们的测试策略必须在不同的水平上应用,例如验证系统设计。

假设我们实施了一个测试或对当前设计或实施进行了评估。 接下来呢?

2. 我们是否通过了功能测试?

有了步骤 1 的结果,我们的工作变得更容易——我们可以基于数据进行决策,确定接下来该做什么!首先,我们应该将测试或评估结果与我们约定的功能要求进行比较。当前的实现或设计是否满足规范?很好,我们可以跳到步骤 4. 然而,如果测试失败或功能评估显示出某些功能差距,那么现在是时候回到步骤 3 并解决这种情况了。

当你没有任何地方说明这些功能要求时,问题就来了。正如在“效率需求应当被规范化”中讨论的那样,这就是为什么要求功能需求或自行定义它们如此重要。即使是在项目 README 中写下的最简单的目标项目清单,也比什么都不写要好。

现在,让我们探讨一下,如果我们的软件当前状态不能通过功能验证会怎么办。

3. 如果测试失败,我们必须修复、实现或设计缺失的部分。

根据我们所处的设计水平,在这一步骤中,我们应该设计、实现或修复功能部分,以弥合当前状态与功能期望之间的差距。正如我们在“合理优化”中讨论的那样,在这里除了明显的合理优化外,不允许其他优化。专注于模块的可读性、设计和简洁性。例如,不要费心考虑通过指针还是值传递参数更优,或者在这里解析整数会不会太慢,除非这是显而易见的。从功能和可读性的角度来看,做任何有意义的事情即可。我们暂时不验证效率,所以现在先不要考虑刻意的优化。

正如你在图 3-5 中可能已经注意到的,步骤 1、2 和 3 构成了一个小循环。每当我们在代码或设计中进行更改时,这为我们提供了一个早期的反馈循环。步骤 3 就像我们驾驶名为“软件”的船航行在大海上时的指引方向。我们知道我们想要去哪里,并且知道如何正确地看向太阳或星星的方向。然而,如果没有像 GPS 这样精确的反馈工具,我们可能会结束在错误的地方航行,直到几周后才意识到。这就是为什么在短间隔内验证我们的航行位置是有益的早期反馈!

对于我们的代码也是如此。我们不想工作几个月后才发现我们没有接近我们从软件中期望的东西。通过对代码或设计的小迭代进行功能阶段循环,进行步骤 1(运行测试)、步骤 2,然后回到步骤 3 进行另一个小修正[¹⁷]。这是多年来工程师们找到的最有效的开发周期。所有现代方法论,如极限编程,Scrum,看板以及其他敏捷技术,都建立在小迭代的前提上。

在可能的数百次迭代之后,我们可能会拥有在第 2 步中为本次开发会话设定的功能要求。最后,现在是确保我们的软件足够快速和高效的时候了!让我们在下一节来看看这个问题。

效率阶段

一旦我们满意软件的功能方面,现在是确保其匹配预期资源消耗和速度的时候了。

将各个阶段拆分并将其彼此隔离起来,乍一看似乎是一种负担,但它会更好地组织您的开发工作流程。它让我们能够深度聚焦,统治我们早期的未知和错误,并帮助我们避免昂贵的聚焦上下文切换。

让我们通过在第 4 步进行初始(基线)效率验证来开始我们的效率阶段。然后,也许我们的软件在没有任何更改的情况下已经足够高效了!

4. 效率评估

在这里,我们采用与功能阶段第 1 步类似的策略,但是朝着效率空间。我们可以定义一个等效于第 1 步中介绍的 TDD 方法的方法。让我们称之为基准驱动优化(BDO)。在实践中,第 4 步看起来像是代码层面的这个过程:

  1. 为我们希望与之比较的效率要求编写基准测试(或扩展现有的基准测试)。即使您知道当前实现尚不高效,也要执行此操作。我们以后会需要这项工作。这并不是一件微不足道的事情,我们将在第第八章中详细讨论这一方面。

  2. 理想情况下,运行所有基准测试以确保您的更改没有影响到无关的操作。实际上,这需要太多时间,因此只专注于您想要检查并仅对其运行基准测试的程序部分(例如,一个操作)。保存结果以备后用。这将成为我们的基线。

与第 1 步类似,更高级别的评估可能需要不同的工具。凭借基准测试或评估结果,让我们进入第 5 步。

5. 我们在 RAERs 范围内吗?

在此步骤中,我们必须将第 4 步的结果与我们收集到的 RAERs 进行比较。例如,我们的延迟是否在当前实现的可接受标准内?我们的操作消耗的资源量是否符合我们所约定的?如果是,则不需要优化!

与第 2 步类似,我们必须为效率建立要求或大致目标。否则,我们对看到的数字是否可接受一无所知。再次参考“获取和评估效率目标”来定义 RAERs。

通过这种比较,我们应该有一个明确的答案。我们是否在可接受的阈值内?如果是,我们可以直接跳转到第 9 步的发布过程。如果不是,接下来的第 6、7 和 8 步将有令人兴奋的优化逻辑等待我们。让我们现在走过这些步骤。

6. 找出主要瓶颈

在这里,我们必须解决在“Optimization Challenges”中提到的第一个挑战。通常情况下,我们很难猜测操作的哪个部分引起了最大的瓶颈;不幸的是,这正是我们应该首先关注优化的地方。

瓶颈 这个词描述了特定资源或软件消耗最多的地方。可能是大量的磁盘读取、死锁、内存泄漏,或者在单个操作期间执行数百万次的函数。一个程序通常只有少数几个这样的瓶颈。要进行有效的优化,我们必须首先理解瓶颈的后果。

作为这个过程的一部分,我们首先需要了解我们在第 5 步中找到的问题的根本原因。我们将在第九章中讨论最适合这项工作的工具。

假设我们找到了执行次数最多的一组函数或程序中消耗最多资源的另一部分。接下来怎么办?

7. 层次选择

在第 7 步,我们必须选择如何解决优化问题。我们应该让代码更高效吗?也许我们可以改进算法?或者在系统层面进行优化?在极端情况下,我们甚至可能希望优化操作系统或硬件!

选择取决于当前的实用性和我们在效率范围内的位置,以及在图 3-1 中的单级优化。重要的是在一个优化迭代中坚持单级优化。类似于功能阶段,进行短迭代和小修正。

一旦我们知道要使哪个层次更高效或更快,我们就准备好进行优化了!

8. 优化!

这就是每个人都在等待的时刻。经过那么多努力之后,我们终于知道:

  • 优化代码或设计的最重要影响点在哪里。

  • 要优化的是什么——哪些资源消耗太大了。

  • 我们可以在其他资源上做出多大的牺牲,因为我们有 RAER。这将涉及到权衡。

  • 我们正在优化的层次。

这些元素使优化过程变得更加容易,通常甚至使其成为可能。现在我们专注于在“Beyond Waste, Optimization Is a Zero-Sum Game”中引入的心智模型。我们正在寻找浪费。我们在寻找可以少做工作的地方。总会有一些事情可以消除,无论是免费的还是通过使用其他资源做其他工作。我将在第十一章中介绍一些模式,并在第十章中展示示例。

假设我们找到了一些改进的想法。这时候你应该去实施或设计它(取决于层次)。但接下来呢?我们不能简单地发布我们的优化,因为:

  • 我们不知道是否引入了功能问题(错误)。

  • 我们不知道我们是否改进了性能。

这就是为什么我们现在必须执行完整的循环(没有例外!)。关键是转向步骤 1 并测试优化的代码或设计。如果出现问题,我们必须修复它们或恢复优化(步骤 2 和 3)。

在进行优化迭代时,很容易忽略功能测试阶段。例如,如果只通过重复使用一些内存来减少一个分配,会出现什么问题呢?

我经常发现自己这样做,这是一个痛苦的错误。不幸的是,当你发现你的代码在几次优化迭代后无法通过测试时,很难找出原因。通常情况下,你必须全部恢复并重新开始。因此,我建议每次优化尝试后运行一个范围单元测试。

一旦我们确信优化没有破坏任何基本功能,就关键检查我们的优化是否改进了我们想要改进的情况。重要的是运行相同的基准测试,确保除了你做的优化(步骤 4)外,没有任何改变。这样可以减少未知因素,并分步迭代我们的优化。

利用最近步骤 4 的结果,将其与初始访问步骤 4 时制作的基准进行比较。这一关键步骤将告诉我们是否优化了任何内容或引入了性能退化。再次强调,不要假设任何事情。让数据来说话!Go 语言在这方面有很棒的工具,我们将在第八章中讨论。

如果新的优化没有更好的效率结果,我们简单地再试几个不同的想法,直到成功为止。如果优化效果更好,我们保存工作并转向步骤 5 以检查是否足够。如果不够,我们必须进行另一次迭代。通常情况下,在我们已经做过的基础上再构建另一个优化是很有用的。也许还有更多可以改进的地方!

我们重复这个周期,在几次(或数百次)之后,希望在步骤 5 得到可接受的结果。在这种情况下,我们可以转向步骤 9 并享受我们的工作!

9. 发布并享受!

做得好!你已经完整地经历了注重效率的开发流程的迭代。现在你的软件基本上可以放心发布和部署了。这个过程可能感觉有些官僚,但习惯后就能自然而然地遵循了。当然,你可能已经在不知不觉中使用了这个流程!

总结

正如我们在本章中学到的,征服效率并不是件容易的事。然而,存在某些模式可以帮助我们系统和有效地导航这个过程。例如,TFBO 流程对我来说在保持效率感知开发方面非常有帮助。

TFBO 中包含的一些框架,如测试驱动开发和基准驱动优化,初始时可能显得繁琐。然而,类似于俗语所说的,“给我六个小时砍树,我会花四个小时磨斧头”(oreil.ly/qNPId),你会发现在适当的测试和基准上花费时间将在长期节省大量精力!

主要的要点是我们可以将优化分为合理和有意识的两种类型。接着,为了关注权衡和我们的努力,我们讨论了定义 RAER,这样我们就能评估我们的软件是否符合大家都能理解的正式目标。然后,我们提到了在效率问题发生时该做什么以及有哪些优化级别。最后,我们讨论了 TFBO 流程,它指导我们通过实际开发过程。

总之,寻找优化可以被视为解决问题的技能。发现浪费并不容易,需要大量实践。这在某种程度上类似于擅长编程面试。最终,有帮助的是看到过去效率不足的模式及其如何改进的经验。通过本书,我们将锻炼这些技能,并揭示许多可以在这个旅程中帮助我们的工具。

然而,在此之前,有关现代计算机架构的重要知识需要学习。我们可以通过例子学习典型的优化模式,但是优化并不通用。如果不理解使得这些优化有效的机制,我们将无法有效地在独特的情境中找到它们并应用它们。在下一章中,我们将讨论 Go 语言如何与典型计算机架构中的关键资源交互。

¹ 可能会有例外情况。也许有些领域可以接受近似结果。有时,如果某些特性阻碍了我们想要的关键效率特征,我们也可以(而且应该)舍弃一些好看的功能。

² 由于剩余并发例程导致周期功能后资源未清理,这些情况通常被称为内存泄漏。

³ 零和游戏来自游戏和经济理论。它描述了一种情况,其中一个玩家只有在其他玩家总共失去了 X 的情况下才能赢得 X。

⁴ 我从由 Damian Gryski 领导的社区驱动的go-perfbook书籍中得到了将优化分为合理和有意识两类的灵感。在他的书中,他还提到了“危险”的优化类别。我觉得进一步细分类别没有意义,因为有意识和危险之间存在模糊的边界,这取决于具体情况和个人喜好。

⁵ 在某些情况下,挑战自己并不是坏事。如果你有时间,参与像 Advent of Code 这样的活动是学习甚至竞争的好方法!然而,这与我们被要求有效地开发功能软件的情况不同。

⁶ 在维护 Prometheus 项目 时,我经常遇到这种情况,我们不断面临用户试图将唯一事件注入 Prometheus 的情况。问题在于,我们设计 Prometheus 是作为一个高效的度量监控解决方案,具有一种专门的时间序列数据库,假设随着时间存储聚合样本。如果输入的系列带有唯一值标签,Prometheus 会慢慢但肯定开始使用许多资源(我们称之为高基数情况)。

⁷ 想象一下,如果拥有世界上所有的资源,我们可以尝试将软件执行优化到物理极限。一旦达到那里,我们可以花几十年研究超越我们所知的当前物理学的事物,推动边界。但实际上,我们可能永远无法在我们的一生中找到“真正”的极限。

⁸ 我从未明确要求创建非功能性规范,周围的 人们 也是如此。

⁹ 足够多的程序用户,即使有正式的性能和可靠性合同,你系统的所有可观察行为也会依赖于某人。这就是所谓的 海伦姆定律

¹⁰ 在本书和优化过程中,我们更经常使用餐巾纸数学,因此我为 附录 A 中的延迟假设准备了一张小抄。

¹¹ 我们将在 第七章 中详细讨论基准测试。

¹² 例如,参见在 “理解你的目标” 中提到的 XY 问题实例。

¹³ 如果问题报告人认为重要或愿意额外支付等等,他们显然可以与产品负责人协商更改规范。

¹⁴ Jon Louis Bentley,《编写高效程序》(Prentice Hall,1982)。

¹⁵ 拉吉·雷迪(Raj Reddy)和艾伦·纽厄尔(Allen Newell)在《计算机科学视角》(Perspectives on Computer Science,A.K. Jones 编,学术出版社)中详细阐述了系统的乘法加速潜力,每个软件设计层面大约可以实现 10 倍的加速。更令人兴奋的是,对于分层系统来说,来自不同层次的加速效应相乘,这为优化时的性能提升提供了巨大的潜力。

¹⁶ 这是一个非常强大的想法。例如,想象一下,您的应用程序在 10 分钟内返回结果。通过在某一级别(例如算法)进行优化,将其减少到 1 分钟,这将是一个游戏改变者。

¹⁷ 理想情况下,我们将对保存的代码文件的每个代码击键或事件进行功能检查。反馈循环越早越好。这方面的主要阻碍是执行所有测试及其可靠性所需的时间。

第四章:如何使用 CPU 资源(或两个)

我们可以做出的最有用的抽象之一是将硬件和基础设施系统的属性视为资源。CPU、内存、数据存储和网络类似于自然界中的资源:它们是有限的,是现实世界中的物理对象,并且必须在生态系统的各个关键参与者之间分配和共享。

Susan J. Fowler,《可生产的微服务》(O’Reilly, 2016

正如您在 “性能背后” 中所学到的,软件效率取决于我们的程序如何使用硬件资源。如果相同的功能使用更少的资源,我们的效率就会提高,并且运行这样的程序的需求和净成本会降低。例如,如果我们使用更少的 CPU 时间(CPU “资源”)或具有较慢访问时间的更少资源(例如磁盘),通常可以减少软件的延迟。

这听起来可能很简单,但在现代计算机中,这些资源以复杂且非平凡的方式相互交互。此外,多个进程使用这些资源,因此我们的程序并不直接使用它们。相反,操作系统为我们管理这些资源。如果这还不够复杂,特别是在云环境中,我们经常进一步“虚拟化”硬件,以便可以以隔离的方式跨许多个体系统共享它们。这意味着“主机”有方法将部分单个 CPU 或磁盘访问授予“客户”操作系统,后者认为这是所有存在的硬件。最终,操作系统和虚拟化机制在我们的程序与实际存储或计算我们数据的物理设备之间创建了层次。

要理解如何编写高效的代码或有效提升程序的效率,我们必须深入了解典型计算机资源如 CPU、不同类型存储和网络的特性、目的和限制。这里没有捷径。此外,我们不能忽视操作系统和典型虚拟化层如何管理这些物理组件。

在本章中,我们将从 CPU 的角度来审视我们的程序执行。我们将讨论 Go 如何在单个和多核任务中使用 CPU。

我们不会讨论所有类型的计算机架构以及所有现有操作系统的所有机制,因为这在一本书中是不可能完成的,更不用说一章了。因此,本章将专注于典型的 x86-64 CPU 架构,包括 Intel 或 AMD、ARM CPU 和现代 Linux 操作系统。这应该让您开始,并为您提供一个跳板,如果您曾经在其他独特类型的硬件或操作系统上运行您的程序。

我们将从探索现代计算机架构中的 CPU 开始,以理解现代计算机是如何设计的,主要关注 CPU 或处理器。然后我将介绍汇编语言,这将帮助我们理解 CPU 核心执行指令的方式。之后,我们将深入了解 Go 编译器,以增进我们对进行go build时发生的事情的认识。此外,我们将深入讨论 CPU 和内存墙问题,展示现代 CPU 硬件为何如此复杂。这个问题直接影响在这些超关键路径上编写高效代码。最后,我们将进入多任务处理的领域,解释操作系统调度程序如何尝试在数量不足的 CPU 核心上分发数千个执行程序,以及 Go 运行时调度程序如何利用这一点为我们实现高效的并发框架。我们将以何时使用并发的总结结束。

机械同情心

最初,这一章节可能会让人感到不知所措,特别是对低级编程新手来说。然而,了解正在发生的事情将有助于我们理解优化,因此要专注于理解每个资源的高级模式和特性(例如 Go 调度器的工作原理)。我们不需要知道如何手动编写机器码,或者如何盲目地制造计算机。

相反,让我们对计算机箱底下的事情如何运作充满好奇。换句话说,我们需要对机械同情心抱有好奇心。

要理解 CPU 架构的工作原理,我们需要解释现代计算机的运行方式。因此,让我们在下一节深入探讨这个问题。

现代计算机架构中的 CPU

在 Go 编程中,我们所做的一切就是构建一组语句,告诉计算机逐步执行什么操作。借助预定义的语言结构,如变量、循环、控制机制、算术和 I/O 操作,我们可以实现与存储在不同介质中的数据交互的任何算法。这也是为什么像 Go 这样的流行编程语言被称为命令式语言——作为开发人员,我们必须描述程序的操作方式。现代硬件的设计也是如此——这也是命令式的。它等待程序指令、可选的输入数据以及所需的输出位置。

编程并不总是如此简单。在通用目的机器出现之前,工程师们必须设计固定程序硬件以实现请求的功能,例如台式计算器。添加功能、修复错误或优化都需要改变电路并制造新设备。可能不是成为“程序员”的最轻松时期!

幸运的是,大约在 1950 年代,世界各地的一些发明家发现了一种可以使用存储在内存中的一组预定义指令来编程的通用机器的机会。最早记录这一想法的之一是伟大的数学家约翰·冯·诺伊曼及其团队。

显然,设备必须能够以某种方式存储不仅计算中所需的数字信息,... 还有计算的中间结果(可能需要存储不同长度的时间),以及控制实际计算例程的指令。... 对于通用机器,必须能够指示设备执行以数字形式表达的任何计算。

Arthur W. Burks、Herman H. Goldstine 和 John von Neumann,《电子计算仪器逻辑设计初步讨论》(高级研究院,1946 年)

值得注意的是,大多数现代通用计算机(如 PC、笔记本电脑和服务器)基于 John von Neumann 的设计。这假设程序指令可以像存储和读取程序数据(指令输入和输出)一样被存储和提取。我们通过从主存储器(或高速缓存)中的特定内存地址读取字节来获取要执行的指令(例如add)和数据(例如加法操作数)。虽然现在听起来并不像一个新颖的想法,但它确立了通用机器的工作方式。我们称之为冯·诺依曼计算机体系结构,你可以在图 4-1 中看到其现代演变的变体。¹

efgo 0401

图 4-1. 带有单个多核 CPU 和统一内存访问(UMA)的高级计算机架构

在现代架构的核心,我们看到一个 CPU 由多个核心组成(2020 年代 PC 中四到六个物理核心是常见的)。每个核心可以执行带有存储在随机访问内存(RAM)或任何其他存储器层中的特定数据的所需指令。

在第五章中解释的 RAM 承担了主要、快速、易失性内存的职责,它可以在计算机通电的同时存储我们的数据和程序代码。此外,内存控制器确保 RAM 得到持续的电源供应,以保持 RAM 芯片上的信息。最后,CPU 可以与各种外部或内部输入/输出(I/O)设备进行交互。从高层次来看,I/O 设备指的是接受发送或接收字节流的任何内容,例如鼠标、键盘、扬声器、显示器、HDD 或 SSD 磁盘、网络接口、GPU 等等,数量众多。

大致来说,CPU、RAM 和流行的 I/O 设备(如磁盘和网络接口)是计算机架构的基本组成部分。这是我们在《“效率要求应该被形式化”》中提到的 RAERs 中使用的“资源”,也是我们在软件开发中通常进行优化的对象。

在本章中,我们将关注我们通用计算机的大脑——CPU。我们何时应该关注 CPU 资源?从效率的角度来看,当以下情况之一发生时,我们应该开始关注我们 Go 进程的 CPU 资源使用情况:

  • 我们的机器无法执行其他任务,因为我们的进程使用了所有可用的 CPU 资源计算能力。

  • 我们的进程运行得出乎意料地慢,而我们却看到更高的 CPU 消耗。

有许多技术可以排除这些症状,但我们必须首先了解 CPU 的内部工作原理和程序执行基础。这是进行高效 Go 编程的关键。此外,它解释了最初可能让我们惊讶的许多优化技术。例如,你知道为什么在 Go(和其他语言中),如果我们计划经常迭代它们,我们应该避免使用类似链表的结构,尽管它们在理论上有快速插入和删除的优势吗?

在我们了解为什么之前,我们必须理解 CPU 核心如何执行我们的程序。令人惊讶的是,我发现通过学习汇编语言工作的方式来解释这一点是最好的。相信我,这可能比你想象的要容易!

汇编语言

CPU 核心间接地可以执行我们编写的程序。例如,考虑在 Example 4-1 中的简单 Go 代码。

示例 4-1. 从文件中读取数字并返回总和的简单函数
func Sum(fileName string) (ret int64, _ error) {
   b, err := os.ReadFile(fileName)
   if err != nil {
      return 0, err
   }

   for _, line := range bytes.Split(b, []byte("\n")) {
      num, err := strconv.ParseInt(string(line), 10, 64)
      if err != nil {
         return 0, err
      }

      ret += num ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   }

   return ret, nil
}

1

这个函数中的主要算术操作将从文件中解析的数字添加到表示总和的整数变量 ret 中。

虽然这种语言远非口语英语,不幸的是,对于 CPU 来说,它仍然太复杂和难以理解。这不是“机器可读”的代码。幸运的是,每种编程语言都有一个专门的工具称为编译器²,它(除了其他讨论在“理解 Go 编译器”中的内容)将我们的高级代码转换为机器代码。你可能熟悉go build命令,它调用默认的 Go 编译器。

机器码是用二进制格式编写的指令序列(著名的零和一)。原则上,每条指令由一个数字(opcode)表示,后面是形式为常量值或主存中地址的可选操作数。我们还可以引用几个 CPU 核心寄存器,这些寄存器是直接安装在 CPU 芯片上的小“槽”,用于存储中间结果。例如,在 AMD64 CPU 上,我们有十六个 64 位通用寄存器,分别称为 RAX、RBX、RDX、RBP、RSI、RDI、RSP,以及 R8-R15。

在转换为机器代码时,编译器通常会添加额外的代码,例如额外的内存安全边界检查。它会根据已知的效率模式自动更改我们的代码以适应特定的体系结构。有时这可能并不是我们期望的。这就是为什么在解决某些效率问题时检查结果的机器代码有时很有用。人们需要阅读机器代码的另一个高级示例是在没有源代码的情况下对程序进行逆向工程。

不幸的是,机器代码对人类来说是不可能阅读的,除非你是天才。然而,在这种情况下,我们可以使用一个很棒的工具。我们可以将示例 4-1 的代码编译成汇编语言 而不是机器代码。我们也可以将编译后的机器代码反汇编为汇编语言。汇编语言代表可以由人类开发者实际阅读(在理论上可以编写)的最低代码级别。它也很好地代表了当转换为机器代码时 CPU 将执行的内容。

值得一提的是,我们可以将编译后的代码反汇编成各种汇编方言。例如:

所有这三种方言都在各种工具中使用,并且它们的语法各不相同。为了更容易理解,请始终确认您的反汇编工具使用的语法。Go 汇编语言是一种尝试尽可能通用的方言,因此可能并不完全代表机器代码。然而,它通常是一致的并且足够接近我们的目的。它可以显示《理解 Go 编译器》中讨论的所有编译优化。这就是为什么本书将始终使用 Go 汇编语言的原因。

我需要理解汇编语言吗?

您不需要知道如何在汇编语言中编程来编写高效的 Go 代码。然而,对汇编语言和反汇编过程的粗略理解是揭示隐藏的低级计算浪费的重要工具。实际上,当我们已经应用了所有更简单的优化时,它通常对于高级优化是有用的。汇编语言还有助于理解编译器在将我们的代码转换为机器代码时应用的变化。有时这些变化可能会让我们感到意外!最后,它还告诉我们 CPU 是如何工作的。

在示例 4-2 中,我们可以看到编译后的示例 4-1 的一个小片段(使用go tool objsdump -s)表示ret += num语句。³

示例 4-2. 从编译后的示例 4-1 中反编译出来的 Go 汇编语言中的代码部分
// go tool objdump -s sum.test
ret += num
0x4f9b6d      488b742450    MOVQ 0x50(SP), SI  ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
0x4f9b72      4801c6       ADDQ AX, SI  ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

1

第一行代表一个四字节(64 位)MOV 指令,告诉 CPU 从存储在寄存器SP地址加上 80 字节的内存中复制 64 位值,并将其放入SI寄存器中。⁴ 编译器决定SI将存储返回参数在我们函数中的初始值,所以ret整数变量用于ret+=num操作。

2

作为第二条指令,我们告诉 CPU 将来自AX寄存器的四字节值添加到SI寄存器中。编译器使用AX寄存器来存储num整数变量,该变量是我们从之前指令(不在本段内)解析的字符串。

前面的示例展示了MOVQADDQ指令。为了使事情更加复杂,每个不同的 CPU 实现允许不同的指令集,具有不同的内存寻址等。行业创建了指令集架构(ISA)来指定软件和硬件之间严格的、可移植的接口。由于 ISA,我们可以将我们的程序编译为与 x86 架构的 ISA 兼容的机器码,并在任何 x86 CPU 上运行。⁵ ISA 定义了数据类型、寄存器、主存储器管理、固定指令集、唯一标识、输入/输出模型等。不同类型的 CPU 有不同的ISA。例如,32 位和 64 位的 Intel 和 AMD 处理器都使用 x86 ISA,而 ARM 则使用其 ARM ISA(例如,新的Apple M 芯片使用 ARMv8.6-A)。

就 Go 开发者而言,ISA 定义了一组指令和寄存器,我们编译的机器码可以使用。为了生成可移植的程序,编译器可以将我们的 Go 代码转换为与特定 ISA(架构)和所需操作系统类型兼容的机器码。在下一节中,让我们看看默认的 Go 编译器是如何工作的。在此过程中,我们将揭示帮助 Go 编译器生成高效快速机器码的机制。

理解 Go 编译器

关于构建有效编译器的话题可以填写几本书。然而,在本书中,我们将试图理解作为对高效代码感兴趣的 Go 开发人员必须了解的 Go 编译器基础知识。通常,我们在典型操作系统上执行的 Go 代码涉及许多内容,不仅仅是编译。首先,我们需要使用编译器编译它,然后我们必须使用链接器将不同的目标文件链接在一起,包括可能的共享库。这些编译和链接过程通常称为构建,它们生成操作系统可以执行的可执行文件(“二进制文件”)。在初始启动时,称为加载,还可以动态加载其他共享库(例如 Go 插件)。

有许多针对不同目标环境设计的 Go 代码构建方法。例如,Tiny Go 优化生成微控制器的二进制文件,gopherjs 生成用于浏览器执行的 JavaScript,而 android 则生成可在 Android 操作系统上执行的程序。但是,本书将重点放在 go build 命令中默认和最流行的 Go 编译器和链接机制上。编译器本身是用 Go 编写的(最初是用 C 编写的)。可以在这里找到粗略的文档和源代码。

go build 可以将我们的代码构建成许多不同的输出。我们可以构建需要在启动时动态链接系统库的可执行文件。我们可以构建共享库,甚至是兼容 C 的共享库。然而,使用 Go 的最常见和推荐的方式是构建将所有依赖项静态链接的可执行文件。它提供了更好的体验,其中我们的二进制文件的调用不需要特定目录中特定版本的系统依赖项。对于具有起始 main 函数的代码,默认构建模式也可以通过 go build -buildmode=exe 明确调用。

go build 命令既调用编译又调用链接。虽然链接阶段也执行某些优化和检查,但编译器可能执行最复杂的任务。Go 编译器一次只专注于一个包。它将包的源代码编译为目标架构和操作系统支持的本机代码。此外,它还验证、优化该代码,并为调试目的准备重要的元数据。我们需要与编译器(以及操作系统和硬件)“合作”,以编写高效的 Go 代码,而不是反其道而行之。

我告诉每个人,如果不确定如何做某事,请问问在 Go 中最惯用的方式是什么。因为许多答案已经调整为与硬件的操作系统相容。

Bill Kennedy,《机械同情心上的比尔·肯尼迪》。

为了使事情更有趣,go build 还提供了一个特殊的交叉编译模式,如果您想要编译使用 C、C++ 或甚至 Fortran 实现的函数混合的 Go 代码!如果您启用了一个称为 cgo 的模式,这是可能的。不幸的是,cgo 不建议使用,应尽量避免使用它。它会使构建过程变慢,C 和 Go 之间传递数据的性能值得怀疑,并且非 cgo 编译已经足够强大,可以为不同架构和操作系统交叉编译二进制文件。幸运的是,大多数库要么是纯 Go 的,要么是使用可以包含在 Go 二进制文件中的汇编代码片段,而无需 cgo

要了解编译器对我们的代码的影响,可以看看 Go 编译器在 Figure 4-2 中执行的阶段。虽然 go build 包括这样的编译,但我们可以仅使用 go tool compile 触发单独的编译(不链接)。

efgo 0402

图 4-2. Go 编译器对每个 Go 包执行的阶段

如前所述,整个过程围绕您在 Go 程序中使用的包展开。每个包都在单独编译,允许并行编译和关注点分离。图 4-2 中展示的编译流程如下:

  1. Go 源代码首先被标记化和解析。语法被检查。语法树引用文件和文件位置,以产生有意义的错误和调试信息。

  2. 构建抽象语法树(AST)。这样的树是一种常见的抽象,允许开发人员创建能够轻松转换或检查解析语句的算法。在 AST 形式中,代码首先进行类型检查。检测出声明但未使用的项。

  3. 首先执行优化的第一遍。例如,初始的死代码被消除,因此二进制大小可以更小,编译的代码量也更少。接着进行逃逸分析(见 “Go 内存管理”),以决定哪些变量可以放在堆栈上,哪些必须分配到堆上。此外,在这个阶段,对于简单和小型函数,还会进行函数内联。

    函数内联

    编程语言中的函数⁶ 允许我们创建抽象,隐藏复杂性,并减少重复代码。然而,调用执行的成本不为零。例如,具有单个参数调用的函数需要额外的约 10 条 CPU 指令⁷。因此,虽然成本固定且通常在纳秒级别,但如果我们在热路径中有数千个这样的调用,并且函数体足够小,这个执行调用可能会有影响。

    内联还有其他好处。例如,编译器可以更有效地在代码中应用其他优化,尤其是在函数更少的情况下,并且不需要在函数作用域之间传递参数时使用堆或大型栈内存(通过复制)。堆和栈的解释请参见“Go 内存管理”。

    编译器会自动用其正文的精确副本替换某些函数调用。这称为内联内联扩展。其逻辑非常智能。例如,从 Go 1.9 开始,编译器可以内联叶和中栈函数

    很少需要手动内联

    对于初学者工程师来说,通过手动内联一些函数进行微优化是很诱人的。然而,尽管在编程的早期阶段开发人员必须这样做,但这种功能通常是编译器的基本职责,它通常更了解何时以及如何内联函数。利用这一事实,首先关注代码的可读性和可维护性,只在最后的情况下手动内联,并始终进行测量。

  4. 在对 AST 进行了早期优化之后,树被转换为静态单赋值(SSA)形式。这种底层更明确的表示形式使得使用一组规则进行进一步优化更加容易。例如,借助 SSA 的帮助,编译器可以轻松地找到不必要的变量赋值位置。⁸

  5. 编译器应用进一步的机器无关优化规则。例如,语句如y := 0*x将简化为y := 0。完整的规则列表是巨大的,并且只能确认这个领域有多复杂。此外,一些代码片段可以由内置函数替换——这是经过高度优化的等效代码(例如原始汇编)。

  6. 根据GOARCHGOOS环境变量,编译器调用genssa函数将 SSA 转换为所需架构(ISA)和操作系统的机器码。

  7. 进一步的 ISA 和操作系统特定优化被应用。

  8. 未死的包机器码被构建为单个对象文件(带有.o后缀)和调试信息。

最终的“目标文件”被压缩为一个名为 Go archivetar文件,通常带有.a文件后缀。⁹ 每个包的这种存档文件可以被 Go 链接器(或其他链接器)使用,以组合成一个单一的可执行文件,通常称为二进制文件。根据操作系统的不同,这样的文件遵循特定的格式,告诉系统如何执行和使用它。对于 Linux 来说,通常是可执行和可链接格式(ELF)。在 Windows 上,可能是便携式可执行格式(PE)。

二进制文件中的机器代码并非唯一的部分。它还包含程序的静态数据,如全局变量和常量。可执行文件还包含大量调试信息,这些信息会占用相当大的二进制文件大小,例如简单的符号表、基本类型信息(用于反射)和 PC-to-line 映射(指令地址对应源代码中的行)。这些额外信息能够帮助宝贵的调试工具将机器代码与源代码链接起来。例如,许多调试工具使用它,如 “Go 中的性能分析” 和前述的 objdump 工具。为了与 Delve 或 GDB 等调试软件兼容,二进制文件还附加了 DWARF 表。¹⁰

除了已有的责任清单外,Go 编译器必须执行额外的步骤,以确保 Go 内存安全性。例如,编译器通常可以在编译时确定某些命令将使用一个安全的内存空间(包含预期的数据结构并为我们的程序保留),但有时在编译期间无法确定,因此需要在运行时执行额外的检查,例如额外的边界检查或空指针检查。

我们将在 “Go 内存管理” 中更详细地讨论这个问题,但是在我们关于 CPU 的对话中,我们需要认识到这些检查会占用我们宝贵的 CPU 时间。虽然 Go 编译器在不必要时会尽力消除这些检查(例如在 SSA 优化的边界检查消除阶段),但在某些情况下,我们可能需要以一种有助于编译器消除某些检查的方式编写代码。¹¹

对于 Go 构建过程,有许多不同的配置选项。第一批大批选项可以通过 go build -ldflags="<flags>" 传递,这代表 链接器命令选项ld 前缀传统上代表 Linux 链接器)。例如:

  • 我们可以通过 -ldflags="-w" 来省略 DWARF 表,从而减小二进制文件大小(如果您在生产环境中不使用调试器,则推荐使用此选项)。

  • 类似地,使用 -ldflags= "-s -w" 可以进一步减小二进制文件的大小,删除 DWARF 和其他调试信息中的符号表。我不建议使用后者选项,因为非 DWARF 元素允许重要的运行时例程,例如收集配置文件。

类似地,go build -gcflags="<flags>" 代表 Go 编译器选项gc 代表 Go Compiler;不要与 GC 混淆,后者指的是垃圾回收,如 “垃圾回收” 中所述)。例如:

  • -gcflags="-S" 打印出 Go 汇编代码。

  • -gcflags="-N" 禁用所有编译器优化。

  • -gcflags="-m=<number> 在打印主要优化决策的同时构建代码,其中数字表示详细级别。参见示例 4-3 中我们在 示例 4-1 中的 Sum 函数上自动编译器优化。

示例 4-3. go build -gcflags="-m=1" sum.go 在 示例 4-1 代码上的输出
# command-line-arguments ./sum.go:10:27: inlining call to os.ReadFile ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
./sum.go:15:34: inlining call to bytes.Split ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
./sum.go:9:10: leaking param: fileName ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
./sum.go:15:44: ([]byte)("\n") does not escape ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
./sum.go:16:38: string(line) escapes to heap ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

1

os.ReadFilebytes.Split 足够简短,所以编译器可以复制 Sum 函数的整个主体。

2

fileName 参数“泄漏”,意味着这个函数在返回后仍然保持其参数活动状态(尽管可能仍在堆栈上)。

3

[]byte("\n") 的内存将分配在堆栈上。像这样的消息有助于调试逃逸分析。在这里了解更多信息:链接

4

string(line) 的内存将分配在更昂贵的堆中。

当增加 -m 数字时,编译器将打印更多详细信息。例如,-m=3 将解释为什么会做出某些决策。在我们预期某些优化(如内联或保持变量在堆栈上)发生时,但在我们的 TFBO 周期(“效率感知开发流程”](ch03.html#ch-conq-eff-flow))的基准测试中仍然看到开销时,此选项非常方便。

Go 编译器实现经过高度测试和成熟,但编写相同功能的方法有无数种。当我们的实现让编译器困惑时,可能不会应用某些天真的实现。通过性能基准测试是否存在问题,分析代码并确认 -m 选项有助于解决问题。更详细的优化也可以使用进一步的选项打印出来。例如,-gcflags="-d=ssa/check_bce/debug=1" 打印出所有边界检查消除优化。

代码越简单,编译器的优化效果就会越好

太聪明的代码难以阅读,并使得维护编程功能变得困难。但它也会使得试图匹配优化等效的模式的编译器感到困惑。使用惯用代码,保持函数和循环简单直接,增加编译器应用优化的机会,这样你就不需要!

熟悉编译器内部特性是有帮助的,尤其是在涉及更高级优化技巧时,这些技巧帮助编译器优化我们的代码。不幸的是,这也意味着我们的优化在不同编译器版本之间可能有些脆弱性。Go 团队保留更改编译器实现和标志的权利,因为它们不属于任何规范的一部分。这可能意味着你编写的一个允许编译器自动内联的函数,在下一个版本的 Go 编译器中可能不会触发内联。因此,当你切换到不同版本的 Go 时,更加重要的是进行基准测试并密切观察程序的效率。

总之,编译过程在解放程序员免于繁琐工作方面起着至关重要的作用。没有编译器优化,我们需要编写更多代码才能达到相同的效率水平,同时牺牲可读性和可移植性。相反,如果你专注于使你的代码简单化,你可以相信 Go 编译器会做一个足够好的工作。如果你需要提高特定热路径的效率,最好再次确认编译器是否按预期进行了操作。例如,编译器可能没有将我们的代码与常见的优化匹配;可能有一些额外的内存安全检查编译器可以进一步消除,或者可能有可以内联但未被内联的函数。在极端情况下,可能需要编写专门的汇编代码,并从 Go 代码中导入它。¹²

Go 的构建过程从我们的 Go 源代码中构建出完全可执行的机器码。当操作系统需要执行时,将机器码加载到内存中,并将第一条指令地址写入程序计数器(PC)寄存器。从那里开始,CPU 核心可以逐条计算每条指令。乍一看,这可能意味着 CPU 的工作相对简单。但不幸的是,内存墙问题导致 CPU 制造商不断进行额外的硬件优化,改变这些指令执行的方式。理解这些机制将使我们更好地控制我们的 Go 程序的效率和速度。让我们在下一节揭示这个问题。

CPU 和内存墙问题

要理解内存墙及其后果,让我们简要深入探讨 CPU 核心内部。CPU 核心的详细信息和实现随时间改变以获得更好的效率(通常变得更加复杂),但基本原理保持不变。原则上,控制单元(如 图 4-1 所示)通过各种 L-cache(从最小且最快的开始)管理从内存中读取的操作,解码程序指令,协调它们在算术逻辑单元(ALU)中的执行,并处理中断。

一个重要的事实是 CPU 按周期工作。大多数 CPU 在一个周期内可以对一组小数据执行一条指令。这种模式被称为冯·诺依曼结构的特征中提到的“单指令单数据(SISD)”,这是冯·诺依曼结构的关键方面。一些 CPU 还允许使用特殊指令如 SSE 进行“单指令多数据(SIMD)”处理,允许在一个周期内对四个浮点数进行相同的算术运算。不幸的是,这些指令在 Go 语言中使用起来并不直接,因此相当少见。

同时,寄存器是 CPU 核心可用的最快本地存储。由于它们是直接连接到 ALU 的小电路,仅需一个 CPU 周期即可读取它们的数据。不幸的是,它们数量有限(取决于 CPU,通常为 16 个用于一般用途),且其大小通常不超过 64 位。这意味着它们在程序生命周期中被用作短期变量。一些寄存器可以用于我们的机器码,而另一些则保留给 CPU 使用。例如,PC 寄存器保存着 CPU 应该获取、解码和执行的下一条指令的地址。

计算完全围绕数据展开。正如我们在第一章中学到的那样,如今有大量数据分散在不同的存储介质中——比可以存储在单个 CPU 寄存器中的数据量要多得多。此外,单个 CPU 周期比从主存储器(RAM)访问数据快——平均快 100 倍,这是我们在附录 A 中粗略的延迟数学计算中得出的结论,本书将沿用这些计算。正如我们在“硬件越来越快且便宜”的误解讨论中所述,技术使我们能够创建具有动态时钟速度的 CPU 核心,但其最大值始终约为 4 GHz。有趣的是,我们不能制造更快的 CPU 核心并不是最重要的问题,因为我们的 CPU 核心已经……太快了!事实上,我们无法制造更快的内存才是当前 CPU 主要效率问题的原因。

我们可以每秒执行大约 360 亿条指令。不幸的是,大部分时间都花在等待数据上。几乎每个应用程序中约 50%的时间都在等待数据。在某些应用程序中,高达 75%的时间都是在等待数据而不是执行指令。如果这让你感到恐惧,那就对了。确实应该如此。

Chandler Carruth,《“算法效率,数据结构性能”》(https://oreil.ly/I55mm)

上述问题通常被称为“内存墙问题”。由于这个问题,我们面临着浪费几十甚至几百个 CPU 周期来执行单条指令的风险,因为获取该指令和数据(然后保存结果)需要很长时间。

这个问题如此突出,以至于最近有关于重新审视冯·诺依曼体系结构的讨论。随着人工智能(AI)使用的机器学习(如神经网络)工作负载变得更加流行,这些工作负载特别受到内存墙问题的影响。因为大部分时间都花在执行复杂的矩阵数学计算上,这需要遍历大量内存。¹⁴

内存墙问题有效地限制了我们的程序执行速度。它还影响移动应用程序的总体能效,这对于移动应用程序尤为重要。尽管如此,它是目前为止最佳的通用硬件。行业通过开发几个主要 CPU 优化措施来缓解这些问题,我们将在下面讨论:分层缓存系统、流水线处理、乱序执行和超线程。这些直接影响我们的低级 Go 代码效率,特别是程序执行速度方面。

分层缓存系统

所有现代 CPU 都包括用于常用数据的本地、快速、小型缓存。L1、L2、L3(有时还有 L4)缓存是芯片上的静态随机访问存储器(SRAM)电路。SRAM 使用不同的技术存储数据,比我们的主内存 RAM 更快,但在大容量下的使用和生产成本更高(主内存在“物理内存”中有解释)。因此,当 CPU 需要从主内存(RAM)获取指令或数据来执行指令时,首先接触 L 缓存。CPU 使用 L 缓存的方式在图 4-3 中展示。¹⁵ 在示例中,我们将使用一个简单的 CPU 指令MOVQ,在示例 4-2 中有解释。

efgo 0403

图 4-3. CPU 执行的“查找”缓存方法来从主内存中读取字节,通过 L 缓存

要从特定内存地址复制 64 位(MOVQ命令)到寄存器SI,我们必须访问通常驻留在主内存中的数据。由于从 RAM 读取速度较慢,它使用 L 缓存首先检查数据。CPU 在第一次尝试时将向 L1 缓存请求这些字节。如果数据不在那里(缓存未命中),它会访问更大的 L2 缓存,然后是最大的 L3 缓存,最终是主内存(RAM)。在任何这些未命中情况下,CPU 将尝试获取完整的“缓存行”(通常为 64 字节,因此是寄存器大小的 8 倍),保存在所有缓存中,并仅使用这些特定字节。

一次性读取更多字节(缓存行)在性能上非常有用,因为它的延迟与读取单个字节相同(在“物理内存”中解释)。从统计上讲,下一个操作可能需要访问之前访问区域的相邻字节。L 缓存部分缓解了内存延迟问题,并减少了要传输的总数据量,保持了内存带宽。

在我们的 CPU 中引入 L 缓存的第一个直接结果是,我们定义的数据结构越小且更对齐,效率就越高。这样的结构更有可能完全适应较低级别的缓存,并避免昂贵的缓存未命中。第二个结果是,对于顺序数据的指令将更快,因为缓存行通常包含相邻存储的多个项。

流水线和乱序执行

如果数据能够在零时间内魔法般地访问,那么我们将拥有每个 CPU 核心周期执行有意义指令的完美情况,以 CPU 核心速度允许的速度执行指令。由于这并非事实,现代 CPU 尝试通过级联流水线保持 CPU 核心的每个部分繁忙。原则上,CPU 核心可以在一个周期内同时执行指令执行所需的多个阶段。这意味着我们可以利用指令级并行性(ILP)执行,例如,在五个 CPU 周期内执行五条独立指令,平均每个周期执行一条指令(IPC)[¹⁶]。例如,在初始的五阶段流水线系统(现代 CPU 具有 14-24 个阶段!),单个 CPU 核心可以在一个周期内同时计算 5 条指令,如图 4-4 所示。

efgo 0404

图 4-4。示例五阶段流水线

经典的五阶段流水线包括五个操作:

IF

获取要执行的指令。

ID

解码指令。

EX

执行指令。

MEM

获取执行操作的操作数。

WB

返回操作结果(如果有)。

正如我们在 L 缓存部分讨论的那样,更复杂的是,即使是数据的获取(例如MEM阶段)也很少仅仅需要一个周期。为了缓解这一点,CPU 核心还采用了一种称为乱序执行的技术。在这种方法中,CPU 试图按照输入数据和执行单元的可用性(如果可能)而不是按照程序中的原始顺序安排指令。对于我们的目的,把它看作是一个利用内部队列进行更有效 CPU 执行的复杂、更动态的流水线即可。

由此产生的流水线和乱序 CPU 执行是复杂的,但前述的简化解释应该足够我们理解作为开发人员的两个关键后果。第一个微不足道的后果是,每次指令流的切换都具有巨大的成本(例如,延迟方面),¹⁷因为流水线必须重置并从头开始,再加上明显的缓存崩溃。我们还未提及必须添加的操作系统开销。我们经常称之为上下文切换,这在现代计算机中是不可避免的,因为典型操作系统使用抢占式任务调度。在这些系统中,单个 CPU 核心的执行流可能每秒被抢占多次,这在极端情况下可能很重要。我们将讨论如何影响这种行为在“操作系统调度器”中。

第二个后果是我们的代码越具有预测性,效果越好。这是因为流水线需要 CPU 核心进行复杂的分支预测,以找到在当前指令之后执行的指令。如果我们的代码充满像 if 语句、switch 语句或者像 continue 这样的跳转语句,那么甚至可能找不到两个可以同时执行的指令,因为一个指令可能决定接下来执行哪条指令。这被称为数据依赖性。现代 CPU 核心的实现甚至进一步执行推测执行。因为它不知道下一条指令是什么,它选择最有可能的一条并假设将选择这样的分支。与浪费 CPU 周期无所作为相比,错误分支上的不必要执行更为可取。因此,出现了许多无分支编码技术,帮助 CPU 预测分支并可能导致更快的代码执行。某些方法由Go 编译器自动应用,但有时需要手动改进。

通常来说,代码越简单,嵌套条件和循环越少,对于分支预测器越好。这就是为什么我们经常听说“向左倾斜”的代码更快。

依我看,一再看到想要快速的代码,都会向页面的左边去。所以如果你写像一个循环和 if、for 和 switch 这样的语句,那么它不会快。顺便说一下,Linux 内核,你知道编码标准是什么吗?八个字符的制表符,80 个字符的行宽。你不能在 Linux 内核中写糟糕的代码。你不能在那里写慢代码。……一旦你的代码中有太多的 if 和决策点……效率就没了。

Andrei Alexandrescu, “人们思维中的速度”

CPU 中分支预测器和推测性方法的存在有另一个后果。这导致在具有 L 缓存的流水线 CPU 架构中,连续内存数据结构的性能大大提高。

连续内存结构的重要性

实际上,在现代 CPU 上,大多数情况下,开发者应该更倾向于使用连续的内存数据结构,比如数组,而不是链表。这是因为典型的链式结构实现(例如树)使用内存指针来指向下一个、过去的、子元素或父元素。这意味着当遍历这样的结构时,CPU 核心在访问节点并检查该指针之前,无法预测接下来要执行的数据和指令。这有效地限制了推测能力,导致 CPU 使用效率低下。

超线程

超线程是英特尔对 CPU 优化技术的专有名称,称为同时多线程 (SMT).¹⁸ 其他 CPU 制造商也实现了 SMT。这种方法允许单个 CPU 核心以对程序和操作系统可见的方式运行为两个逻辑 CPU 核心。¹⁹ SMT 促使操作系统在同一物理 CPU 核心上调度两个线程。虽然单个物理核心永远不会同时执行多个指令,但在队列中有更多指令有助于在空闲时使 CPU 核心忙碌。鉴于内存访问等待时间,这可以在不影响进程执行延迟的情况下更有效地利用单个 CPU 核心。此外,SMT 中的额外寄存器使 CPU 能够更快地在单个物理核心上多个线程之间进行上下文切换。

SMT 必须得到操作系统的支持和集成。当启用时,您应该在您的机器上看到比物理核心多两倍的核心。要了解您的 CPU 是否支持超线程,请查看规格中的“每核线程数”信息。例如,使用lscpu Linux 命令在示例 4-4 中,我的 CPU 有两个线程,这意味着超线程是可用的。

示例 4-4. 在我的 Linux 笔记本上执行lscpu命令的输出
Architecture:                    x86_64
CPU op-mode(s):                  32-bit, 64-bit
Byte Order:                      Little Endian
Address sizes:                   39 bits physical, 48 bits virtual
CPU(s):                          12
On-line CPU(s) list:             0-11
Thread(s) per core:              2 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) Core(s) per socket:              6
Socket(s):                       1
NUMA node(s):                    1
Vendor ID:                       GenuineIntel
CPU family:                      6
Model:                           158
Model name:                      Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz
CPU MHz:                         2600.000
CPU max MHz:                     4600.0000
CPU min MHz:                     800.0000

1

我的 CPU 支持 SMT,并且在我的 Linux 安装中已启用。

通常情况下,默认情况下启用 SMT,但在新内核上可以根据需求进行调整。这在运行我们的 Go 程序时会有后果。通常,我们可以选择是否应该为我们的进程启用或禁用这种机制。但是,大多数情况下,最好保持启用状态,因为这使得我们可以在单台计算机上运行多个不同任务时充分利用物理核心。然而,在某些极端情况下,可能值得将整个物理核心专用于单个进程,以确保服务质量最高。一般来说,每个特定硬件的基准测试应该能告诉我们。

总之,所有上述的 CPU 优化和相应的编程技术只在优化周期的最后阶段使用,并且只在我们想要在关键路径上挤出最后几十纳秒时使用。

编写关键路径上 CPU 高效代码的三个原则

会产生 CPU 友好代码的三条基本规则如下:

  • 使用做更少工作的算法。

  • 专注于编写低复杂度的代码,这样更容易为编译器和 CPU 分支预测器进行优化。理想情况下,将“热”代码与“冷”代码分开。

  • 在计划经常迭代或遍历它们时,倾向于使用连续的内存数据结构。

通过对 CPU 硬件动态的简要了解,让我们深入探讨关键路径上运行成千上万程序的关键软件类型——调度器。

调度器

调度通常意味着为某个进程分配必要的、通常是有限的资源以完成。例如,在汽车工厂中,组装汽车零部件必须在特定的地点和特定的时间紧密安排,以避免停机。我们还可能需要在某些时间段内安排会议参与者之间的会议。

在现代计算机或服务器集群中,我们有成千上万的程序需要在共享的资源如 CPU、内存、网络、磁盘等上运行。这就是为什么行业开发了许多类型的调度软件(通常称为调度器),专注于将这些程序分配给多个级别的空闲资源。

在本节中,我们将讨论 CPU 调度。从底层开始,我们有一个操作系统,它在有限数量的物理 CPU 上调度任意程序。操作系统的机制应告诉我们同时运行多个程序如何影响我们的 CPU 资源,以及如何影响我们自己的 Go 程序执行延迟。它还将帮助我们了解开发人员如何同时利用多个 CPU 内核,以并行或同时方式实现更快的执行。

操作系统调度器

与编译器一样,有许多不同的操作系统(OSes),每个都有不同的任务调度和资源管理逻辑。虽然大多数系统都在类似的抽象上操作(例如,带有优先级的线程、进程),但在本书中我们将专注于 Linux 操作系统。它的核心称为内核,具有许多重要功能,如管理内存、设备、网络访问、安全性等。它还通过一个可配置的组件称为调度器来确保程序的执行。

作为资源管理的核心部分,操作系统线程调度器必须保持以下简单的不变性:确保准备好的线程在可用的核心上调度。

J.P. Lozi 等人的文章 “Linux 调度器:十年浪费核心”

Linux 调度程序的最小调度单元称为操作系统线程。线程(有时也称为任务轻量级进程)包含独立的一组机器代码,以 CPU 指令的形式顺序运行。虽然线程可以维护其执行状态、堆栈和寄存器集,但它们无法超出上下文运行。

每个线程作为进程的一部分运行。进程代表正在执行的程序,并可以通过其进程标识号(PID)进行识别。当我们告诉 Linux 操作系统执行我们编译的程序时,将创建一个新进程(例如使用fork系统调用)。

进程创建包括分配新的 PID、创建带有其机器代码的初始线程(例如 Go 代码中的func main())和栈、标准输出和输入文件以及大量其他数据(例如打开文件描述符列表、统计数据、限制、属性、挂载项目、组等)。此外,还会创建新的内存地址空间,必须保护它免受其他进程的影响。所有这些信息在整个程序执行期间都在专用目录 /proc/<PID> 中维护。

线程可以创建新线程(例如使用clone系统调用),这些线程将拥有独立的机器代码序列,但会共享同一内存地址空间。线程也可以创建新进程(例如使用fork系统调用),这些进程将独立运行并执行所需程序。线程维护其执行状态:运行、准备和阻塞。这些状态的可能转换如图 4-5 所示。

线程状态告诉调度程序线程当前正在做什么:

运行

线程被分配到 CPU 核心并执行其工作。

阻塞

线程正在等待某个事件,该事件可能比上下文切换花费的时间更长。例如,线程从网络连接读取数据,正在等待数据包或者在互斥锁上的轮询。这是调度程序介入并允许其他线程运行的时机。

准备

线程准备执行但正在等待轮到它。

efgo 0405

图 4-5. 线程状态在 Linux 操作系统调度程序中的显示

正如你可能已经注意到的那样,Linux 调度程序采用抢占式线程调度。抢占式意味着调度程序可以随时冻结线程的执行。在现代操作系统中,通常有多个线程等待执行,但可用的 CPU 核心有限,因此调度程序必须在单个 CPU 核心上运行多个“准备好”的线程。每次线程等待 I/O 请求或其他事件时,线程被抢占。线程还可以告知操作系统放弃自己的执行(例如使用sched_yield系统调用)。被抢占时,线程进入“阻塞”状态,其他线程可以顶替它暂时执行。

傻瓜调度算法可以等待线程抢占自身。这对于 I/O 绑定的线程非常有效,它们经常处于“阻塞”状态——例如,具有图形界面或与网络调用交互的轻量级 Web 服务器的交互系统。但如果线程是 CPU 绑定的,这意味着它大部分时间只使用 CPU 和内存——例如,执行一些计算密集型的工作如线性搜索、矩阵乘法或暴力破解散列密码?在这种情况下,CPU 核心可能在一个任务上忙碌数分钟,这将使系统中所有其他线程饿死。例如,想象一下在浏览器中无法输入或调整窗口大小一分钟——看起来像是系统长时间冻结!

这个主要的 Linux 调度器实现解决了这个问题。它被称为完全公平调度器(CFS),并且它为线程分配了短暂的轮换时间。每个线程被赋予一定的 CPU 时间片,通常在 1 毫秒到 20 毫秒之间,这造成了线程同时运行的假象。这对必须对人类交互保持响应的桌面系统尤其有帮助。这种设计还有一些其他重要的后果:

  • 要执行的线程越多,它们每次轮换的时间就越少。然而,这可能导致 CPU 核心的生产利用率降低,开始花费更多时间进行昂贵的上下文切换。

  • 在过载的机器上,每个线程在 CPU 核心上的轮换时间较短,也可能每秒的轮换次数较少。虽然没有线程完全饥饿(阻塞),它们的执行速度可能会显著减慢。

    CPU 过载

    编写高效的 CPU 代码意味着我们的程序浪费的 CPU 周期显著减少。当然,这总是很好的,但是如果 CPU 过载,即使是高效的实现也可能表现得非常缓慢。

    CPU 过载或系统意味着太多的线程在竞争可用的 CPU 核心。因此,机器可能被超调度,或者一个或两个进程生成了太多的线程来执行某些重型任务(我们称这种情况为嘈杂的邻居)。如果出现 CPU 过载情况,检查机器的 CPU 利用率指标应该显示 CPU 核心以 100% 的容量运行。在这种情况下,每个线程的执行都会变慢,导致系统冻结、超时和缺乏响应。

  • 依靠纯程序执行延迟(有时称为 墙上时间墙钟时间)来估算我们程序的 CPU 效率是困难的。这是因为现代操作系统调度器是抢占式的,而程序通常会等待其他 I/O 或同步操作。因此,要可靠地检查在修复后,我们的程序是否比以前的实现更有效地利用了 CPU,这是相当困难的。这就是为什么行业定义了一个重要的度量标准,用于收集我们程序进程(所有其线程)在所有 CPU 核心上处于“运行”状态的时间。我们通常称之为 CPU 时间,并将在 “CPU 使用率” 中讨论。

    在负载过重的机器上的 CPU 时间

    测量 CPU 时间是检查我们程序 CPU 效率的一种好方法。然而,在查看某个进程执行时间的狭窄窗口时要小心。例如,较低的 CPU 时间可能意味着我们的进程在那一时刻没有使用太多 CPU,但它也可能表示 CPU 负载过重。

    总体而言,在同一系统上共享进程存在问题。这就是为什么在虚拟化环境中,我们倾向于保留这些资源的原因。例如,我们可以将一个进程的 CPU 使用限制为每秒钟 200 毫秒的 CPU 时间,这相当于一个 CPU 核心的 20%。

  • CFS 设计的最终后果是,它对于确保单个线程专用的 CPU 时间过于公平了。Linux 调度器具有优先级、用户可配置的“niceness”标志以及不同的调度策略。现代 Linux 操作系统甚至有一种调度策略,用特殊的实时调度程序替代 CFS,以执行需要第一顺序执行的线程。²⁰

    不幸的是,即使有了实时调度程序,Linux 系统也不能确保高优先级线程将获得它们所需的所有 CPU 时间,因为它仍然会努力确保低优先级线程不会挨饿。此外,因为 CFS 和实时对应物都是抢占式的,它们不是确定性和可预测性的。因此,在有严格实时要求的任务(例如毫秒级交易或飞机软件)之前,不能保证足够的执行时间。这就是为什么一些公司为 严格实时程序 开发了他们自己的调度程序或系统,如 Zephyr OS

尽管 CFS 调度器具有一定复杂的特性,它仍然是现代 Linux 系统中最流行的线程编排系统。2016 年,CFS 还针对多核机器和 NUMA 架构进行了全面升级,基于 一篇著名的研究论文 的发现。因此,现在线程可以智能地分布在空闲核心上,同时确保迁移不会太频繁,也不会在共享相同资源的线程之间进行。

在对操作系统调度程序有基本了解之后,让我们深入探讨为何 Go 调度程序的存在以及它如何使开发人员能够在单个或多个 CPU 核心上并发运行多个任务。

Go 运行时调度器

Go 并发框架建立在这样一个前提上,即由于典型工作流程的 I/O 密集特性,单个 CPU 指令流(例如函数)很难利用所有 CPU 周期。虽然操作系统线程抽象通过将线程复用到一组 CPU 核心中来缓解这一问题,但 Go 语言带来了另一层——goroutine——它在一组线程上多路复用函数。goroutines 的想法类似于协程,但并非完全相同(goroutines 可以被抢占),并且因为它是在 Go 语言中,所以有go前缀。与操作系统线程类似,当 goroutine 在系统调用或 I/O 上被阻塞时,Go 调度器(而非操作系统!)可以快速切换到另一个 goroutine,后者会在同一个线程上恢复执行(如果需要,也可以在不同线程上)。

实质上,Go 已将应用程序级别的 I/O 密集型工作转化为操作系统级别的 CPU 密集型工作。由于所有的上下文切换都发生在应用程序级别,因此我们不会像使用线程时那样每次上下文切换损失相同的 12K 指令(平均)。在 Go 中,这些相同的上下文切换只会花费你 200 纳秒或 2.4K 指令。调度程序还有助于提高缓存行效率和 NUMA 效率。这就是为什么我们不需要比虚拟核心更多的线程。

William Kennedy,《Go 中的调度:第二部分—Go 调度器》

因此,在 Go 语言中,我们在用户空间中拥有非常廉价的执行“线程”(一个新的 goroutine 只分配了几千字节用于初始的本地栈),这减少了我们机器上竞争线程的数量,并允许我们的程序中有数百个 goroutine 而不会产生过多的开销。每个 CPU 核心只需一个操作系统线程就足以完成所有 goroutine 的工作。²¹ 这使得诸如事件循环、映射-归约、管道、迭代器等多种模式成为可能,而无需涉及更昂贵的内核多线程。

使用 Go 语言中的并发 goroutines 形式是执行以下操作的绝佳方式:

  • 表示复杂的异步抽象(例如事件)

  • 充分利用我们的 CPU 来处理 I/O 密集型任务

  • 创建一个能够利用多个 CPU 以更快速度执行的多线程应用程序

在 Go 中启动另一个 goroutine 非常简单。语言内置了一个go <func>()的语法。示例 4-5 展示了一个启动两个 goroutine 并完成工作的函数。

示例 4-5. 启动两个 goroutine 的函数
func anotherFunction(arg1 string) { /*...*/ }

func function() {
   // ... ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

   go func() {
      // ... ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   }()

   go anotherFunction("argument1") ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

   return ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
}

1

当前 goroutine 的范围。

2

新的 goroutine 的作用域将随时并发运行。

3

anotherFunction 将随时并发运行。

4

function 终止时,我们启动的两个 goroutine 仍然可以运行。

重要的是要记住,所有的 goroutine 在彼此之间具有平等的层次结构。从技术上讲,当 goroutine A 启动 BB 启动 A 时没有区别。在这两种情况下,AB goroutines 都是平等的,它们不知道彼此的存在。²² 除非我们实现显式的通信或同步并“请求” goroutine 停止,它们也不能彼此停止。唯一的例外是通过 main() 函数启动的主 goroutine。如果主 goroutine 完成,整个程序将终止,强制终止所有其他 goroutines。

关于通信,goroutines 与操作系统线程类似,可以访问进程内的同一内存空间。这意味着我们可以使用共享内存在 goroutines 之间传递数据。然而,这并不那么简单,因为在 Go 中几乎没有操作是原子的。从同一内存中并发写入(或写入和读取)可能会导致数据竞争,从而引起非确定性行为甚至数据损坏。为了解决这个问题,我们需要使用像显式原子函数(如 示例 4-6 中所示)或互斥锁(如 示例 4-7 中所示)这样的同步技术,换句话说,是一种锁定机制。

示例 4-6. 通过专用原子加法实现安全的多 goroutine 通信
func sharingWithAtomic() (sum int64) {
   var wg sync.WaitGroup ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

   concurrentFn := func() {
      atomic.AddInt64(&sum, randInt64())
      wg.Done()
   }
   wg.Add(3)
   go concurrentFn()
   go concurrentFn()
   go concurrentFn()

   wg.Wait()
   return sum
}

1

请注意,虽然我们使用原子操作在 concurrentFn goroutines 之间同步添加,但我们还使用额外的 sync.WaitGroup(另一种形式的锁定)等待所有这些 goroutines 完成。我们在 示例 4-7 中也是如此。

示例 4-7. 通过互斥锁实现安全的多 goroutine 通信(锁定)
func sharingWithMutex() (sum int64) {
   var wg sync.WaitGroup
   var mu sync.Mutex

   concurrentFn := func() {
      mu.Lock()
      sum += randInt64()
      mu.Unlock()
      wg.Done()
   }
   wg.Add(3)
   go concurrentFn()
   go concurrentFn()
   go concurrentFn()

   wg.Wait()
   return sum
}

在原子和锁之间的选择取决于可读性、效率要求以及您想要同步的操作。例如,如果您想要在数字上并发执行简单的操作,如写入或读取值、加法、替换或比较和交换,可以考虑使用 atomic 包。原子操作通常比互斥锁更高效,因为编译器将它们转换为特殊的 原子 CPU 操作,可以以线程安全的方式更改单个内存地址下的数据。²³

然而,如果使用原子操作影响了代码的可读性,代码不在关键路径上,或者我们有更复杂的操作需要同步,我们可以使用锁。Go 语言提供了sync.Mutex,允许简单的锁定,以及sync.RWMutex,允许读锁定(RLock())和写锁定(Lock())。如果有许多不修改共享内存的 goroutine,请使用RLock()对它们进行锁定,这样它们之间就不会有锁争用,因为并发读取共享内存是安全的。只有当一个 goroutine 想要修改该内存时,它才能通过Lock()获取完整的锁定,这将阻塞所有读取操作。

另一方面,锁和原子操作并不是唯一的选择。Go 语言在这个主题上还有另一个王牌。在协程概念之上,Go 还利用了C. A. R. Hoare 的通信顺序进程(CSP)范式,这也可以被视为 Unix 管道的类型安全的泛化。

不要通过共享内存进行通信;相反,通过通信共享内存。

“Effective Go”

这种模型鼓励通过使用通道概念在 goroutine 之间实现通信管道来共享数据。通过共享相同的内存地址来传递数据需要额外的同步。然而,如果一个 goroutine 将数据发送到某个通道,另一个 goroutine 接收它,整个流程自然同步,并且共享数据永远不会被两个 goroutine 同时访问,确保线程安全。²⁴ 示例通道通信在示例 4-8 中展示。

示例 4-8. 通过通道进行内存安全的多 goroutine 通信示例
func sharingWithChannel() (sum int64) {
   result := make(chan int64) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

   concurrentFn := func() {
      // ...
      result <- randInt64() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   }
   go concurrentFn()
   go concurrentFn()
   go concurrentFn()

   for i := 0; i < 3; i++ { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
      sum += <-result ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
   }
   close(result) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
   return sum
}

1

可以使用ch := make(chan <type>, <buffer size>)语法在 Go 中创建通道。

2

我们可以向通道发送特定类型的值。

3

注意,在这个例子中,我们不需要sync.WaitGroup,因为我们了解我们期望接收到多少条精确的消息。如果没有这些信息,我们将需要一个等待组或其他机制。

4

我们可以从通道读取特定类型的值。

5

如果不再计划通过通道发送任何内容,也应该关闭通道。这会释放资源并解除某些接收和发送流程的阻塞(稍后会详细说明)。

通道的重要方面是它们可以具有缓冲区。在这种情况下,它的行为类似于队列。如果我们创建了一个具有三个元素缓冲区的通道,一个发送 goroutine 可以在有人从该通道读取之前发送三个元素。如果我们发送了三个元素并关闭了通道,接收 goroutine 在注意到通道关闭之前仍然可以读取三个元素。一个通道可以处于三种状态。重要的是记住,当在这些状态之间切换时,从该通道发送或接收的 goroutine 的行为如何。

已分配、开启的通道

如果我们使用make(chan <type>)创建一个通道,它将在分配时就开启。假设没有缓冲区,这样的通道将阻塞尝试发送值,直到另一个 goroutine 接收或者我们在使用带有多个 case 的 select 语句时。类似地,通道接收将阻塞,直到有人发送到该通道,除非我们在带有多个 case 的 select 语句中接收,或者该通道已关闭。

关闭的通道

如果我们通过close(ch)关闭了分配的通道,发送到该通道将导致恐慌,接收将立即返回零值。这就是为什么建议将关闭通道的责任留给发送数据的 goroutine(发送者)。

空通道

如果您定义了通道类型(var ch chan <type>)而不使用make(chan <type>)分配它,我们的通道是 nil。我们也可以通过赋值 nil (ch = nil) 来“nil”一个已分配的通道。在这种状态下,发送和接收将永远阻塞。实际上,将通道设置为 nil 很少有用。

Go 通道是一种令人惊叹和优雅的范式,允许构建非常可读的基于事件的并发模式。然而,就 CPU 效率而言,与atomic包和互斥锁相比,它们可能是最低效的。但不要因此灰心!对于大多数实际应用(如果没有过度使用!),通道可以将我们的应用程序结构化为强大且高效的并发实现。我们将探讨一些使用通道的实际模式,在“通过并发优化延迟”中展示。

在完成本节之前,理解如何在 Go 程序中调整并发效率非常重要。并发逻辑由 Go 调度程序在Go 运行时包中实现,它还负责其他事务,如垃圾收集(参见“垃圾收集”)、分析或堆栈帧。Go 调度程序相当自动化。配置标志不多。目前,开发者可以通过两种实际的方式控制代码中的并发:

一些 goroutine 的数量

作为开发人员,我们通常控制我们程序中创建的 goroutines 数量。通常不是为每个小工作单元生成它们是最佳选择,所以不要过度使用它们。还值得注意的是,许多标准或第三方库的抽象可以生成 goroutines,特别是那些需要 Close 或取消的操作。特别是,常见操作如 http.Docontext.WithCanceltime.After 都会创建 goroutines。如果使用不当,goroutines 可能会被泄漏(留下孤立的 goroutines),这通常会浪费内存和 CPU 资源。我们将探讨在 “Goroutine” 中调试 goroutines 数量和快照的方法。

高效代码的第一条规则

总是关闭或释放您使用的资源。有时,如果忘记关闭它们,简单的结构可能会导致内存和 goroutines 的巨大且无界的浪费。我们将在 “不要泄露资源” 中探讨常见示例。

GOMAXPROCS

这个重要的环境变量可以设置来控制你希望在 Go 程序中利用的虚拟 CPU 数量。可以通过 runtime.GOMAXPROCS(n) 函数应用相同的配置值。Go 调度程序如何使用这个变量的底层逻辑是相当复杂的²⁶,但它通常控制 Go 可以期望的并行 OS 线程执行的数量(内部称为“proc”数)。然后,Go 调度程序将维护 GOMAXPROCS/proc 数量的队列,并尝试在它们之间分发 goroutines。GOMAXPROCS 的默认值始终是操作系统公开的虚拟 CPU 核心数,这通常会为您提供最佳性能。如果希望您的 Go 程序使用较少的 CPU 核心(较少的并行性)以换取潜在的更高延迟,请减少 GOMAXPROCS 值。

推荐的 GOMAXPROCS 配置

GOMAXPROCS 设置为您希望您的 Go 程序同时利用的虚拟核心数。通常情况下,我们希望使用整个机器,因此默认值应该适用。

对于虚拟化环境,特别是使用像容器这样的轻量级虚拟化机制,请使用 Uber 的 automaxprocs,它将根据容器被允许使用的 Linux CPU 限制调整 GOMAXPROCS,这通常是我们想要的。

多任务始终是引入语言的一个棘手概念。我认为在 Go 语言中,带有通道的 goroutines 是这个问题的相当优雅的解决方案,它允许许多可读的编程模式而不会牺牲效率。我们将通过改进本章介绍的 “通过并发优化延迟” 中所示的 Example 4-1 来探索实际的并发模式。

现在让我们看看在我们的 Go 程序中何时可能会使用并发。

何时使用并发

与任何效率优化一样,将单个 goroutine 代码转换为并发代码时,同样适用经典规则。没有例外。我们必须专注于目标,应用 TFBO 循环,尽早进行基准测试,并寻找最大的瓶颈。像任何事情一样,增加并发性有其权衡,也有一些情况我们应该避免。让我们总结并发代码与顺序代码相比的实际好处和缺点:

优点

  • 并发允许我们通过将工作分解为片段并同时执行每个部分来加快工作进度。只要同步和共享资源不是重要的瓶颈,我们应该期望改善延迟。

  • 因为 Go 调度程序实现了有效的抢占机制,所以并发可以提高 I/O 绑定任务的 CPU 核心利用率,这应该能够在GOMAXPROCS=1(单个 CPU 核心)时减少延迟。

  • 特别是在虚拟环境中,我们经常为程序保留一定的 CPU 时间。并发允许我们在可用的 CPU 时间中更均匀地分配工作。

  • 对于某些情况,比如异步编程和事件处理,并发很好地表示了问题领域,尽管存在一些复杂性,但是可以提高可读性。另一个例子是 HTTP 服务器。将每个 HTTP 进入请求视为单独的 goroutine,不仅可以有效利用 CPU 核心,而且自然地适应了代码的阅读和理解方式。

缺点

  • 并发会显著增加代码的复杂性,特别是在将现有代码转换为并发代码时(而不是从一开始就围绕通道构建 API)。这影响了可读性,因为它几乎总是模糊了执行流程,更糟糕的是,它限制了开发者预测所有边缘情况和潜在错误的能力。这是我建议尽可能推迟引入并发的主要原因之一。一旦不得不引入并发,尽量在给定问题中使用尽可能少的通道。

  • 有了并发,由于无限并发(单个时刻的无控 goroutine 数量)或泄露 goroutine(孤立的 goroutine),存在饱和资源的风险。这也是我们需要关注和测试的内容(详见“不泄露资源”)。

  • 尽管 Go 的并发框架非常高效,但 goroutines 和 channels 并非没有开销。如果使用不当,可能会影响我们代码的效率。专注于为每个 goroutine 提供足够的工作量来证明其成本是有必要的。基准测试是必不可少的。

  • 当使用并发时,我们突然向程序添加了三个更非平凡的调优参数。我们有一个GOMAXPROCS设置,根据我们如何实现事物,我们可以控制我们产生的 goroutine 数量以及我们应该具有多大的通道缓冲区。找到正确的数字需要数小时的基准测试,并且仍然容易出错。

  • 并发代码很难进行基准测试,因为它更加依赖环境、可能存在的噪声邻居、多核设置、操作系统版本等。另一方面,顺序的单核代码具有更加确定和可移植的性能,更容易证明并进行比较。

正如我们所见,使用并发并不是解决所有性能问题的灵丹妙药。这只是我们手中的另一个工具,可以用来实现效率目标。

添加并发应该是我们最后尝试的有意义优化之一。

根据我们的 TFBO 周期,如果您仍然不能满足您的 RAERs,例如速度方面,请确保在添加并发之前尝试更简单的优化技术。经验法则是,在我们的 CPU 分析器(在第九章中解释)显示我们的程序只在对我们功能至关重要的事务上花费 CPU 时间之前,是我们知道的最有效的方式。

上述的缺点列表是一个原因,但第二个原因是,在基本的(不使用并发)优化之后,我们程序的特性可能会有所不同。例如,我们以为我们的任务是 CPU 密集型的,但在改进之后,我们可能发现大部分时间都花在等待 I/O 操作上。或者我们可能意识到,其实我们根本不需要大规模的并发修改。

总结

现代 CPU 硬件是一个复杂的组件,它允许我们高效地运行我们的软件。随着操作系统的持续发展、Go 语言开发和硬件的进步,只会出现更多的优化技术和复杂性,以降低运行成本并增加处理能力。

在本章中,我希望给了你一些基础知识,帮助你优化 CPU 资源的使用,以及通常的软件执行速度。首先,我们讨论了汇编语言及其在 Go 开发中的用处。然后,我们探讨了 Go 编译器的功能、优化方法和调试方式。

后来,我们深入探讨了 CPU 执行的主要挑战:现代系统中的内存访问延迟。最后,我们讨论了诸如 L 缓存、流水线处理、CPU 分支预测和超线程等各种低级优化技术。

最后,我们探讨了在生产系统中执行我们程序的实际问题。不幸的是,我们机器上的程序很少是唯一的进程,因此高效的执行非常重要。最后,我们总结了 Go 并发框架的优缺点。

在实践中,优化 CPU 资源是现代基础设施中实现更快执行和减少工作负载成本的关键。不幸的是,CPU 资源只是一个方面。例如,我们的选择优化可能更倾向于使用更多内存以减少 CPU 使用率,或者反之。

因此,我们的程序通常会使用大量的内存资源(以及通过磁盘或网络的 I/O 流量)。虽然执行与 CPU 资源(如内存和 I/O)相关联,但根据我们的需求(例如,更便宜的执行、更快的执行或两者兼有),它可能是我们优化列表中的第一项。让我们在下一章讨论内存资源。

¹ 严格来说,现代计算机现在为程序指令和数据分别具有不同的缓存,尽管两者都存储在主内存中。这就是所谓的修改后的哈佛架构。在本书中我们追求的优化级别上,我们可以安全地跳过这个细节层次。

² 对于脚本(解释)语言,没有完全的代码编译。相反,有一个解释器逐条编译代码语句。另一种独特类型的语言是由使用 Java 虚拟机(JVM)的语言系列代表。这样的机器可以在运行时动态地从解释切换到即时(JIT)编译以进行优化。

³ 通过使用go build -gcflags *-S* <source>将源代码编译为汇编,可以获得类似于示例 4-2 的输出。

⁴ 请注意,在 Go 汇编寄存器中,名称被抽象化以实现可移植性。由于我们将编译为 64 位架构,SPSI将表示 RSP 和 RSI 寄存器。

⁵ 可能会存在不兼容性,但主要是与特殊用途指令(如加密或 SIMD 指令)有关,可以在执行前运行时检查它们是否可用。

⁶ 注意,从编译器的角度来看,结构方法只是函数,第一个参数就是该结构,因此这里也适用相同的内联技术。

⁷ 函数调用需要更多的 CPU 指令,因为程序必须通过堆栈传递参数变量和返回参数,保持当前函数的状态,在函数调用后重置堆栈,添加新的堆栈帧等。

⁸ Go 工具允许我们通过GOSSAFUNC环境变量在 SSA 形式中检查我们程序的状态,只需构建我们的程序并使用GOSSAFUNC=<function to see> go build,然后打开生成的ssa.html文件。您可以在这里了解更多信息。

⁹ 您可以使用tar <archive>go tool pack e <archive>命令解包它。Go 归档通常包含对象文件和__.PKGDEF文件中的包元数据。

¹⁰ 然而,有关从默认构建过程中删除它的讨论。

¹¹ 边界检查消除 不在本书中详细解释,因为这是一种罕见的优化思想。

¹² 这在标准库中的关键代码中非常常见。

¹³ 在 SISD 和 SIMD 的基础上,弗林分类法还指定了 MISD,它描述了在同一数据上执行多个指令,并且 MIMD,描述了完全并行。MISD 是罕见的,仅在可靠性重要时才会发生。例如,四个飞行控制计算机在每个 NASA 航天飞机上执行完全相同的计算,以进行四重错误检查。另一方面,由于多核甚至多 CPU 设计,MIMD 更为常见。

¹⁴ 这就是为什么我们在通用设备中看到专用芯片(称为神经处理单元或 NPU)的原因,例如 Google 手机中的张量处理单元(TPU),iPhone 中的 A14 仿生芯片,以及苹果笔记本电脑中的专用 NPU。

¹⁵ 缓存的大小可以不同。示例大小取自我的笔记本电脑。您可以通过使用 sudo dmidecode -t cache 命令在 Linux 中检查 CPU 缓存的大小。

¹⁶ 如果一个 CPU 总共每个周期最多执行一条指令(IPC ≤ 1),我们称其为标量 CPU。大多数现代 CPU 核心的 IPC ≤ 1,但是有一些 CPU 拥有多个核心,使得 IPC > 1。这使得这些 CPU 成为超标量 CPU。IPC 已经迅速成为 CPU 的性能指标。

¹⁷ 高昂的成本一点也不为过。上下文切换的延迟取决于许多因素,但是据测量,在最佳情况下,直接延迟(包括操作系统切换延迟)约为 1,350 纳秒 — 如果必须迁移到不同的核心,则为 2,200 纳秒。这仅仅是直接延迟,从一个线程的结束到另一个线程的开始。总延迟将包括缓存和流水线预热的间接成本,可能高达 10,000 纳秒(这是我们在表 A-1 中看到的情况)。在此期间,我们可以计算大约 40,000 条指令。

¹⁸ 在一些资料中,这种技术也称为 CPU 线程(又称硬件线程)。由于可能与操作系统线程引起混淆,本书将避免使用这种术语。

¹⁹ 不要将超线程逻辑核心与在虚拟化中使用的虚拟 CPU(vCPU)混淆。客户操作系统根据主机选择使用机器的物理或逻辑 CPU,但在两种情况下,它们都被称为 vCPU。

²⁰ 有关调整操作系统的良好材料,很多。许多虚拟化机制,如具有像 Kubernetes 这样的编排系统的容器,也有它们的优先级和亲和性概念(将进程固定到特定的核心或机器)。在本书中,我们专注于编写高效的代码,但必须意识到执行环境的调整在确保程序快速可靠执行中起着重要作用。

²¹ 围绕 Go 运行时实现 Go 调度的细节相当令人印象深刻。基本上,Go 尽一切努力保持操作系统线程忙碌(旋转操作系统线程),以便尽可能长时间地保持不进入阻塞状态。如果需要,它可以从其他线程窃取 goroutine,轮询网络等,以确保我们保持 CPU 忙碌,使操作系统不会抢占 Go 进程。

²² 在实践中,有办法使用调试跟踪获取这些信息。但是,我们不应依赖程序知道哪个 goroutine 是正常执行流的父 goroutine。

²³ 有趣的是,即使是 CPU 上的原子操作也需要某种形式的锁定。不同之处在于,与专门的自旋锁机制不同,原子指令可以使用更快的内存总线锁

²⁴ 假设程序员遵循这个规则。有一种方法可以发送一个指向共享内存的指针变量(例如,*string),这违反了通过通信共享信息的规则。

²⁵ 我特意省略了两个额外的机制。首先,runtime.Gosched() 存在,允许当前 goroutine 让出执行权,以便其他 goroutine 在此期间执行一些工作。这个命令如今不太有用,因为当前的 Go 调度器是抢占式的,手动让出执行权已变得不切实际。第二个有趣的操作,runtime.LockOSThread(),听起来有用,但它并非设计用于效率;相反,它将 goroutine 固定到操作系统线程,以便我们可以从中读取某些操作系统线程状态。

²⁶ 我建议观看Chris Hines 在 GopherCon 2019 的演讲,以了解有关 Go 调度器的低级细节。

第五章:Go 如何使用内存资源

在第四章中,我们开始深入了解现代计算机的内部运作。我们讨论了使用 CPU 资源的效率方面。CPU 中指令的高效执行很重要,但执行这些指令的唯一目的是修改数据。不幸的是,数据变更的路径并不总是简单的。例如,在冯·诺依曼体系结构中(见图 4-1),当从主内存(RAM)访问数据时,我们会遇到 CPU 和内存墙问题。

工业界发明了许多技术和优化层来克服这样的挑战,包括内存安全性和确保大内存容量。由于这些发明,从 RAM 访问八字节到 CPU 寄存器可能被表示为简单的MOVQ <目标寄存器> <地址 XYZ>指令。然而,CPU 从存储这些字节的物理芯片获取信息的实际过程非常复杂。我们讨论了像分层缓存系统这样的机制,但实际上远不止这些。

从某些方面来说,这些机制尽可能地从程序员那里抽象出来。因此,例如,当我们在 Go 代码中定义一个变量时,我们无需考虑需要预留多少内存,以及它需要适应多少个 L 缓存。这对开发速度来说是很好的,但有时当我们需要处理大量数据时可能会让我们感到意外。在这些情况下,我们需要重新审视我们对内存资源的机械同情,优化 TFBO 流程(“效率感知开发流程”),以及良好的工具支持。

本章将重点讨论理解 RAM 资源。我们将从整体上探索内存的相关性。然后,我们将在“我们是否有内存问题?”中设定语境。接下来,我们将解释与内存访问相关的每个元素的模式和后果,从下到上。内存的数据旅程始于“物理内存”,硬件内存芯片。然后我们将转向操作系统(OS)内存管理技术,允许在多进程系统中管理有限的物理内存空间:“虚拟内存”和“OS 内存映射”,更详细地解释“mmap 系统调用”。

当解释了内存访问的较低层之后,我们可以转向对于希望优化内存效率的 Go 程序员至关重要的关键知识 —— 解释“Go 内存管理”。这包括必要的元素,例如内存布局,“值、指针和内存块”的含义,以及带来可衡量后果的“Go 分配器”的基础知识。最后,我们将探索“垃圾回收”。

我们将在本章中详细讨论存储器的许多细节,但关键目标是建立 Go 程序在处理存储器使用时的模式和行为的直觉。例如,在访问存储器时可能会出现哪些问题?我们如何测量存储器使用量?分配存储器意味着什么?我们如何释放存储器?我们将在本章中探索这些问题的答案。但让我们首先澄清为什么 RAM 对于我们的程序执行至关重要。它究竟有多重要?

内存的重要性

所有 Linux 程序执行它们编程功能所需的资源不仅仅是 CPU。例如,让我们以像Caddy(用 Go 语言编写)或NGINX(用 C 语言编写)这样的 Web 服务器为例。这些程序允许从磁盘提供静态内容或代理 HTTP 请求等功能。它们使用 CPU 执行编写的代码。但是,像这样的 Web 服务器还与其他资源进行交互,例如:

  • 使用 RAM 缓存基本的 HTTP 响应

  • 使用磁盘加载配置、静态内容或写入日志行以满足可观察性需求

  • 使用网络为远程客户端提供 HTTP 请求服务

因此,CPU 资源只是方程式的一部分。对于大多数程序而言,它们被创建用于保存、读取、管理、操作和转换来自不同介质的数据。

有人会争论,“内存”资源,通常称为 RAM,¹处于这些交互的核心。RAM 是计算机的支柱,因为每个外部数据(来自磁盘、网络或其他设备的字节)必须在内存中缓冲,以便 CPU 访问。因此,例如,操作系统启动新进程的第一步是将程序的部分机器代码和初始数据加载到内存中,以便 CPU 执行。

遗憾的是,在我们的程序中使用存储器时,我们必须注意三个主要的警告:

  • RAM 访问速度远远慢于 CPU 操作速度。

  • 我们的机器中始终存在有限数量的 RAM(通常每台机器从几 GB 到数百 GB 不等),这迫使我们关注空间效率。²

  • 除非持久类型的存储器能够以类似 RAM 的速度、价格和健壮性进行商品化,否则我们的主存储器是严格易失性的。当计算机断电时,所有信息完全丢失。³

存储器的短暂特性和有限大小,是我们被迫向计算机添加一个辅助的持久 I/O 资源的原因,即磁盘。如今,我们有相对较快的固态硬盘(SSD),但速度仍然比 RAM 慢约 10 倍,寿命有限(约五年)。另一方面,我们有速度较慢和价格较便宜的硬盘驱动器(HDD)。尽管比 RAM 便宜,磁盘资源也是一种稀缺资源。

最后但同样重要的是,出于可伸缩性和可靠性的原因,我们的计算机依赖于来自远程位置的数据。行业发明了不同的网络和协议,允许我们与远程软件(例如数据库)甚至远程硬件(通过 iSCSI 或 NFS 协议)进行通信。我们通常将这类 I/O 抽象为网络资源的使用。不幸的是,由于其不可预测的特性、有限的带宽和更大的延迟,网络是最具挑战性的资源之一。

在使用这些资源的同时,我们通过内存资源进行操作。因此,了解其工作原理至关重要。程序员可以做很多事情来影响应用程序的内存使用。但不幸的是,在没有适当教育的情况下,我们的实现往往容易出现效率低下和计算资源或执行时间浪费的情况。这个问题在当今程序需要处理大量数据时尤为突出。这就是为什么我们经常说高效编程关键在于数据。

Go 程序中内存效率问题通常是最常见的问题。

Go 是一种具有垃圾回收的语言,这使得 Go 成为一种非常高效的语言。然而,垃圾收集器(GC)牺牲了对内存管理的一些可见性和控制(详见《垃圾回收》)。

即使我们忽略了 GC 的开销,对于需要处理大量数据或处于某些资源约束下的情况,我们必须更加谨慎地处理程序如何使用内存。因此,我建议阅读本章时额外小心,因为大多数一级优化通常围绕内存资源展开。

我们何时应开始内存优化过程?几种常见的症状可能表明我们可能存在内存效率问题。

我们是否有内存问题?

了解 Go 如何使用计算机的主内存及其效率后果非常有用,但我们也必须遵循务实的方法。与任何优化一样,我们应该在确认存在问题之前避免优化内存。我们可以定义一组情况,这些情况应引起我们对 Go 内存使用和潜在优化的兴趣:

  • 我们的物理计算机、虚拟机、容器或进程由于内存不足(OOM)信号而崩溃,或者我们的进程即将达到内存限制。⁴

  • 我们的 Go 程序执行速度比平常慢,同时内存使用高于平均水平。剧透:我们的系统可能因为内存压力而导致抖动或交换,如《操作系统内存映射》中所解释的那样。

  • 我们的 Go 程序执行速度比平常慢,同时 CPU 利用率高涨。剧透:如果创建了过多的短生命周期对象,则分配或释放内存会减慢我们的程序。

如果您遇到这些情况中的任何一种,那么可能是时候调试和优化您的 Go 程序的内存使用了。正如我将在 “复杂度分析” 中教授您的,如果您知道自己在寻找什么,一组早期警告信号可以指示可能轻松避免的严重内存问题。此外,建立这种积极的直觉可能使您成为一个有价值的团队资产!

但是,没有良好的基础我们无法建设任何东西。就像 CPU 资源一样,如果不真正理解优化,您将无法应用它们!我们必须了解这些优化背后的原因。例如,示例 4-1 在输入中为 100 万个整数分配了 30.5 MB 的内存。但这意味着什么?空间是在哪里保留的?这意味着我们确切地使用了 30.5 MB 的物理内存,还是更多?这些内存是否曾经被释放过?本章旨在使您意识到这些问题,让您能够回答所有这些问题。我们将了解为什么内存通常是问题所在,以及我们可以采取什么措施。

让我们从硬件(HW)、操作系统(OS)和 Go 运行时的内存管理基础知识开始讲起。让我们从直接影响我们程序执行的物理内存的基本细节开始讲起。这些知识可能有助于您更好地理解现代物理内存的规格和文档!

物理内存

我们以比特的形式数字化地存储信息,这是计算机的基本存储单位。比特可以有两个值之一,0 或 1。有了足够的比特,我们可以表示任何信息:整数、浮点值、字母、消息、声音、图像、视频、程序、元宇宙等。

我们在执行程序时使用的主要物理内存(RAM)基于动态随机存取存储器(DRAM)。这些芯片焊接在模块中,通常称为 RAM “条”。当连接到主板时,这些芯片允许我们在 DRAM 持续供电的情况下存储和读取数据位。

DRAM 包含数十亿个存储单元(与 DRAM 可存储的比特数相同)。每个存储单元包括一个作为开关的访问晶体管和一个存储电容器。晶体管保护对电容器的访问,电容器被充电以存储 1 或被放电以保持 0 值。这使得每个存储单元能够存储单个比特信息。与通常更快速且用于 CPU 中的较小类型内存(如寄存器和层次缓存中的静态 RAM(SRAM)相比,这种架构更简单且更便宜。

到目前为止,用于 RAM 的最流行的存储器是 DRAM 家族中较为简单的同步(时钟)版本——SDRAM,特别是第五代 SDRAM 称为 DDR4。

八位形成一个“字节”。这个数字来自于过去,最小的能够保存文本字符的位数是八位⁵。行业将“字节”作为最小的有意义的信息单元进行了标准化。

结果,大多数硬件都是按字节寻址的。这意味着,从软件程序员的角度来看,有指令可以通过单个字节访问数据。如果你想访问一个单个位,你需要访问整个字节,并使用位掩码来获取或写入你想要的位。

字节寻址使开发人员在处理来自不同媒介的数据(如内存、磁盘、网络等)时的工作更加容易。然而,这产生了一种错误的印象,即数据总是以字节粒度访问。不要让这一点误导你。通常情况下,底层硬件必须传输一个更大的数据块,才能给你所需的字节。

例如,在“分层缓存系统”中,我们了解到 CPU 寄存器通常是 64 位(8 字节),而缓存行甚至更大(64 字节)。然而,我们有 CPU 指令可以将一个字节从内存复制到 CPU 寄存器。然而,一位经验丰富的开发人员会注意到,为了复制那个单个字节,在许多情况下,CPU 将从物理内存中提取不止 1 个字节,而至少是一个完整的缓存行(64 字节)。

从高级的角度来看,物理内存(RAM)也可以被看作是按字节寻址的,如图 5-1 所示。

内存空间可以被看作是连续的一组具有唯一地址的字节插槽。每个地址是从零到系统中总内存容量以字节为单位的最大数。正因为如此,使用仅 32 位整数进行内存地址编址的 32 位系统通常无法处理超过 4 GB 的 RAM - 我们可以表示的最大数是2 32。这项限制在引入 64 位操作系统时消除了,这些操作系统使用 64 位(8 字节)⁶整数进行内存编址。

efgo 0501

图 5-1。物理内存地址空间

我们在“CPU 和内存墙问题”中讨论过,与例如 CPU 速度相比,内存访问速度并不快。但还有更多的事情。按字节寻址理论上应该允许快速、随机地访问主内存中的字节。毕竟,这就是为什么主内存被称为“随机存取存储器”的原因。不幸的是,如果我们查看附录 A 中的便签数学,我们发现顺序内存访问可能比随机访问快 10 倍(或更多!)

但事实并非如此——我们不期望未来在这一领域有任何改进。在过去几十年中,我们只改善了顺序读取的速度(带宽),而完全没有改进随机访问的延迟!在延迟方面的缺乏改进并非错误,而是战略选择——现代 RAM 模块的内部设计必须应对各种需求和限制,例如:

容量

对于更大容量的 RAM 存储有着强烈需求,例如计算更多数据或运行更逼真的游戏。

带宽和延迟

我们希望在写入或读取大块数据时,能等待更少的时间来访问内存,因为内存访问是 CPU 操作的主要减速因素。

电压

每个内存芯片都有更低的电压需求的需求,这将允许在保持低功耗和可管理的热特性的同时运行更多内存芯片(我们的笔记本电脑和智能手机可以更长时间待机!)。

成本

RAM 是计算机中必须大量使用的基本组件;因此,生产和使用成本必须保持低廉。

随机访问速度较慢对我们将在本章学习的许多管理器层有许多影响。例如,这就是为什么 CPU 带有 L1 缓存会预取和缓存更大的内存块,即使只需一个字节来进行计算。

让我们总结一下关于像 DDR4 SDRAM 这样的现代 RAM 硬件几个值得记住的事情:

  • 内存的随机访问相对较慢,通常情况下,很难有很多好的方法来尽快改进。如果有的话,更低的功耗、更大的容量和带宽只会增加这种延迟。

  • 通过允许我们传输更大的相邻(顺序)内存块,行业正在提高整体内存带宽。这意味着对齐 Go 数据结构并了解它们在内存中的存储方式至关重要,确保我们能更快地访问它们。

无论是顺序还是随机访问,我们的程序从不直接访问物理内存——操作系统管理 RAM 空间。这对开发人员来说是个好消息,因为我们不需要理解低级内存访问细节。但为何在我们的程序和硬件之间必须有操作系统,还有更重要的原因。因此,让我们讨论一下这对我们的 Go 程序意味着什么。

操作系统内存管理

操作系统在内存管理方面的目标是什么?隐藏物理内存访问的复杂性只是其中一部分。另一个更重要的目标是允许同时安全地在成千上万的进程和它们的操作系统线程之间使用同一物理内存。⁷ 在共享内存空间上进行多进程执行的问题由于多种原因非常复杂:

每个进程专用的内存空间

程序被编译时假定几乎完全和连续地访问 RAM。因此,操作系统必须跟踪我们地址空间中物理内存的哪些插槽(如图 5-1 所示)属于哪个进程。然后我们需要找到一种协调这些“预留”的方法,以便只能访问已分配的地址。

避免外部碎片化

数以千计的进程具有动态内存使用,由于不良的内存分配而导致内存的巨大浪费,这构成了巨大的风险。我们称之为内存的外部碎片化问题

内存隔离

我们必须确保没有进程触碰到为其他在同一台机器上运行的进程保留的物理内存地址(例如操作系统进程!)。这是因为任何意外的写入或读取超出进程内存(越界内存访问)都可能导致其他进程崩溃,破坏持久性介质上的数据(例如磁盘),或者使整台机器崩溃(例如如果损坏了操作系统使用的内存)。

内存安全

操作系统通常是多用户系统,这意味着进程对不同资源(例如磁盘上的文件或其他进程的内存空间)有不同的权限。这就是为什么提到的越界内存访问会带来严重的安全风险。⁸ 想象一下,一个恶意进程没有权限读取其他进程内存中的凭据,或者导致拒绝服务(DoS)攻击。⁹ 这对于虚拟化环境尤为重要,因为单个内存单元可以跨多个操作系统和更多用户共享。

高效的内存使用

程序从不同时使用它们请求的所有内存。例如,指令代码和静态分配的数据(例如常量变量)可能会有数十兆字节大。但对于单线程应用程序,在给定的秒钟内使用的数据最多只有几千字节。错误处理的指令很少被使用。数组通常被超大化以应对最坏情况。

要解决所有这些挑战,现代操作系统使用三种基本机制来管理内存,我们将在本节中学习:分页虚拟内存、内存映射和硬件地址转换。让我们从解释虚拟内存开始。

虚拟内存

虚拟内存背后的关键思想是,每个进程都被赋予了自己的逻辑上简化的 RAM 视图。因此,编程语言设计师和开发人员可以有效地管理进程的内存空间,就好像他们拥有整个内存空间一样。更重要的是,使用虚拟内存,进程可以使用从 0 到 2 64 - 1 的完整地址范围进行数据访问,即使物理内存只能容纳例如 2 35 地址(32 GB 的内存)。这使得进程从协调内存与其他进程、二进制装箱挑战和其他重要任务(例如物理内存碎片整理、安全性、限制和交换)中解放出来。相反,所有这些复杂且容易出错的内存管理任务都可以委托给内核(Linux 操作系统的核心部分)。

有几种实现虚拟内存的方式,但最流行的技术是称为 分页 的技术。¹⁰ 操作系统将物理和虚拟内存划分为固定大小的内存块。虚拟内存块称为 页面,而物理内存块称为 。页面和帧都可以单独管理。默认页面大小通常为 4 KB,¹¹但可以根据特定 CPU 的能力更改为更大的页面大小。¹² 也可以在正常工作负载中使用 4 KB 页面,并使用(有时对进程透明!)专用的 大页面 从 2 MB 到 1 GB。

页面大小的重要性

选择 4 KB 数字是在 1980 年代进行的,许多人认为,考虑到现代硬件和更便宜的 RAM(以每字节美元计算),现在是时候提高这个数字了。

然而,页面大小的选择是一种权衡游戏。更大的页面不可避免地会浪费更多的内存空间,¹³这通常被称为内部内存碎片化。另一方面,保持 4 KB 的页面大小或者将其设置得更小会使内存访问变慢,并且内存管理变得更加昂贵,最终可能会阻止我们计算机中使用更大的 RAM 模块的能力。

操作系统可以动态地将虚拟内存中的页面映射到特定的物理内存帧(或其他介质,比如磁盘空间的块),对于进程来说这通常是透明的。页面的映射、状态、权限和其他元数据存储在操作系统维护的众多层次的页表中的页面条目中。¹⁴

为了实现易于使用和动态虚拟内存,我们需要一个多功能的地址转换机制。问题在于,只有操作系统知道当前虚拟和物理空间之间(或缺乏之间)的内存映射。我们运行的程序进程只知道虚拟内存地址,因此机器代码中的所有 CPU 指令都使用虚拟地址。如果我们尝试在每次内存访问时向 OS 查询以转换每个地址,程序将变得更慢,因此行业为转换内存页面找到了专门的硬件支持。

自 1980 年代以来,几乎每种 CPU 架构都开始包括用于每个内存访问的内存管理单元(MMU)。MMU 根据操作系统页表条目将 CPU 指令引用的每个内存地址转换为物理地址。为了避免访问 RAM 来搜索相关页面表,工程师们增加了翻译后备缓冲器(TLB)。TLB 是一个小缓存,可以缓存几千个页面表条目(通常是 4 KB 条目)。整体流程看起来像图 5-2 所示。

efgo 0502

图 5-2. CPU 中由 MMU 和 TLB 完成的地址转换机制。操作系统必须注入相关页面表,以便 MMU 知道虚拟地址对应的物理地址。

TLB 非常快速,但容量有限。如果 MMU 在 TLB 中找不到访问的虚拟地址,就会发生 TLB 未命中。这意味着 CPU(硬件 TLB 管理)或操作系统(软件管理的 TLB)必须遍历 RAM 中的页面表,这会导致显著的延迟(约一百个 CPU 时钟周期)!

需要指出的是,并非每个“已分配”的虚拟内存页面都有相应的保留物理内存页面。事实上,大多数虚拟内存根本没有被 RAM 支持。因此,我们几乎总是可以看到进程使用了大量虚拟内存(在诸如 ps 这样的 Linux 工具中称为 VSSVSZ)。但是,为该进程保留的实际物理内存(通常称为 RSSRES,即“常驻内存”)可能非常小。经常会出现单个进程分配的虚拟内存超过整台机器可用的情况!在我的机器上可以看到类似的情况,详见图 5-3。

efgo 0503

图 5-3. htop 输出的前几行,显示了几个 Chrome 浏览器进程当前的使用情况,按虚拟内存大小排序。

正如我们在图 5-3 中可以看到的那样,我的机器有 32 GB 物理内存,当前使用了 16.2 GB。然而,我们看到 Chrome 进程每个使用了 45.7 GB 的虚拟内存!但是,如果看 RES 列,仅有 507 MB 常驻内存,其中 126 MB 与其他进程共享。这是如何可能的?进程如何认为自己有 45.7 GB 可用的 RAM,考虑到机器只有 32 GB,并且系统实际上只分配了几百 MB 的 RAM?

我们可以将这种情况称为内存过度承诺,它存在的原因与航空公司经常超售航班座位相同。平均而言,许多旅客在最后一刻取消行程或者不出现登机。因此,为了最大化飞机的使用率,航空公司更有利可图地出售比飞机座位多的机票,并在“座位不足”的情况下“优雅地”处理(例如将不幸的乘客转移到另一趟航班)。这意味着真正的座位“分配”发生在旅客在登机过程中实际“访问”座位时。

操作系统默认采用相同的过度承诺策略¹⁵,用于试图分配物理内存的进程。只有当我们的程序访问物理内存时,才分配物理内存,而不是在“创建”大对象时,例如make([]byte, 1024)(你将在“Go 分配器”中看到实际例子)。

过度承诺通过页面和内存映射技术实现。通常,内存映射指的是 Linux 上提供的低级内存管理能力,通过mmap系统调用(在 Windows 上是类似的MapViewOfFile函数)。

开发者可以在特定用例中显式地利用mmap

mmap调用广泛用于几乎所有数据库软件,例如MySQLPostgreSQL,以及使用 Go 编写的项目,如PrometheusThanosM3dbmmap(以及其他内存分配技术)也是 Go 运行时和其他编程语言在底层使用的内存分配机制,例如堆内存(在“Go 内存管理”中讨论)。

对大多数 Go 应用程序而言,不建议显式使用mmap。相反,我们应该坚持使用 Go 运行时的标准分配机制,我们将在“Go 内存管理”中学习。正如我们的“高效开发流程”所述,只有通过基准测试显示这已经不够时,我们才考虑转向更高级的方法,如mmap。这也是为什么mmap甚至不在我的第十一章列表中!

然而,在我们开始讨论内存资源时,我解释mmap的原因有其合理性。即使我们不显式使用它,操作系统也使用相同的内存映射机制来管理系统中所有分配的页面。我们在 Go 程序中使用的数据结构间接保存到某些虚拟内存页面中,然后由操作系统或 Go 运行时类似于mmap管理。因此,理解显式的mmap系统调用将方便地解释 Linux 操作系统用来管理虚拟内存的按需分页和映射技术。

让我们接下来专注于 Linux mmap 系统调用。

mmap 系统调用

要了解操作系统的内存映射模式,让我们讨论一下 mmap 系统调用。示例 5-1 展示了一个简化的抽象,使用 mmap 操作系统调用,在我们的进程虚拟内存中分配一个字节切片,而不需要 Go 内存管理的协调。

示例 5-1. 适配的 Linux 特定 Prometheus mmap 抽象,允许创建和维护只读内存映射的字节数组
import (
    "os"

    "github.com/efficientgo/core/errors"
    "github.com/efficientgo/core/merrors"
    "golang.org/x/sys/unix"
)

type MemoryMap struct {
    f *os.File // nil if anonymous.
    b []byte
}

func OpenFileBacked(path string, size int) (mf *MemoryMap, _ error) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }

    b, err := unix.Mmap(int(f.Fd()), 0, size, unix.PROT_READ, unix.MAP_SHARED) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    if err != nil {
        return nil, merrors.New(f.Close(), err).Err() ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    }

    return &MemoryMap{f: f, b: b}, nil
}

func (f *MemoryMap) Close() error {
    errs := merrors.New()
    errs.Add(unix.Munmap(f.b)) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    errs.Add(f.f.Close())
    return errs.Err()
}

func (f *MemoryMappedFile) Bytes() []byte { return f.b }

1

OpenFileBacked 创建了一个由提供的路径中的文件支持的显式内存映射。

2

unix.Mmap 是一个 Unix 特定的 Go 助手,使用 mmap 系统调用来在返回的 []byte 数组中创建文件在磁盘上的直接映射(在 0 到 size 地址之间)。我们还传递了只读标志 (PROT_READ) 和共享标志 (MAP_SHARED)。¹⁶ 我们还可以跳过传递文件描述符,并将 0 作为第一个参数传递,并将 MAP_ANON 作为最后一个参数,以创建匿名映射(稍后详述)。¹⁷

3

我们使用 merrors 包确保如果 Close 也返回错误,我们捕获了两个错误。

4

unix.Munmap 是从虚拟内存中删除映射并释放 mmap 映射的字节的少数几种方法之一。

从打开的 MemoryMap.Bytes 结构返回的字节切片可以像通过典型方式获得的普通字节切片一样读取,例如 make([]byte, size)。然而,由于我们将这个内存映射位置标记为只读 (unix.PROT_READ),因此在对这样一个切片进行写入时,操作系统将会用 SIGSEGV 原因终止 Go 进程。¹⁸ 此外,如果我们在对其进行 Close (Unmap) 后读取这个切片,也会发生分段错误。

乍一看,mmap 映射的字节数组看起来像是一个普通的字节切片,但额外增加了一些步骤和限制。那么它有什么独特之处呢?最好用一个例子来解释!想象一下,我们想要在 []byte 切片中缓冲一个 600 MB 的文件,这样我们就可以根据需要从该文件的任意偏移量快速访问几个字节。可能听起来 600 MB 太多了,但在数据库或缓存中,这样的需求是很常见的,因为从磁盘按需读取可能太慢。

没有显式 mmap 的天真解决方案可能看起来像是 示例 5-2。每隔几条指令,我们将查看操作系统内存统计告诉我们关于物理 RAM 上分配页面的情况。

示例 5-2. 缓冲来自文件的 600 MB,以便从文件的三个不同位置访问三个字节
f, err := os.Open("test686mbfile.out") ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
if err != nil {
   return err
}

b := make([]byte, 600*1024*1024)
if _, err := f.Read(b); err != nil { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   return err
}

fmt.Println("Reading the 5000th byte", b[5000]) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
fmt.Println("Reading the 100 000th byte", b[100000]) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
fmt.Println("Reading the 104 000th byte", b[104000]) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

if err := f.Close(); err != nil {
   return err
}

1

我们打开了 600+ MB 的文件。此时,如果你在 Linux 机器上运行了 ls -l /proc/$PID/fd 命令(其中 $PID 是此执行程序的进程 ID),你会看到文件描述符告诉你该进程已使用了这些文件。其中一个描述符是指向我们刚刚打开的 test686mbfile.out 文件的符号链接。该进程将会持有该文件描述符直到文件关闭。

2

我们将 600 MB 读入预先分配的 []byte 切片中。在 f.Read 方法执行后,进程的 RSS 显示为 621 MB。¹⁹ 这意味着我们需要超过 600 MB 的空闲物理 RAM 来运行此程序。虚拟内存大小(VSZ)也增加了,达到了 1.3 GB。

3

无论我们从缓冲区访问哪些字节,我们的程序都不会为缓冲区在 RSS 上分配任何额外的字节(然而,对于 Println 的逻辑可能需要额外的字节)。

通常,示例 5-2 证明了,如果没有显式的 mmap,我们需要从一开始就为我们的进程至少保留 600 MB 内存(约 150,000 页)在物理 RAM 中。我们需要保留它们直到垃圾收集进程将其回收。

如果使用显式的 mmap,相同的功能会是什么样子呢?让我们在 示例 5-3 中使用 示例 5-1 的抽象,做类似的事情。

示例 5-3. 内存映射 600 MB 的文件以访问来自三个不同位置的三个字节,使用 示例 5-1
f, err := mmap.OpenFileBacked("test686mbfile.out," 600*1024*1024) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
if err != nil {
   return err
}
b := f.Bytes() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

fmt.Println("Reading the 5000th byte", b[5000]) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
fmt.Println("Reading the 100 000th byte", b[100000]) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
fmt.Println("Reading the 104 000th byte", b[104000]) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)

if err := f.Close(); err != nil { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/6.png)
   return err
}

1

我们打开测试文件并将其内容的 600 MB 内存映射到 []byte 切片中。此时,类似于 示例 5-2,我们在 fd 目录中看到了与我们的 test686mbfile.out 文件相关的文件描述符。然而更重要的是,如果你执行了 ls -l /proc/$PID>/map_files 命令(这里再次提醒,$PID 是进程 ID),你会看到另一个符号链接指向我们刚刚提到的 test686mbfile.out 文件。这表示一个文件支持的内存映射。

2

在这个语句之后,我们得到了包含文件内容的字节缓冲区 b。然而,如果我们检查此进程的内存统计信息,操作系统并没有为切片元素在物理内存中分配任何页面。²⁰ 因此,尽管 b 中有 600 MB 的内容可访问,总的 RSS 仅为 1.6 MB!与此相反,VSZ 大约为 1.3 GB,这表明操作系统告知 Go 程序它可以访问这个空间。

3

从我们的切片中访问单个字节后,我们看到 RSS 增加,大约增加了 48–70 KB 的 RAM 页面。这意味着当我们的代码想要从b中访问单个具体字节时,操作系统仅分配了几个(大约 10 个)页面在 RAM 中。

4

访问远离已分配页面的不同字节将触发额外页面的分配。RSS 读数将显示 100–128 KB。

5

如果我们访问前一个读取的 4,000 字节之外的单个字节,操作系统不会分配任何额外的页面。这可能有几个原因。^(21)例如,当我们的程序在偏移量 100,000 处读取文件内容时,操作系统已经为这里访问的字节分配了一个 4 KB 页面。因此,RSS 读数仍然显示 100–128 KB。

6

如果我们移除内存映射,所有相关页面最终将从 RAM 中取消映射。这意味着我们的进程总 RSS 数应该更小。^(22)

了解更多关于您的进程和操作系统资源行为的低估方法

Linux 为当前进程或线程状态提供了令人惊叹的统计和调试信息。所有信息都作为特殊文件存在于/proc/<PID>*内。能够调试每个详细的统计数据(例如每个小内存映射状态)和配置对我来说是一个启发。通过阅读proc(进程伪文件系统)文档,了解更多你可以做的事情。

如果您计划更多地在低级 Linux 软件上工作,我建议您熟悉 Linux 伪文件系统或使用它的工具。

当我们在示例 5-3 中使用显式mmap时突出显示的一个主要行为是按需分页。当进程使用mmap请求任何虚拟内存时,操作系统不会在 RAM 上分配任何页面,无论多大。相反,操作系统只会为进程提供虚拟地址范围。稍后,当 CPU 执行从该虚拟地址范围中访问内存的第一条指令时(例如我们在示例 5-3 中的fmt.Println("Reading the 5000th byte," b[5000])),MMU 将生成页面错误。页面错误是由操作系统内核处理的硬件中断。然后,操作系统可以以各种方式响应:

分配更多的 RAM 页面

如果我们在 RAM 中有空闲页面(物理内存页面),操作系统可以将其中一些标记为已使用并映射到触发页面错误的进程。这是操作系统实际上“分配”RAM(并增加RSS度量)的唯一时刻。

释放未使用的 RAM 页面并重复使用它们

如果没有空闲帧存在(机器上的高内存使用),操作系统可以删除属于任何进程的文件支持映射的几个帧,只要这些帧当前未被访问。结果,操作系统在不得不采取更残酷方法之前,可以取消物理帧中的许多页面映射。然而,这可能会导致其他进程生成另一个页面故障。如果这种情况经常发生,整个操作系统和所有进程都将严重减速(内存抖动情况)。

触发内存不足(OOM)情况

如果情况恶化,并且所有未使用的文件支持内存映射页面被释放,并且我们仍然没有空闲页面,操作系统实际上是内存不足的。可以在操作系统中配置处理该情况,但一般有三个选择:

  • 操作系统可以开始取消映射物理内存中由匿名文件支持的内存映射页面。为了避免数据丢失,可以配置一个交换磁盘分区(swapon --show 命令将显示您的 Linux 系统中交换分区的存在和使用情况)。然后,这些磁盘空间用于备份匿名文件内存映射中的虚拟内存页面。正如你可以想象的那样,这可能会导致类似(如果不是更严重的话)的内存抖动情况和整体系统减速。²³

  • 操作系统的第二个选项是简单地重新启动系统,通常称为系统级 OOM 崩溃

  • 最后一个选项是通过立即终止几个优先级较低的进程(例如,来自用户空间的进程)从 OOM 情况中恢复。通常是通过操作系统发送SIGKILL信号来完成的。杀死进程的检测因系统而异,²⁴但如果我们想要更多的确定性,系统管理员可以使用例如cgroups²⁵或ulimit来配置每个进程或进程组的特定内存限制。

除了按需分页策略外,值得一提的是,操作系统在进程终止时或显式释放某些虚拟内存时从未立即释放任何 RAM 帧页面。在那一点上只更新虚拟映射。而物理内存主要通过懒惰方式(按需)与帮助页面帧回收算法(PFRA)重新获取。这本书不会讨论这个。

一般来说,mmap系统调用可能看起来复杂且难以理解。然而,它解释了当我们的程序通过请求操作系统分配一些 RAM 时意味着什么。现在让我们将我们学到的东西组合成操作系统如何管理 RAM 的大局,并讨论我们开发者在处理内存资源时可能观察到的后果。

操作系统内存映射

在 Example 5-3 中呈现的显式内存映射只是可能的操作系统内存映射技术的一个例子。除了稀有的基于文件的映射和高级的非堆解决方案之外,在我们的 Go 程序中几乎没有必要显式使用这样的 mmap 系统调用。然而,为了有效管理虚拟内存,操作系统对几乎所有的 RAM 都透明地使用相同的页面内存映射技术!我们机器上的示例内存映射情况显示在 Figure 5-4 中,将几种常见的页面映射情况汇总为一个图形。

efgo 0504

图 5-4. 两个进程虚拟内存中几个内存页的示例 MMU 转换

Figure 5-4 中的情况可能看起来复杂,但我们已经讨论过其中的一些情况。让我们从进程 1 或 2 的角度列举它们:

A

代表了已经在 RAM 上映射了帧的匿名文件映射的最简单情况。例如,如果进程 1 在其虚拟空间中的地址 0x20000x2FFF 之间写入或读取一个字节,MMU 将把地址转换为 RAM 的物理地址 0x9000,加上所需的偏移量。因此,CPU 将能够将其作为缓存行提取或写入到其 L-cache 和所需的寄存器中。

B

表示一个基于文件的内存页映射到物理帧,就像我们在 Example 5-3 中创建的那样。由于两个映射都将到磁盘上的同一文件,因此该帧也与另一个进程共享。只有在映射未设置为 MAP_PRIVATE 时才允许这样做。

C

这是一个尚未访问的匿名文件映射。例如,如果进程 1 向地址 0x00xFFF 之间的地址写入一个字节,CPU 会生成一个页面故障硬件中断,操作系统将需要找到一个空闲的帧。

D

这是像 C 一样的匿名页,但已经写入了一些数据。然而,操作系统似乎启用了交换并将其从 RAM 中取消映射,因为该页面长时间未被进程 2 使用,或者系统内存压力。操作系统将数据备份到交换分区中的交换文件中,以避免数据丢失。进程 2 访问虚拟地址 0x10000x1FFF 之间的任何字节都会导致页面错误,这将告诉操作系统在 RAM 中找到一个空闲的帧,并从交换文件中读取页面 D 的内容。只有这样,数据才能对进程 2 可用。请注意,大多数操作系统默认禁用匿名页的这种交换逻辑。

现在,您应该对操作系统的内存管理基础知识和虚拟内存模式有了更清晰的了解。因此,让我们现在来列出这些对 Go(以及任何其他编程语言)的重要影响的列表:

实际上,观察虚拟内存的大小从来都不是有用的。

按需分页是为什么我们总是看到更大的虚拟内存使用量(由虚拟集大小或 VSS 表示)比进程的常驻内存使用量(RSS)(例如,在图 5-3 中的浏览器内存使用情况)。虽然进程认为它在虚拟地址空间上看到的所有页面都在 RAM 中,但它们中的大多数可能当前未映射并存储在磁盘上(映射文件或交换分区)。在大多数情况下,评估您的 Go 程序使用的内存量时,可以忽略 VSS 指标。

精确地说出在给定时间内一个进程(或系统)使用了多少内存是不可能的。

如果 VSS 指标无法帮助评估进程内存使用情况,我们可以使用什么度量标准?对于关注程序内存效率的 Go 开发者,了解当前和过去的内存使用情况是必要信息。它告诉我们我们的代码有多有效,以及我们的优化是否按预期工作。

不幸的是,由于我们在本节学到的按需分页和内存映射行为,这目前非常困难——我们只能粗略估计。我们将在“内存使用”中讨论最佳可用的度量标准,但如果 RSS 指标显示比预期多或少几千字节甚至兆字节,也不要感到意外。

操作系统的内存使用扩展到所有可用的 RAM。

由于延迟释放和页面缓存,即使我们的 Go 进程释放了所有内存,如果系统上一般的内存压力很低,有时 RSS 看起来仍然非常高。这意味着有足够的物理 RAM 来满足其余进程,因此操作系统不会释放我们的页面。这通常是为什么 RSS 指标不是非常可靠的原因,正如在“内存使用”中讨论的那样。

我们的 Go 程序内存访问的尾延迟比单纯的物理 DRAM 访问延迟要慢得多。

使用带有虚拟内存的操作系统的代价很高。在最坏的情况下,已经由 DRAM 设计引起的缓慢内存访问会更加缓慢(在“物理内存”中提到)。如果我们堆叠可能发生的事情,如 TLB 未命中、页面错误、寻找空闲页面或从磁盘加载按需内存,我们将面临极高的延迟,这会浪费数千个 CPU 周期。操作系统尽可能确保这些糟糕情况很少发生,因此摊销(平均)访问延迟尽可能低。

作为 Go 开发者,我们可以通过一些控制措施来减少额外延迟发生的风险。例如,我们可以在程序中使用更少的内存或者优先顺序内存访问(稍后详述)。

高 RAM 使用可能导致程序执行缓慢。

当我们的系统执行许多进程想要访问接近 RAM 容量的大量页面时,内存访问延迟和操作系统的清理例程可能会占用大部分 CPU 周期。此外,正如我们讨论的那样,诸如内存崩溃、常量内存交换和页面回收机制将减慢整个系统。因此,如果您的程序延迟高,不一定是 CPU 执行了太多工作或执行了慢操作(例如 I/O),可能只是使用了大量内存!

希望您能理解操作系统内存管理对我们如何考虑内存资源的影响。就像在 “物理内存” 中所述,我只解释了内存管理的基础知识。这是因为内核算法在演变,不同的操作系统管理内存方式也不同。我提供的信息应该给您一个大致了解标准技术及其后果的基础。这样的基础还应该让您从像 Daniel P. Bovet 和 Marco Cesati 的 理解 Linux 内核(O'Reilly)或 LWN.net 等材料中更深入地学习。

有了这些知识,让我们讨论一下 Go 如何选择利用操作系统和硬件提供的内存功能。这应该有助于我们找到在我们的 TFBO 流程中专注于 Go 程序内存效率的正确优化方法。

Go 内存管理

这里编程语言的任务是确保编写程序的开发人员可以安全、高效地(理想情况下)使用内存创建变量、抽象和操作!因此,让我们深入了解 Go 语言如何实现这一点。

Go 使用一种相对标准的内部进程内存管理模式,与其他语言(例如 C/C++)共享,但也有一些独特的元素。正如我们在 “操作系统调度程序” 中学到的,当一个新进程启动时,操作系统会创建关于该进程的各种元数据,包括一个新的专用虚拟地址空间。操作系统还会根据程序二进制文件中存储的信息,为一些起始段创建初始内存映射。一旦进程启动,它会使用 mmapbrk/sbrk²⁶ 在需要时动态分配更多的虚拟内存页面。Go 中虚拟内存的一个示例组织如 图 5-5 所示。

efgo 0505

图 5-5. 执行的 Go 程序在虚拟地址空间中的内存布局

我们可以列举几个常见的部分:

.text.data 和共享库

程序代码和所有全局数据,如全局变量,在进程启动时由操作系统自动内存映射(不管它需要 1 MB 还是 100 GB 的虚拟内存)。这些数据是只读的,由二进制文件支持。此外,CPU 每次只执行程序的一个小连续部分,以便操作系统可以在物理内存中保留少量代码和数据页。这些页也经常共享(多个进程使用相同的二进制文件,以及一些动态链接的共享库)。

块起始符号(.bss

当操作系统启动一个进程时,它还会为未初始化数据(.bss)分配匿名页。.bss使用的空间是事先已知的,例如,http包定义了DefaultTransport全局变量。虽然我们不知道这个变量的值,但我们知道它将是一个指针,因此我们需要为它准备八个字节的内存。这种内存分配称为静态分配。这个空间只分配一次,由匿名页支持,并且从虚拟内存中永远不会释放(至少不会释放;如果启用了交换,它可以从 RAM 中取消映射)。

图 5-5 中第一个(也可能是最重要的)动态段是为动态分配保留的内存,通常称为(不要与具有相同名称的数据结构混淆)。动态分配用于需要在单个函数作用域之外可用的程序数据(例如变量)。因此,这些分配是事先未知的,并且必须存储在内存中,时间不确定。进程启动时,操作系统为堆准备了初始数量的匿名页。之后,操作系统允许进程对该空间有一定程度的控制。它可以通过sbrk系统调用增加或减少其大小,或者通过准备或删除额外的虚拟内存使用mmapunmmap系统调用。进程负责以最佳方式组织和管理堆,不同的语言以不同方式实现这一点:

  • C 强制程序员手动为变量分配和释放内存(使用mallocfree函数)。

  • C++添加了智能指针,如std::unique_ptrstd::shared_ptr,它们提供了简单的计数机制来跟踪对象的生命周期(引用计数)。²⁷

  • Rust 拥有强大的内存所有权机制,但对于非内存关键代码区域来说,这使得编程变得更加困难。²⁸

  • 最后,像 Python、C#、Java 和其他语言实现了先进的堆分配器和垃圾收集机制。垃圾收集器定期检查是否有未使用的内存可以释放。

    在这方面,Go 在内存管理上更接近于 Java 而不是 C。Go 隐式地(对程序员透明地)分配需要在堆上进行动态分配的内存。出于这个目的,Go 有其独特的组件(用 Go 和汇编语言实现);参见“Go Allocator”和“Garbage Collection”。

大多数情况下,优化堆使用量就足够了

堆是内存中通常存储大量数据的区域。查看堆大小通常足以评估大多数情况下 Go 进程的内存使用情况。此外,堆管理带来的运行时垃圾收集开销也是相当可观的。这两者使得堆在优化内存使用时成为我们的首选分析对象。

手动过程映射

Go 运行时和编写 Go 代码的开发人员都可以手动分配额外的内存映射区域(例如使用我们的示例 5-1 抽象)。当然,进程可以决定使用什么类型的内存映射(私有或共享、读取或写入、匿名或文件支持),但它们都在进程的虚拟内存中有一个专用空间,如图 5-5 所示。

Go 内存布局的最后一部分是为函数栈保留的。栈是一种简单而快速的结构,允许按后进先出(LIFO)顺序访问值。编程语言使用它们来存储所有可以使用自动分配的元素(例如变量)。与堆完成的动态分配相反,自动分配对于像局部变量、函数输入或返回参数这样的本地数据非常有效。这些元素的分配可以是“自动的”,因为编译器可以在程序开始之前推断出它们的生命周期。

有些编程语言可能只有一个栈或者每个线程有一个栈。Go 在这方面有些独特。正如我们在“Go Runtime Scheduler”中学到的,Go 的执行流程是围绕着 goroutine 设计的。因此,Go 每个 goroutine 维护一个单一的动态大小的栈。这甚至可能意味着成千上万个栈。每当 goroutine 调用另一个函数时,我们可以将其局部变量和参数推入栈中的栈帧。当我们离开函数时,我们可以从栈中弹出这些元素(释放栈帧)。如果栈结构需要比虚拟内存中预留的空间更多的空间,Go 将通过mmap系统调用向操作系统请求更多的内存,用于栈段。

由于栈非常快速,不需要额外的开销来确定何时删除由某些元素使用的内存(无使用跟踪)。因此,理想情况下,我们编写算法时应主要在栈上分配,而不是堆上。不幸的是,在许多情况下,由于栈的限制(无法分配过大的对象)或变量必须存在比函数作用域更长的时间,因此编译器将自动决定哪些数据可以自动分配(在栈上),哪些必须动态分配(在堆上)。这个过程称为逃逸分析,您可以在示例 4-3 中看到。

所讨论的所有机制(除了手动映射)都有助于 Go 开发人员。我们不需要关心变量的内存分配位置和方式。这是一个巨大的优势——例如,当我们想要进行一些 HTTP 调用时,我们只需使用标准库创建一个 HTTP 客户端,例如,使用client := http.Cli⁠ent{}代码语句。由于 Go 的内存设计,我们可以立即开始使用client,专注于我们代码的功能性、可读性和可靠性。

  • 当我们不需要确保操作系统有空闲的虚拟内存页来容纳client变量时,我们也不需要为其找到有效的段和虚拟地址。如果变量可以存储在堆栈上,则编译器将自动执行这两者操作;如果是动态分配在堆上,则运行时分配器将自动完成。

  • 当我们停止使用client变量时,我们不需要记住释放内存。相反,假设client超出代码范围(没有引用它),那么在 Go 语言中,数据将被释放——如果存储在堆栈上,则立即释放;如果存储在堆上,则在下一次垃圾收集执行周期中释放(更多详情请参见“垃圾回收”)。

    这种自动化大大减少了潜在的内存泄漏(“我忘记释放client的内存”)或悬空指针(“我释放了client的内存,但实际上某些代码仍在使用它”)的风险。

通常情况下,我们不需要关心 Go 语言对象的存储段。

我如何知道变量是在堆上还是栈上分配的?从正确性的角度来看,您无需知道。在 Go 中,每个变量存在的时间取决于是否有对它的引用。实现选择的存储位置与语言语义无关。

存储位置确实会影响编写高效程序。

Go 团队,《Go:常见问题解答(FAQ)》

但是,由于分配如此轻松,存在未注意到内存浪费的风险。

透明分配意味着存在过度使用的风险

在 Go 中,分配是隐式的,这使得编码更加简单,但也有折衷之处。其中一个是内存效率:如果我们看不到显式的内存分配和释放,很容易忽略代码中明显的高内存使用情况。

这类似于用现金还是信用卡购物。使用信用卡可能会超支,因为看不到钱的流动。使用信用卡时,我们花的钱对我们来说几乎是透明的——在 Go 中的分配也是如此。

总之,Go 是一种非常高效的语言,因为在编程时,我们不需要担心变量和抽象数据存储的位置和方式。然而,有时当我们的测量指示效率问题时,了解我们的程序中可能分配一些内存的部分,以及如何发生和释放内存,还是很有用的。所以让我们揭开这一点。

值、指针和内存块

在我们开始之前,让我们搞清楚一件事——你不需要知道什么类型的语句会触发内存分配,以及分配发生在哪里(堆栈或堆),以及分配了多少内存。但是,正如你将在第七章和第九章中学到的,许多强大的工具可以快速而准确地告诉我们所有这些信息。在大多数情况下,我们可以在几秒钟内找到哪些代码行分配了多少内存。因此,通常有一个共同的主题:我们不应该去猜测这些信息(因为人类倾向于猜错),因为有工具可以提供这些信息。

通常如此,但建立一些基本的分配意识并无妨。相反,这可能使我们在使用这些工具分析内存使用时更加有效。目标是建立对哪些代码片段可能分配大量内存的健康直觉,以及我们需要小心的地方。

许多书籍试图通过列出常见的分配语句的例子来教授这一点。这很好,但有点像给某人一条鱼而不是钓鱼竿。因此,这很有帮助,但仅适用于“常见”的语句。理想情况下,我希望你理解背后的规则,了解为什么某些东西会分配内存。

让我们深入了解在 Go 中如何引用对象,以便更快地注意到那些分配。我们的代码可以对存储在某些内存中的对象执行某些操作。因此,我们必须通过变量将这些对象与操作关联起来,通常使用 Go 的类型系统来描述这些变量,以便编译器和开发人员更容易理解。

然而,Go 是面向值而不是引用(与许多托管运行时语言相反)。这意味着 Go 变量从不引用对象。相反,变量始终存储对象的整个。对此规则没有例外!

要更好地理解这一点,图 5-6 展示了三个变量的内存表示。

efgo 0506

图 5-6. 进程虚拟内存上分配的三个变量的表示

将变量视为持有值的盒子

每当编译器在调用作用域中看到 var 变量或函数参数的定义(包括参数),它就为盒子分配一个连续的“内存块”。该盒子足够大,可以容纳给定类型的整个值。例如,var var1 intvar var2 int 需要一个八字节的盒子。²⁹

由于我们在“盒子”中有可用空间,我们可以复制一些值。在图 5-6 中,我们可以复制一个整数 1var1。现在,Go 没有引用变量,所以即使我们将 var1 的值分配给另一个名为 var2 的盒子,这仍然是另一个具有唯一空间的盒子。我们可以通过打印 &var1&var2 来确认。它应该分别打印 0xA0400xA038。因此,简单赋值始终是一种复制,它增加了与值大小成比例的延迟。

与 C++ 不同,Go 程序中每个变量的定义占据唯一的内存位置。不可能创建一个 Go 程序,其中两个变量共享同一内存存储位置。可以创建两个内容指向同一存储位置的变量,但这并不相同。

Dave Cheney,《“Go 中没有传引用”》

var3 盒子是指向整数类型的指针。一个“指针”变量是一个存储表示内存地址的值的盒子。内存地址的类型只是 uintptrunsafe.Pointer,因此只是一个允许指向内存中另一个值的 64 位无符号整数。因此,任何指针变量都需要一个八字节的盒子。

指针也可以是nil(Go 的 NULL 值),表示指针不指向任何东西。在图 5-6 中,我们可以看到var3盒子也包含一个值—var1盒子的内存地址。

这也与更复杂的类型一致。例如,var var4var var5 都只需要为 24 字节的盒子。这是因为slice 结构体值有三个整数。

Go 切片的内存结构

Slice 允许轻松动态操作给定类型的基础数组。切片数据结构需要一个可以容纳长度容量和指向所需数组的指针的内存块。³⁰

通常,切片只是一个更复杂的结构体。你可以将结构体看作是一个文件柜—它充满了抽屉(结构体字段),这些抽屉只是简单地与同一文件柜中的其他抽屉共享内存块。因此,例如,slice 类型有三个抽屉,其中一个是指针类型。

slice 和其他几种特殊类型有两种特殊行为:

  • 您可以使用内置函数make,它仅适用于mapchanslice类型。它返回类型的值³¹并分配底层结构,例如为切片分配数组,为通道分配缓冲区和为映射分配哈希表。

  • 我们可以将nil放入类型的盒子中,例如funcmapchanslice,尽管它们严格来说不是指针,例如[]byte(nil)

var4var5柜子的一个抽屉是一种指针类型,它保存了内存地址。由于在var5中的make([]byte, 5000),它指向另一个内存块,其中包含一个 5000 元素的字节数组。

结构填充

三个 64 位字段的切片结构需要一个 24 字节长的内存块。但是结构类型的内存块大小并不总是其字段大小的总和!

像 Go 中的智能编译器一样,可能会尝试将类型大小对齐到典型的缓存行或操作系统或内部 Go 分配器页面大小。因此,Go 编译器有时会在字段之间添加填充³²。

为了加强这一知识,让我们在设计新函数或方法时提出一个常见问题:我的参数应该是指针还是值?当然,我们首先要回答的显然是,如果我们希望调用者看到该值的修改。但效率也是一个方面。让我们讨论一下示例 5-4 的差异,假设我们不需要从外部看到这些参数的修改。

示例 5-4. 使用值、指针以及像slice这样的特殊类型,不同的参数突出显示了它们的差异。
func myFunction(
    arg1 int, arg2 *int, ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    arg3 biggie, arg4 *biggie, ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    arg5 []byte, arg6 *[]byte, ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    arg7 chan byte, arg8 map[string]int, arg9 func(), ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
) {
   // ...
}

type biggie struct { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    huge [1e8]byte
    other *biggie
}

1

函数参数就像任何新声明的变量一样:盒子。因此,对于arg1,它将创建一个八字节的盒子(很可能将其分配在堆栈上)并在调用myFunction时复制传递的整数。对于arg2,它将创建一个类似的八字节盒子,它将复制指针而不是整数。

对于这样简单的类型,如果不需要修改值,则避免使用指针更有意义。您使用相同量的内存和相同的复制开销。唯一的区别是arg2指向的值必须存在于堆上,这更昂贵,并且在许多情况下可以避免。

2

对于自定义的struct参数,规则是相同的,但是大小和复制开销可能更重要。例如,arg3是一个巨大的biggie结构,它具有非凡的大小。由于具有 1 亿个元素的静态数组,该类型需要一个约 100 MB 的内存块。

对于像这样的大型类型,在通过函数传递时应考虑使用指针。这是因为每次调用 myFunction 都会为 arg3 框在堆上分配 100 MB(它太大了,无法放在栈上)!此外,它还会花费 CPU 时间在框之间复制大对象。因此,arg4 将在栈上分配八字节(仅复制那部分)并指向堆上的 biggie 对象,这个对象可以在函数调用之间重复使用。

请注意,尽管 biggiearg3 中被复制,但复制是浅层的,即 arg3.other 将与前一个框共享内存!

3

slice 类型的行为类似于 biggie 类型。我们必须记住切片的底层struct类型(参见 切片的底层struct类型)。

因此,arg5 将分配一个 24 字节的框并复制三个整数。相比之下,arg6 将分配一个八字节的框并只复制一个整数(指针)。从效率的角度来看,这并不重要。只有在我们希望公开对底层数组的修改(arg5arg6 都允许)或者在我们希望公开对 pointerlencap 字段的修改时,才重要。

4

chanmapfunc() 等特殊类型可以类似于指针进行处理。它们通过堆共享内存,唯一的成本是将指针值分配并复制到 arg7arg8arg9 框中。

相同的决策流程可以应用于决定指针与值类型的选择:

  • 返回参数

  • struct 字段

  • map、slice 或 channels 的元素

  • 方法接收器(例如 func (receiver) Method()

希望前面的信息能让您了解哪些 Go 代码语句会分配内存以及大致分配了多少内存。一般来说:

  • 每个变量声明(包括函数参数、返回参数和方法接收器)都会分配整个类型或者只是指向它的指针。

  • make 会分配特殊类型及其底层(指向的)结构。

  • new(<type>) 等同于 &<type>,因此它会在单独的内存块中为类型分配指针框和堆中的类型。

大多数程序内存分配只有在运行时才能知道;因此,需要动态分配(在堆上)。因此,当我们优化 Go 程序的内存时,99% 的情况下我们只关注堆。Go 自带两个重要的运行时组件:分配器和垃圾收集器(GC),负责堆管理。这些组件是程序运行时引入的非平凡软件部分,通常会通过程序运行时引入某些浪费的 CPU 循环和一些内存浪费。鉴于其非确定性和非立即释放内存的特性,详细讨论这一点是值得的。让我们在接下来的两个部分中详细讨论这个问题。

Go 分配器

管理堆远非易事,因为它对物理内存的挑战与操作系统面对的相似。例如,Go 程序运行多个 goroutine,并且每个 goroutine 对堆内存的需求大小不同且动态变化。

Go 分配器是由 Go 团队维护的一块内部运行时 Go 代码。顾名思义,它可以动态(在运行时)分配操作对象所需的内存块。此外,它经过优化,以避免锁定和碎片化,并减少向操作系统的慢系统调用。

在编译过程中,Go 编译器执行复杂的栈逃逸分析,以检测是否可以自动分配对象的内存(见 Example 4-3)。如果可以,它会添加适当的 CPU 指令,将相关的内存块存储在内存布局的堆栈段中。然而,在大多数情况下,编译器无法避免将大部分内存放在堆上。在这些情况下,它会生成不同的 CPU 指令来调用 Go 分配器代码。

Go 分配器负责在虚拟内存空间中进行内存块装箱。如果需要,它会使用 mmap 请求更多空间来自操作系统,使用私有的匿名页面,并且这些页面被初始化为零。³³ 正如我们在 “操作系统内存映射” 中学到的,这些页面只有在访问时才会在物理 RAM 上分配。

一般来说,Go 开发者可以不了解 Go 分配器的内部细节。但是,记住以下几点是足够的:

  • 它基于名为 TCMalloc 的自定义 Google C++ malloc 实现。

  • 它了解操作系统虚拟内存页,但是操作的是 8 KB 的页面。

  • 它通过将内存块分配给包含一个或多个 8 KB 页面的特定 span 来缓解碎片化问题。每个 span 都为类内存块大小创建。例如,在 Go 1.18 中,有 67 个不同的 大小类(大小桶),最大的是 32 KB。

  • 不包含指针的对象的内存块标记为 noscan 类型,这样可以更轻松地在垃圾收集阶段跟踪嵌套对象。

  • 对象的内存块超过 32 KB(例如,600 MB 的字节数组)会被特殊处理(直接分配,而非使用 span)。

  • 如果运行时需要更多来自操作系统的虚拟空间用于堆,它会一次性分配更大的内存块(至少 1 MB),从而分摊系统调用的延迟。

所有上述内容都在不断变化,开源社区和 Go 团队不断添加各种小优化和功能。

他们说,一段代码片段胜过千言万语,因此让我们通过一个例子来可视化并解释由 Go、操作系统和硬件混合造成的一些分配特性。示例 5-5 展示了与示例 5-3 相同的功能,但不使用显式的mmap,而是依赖于 Go 的内存管理,且没有底层文件。

示例 5-5。分配一个大的[]byte切片,然后使用不同的访问模式
b := make([]byte, 600*1024*1024) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
b[5000] = 1
b[100000] = 1
b[104000] = 1 ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
for i := range b { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
   b[i] = 1
}

1

变量b声明为[]byte切片。以下的make语句用于创建一个包含 600 MB 数据的字节数组(大约 6 亿个元素)。这个内存块被分配在堆上。³⁴

如果我们仔细分析这种情况,可以看到 Go 分配器似乎为该切片创建了三个连续的匿名映射,虚拟内存大小分别为 2 MB、598 MB 和 4 MB。(总大小通常比请求的 600 MB 要大,这是因为 Go 分配器内部采用分桶算法。)让我们总结一下这些有趣的统计数据:

  • 我们切片使用的三个内存映射的 RSS 分别为 548 KB、0 KB 和 120 KB(远低于虚拟内存大小)。

  • 整个进程的总 RSS 显示为 21 MB。分析显示大部分来自堆外。

  • Go 报告堆大小为 600.15 MB(尽管 RSS 明显较低)。

2

只有在我们开始访问切片元素(无论是写入还是读取)后,操作系统才会开始保留围绕这些元素的实际物理内存。我们的统计数据:

  • 三个内存映射的 RSS 分别为:556 KB、(仍然)0 KB 和 180 KB(比访问前稍多几 KB)。

  • 总 RSS 仍显示为 21 MB。

  • Go 报告堆大小为 600.16 MB(实际上稍多几 KB,可能是由于后台 goroutine 的原因)。

3

当我们循环访问所有元素后,我们将看到操作系统按需为我们的b切片映射了所有页面到物理内存。我们的统计数据证明了这一点:

  • 三个内存映射的 RSS 分别为:1.5 MB(完全映射)、598 MB 和 1.2 MB。

  • 整个进程的总 RSS 显示为 621.7 MB(最终与堆大小相同)。

  • Go 报告堆大小为 600.16 MB。

这个例子可能与示例 5-2 和 5-3 相似,但也有所不同。请注意,在示例 5-5 中,并没有(显式)涉及可以在页面未映射时存储数据的文件。我们还利用 Go 分配器来高效组织和管理不同的匿名页面映射,而在示例 5-3 中,Go 分配器并不知道该内存使用情况。

Go 运行时内部知识与操作系统知识

Go 分配器通过我们可以通过不同的可观察性机制收集的某些信息进行跟踪,如在第六章中讨论的。

使用这些时要小心。在前面的例子中,我们看到由 Go 分配器跟踪的堆大小明显大于物理 RAM 上实际使用的内存(RSS)!³⁵ 同样,例如明确的mmap使用,如示例 5-3,在任何 Go 运行时指标中都没有反映出来。这就是为什么在我们的 TFBO 之旅中,如在“内存使用”中讨论的那样,依赖于不止一个指标是好的。

Go 堆管理的行为支持按需分页,往往是不确定和模糊的。我们也不能直接控制它。例如,如果你在你的机器上尝试重现示例 5-5,你很可能会观察到略有不同的映射,更多或更少不同的 RSS 数字(具有几 MB 的容差),以及不同的堆大小。这一切都取决于你使用的 Go 版本,内核版本,RAM 容量和型号,以及系统负载。这对我们 TFBO 过程的评估步骤提出了重要挑战,我们将在“实验的可靠性”中讨论。

不要因为内存增加了一点而感到困扰

不要试图理解你的进程 RSS 内存的每一百字节或千字节来自哪里。在大多数情况下,无法在那么低的层次上告知或控制。堆管理开销,由操作系统和 Go 分配器进行的推测性页面分配,动态的操作系统映射行为,以及最终的内存收集(我们将在下一节中了解)使得在这种“微观”的千字节级别上的事情变得不确定。

即使你在一个环境中发现了某种模式,在其他环境中也会有所不同,除非我们谈论的是数百兆字节或更多的大数字!

这里的教训是我们必须调整我们的思维方式。总会有一些未知数。重要的是理解对可能导致内存使用过高的最大贡献的更大未知数。结合这种分配器意识,你将在第六章和第九章学习如何做到这一点。

到目前为止,我们已经讨论了如何通过 Go 分配器有效地为我们的内存块预留内存,并且如何访问它。然而,如果没有逻辑来释放我们的代码不再需要的内存块,我们就不能无限制地预留更多内存。这就是为什么理解堆管理的第二部分——垃圾收集,负责从堆中释放未使用的对象,至关重要。让我们在下一节中探讨这个问题。

垃圾收集

你付出的内存分配成本不止一次。第一次显然是当你分配它时。但每次垃圾收集运行时你都要付出。

Damian Gryski,“go-perfbook”

堆管理的第二部分类似于吸尘您的房子。它涉及到从程序的堆中移除寓意的垃圾 - 未使用的对象的过程。一般来说,垃圾收集器 (GC) 是一个额外的后台例程,在特定时刻执行 “收集”。收集的节奏至关重要:

  • 如果 GC 运行不够频繁,我们就有可能分配大量新的 RAM 空间,而无法重新使用当前由垃圾(未使用对象)分配的内存页面。

  • 如果 GC 运行过于频繁,我们就有可能花费大部分程序时间和 CPU 资源在 GC 工作上,而不是推进我们的功能。我们将在后面学到,GC 虽然相对快速,但它可能会直接或间接地影响系统中其他 goroutine 的执行,尤其是如果堆中有许多对象(如果我们分配了很多内存)。

GC 运行的间隔不是基于时间的。相反,两个配置变量(独立工作)定义了节奏:GOGC 和从 Go 1.19 开始的 GOMEMLIMIT。要了解更多信息,请阅读 有关 GC 调优的官方详细指南。对于本书,让我们简要解释一下:

GOGC 选项代表 “GC 百分比”。

GOGC 默认启用,值为 100。这意味着下一个 GC 收集将在堆大小扩展到上一个 GC 周期结束时的大小的 100% 时进行。GC 的节奏算法根据当前堆的增长估计何时可以达到该目标。也可以使用 debug.SetGCPercent 函数 进行程序设置。

GOMEMLIMIT 选项控制软内存限制。

GOMEMLIMIT 选项在 Go 1.19 中引入。默认情况下禁用(设置为 math.MaxInt64),在接近(或超过)设置的内存限制时可以更频繁地运行 GC。它可以与 GOGC=off(禁用)一起使用,也可以使用 debug.SetMemoryLimit 函数 进行程序设置。

GOMEMLIMIT 不会阻止您的程序分配超过设置值的内存!

GC 的软内存限制配置之所以被称为 “软”,是有原因的。它告诉 GC 有多少内存超额空间可以用于 GC 的 “懒惰”,以节省 CPU 时间。

但是,当您的程序分配并使用超过所需限制的内存时,如果设置了 GOMEMLIMIT 选项,情况只会变得更糟。这是因为 GC 几乎会持续运行,占用其他功能宝贵的 25% CPU 时间。

我们仍然需要优化程序的内存效率!

手动触发。

程序员也可以通过调用 runtime.GC() 来手动触发另一次 GC 收集。这在测试或基准测试代码中经常使用,因为它可以阻塞整个程序。其他像 GOGCGOMEMLIMIT 的节奏配置可能会在其中运行。

Go 的 GC 实现可以描述为并发、非代数、三色标记和扫描收集器的实现。无论是由程序员还是由基于运行时的GOGCGOMEMLIMIT选项调用,runtime.GC()实现都包括几个阶段。第一个阶段是标记阶段,必须要:

  1. 执行“停止世界”(STW)事件,向所有 goroutine 注入一个关键的写入屏障(对写入数据的锁)。尽管 STW 相对较快(平均 10 至 30 微秒),但影响相当大——它会暂停进程中所有 goroutine 的执行。

  2. 尝试使用进程分配的 CPU 容量的 25%来并发标记堆中仍在使用的所有对象。

  3. 通过从 goroutine 中移除写入屏障来终止标记。这需要另一个 STW 事件。

在标记阶段之后,GC 功能通常完成。尽管听起来很有趣,GC 并不释放任何内存!相反,清扫阶段会释放未标记为使用中的对象。这是懒惰完成的:每次 goroutine 要通过 Go 分配器分配内存时,必须先执行清扫工作,然后再分配。尽管技术上是垃圾收集功能,但这被计算为分配延迟值得注意!

一般来说,Go 分配器和 GC 构成了复杂的桶式对象池的实现,其中每个不同大小的槽池都为即将到来的分配做好准备。当一个分配不再需要时,它最终会被释放。这种分配的内存空间不会立即释放给操作系统,因为它可能很快被分配给另一个即将到来的分配(这类似于使用sync.Pool进行池化模式的讨论,我们将在“内存重用和池化”中讨论)。当自由桶的数量足够大时,Go 会释放内存给操作系统。但即便如此,也不一定意味着运行时立即删除映射区域。例如,在 Linux 上,Go 运行时通常通过默认情况下的madvise系统调用,使用MADV_DONTNEED参数“释放”内存。³⁶ 这是因为我们的映射区域可能很快再次被需要,因此保留它们以防万一并要求操作系统只有在其他进程需要这些物理内存时才将它们收回,这样更快。

注意,当应用于共享映射时,MADV_DONTNEED可能不会立即释放范围内的页面。内核可以延迟释放页面直到合适的时机。但是,调用进程的驻留集大小(RSS)将立即减少。

Linux 社区, "madvise(2),Linux 手册页面"

有了 GC 算法背后的理论,我们可以更容易地理解在 示例 5-6 中,如果我们试图清理在 示例 5-5 中创建的大型 600 MB 字节切片使用的内存时会发生什么。

示例 5-6. 大型切片在 示例 5-5 中创建后的内存释放(解分配)
b := make([]byte, 600*1024*1024)
for i := range b { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   b[i] = 1
}

b[5000] = 1 ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
b = nil ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
runtime.GC() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

// Let's allocate another one, this time 300 MB!
b = make([]byte, 300*1024*1024)
for i := range b { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
   b[i] = 2
}

1

正如我们在 示例 5-5 中讨论的,分配大型切片并访问所有元素后的统计数据可能如下所示:

  • 切片在三个内存映射中分配,对应的虚拟内存大小(VSS)分别为:2 MB、598 MB 和 4 MB。

  • 三个内存映射的 RSS 分别为:1.5 MB、598 MB 和 1.2 MB。

  • 整个进程的总 RSS 显示为 621.7 MB。

  • Go 报告堆大小为 600.16 MB。

2

在访问 b 中的数据后,即使在 b = nil 之前,GC 的 Mark 阶段也会将 b 视为“垃圾”来清理。然而,GC 有自己的节奏;因此,即使在此语句之后,内存不会立即释放,内存统计数据仍将保持不变。

3

在典型情况下,当您不再使用 b 值并且函数范围结束时,或者您将 b 内容替换为指向不同对象的指针时,不需要显式的 b = nil 语句。GC 将知道 b 指向的数组是垃圾。然而,有时在长期运行的函数中(例如,通过 Go 通道传递的后台作业的 goroutine),将变量设置为 nil 会更有用,以确保下一次 GC 运行会更早地将其标记为待清理状态。

4

在我们的测试中,让我们手动调用 GC 来查看发生了什么。在这个语句之后,统计数据将如下所示:

  • 所有三个内存映射仍然存在,并且具有相同的 VSS 值。这证明了我们关于 Go 分配器仅建议内存映射,而不会立即删除它们的说法!

  • 三个内存映射的 RSS 分别为:1.5 MB、0(已释放的 RSS)和 60 KB。

  • 整个进程的总 RSS 显示为 21 MB(回到最初的数字)。

  • Go 报告堆大小为 159 KB。

5

让我们再分配一个两倍小的切片。以下的内存统计数据证明了 Go 将尝试重用先前的内存映射的理论!

  • 同样的三个内存映射仍然存在,并且具有相同的 VSS 值。

  • 三个内存映射的 RSS 分别为:1.5 MB、300 MB 和 60 KB。

  • 整个进程的总 RSS 显示为 321 MB。

  • Go 报告堆大小为 300.1 KB。

正如我们之前提到的,GC 的美妙之处在于它简化了程序员的生活,因为它无需担心分配,内存安全性以及对大多数应用程序的高效性。不幸的是,当我们的程序违反了我们的效率期望时,它也使我们的生活变得有点困难,而原因并非你所想象的那样。Go 分配器和 GC 配对的主要问题是,它们隐藏了我们内存效率问题的根本原因——在几乎所有情况下,我们的代码分配了过多的内存!

把垃圾收集器想象成扫地机器人:仅仅因为你有一个,并不意味着你可以告诉你的孩子不要在地板上随意扔垃圾。

Halvar Flake,Twitter

让我们探讨一下在 Go 中,当我们在分配的数量和类型上不小心时,可能会注意到的潜在症状:

CPU 开销

首先且最重要的是,GC 必须遍历堆上存储的所有对象,以确定哪些对象正在使用中。这可能会使用大量的 CPU 资源,特别是如果堆中有许多对象的话。

如果堆上存储的对象富含指针类型,则会强制 GC 遍历它们以检查它们是否指向尚未标记为“正在使用”的对象。鉴于我们计算机中有限的 CPU 资源,我们为 GC 做的工作越多,我们能为核心程序功能执行的工作就越少,这会导致更高的程序延迟。

在具有垃圾收集功能的平台上,内存压力自然会转化为增加的 CPU 消耗。

Google 团队,Site Reliability Engineering

程序潜在延迟的额外增加

消耗在 GC 上的 CPU 时间只是一件事,还有更多。首先,执行两次 STW 事件会减慢所有 goroutine 的速度。这是因为 GC 必须停止所有 goroutine 并注入(然后删除)写入屏障。它还会阻止某些必须在内存中存储数据的 goroutine 在 GC 标记的时候继续工作。

还有第二个经常被忽视的影响。GC 收集运行破坏了分层缓存系统的效率。

为了使您的程序运行更快,您希望所有操作都在缓存中进行。……在硅片中有技术和物理原因,分配内存、丢弃它并由 GC 清理会使您的程序变慢,因为 GC 正在执行其工作,但它还会拖慢您的程序,因为它将所有东西从 CPU 缓存中移除。

Bryan Boreham,“让你的 Go 更快!”

内存开销

自 Go 1.19 以来,已经有一种方法可以为 GC 设置软内存限制。这仍意味着我们经常需要在我们的代码中实现检查,以防止无限制的分配(例如,拒绝读取过大的 HTTP 请求体),但至少 GC 更及时,如果你需要避免那种开销。

尽管如此,收集阶段是最终的。这意味着我们在新的分配到来之前可能无法释放一些内存块。将GOGC选项更改为较少运行 GC 只会加剧问题,但如果你优化 CPU 资源并且在机器上有剩余 RAM 的话,这可能是一个很好的折衷方案。

此外,在极端情况下,如果垃圾回收速度不足以处理所有新的分配,我们的程序甚至可能会泄露内存!

垃圾回收器有时会对我们程序的效率产生意想不到的影响。希望在本节之后,你能够注意到何时受到影响。你还可以通过第九章中解释的可观察性工具来发现垃圾回收的瓶颈,如第九章所述。

大多数内存效率问题的解决方案

产生更少的垃圾!

在 Go 中很容易过度分配内存。这就是为什么解决垃圾回收瓶颈或其他内存效率问题的最佳方法是减少分配。我将介绍“三 R 优化方法”,它通过不同的优化帮助解决这些效率问题。

总结

这是一章长篇大论,但你做到了!遗憾的是,内存资源是最难解释和掌握的之一。或许这就是为什么有这么多机会来减少我们 Go 程序分配的大小或数量。

你了解了我们的代码需要在内存中分配比特和这些比特最终落到 DRAM 芯片之间的漫长多层路径。你了解了许多内存折衷、行为以及在操作系统层面的后果。最后,你现在知道 Go 如何使用这些机制,以及为什么 Go 中的内存分配如此透明。

或许你已经可以找出为什么示例 4-1 在输入文件为 3MB 时每次操作都使用 30.5MB 堆的根本原因了。在“优化内存使用”中,我将提出算法和代码改进来减少示例 4-1 使用的内存量,同时提高延迟。

这个领域正在不断发展,这点很重要。Go 编译器、Go 垃圾回收器和 Go 分配器都在不断改进、变化和扩展,以满足 Go 用户的需求。然而,大多数即将到来的变更可能仅仅是我们现在在 Go 中已有内容的迭代。

我们将要介绍的第六章和第七章是本书中我认为最关键的两章。我已经在之前的章节中提到了许多工具,用来解释主要概念:度量、基准测试和性能分析。现在是详细学习它们的时候了!

¹ 在本书中,当我说“内存”时,我指的是 RAM,反之亦然。其他媒介在计算机体系结构中提供了将数据“记忆”的方法(例如 L 缓存),但我们倾向于将 RAM 视为“主”内存资源。

² 不仅因为物理限制,如芯片引脚不足、空间不足和为晶体管提供能量不足,而且因为管理大内存会带来巨大的开销,正如我们将在“操作系统内存管理”中学到的那样。

³ 在某种程度上,RAM 的易失性有时可以被视为一种特性,而不是缺陷!你是否曾想过为什么重新启动计算机或进程通常可以解决问题?内存的易失性迫使程序员实施强大的初始化技术,从备份介质重建状态,提高可靠性并减少潜在的程序错误。在极端情况下,仅崩溃软件与重启是主要的故障处理方式。

⁴ 我们可以通过简单地增加系统内存或切换到具有更多内存资源的服务器(或虚拟机)来解决这个问题。如果不是内存泄漏并且可以增加这样的资源(例如,云中有更多内存的虚拟机),这可能是一个可靠的解决方案。但我建议调查您的程序内存使用情况,特别是如果您不断需要扩展系统内存。那么,由于可以优化的微不足道的浪费空间,可能存在简单的优势。

⁵ 如今,像 UTF-8 这样的流行编码可以动态地使用从一个到四个字节的内存来表示单个字符。

⁶ 通过简单地将“指针”大小加倍,我们将可以扩展可以寻址的元素数量到极限。我们甚至可以估计 64 位足以寻址地球上所有沙滩上的所有沙粒!

⁷ 我在“操作系统调度器”中介绍了“进程”和“线程”术语。

⁸ 由于各种 bug 导致的许多常见漏洞和暴露(CVE)问题存在,允许越界访问内存

⁹ 这可能不太直观,但恶意进程如果无法限制对另一个进程内存的访问,就可能执行拒绝服务攻击(DoS)。例如,通过将计数器设置为不正确的值或破坏循环不变量,受害程序可能会出错或耗尽机器资源。

¹⁰ 在过去,分段被用于实现虚拟内存。这被证明缺乏灵活性,特别是不能移动这个空间以进行碎片整理(更好地打包内存)。即使使用页面方式,分段也由进程本身(以底层页面为基础)应用于虚拟内存。此外,内核有时仍然使用非分页分段来管理其关键内核内存部分。

¹¹ 您可以使用 getconf PAGESIZE 命令在 Linux 系统上检查当前页面大小。

¹² 例如,通常情况下,Intel CPU 支持硬件支持的 4 KB, 2 MB 或 1 GB 页

¹³ 即使是天真和保守的计算,也表明大约 24% 的总内存用于浪费了 2 MB 的页面

¹⁴ 我们不会讨论页表的实现,因为这相当复杂,不是 Go 开发者需要担心的事情。然而,这个主题非常有趣,因为分页的简单实现会导致内存使用的巨大开销(如果内存管理占用大部分内存空间,那么内存管理的意义何在?)。您可以在 这里 了解更多信息。

¹⁵ 还有一个选项可以在 Linux 上 禁用过度承诺机制。当禁用时,虚拟内存大小(VSS)不允许大于进程使用的物理内存(RSS)。您可能希望这样做以便进程通常具有更快的内存访问,但浪费的内存是巨大的。因此,我从未见过这样的选项在实践中使用。

¹⁶ MAP_SHARED 意味着如果其他进程访问相同的文件,它们可以重用相同的物理内存页面。如果映射文件随时间不变化,这是无害的,但对于映射可修改内容有更复杂的细微差别。

¹⁷ 可以在 mmap 文档 中找到所有选项的完整列表。

¹⁸ SIGSEV 表示分段错误。这告诉我们进程试图访问一个无效的内存地址。

¹⁹ 在 Linux 上,您可以通过执行 ps -ax --format=pid,rss,vsz | grep $PID 来找到这些信息,其中 $PID 是进程 ID。

²⁰ 我怎么知道?我们可以通过 /proc/*<PID>*/smaps 文件在 Linux 上为每个内存映射进程获取精确的统计信息。

²¹ 有许多原因说明为什么在内存映射的情况下访问附近的字节可能不需要在 RAM 上分配更多页面。例如,缓存层次结构(在 “层次化缓存系统” 中讨论)、操作系统和编译器决定一次性拉取更多内容,或者这样的页面已经是由于先前访问而共享或私有页面。

²² 请注意,操作系统仍然可以为该文件分配物理页框,但不会计入我们的进程中。这被称为 页缓存,如果任何进程尝试记忆同一文件,它可能会在系统面临高内存压力时或由管理员手动释放,例如通过 sysctl -w vm.drop_caches=1

²³ 大多数计算机通常默认关闭交换。

²⁴ “教授 OOM 杀手” 解释了在选择要首先终止的进程时遇到的一些问题。这里的教训是全局 OOM 杀手通常难以 预测

²⁵ 可以在 这里 找到内存控制器的精确实现。

²⁶ 请记住,无论操作系统为进程提供什么类型或数量的虚拟内存,它都使用内存映射技术。 sbrk 允许更简单地调整通常由堆覆盖的虚拟内存部分的大小。但是,它像任何其他使用匿名页面的 mmap 一样工作。

²⁷ 当然,没有人阻止在 C 和 C++ 中使用这些机制之上实现外部垃圾回收。

²⁸ Rust 中的所有权模型要求程序员对每个内存分配及其拥有部分都要非常警觉。尽管如此,如果我们能够将这种内存管理范围仅限于代码的某一部分,我仍然是 Rust 所有权模型的铁杆粉丝。我相信,将某种所有权模式引入 Go 会是有益的,其中少量代码可以使用该模式,而其余代码则使用垃圾回收。期待有朝一日能实现这一愿景? 😃

²⁹ 您可以使用 unsafe.Sizeof 函数来显示盒子的大小。

³⁰ 请查看便捷的 reflect.SliceHeader 结构,它表示一个切片。

³¹ 从技术上讲,类型 map 的变量是指向 hashmap 的指针。然而,为了避免总是键入 *map,Go 团队决定 隐藏该细节

³² 我们不会在本版本中讨论 结构体填充。还有一个很棒的工具可以帮助您注意 由结构体对齐引入的浪费

³³ 这也是为什么在 Go 中,每个新结构体开始时都有定义好的零值或 nil,而不是随机值的原因之一。

³⁴ 我们知道这一点是因为go build -gcflags="-m=1" slice.go输出了./slice.go:11:11: make([]byte, size) escapes to heap这一行。

³⁵ 这种行为经常被更高级的内存装载所利用,但在 Go 1.19 引入讨论中的内存软限制后通常不再需要。详细内容请参见“垃圾回收”。

³⁶ 通过更改GODEBUG 环境变量,我们还可以改变 Go 的内存释放策略。例如,我们可以设置GODEBUG=madvdontneed=0,这样就会改用MADV_FREE来通知操作系统关于不需要的内存空间。MADV_DONTNEEDMADV_FREE之间的区别恰好在 Linux 社区引用中提到的点上。对于MADV_FREE,对于 Go 程序,内存释放甚至更快,但是调用进程的常驻集大小(RSS)指标在操作系统回收该空间之前可能不会立即减少。这在一些系统上(例如轻虚拟化系统如 Kubernetes)已被证明引发了严重问题,因为它们依赖 RSS 来管理进程。这种情况发生在 2019 年,当 Go 默认为MADV_FREE几个版本。更多相关信息在我的博客文章中有详细解释。

³⁷ 严格来说,Go 确保分配给进程的总 CPU 的最大 25%用于 GC。然而,这并不是万能解决方案。通过减少最大 CPU 使用时间,我们只是在较长时间段内使用相同的量。

第六章:效率可观察性

在“高效开发流程”中,你学会了遵循 TFBO(测试、修复、基准测试和优化)流程,以最小的努力验证和实现所需的效率结果。在效率阶段的元素周围,可观察性起着关键作用,特别是在第 7 和第九章。我们将在图 6-1 中专注于该阶段。

efgo 0601

图 6-1。图 3-5 的一部分,重点关注需要良好可观察性的部分

在本章中,我将解释这一流程所需的可观察性和监控工具。首先,我们将了解什么是可观察性以及它解决了什么问题。然后,我们将讨论不同的可观察性信号,通常分为日志、追踪、指标,以及最近的概要。接下来,我们将在“示例:为延迟进行仪器化”中解释前三个信号,以延迟作为我们可能想要测量的效率信息的示例(概要在第九章中解释)。最后但同样重要的是,我们将通过“效率指标语义”详细介绍与程序效率相关的指标的特定语义和来源。

你无法改进你不测量的东西!

这句经常被归因于彼得·德鲁克的名言是改善任何事物的关键:业务收入、汽车效率、家庭预算、体脂肪,甚至幸福

特别是当涉及到我们低效软件产生的看不见的浪费时,我们可以说,如果在改变之前和之后不进行评估和测量,优化软件是不可能的。每个决定都必须以数据驱动,因为在这个虚拟空间中,我们的猜测往往是错误的。

毫不拖延,让我们学习如何以最简单的方式来衡量我们软件的效率——这个行业称之为可观察性的概念。

可观察性

要控制软件的效率,我们首先需要找到一种结构化和可靠的方法来测量我们的 Go 应用程序的延迟和资源使用情况。关键是尽可能准确地计算这些值,并在最后以易于理解的数值形式呈现出来。这就是为什么对于消耗测量,我们有时(并非总是!)使用“度量信号”,这是被称为可观察性的基本软件(或系统)特征的支柱。

可观察性

在云原生基础设施世界中,我们经常谈论我们应用程序的可观察性。不幸的是,可观察性是一个非常负载的词。¹ 它可以总结如下:从外部信号推断系统状态的能力。

当今行业使用的外部信号通常可以大致分为四类:指标、日志、追踪和性能分析。²

可观测性是当今一个重要的话题,因为它在开发和运行软件过程中能帮助我们应对许多情况。可观测性模式使我们能够调试程序的失败或意外行为,找到事故的根本原因,监控健康状况,对未预料到的情况进行警报,执行计费,测量SLI(服务水平指标),进行分析等等。当然,我们只会专注于可观测性的那些部分,这些部分将帮助我们确保软件的效率与我们的需求相匹配(即 RAERs 在“效率要求应被正式化”中提到的)。那么,什么是可观测性信号?

  • 指标是对时间间隔内数据的数值表示。指标可以利用数学建模和预测的能力,从而推导出系统在当前和未来时间间隔内的行为知识。
  • 事件日志是一种不可变的、带有时间戳的记录,记录了随时间发生的离散事件。一般来说,事件日志有三种形式,但本质上是相同的:时间戳和一些上下文信息的负载。
  • 追踪是一个代表一系列因果相关的分布式事件的表示形式,它编码了通过分布式系统的端到端请求流。追踪是日志的一种表示形式;追踪的数据结构几乎与事件日志相似。一个单独的追踪可以提供对请求经过的路径以及请求结构的可见性。

Cindy Sridharan,《分布式系统可观测性》(O’Reilly,2018)

一般来说,所有这些信号都可以用来观察我们的 Go 应用程序的延迟和资源消耗,以进行优化。例如,我们可以测量特定操作的延迟,并将其作为指标暴露出来。我们可以将该值编码为日志行或追踪注释(例如,“行李”项)。我们可以通过减去两个日志行的时间戳来计算延迟——操作开始和操作完成的时间戳。我们可以使用追踪跨度来跟踪跨度的延迟(完成的个体工作单元)。

但是,无论我们用什么方式将这些信息传递给我们(通过特定于度量的工具、日志、追踪或概要文件),最终都必须具有度量语义。我们需要将信息推导为数值,以便随时间收集它;进行减法运算;查找最大值、最小值或平均值;以及按维度聚合。我们需要这些信息来进行可视化和分析。我们需要它允许工具在需要时做出反应并提醒我们,可能构建进一步消费它的自动化,并比较其他指标。这就是为什么效率讨论通常会通过度量聚合进行导航的原因:我们应用程序的尾延迟、随时间的最大内存使用等。

正如我们讨论的那样,为了优化任何东西,您必须开始测量它,因此行业已经开发出许多指标和工具来捕获各种资源的使用情况。观察或测量的过程始终从仪器化开始。

仪器化

仪器化是向我们的代码添加或启用仪器,以公开我们所需的可观察信号的过程。

仪器化可以采用多种形式:

手动仪器化

我们可以向我们的代码添加几个语句,导入一个生成可观测信号的 Go 模块(例如,Prometheus 客户端用于度量go-kit 日志记录器,或者跟踪库),并将其连接到我们执行的操作中。当然,这需要修改我们的 Go 代码,但通常会产生更个性化和丰富的信号以及更多的上下文。通常,它代表了开箱即用信息,因为我们可以收集针对程序功能定制的信息。

自动仪器化

有时,仪器化意味着安装(和配置)一个工具,该工具可以通过观察外部效果来获取有用信息。例如,服务网格通过观察 HTTP 请求和响应来收集可观察性,或者工具钩入操作系统并通过cgroupseBPF收集信息。³ 自动仪器化不需要更改和重新构建代码,并且通常代表了封闭盒信息。

此外,基于信息的粒度对仪器化进行分类也很有帮助:

捕获原始事件

此类仪器化将尝试为我们过程中的每个事件提供单独的信息。例如,假设我们想知道我们的进程为所有 HTTP 请求提供了多少次以及发生了什么错误。在这种情况下,我们可以有一个为每个请求提供单独信息的仪器化(例如,作为日志行)。此外,此信息通常包含有关其上下文的一些元数据,例如状态代码、用户 IP、时间戳以及发生错误的进程和代码语句(目标元数据)。

一旦被摄取到某个可观测性后端,这些原始数据在内容上非常丰富,并且理论上允许任何的即席分析。例如,我们可以扫描所有事件以找到错误的平均数或百分位分布(更多内容请参阅“延迟”)。我们可以导航到每个表示单个事件的错误,以便详细检查。不幸的是,这种类型的数据通常是使用、摄取和存储成本最高的数据。我们往往会因此而冒失误的风险,因为可能会错过一个或两个个体事件。在极端情况下,需要复杂的大数据和数据挖掘探索技能和自动化来找到您想要的信息。

捕获聚合信息

我们可以捕获预聚合数据而不是原始事件。由这种仪器化交付的每一条信息都代表一组事件的某些信息。在我们的 HTTP 服务器示例中,我们可以计算成功和失败的请求,并定期传递该信息。在转发此信息之前,我们甚至可以进一步计算代码内的错误比率。值得一提的是,这种类型的信息也需要元数据,以便我们可以总结、进一步聚合、比较和分析这些聚合的信息片段。

预聚合的仪表化迫使 Go 进程或自动仪器化工具承担更多工作,但通常结果更易使用。此外,由于数据量较小,仪表化、信号传递和后端的复杂性更低,从而显著提高了可靠性并降低了成本。在这里也存在一些权衡。我们会丢失一些信息(通常称为基数)。预建的信息决策是事先做出的,并且编码到仪表化中。如果突然有不同的问题需要回答(例如,跨多个进程的单个用户有多少错误),而您的仪器化未设置为预聚合该信息,那么您就需要进行更改,这需要时间和资源。然而,如果您大致知道将来会询问什么,聚合类型的信息就是一个了不起的胜利和更加务实的方法。⁴

最后但同样重要的是,一般而言,我们可以将我们的可观测流程设计成推拉式采集模型:

推送

一个中心化的远程进程系统从您的应用程序(包括您的 Go 程序)中收集可观测信号。

拉取

一个应用程序进程将信号推送到远程集中的可观测性系统的系统。

推送与拉取

每种惯例都有其利弊。您可以推送您的度量、日志和跟踪,但也可以从您的进程中拉取所有这些。我们还可以使用混合方法,每种可观测信号使用不同的方法。

推送与拉取方法有时是一个有争议的话题。不仅在可观测性方面,在任何其他架构中都是如此。我们将在“指标”中讨论其利弊,但困难的事实是,两种方式都可以同样良好地扩展,只是使用不同的解决方案、工具和最佳实践。

在学习了这三个类别之后,我们应该准备深入探讨可观测信号。为了测量和传递效率优化的可观测信息,我们无法避免学习更多关于仪器化这三种常见可观测信号——记录、跟踪和指标的内容。在下一节中,让我们做到这一点,同时保持一个实际的目标——测量延迟。

示例:为延迟进行仪器化

在本节中,您将学习的所有三种信号都可以用于构建适合我们讨论的三种类别的可观测性。每种信号都可以:

  • 可以手动或自动进行仪器化

  • 提供汇总信息或原始事件

  • 可以从进程中提取(收集,尾部跟踪或抓取)或推送(上传)

然而,每种信号——记录、跟踪或指标——可能在这些工作中更合适或更不合适。在本节中,我们将讨论这些倾向。

学习如何使用可观测信号及其权衡的最佳方法是专注于实际目标。假设我们想要测量代码中特定操作的延迟。如介绍所述,我们需要开始测量延迟以评估其情况,并在每次优化迭代期间决定是否需要进一步优化我们的代码。正如您将在本节中了解到的,我们可以使用任何一种可观测信号来获得延迟结果。关于信息呈现方式、复杂仪器化等细节将帮助您在旅程中理解选择的内容。让我们深入探讨吧!

记录

日志可能是了解仪器的最清晰信号。因此,让我们探索一下最基本的仪器,我们可以将其归类为记录以收集延迟测量数据。在 Go 代码中对单个操作进行基本的延迟测量是直接的,这要归功于标准的time。无论您是手动操作还是使用标准或第三方库来获取延迟数据,如果它们是用 Go 语言编写的,它们都使用了示例 6-1 中使用time包的模式。

示例 6-1. Go 中手动和最简单的单个操作延迟测量
import (
    "fmt"
    "time"
)

func ExampleLatencySimplest() {
    for i := 0; i < xTimes; i++ {
        start := time.Now() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        err := doOperation()
        elapsed := time.Since(start) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

        fmt.Printf("%v ns\n", elapsed.Nanoseconds()) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

        // ...
    }
}

1

time.Now()从操作系统时钟中捕获当前墙上时间(时钟时间),以time.Time形式呈现。请注意xTime,示例变量指定所需的运行次数。

2

在我们的协作函数完成后,我们可以使用time.Since(start)捕获从start到当前时间的时间,该方法返回方便的time.Duration

3

我们可以利用这样的工具来传递我们的度量样本。例如,我们可以使用.Nanoseconds()方法将持续时间以纳秒打印到标准输出。

可以说,示例 6-1 代表了最简单的仪器化和可观测性形式。我们进行延迟测量,并通过将结果打印到标准输出来传递它。鉴于每次操作都将输出一行新数据,示例 6-1 代表了原始事件信息的手动仪器化。

不幸的是,这有点幼稚。首先,正如我们将在“实验的可靠性”中学到的那样,任何单一的测量都可能具有误导性。我们必须捕获更多这样的测量结果,理想情况下是数百或数千次,以供统计目的使用。当我们有一个进程,只有一个我们想要测试或基准测试的功能时,示例 6-1 将打印出我们稍后可以分析的数百个结果。然而,为了简化分析,我们可以尝试预先聚合一些结果。与其记录原始事件,我们可以使用数学平均函数进行预先聚合并输出结果。示例 6-2 展示了将事件聚合成更易于消费结果的修改版本。

示例 6-2. 仪器化 Go,记录 Go 操作的平均延迟
func ExampleLatencyAggregated() {
    var count, sum int64
    for i := 0; i < xTimes; i++ {
        start := time.Now()
        err := doOperation()
        elapsed := time.Since(start)

        sum += elapsed.Nanoseconds() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        count++

        // ...
    }

    fmt.Printf("%v ns/op\n", sum/count) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
}

1

而不是打印原始延迟,我们可以收集总和和操作数的数量。

2

这两个信息片段可用于计算组事件的准确平均值,并呈现给用户,而不是唯一的延迟。例如,一次运行在我的机器上打印了188324467 ns/op字符串。

由于我们停止了对原始事件的延迟呈现,示例 6-2 表示了一种手动的、聚合的信息可观测性。这种方法允许我们快速获取所需的信息,而无需复杂(且耗时的)工具分析我们的日志输出。

此示例展示了 Go 基准测试工具如何进行平均延迟计算。我们可以使用带有_test.go后缀的文件中的示例 6-3 片段实现与示例 6-2 完全相同的逻辑。

示例 6-3. 最简单的 Go 基准测试,将测量每次操作的平均延迟
func BenchmarkExampleLatency(b *testing.B) {
    for i := 0; i < b.N; i++ { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        _ = doOperation()
    }
}

1

在基准测试框架中,带有变量 Nfor 循环是必不可少的。它允许 Go 框架尝试不同的 N 值来执行足够的测试运行,以满足配置的运行次数或测试持续时间。例如,默认情况下,Go 基准测试运行时间为一秒,这通常对于可靠的输出来说太短了。

当我们使用 go test 运行 示例 6-3 时(详细解释见 “Go 基准测试”),它会输出特定的信息。信息的一部分是一个包含运行次数和每次操作的平均纳秒数的结果行。在我的机器上,其中一个运行的输出延迟为 197999371 ns/op,这通常与 示例 6-2 的结果相匹配。我们可以说,Go 基准测试是使用日志记录信号进行自动仪器化,用于诸如延迟等的聚合信息。

除了收集整个操作的延迟信息之外,我们还可以从这些测量的不同粒度中获得许多见解。例如,我们可能希望捕获单个操作内几个子操作的延迟。最后,在更复杂的部署中,当我们的 Go 程序是分布式系统的一部分时,正如 “宏基准测试” 中讨论的那样,我们可能有许多进程需要跨系统进行测量。对于这些情况,我们必须使用更复杂的日志记录器,它们会为我们提供更多的元数据和传递日志信号的方法,不仅仅是简单地打印到文件,还可以通过其他方式实现。

我们必须附加到日志信号的信息量会导致一种称为 Go 中的日志记录器模式的模式。日志记录器是一种结构,允许我们以最简单和最可读的方式手动为我们的 Go 应用程序加入日志。日志记录器隐藏了复杂性,比如:

  • 日志行的格式化。

  • 根据日志级别(例如调试、警告、错误或更多)决定是否记录。

  • 将日志行传递到配置的位置,例如输出文件。还可以选择更复杂的基于推送的日志传递方式,可以将日志传送到远程后端,必须支持退避重试、授权、服务发现等功能。

  • 添加基于上下文的元数据和时间戳。

Go 标准库非常丰富,包含许多有用的工具,包括日志记录。例如,log包含一个简单的日志记录器。它对许多应用程序都能很好地工作,但也容易出现一些使用上的陷阱。⁵

在使用 Go 标准库的日志记录器时请谨慎。

如果你想使用 log 包中的标准 Go 日志记录器,有几件事需要记住:

  • 不要使用全局的 log.Default() 日志记录器,也不要使用 log.Print 等函数。迟早会让你后悔。

  • 永远不要直接在你的函数和结构中存储或使用*log.Logger,特别是当你编写一个库时。⁶ 如果你这样做,用户将被迫使用非常有限的log日志记录器,而不是他们自己的日志记录库。相反,使用自定义接口(例如go-kit 日志记录器),这样用户可以将他们的日志记录器适配到你在代码中使用的日志记录器。

  • 永远不要在主函数之外使用Fatal方法。它会引发 panic,这不应该是你的默认错误处理方式。

为了避免意外陷入这些陷阱,在我参与的项目中,我们决定使用第三方流行的go-kit⁷日志记录器。go-kit 日志记录器的额外优势是很容易保持一定的结构。结构逻辑对于拥有可靠解析器的自动日志分析与像OpenSearchLoki这样的日志后端非常重要。为了衡量延迟,让我们通过一个日志记录器使用示例来演示,在示例 6-4 中展示了它的输出。我们使用了go-kit模块,但其他库遵循类似的模式。

示例 6-4. 使用go-kit日志记录器捕获延迟
import (
    "fmt"
    "time"

    "github.com/go-kit/log"
    "github.com/go-kit/log/level"
)

func ExampleLatencyLog() {
    logger := log.With( ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        log.NewLogfmtLogger(os.Stderr), "ts", log.DefaultTimestampUTC,
    )

    for i := 0; i < xTimes; i++ {
        now := time.Now()
        err := doOperation()
        elapsed := time.Since(now)

        level.Info(logger).Log( ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
            "msg", "finished operation",
            "result", err,
            "elapsed", elapsed.String(),
        )

        // ...
    }
}

1

我们初始化了日志记录器。通常,库允许你将日志行输出到文件(例如标准输出或错误),或直接推送到某些集合工具,例如fluentbitvector。在这里,我们选择将所有日志输出到标准错误中,并为每行日志附加时间戳。我们还选择以人类可读的方式格式化日志,使用NewLogfmtLogger(仍然是结构化的,可以被软件解析,以空格作为分隔符)。

2

在示例 6-1 中,我们只是简单地打印了延迟数字。在这里,我们为其添加了某些元数据,以便更轻松地在系统中的不同进程和操作中使用该信息。请注意,我们保持了一定的结构。我们传递了偶数个参数,代表键值对。这使得我们的日志行结构化,更易于自动化使用。此外,我们选择了level.Info,这意味着如果我们选择了仅错误级别,这行日志将不会被打印出来。

示例 6-5. 通过示例 6-4 生成的示例输出日志(为了可读性进行了换行)
level=info ts=2022-05-02T11:30:46.531839841Z msg="finished operation" \
result="error other" elapsed=83.62459ms ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) level=info ts=2022-05-02T11:30:46.868633635Z msg="finished operation" \
result="error other" elapsed=336.769413ms
level=info ts=2022-05-02T11:30:47.194901418Z msg="finished operation" \
result="error first" elapsed=326.242636ms
level=info ts=2022-05-02T11:30:47.51101522Z msg="finished operation" \
result=null elapsed=316.088166ms
level=info ts=2022-05-02T11:30:47.803680146Z msg="finished operation" \
result="error first" elapsed=292.639849ms

1

多亏了日志结构,这对我们来说很容易阅读,而且自动化可以清楚地区分诸如msgelapsedinfo等不同字段,无需昂贵且易出错的模糊解析。

使用记录器进行日志记录可能仍然是手动向我们提供延迟信息的最简单方式。我们可以尾随文件(或者如果我们的 Go 进程在 Docker 中运行,则使用 docker log,或者如果我们在 Kubernetes 上部署它,则使用 kubectl logs)以读取这些日志行进行进一步分析。还可以设置一个自动化程序,尾随这些文件或直接将它们推送到收集器,添加更多信息。然后可以配置收集器将这些日志行推送到免费和开源的日志后端,如OpenSearchLokiElasticsearch,或者许多付费供应商。因此,您可以将许多进程的日志行保存在一个地方,搜索、可视化、分析它们,或者构建进一步的自动化来处理它们。

日志记录是否适合我们的效率可观测性?是和否。对于“微基准”中解释的微基准,日志记录是我们的主要测量工具,因为它简单。另一方面,在宏观层面,如“宏基准”,我们倾向于使用日志记录作为原始事件类型的可观测性工具,在这样的规模上,分析和保持可靠性变得非常复杂和昂贵。尽管如此,由于日志记录如此普遍,我们可以通过日志记录在更大的系统中找到效率瓶颈。

日志记录工具也在不断发展。例如,许多工具允许我们从日志行中派生指标,比如 Grafana Loki 的LogQL 中的度量查询。然而,在实践中,简单性是有代价的。问题之一源于有时日志直接由人类使用,有时由自动化使用(例如,从日志中派生指标或对日志中发现的情况做出反应)。因此,日志通常是非结构化的。即使使用像 示例 6-4 中的 go-kit 这样的出色记录器,日志的结构也是不一致的,这使得解析用于自动化非常困难和昂贵。例如,像示例 6-5 中的延迟测量中存在的不一致单位,对人类来说很好,但几乎不可能将其派生为指标值。像Google mtail这样的解决方案尝试使用自定义解析语言来解决这个问题。然而,复杂性和不断变化的日志结构使得难以使用这个信号来衡量我们代码的效率。

让我们看看下一个可观察信号——追踪,以了解它在哪些领域可以帮助我们实现效率目标。

追踪

缺乏一致结构的日志记录导致了追踪信号的出现,以解决部分日志问题。与日志记录相比,追踪是关于系统的结构化信息片段。该结构围绕事务建立,例如请求-响应架构。这意味着诸如状态码、操作结果和操作延迟等内容本地编码,因此更易于被自动化工具使用。作为一种权衡,你需要额外的机制(例如用户界面)以可读的方式向人类公开这些信息。

此外,操作、子操作甚至跨进程调用(例如 RPC)都可以通过上下文传播机制链接在一起,这些机制与标准的网络协议(如 HTTP)良好配合。这感觉就像是我们效率需求中测量延迟的完美选择,对吧?让我们找出答案。

与日志记录一样,你可以选择许多不同的手动仪表化库。对于 Go 语言,流行的开源选择包括OpenTracing库(目前已弃用但仍然可用)、OpenTelemetry,或来自专用追踪供应商的客户端。不幸的是,在撰写本书时,OpenTelemetry 库具有过于复杂的 API,难以在本书中进行解释,并且它仍在变化,因此我开始了一个名为tracing-go的小项目,将 OpenTelemetry 客户端 SDK 封装成最小的追踪仪器。虽然 tracing-go 是我对最小追踪功能集的解释,但它应该教会你上下文传播和 span 逻辑的基础知识。让我们探索一个使用 tracing-go 进行手动仪表化的示例,以测量在示例 6-6 中使用追踪测量虚拟doOperation函数延迟(及更多!)。

示例 6-6。使用tracing-go捕获操作和潜在子操作的延迟
import (
    "fmt"
    "time"

    "github.com/bwplotka/tracing-go/tracing"
    "github.com/bwplotka/tracing-go/tracing/exporters/otlp"
)

func ExampleLatencyTrace() {
    tracer, cleanFn, err := tracing.NewTracer(otlp.Exporter("<endpoint>")) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    if err != nil { /* Handle error... */ }
    defer cleanFn()

    for i := 0; i < xTimes; i++ {
        ctx, span := tracer.StartSpan("doOperation") ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        err := doOperationWithCtx(ctx)
        span.End(err) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

        // ...
    }
}

func doOperationWithCtx(ctx context.Context) error {
    _, span := tracing.StartSpan(ctx, "first operation") ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    // ...
    span.End(nil)

    // ...
}

1

和其他一切一样,我们必须初始化我们的库。在我们的例子中,通常意味着创建一个能够发送形成跟踪的 span 的Tracer实例。我们将 span 推送到某个收集器,最终传输到追踪后端。这就是为什么我们必须指定某个地址以发送到的原因。在这个例子中,你可以指定收集器的 gRPC host:port地址(例如,支持gRPC OTLP 追踪协议OpenTelemetry 收集器端点)。

2

使用跟踪器,我们可以创建一个初始根 span。根表示跨越整个事务的 span。在创建过程中会创建一个traceID,用于标识跟踪中的所有 span。每个 span 代表一个个体工作单元。例如,我们可以添加不同的名称,甚至像日志或事件这样的 baggage 项。创建过程还会得到一个 context.Context 实例。作为创建的一部分,此 Go 原生上下文接口可用于创建子 span,如果我们的 doOperation 函数将执行任何值得检测的子工作单元。

3

在手动工具中,我们必须告诉跟踪提供程序工作何时完成以及结果如何。在 tracing-go 库中,我们可以使用 end.Stop(<error 或 nil>)。一旦停止 span,它将记录从开始到结束的延迟,潜在的错误,并标记自身为可以异步发送给 Tracer。跟踪导出器实现通常不会立即发送 span,而是将它们缓冲以进行批量推送。Tracer 还会检查基于所选采样策略的端点是否可以发送包含某些 span 的跟踪(稍后会详细介绍)。

4

有了注入的 span 创建器上下文之后,我们可以向其添加子 span。当您希望调试涉及执行一项工作的不同部分和顺序时,这将非常有用。

跟踪中最有价值的部分之一是上下文传播。这就是分布式跟踪与非分布式信号的区别所在。我们在示例中没有反映这一点,但想象一下,如果我们的操作向其他微服务发出网络调用。分布式跟踪允许通过传播 API(例如,使用 HTTP 标头的某种编码)传递各种跟踪信息,如traceID或采样。参见关于上下文传播的相关博客文章。为了在 Go 中实现这一点,您必须添加一个具有传播支持的特殊中间件或 HTTP 客户端,例如OpenTelemetry HTTP 传输

由于其复杂结构,原始跟踪和 span 对人类来说不可读。这就是为什么许多项目和供应商通过提供有效使用跟踪的解决方案来帮助用户。存在像 Grafana Tempo with Grafana UIJaeger 这样的开源解决方案,它们提供良好的用户界面和跟踪收集,以便您观察自己的跟踪。让我们看看我们的跟踪从 Example 6-6 在后者项目中的展示。图 6-2 显示了多跟踪搜索视图,图 6-3 显示了我们的单个 doOperation 跟踪的外观。

efgo 0602

图 6-2。将一百个操作显示为一百个跟踪,并显示它们的延迟结果

efgo 0603

图 6-3。单击一个跟踪以查看其所有 span 和关联数据

工具和用户界面可能各不相同,但通常它们遵循我在本节中解释的相同语义。在图 6-2 中的视图允许我们根据时间戳、持续时间、涉及的服务等搜索跟踪数据。当前的搜索匹配我们的一百个操作,然后在屏幕上列出。它还放置了一个方便的交互式图表,显示其延迟时间,因此我们可以导航到我们想要的操作。一旦点击,图 6-3 中的视图就会呈现出来。在这个视图中,我们可以看到这个操作的多个跨度分布。如果操作涉及多个进程,并且我们使用了网络上下文传播,所有关联的跨度将在此列出。例如,从图 6-3 中,我们可以立即看出第一个操作占用了大部分的延迟时间,并且最后一个操作引入了错误。

所有跟踪的好处使其成为学习系统交互、调试或找出基本效率瓶颈的优秀工具。它还可以用于系统延迟测量的临时验证(例如在我们的 TFBO 流程中评估延迟)。但不幸的是,在实践中,要用于效率或其他需求时,跟踪也有一些需要注意的缺点:

可读性和可维护性

跟踪的优势在于你可以将大量有用的上下文信息整合到你的代码中。在极端情况下,你甚至可以通过查看所有的跟踪和它们发出的跨度来重写整个程序或甚至整个系统。但是,这种手动仪器化有一个问题。所有这些额外的代码行会增加我们现有代码的复杂性,进而降低可读性。我们还需要确保我们的仪器化能够跟随不断变化的代码而更新。

在实践中,跟踪行业倾向于偏好自动仪器化,理论上可以自动添加、维护和隐藏这样的仪器化。像 Envoy(尤其是服务网格技术)这样的代理是成功的自动仪器化跟踪工具的绝佳示例,它们记录了跨进程的 HTTP 调用。但不幸的是,更复杂的自动仪器化并不那么容易。主要问题在于自动化必须连接到一些通用路径,如常见的数据库或库操作、HTTP 请求或系统调用(例如通过 Linux 中的 eBPF 探针)。此外,这些工具通常难以理解你希望在应用程序中捕获的更多信息(例如特定代码变量中客户端的 ID)。除此之外,像 eBPF 这样的工具非常不稳定,并且依赖于内核版本。

在抽象层下隐藏仪器化

在手动和完全自动化工具之间存在一个折衷方案。我们可以仅手动对一些常见的 Go 函数和库进行仪器化,这样使用它们的所有代码将隐式(自动地!)保持一致的跟踪。

例如,我们可以为每个 HTTP 或 gRPC 请求向我们的进程添加一个追踪。对于这一目的,已经存在HTTP 中间件gRPC 拦截器

成本和可靠性

由设计追踪事件落入可观察性的原始事件类别。这意味着追踪通常比预先聚合的等效方式更昂贵。原因是我们使用追踪发送的大量数据。即使我们对单个操作非常适度地进行仪表化,我们理想情况下也应该有数十个追踪跨度。如今,系统必须支持许多查询每秒(QPS)。在我们的示例中,即使是 100 QPS,我们也会生成超过 1,000 个跨度。每个跨度必须被传送到某个后端以有效使用,并在摄取和存储的两端进行复制。然后,您需要大量的计算能力来分析这些数据,以查找例如跨度或追踪之间的平均延迟。这很容易超过您在没有可观察性的情况下运行系统的价格!

该行业对此有所了解,这就是为什么我们有追踪采样,因此某些决策配置或代码决定传递哪些数据以及忽略哪些数据。例如,您可能希望仅收集失败的操作或操作所花费超过 120 秒的追踪数据。

不幸的是,采样也有其缺点。例如,执行尾部采样是具有挑战性的。⁹ 最后但并非最不重要的是,采样使我们错过了一些数据(类似于分析剖析)。在我们的延迟示例中,这可能意味着我们测量的延迟仅代表发生的所有操作的一部分。有时可能足够,但很容易通过采样得出错误的结论,这可能导致错误的优化决策。

短暂的持续时间

我们将在“延迟”中详细讨论这一点,但在尝试优化持续时间仅为几毫秒或更短的非常快速函数时,追踪将不会提供太多信息。与time包类似,跨度本身会引入一些延迟。此外,为许多小操作添加跨度可能会大大增加整体摄取、存储和追踪查询的成本。

这在流式算法中尤为明显,例如分块编码、压缩或迭代器。如果我们执行部分操作,通常我们仍然对某些逻辑所有迭代的延迟感兴趣。我们无法使用追踪来做到这一点,因为我们需要为每次迭代创建微小的跨度。对于这些算法,《Go 语言中的分析剖析》(“Profiling in Go”)提供了最佳的可观察性。

尽管存在一些缺点,跟踪在许多情况下变得非常强大,甚至取代日志记录信号。供应商和项目增加了更多功能,例如,Tempo 项目的指标生成器,允许从跟踪记录指标(例如,适合我们效率需求的平均或尾部延迟)。毫无疑问,如果您对跟踪感兴趣,OpenTelemetry社区将带来令人惊奇的成果。

一个框架的缺点往往是选择不同折衷方案的其他框架的优势。例如,许多跟踪问题源于其自然地表示系统中发生的原始事件(可能触发其他事件)。现在让我们讨论一种处于相反方向的信号——设计用于捕获随时间变化的聚合。

指标

指标是旨在观察聚合信息的可观察信号。这种面向聚合的指标工具可能是解决我们效率目标的最实用方式。作为开发人员和 SRE,在我的日常工作中,观察和调试生产工作负载时,指标是我使用最多的工具。此外,指标是谷歌用于监控的主要信号

示例 6-7 显示了可用于测量延迟的预聚合仪器。此示例使用Prometheus client_golang。¹⁰

示例 6-7。使用 Prometheus client_golang和直方图指标测量doOperation的延迟
import (
    "fmt"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func ExampleLatencyMetric() {
    reg := prometheus.NewRegistry() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    latencySeconds := promauto.With(reg).

NewHistogramVec(prometheus.HistogramOpts{ ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        Name:    "operation_duration_seconds",
        Help:    "Tracks the latency of operations in seconds.",
        Buckets: []float64{0.001, 0.01, 0.1, 1, 10, 100},
    }, []string{"error_type"}) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

    go func() {
        for i := 0; i < xTimes; i++ {
             now := time.Now()
             err := doOperation()
             elapsed := time.Since(now)

             latencySeconds.WithLabelValues(errorType(err)).
                 Observe(elapsed.Seconds()) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

             // ...
        }
    }()

    err := http.ListenAndServe(
        ":8080",
        promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
    ) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
    // ...
}

1

使用 Prometheus 库始终从创建新的指标注册表开始。¹¹

2

下一步是使用您想要的指标定义填充注册表。Prometheus 允许几种类型的指标,但是用于效率最佳的典型延迟测量最好是直方图。因此,除了类型之外,还需要帮助和直方图桶。稍后我们将更多地讨论桶和直方图的选择。

3

作为最后一个参数,我们定义此指标的动态维度。在这里,我建议测量不同类型的错误(或无错误)的延迟。这非常有用,因为很多时候,故障具有其他时间特性。

4

我们使用浮点数秒精确观察延迟。我们在简化的 goroutine 中运行所有操作,因此我们可以在功能执行时公开度量。Observe方法将此类延迟添加到直方图的桶中。请注意,我们观察特定错误的这种延迟。我们也不会随意使用错误字符串——我们会使用自定义的errorType函数将其转换为类型。这很重要,因为在维度中控制数值的数量使得我们的度量有价值且廉价。

5

消费这些度量的默认方式是允许其他进程(例如,Prometheus 服务器)从我们的注册表中拉取当前度量的状态。例如,在这个简化的¹²代码中,我们通过8080端口的 HTTP 端点提供这些度量。

Prometheus 数据模型支持四种度量类型,在Prometheus 文档中有详细描述:计数器、仪表、直方图和摘要。我选择使用更复杂的直方图来观察延迟,而不是计数器或仪表度量有其原因,我在“延迟”中解释了原因。暂时来说,直方图允许我们捕获延迟的分布,这通常是在观察生产系统的效率和可靠性时所需的。这些度量,定义并在示例 6-7 中进行仪表化,将显示在 HTTP 端点上,如示例 6-8 所示。

示例 6-8. 从示例 6-7 消费时度量输出的样本,通过OpenMetrics 兼容的 HTTP 端点
# HELP operation_duration_seconds Tracks the latency of operations in seconds.
# TYPE operation_duration_seconds histogram
operation_duration_seconds_bucket{error_type="",le="0.001"} 0 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) operation_duration_seconds_bucket{error_type="",le="0.01"} 0
operation_duration_seconds_bucket{error_type="",le="0.1"} 1
operation_duration_seconds_bucket{error_type="",le="1"} 2
operation_duration_seconds_bucket{error_type="",le="10"} 2
operation_duration_seconds_bucket{error_type="",le="100"} 2
operation_duration_seconds_bucket{error_type="",le="+Inf"} 2
operation_duration_seconds_sum{error_type=""} 0.278675917 ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) operation_duration_seconds_count{error_type=""} 2

1

每个桶代表了延迟小于或等于le指定值的操作数(计数器)。例如,我们可以立即看到我们从进程启动中看到了两次成功操作。第一次比 0.1 秒快,第二次比 1 秒慢但比 0.1 秒快。

2

每个直方图还捕获了一定数量的观察操作和总结值(在这种情况下是观察延迟的总和)。

如 “可观测性” 所述,每个信号都可以被拉取或推送。然而,Prometheus 生态系统默认使用拉取方法来获取指标。虽然不是简单的拉取。在 Prometheus 生态系统中,我们不像从文件中拉取(尾随)日志跟踪的样本或事件堆积那样。相反,应用程序以 OpenMetrics 格式提供 HTTP 负载(例如 示例 6-8),然后由 Prometheus 服务器或兼容 Prometheus 的系统(例如 Grafana 代理或 OpenTelemetry 收集器)定期获取(抓取)。使用 Prometheus 数据模型,我们抓取关于进程的最新信息。

要使用 Prometheus 和我们在 示例 6-7 中仪表化的 Go 程序,我们必须启动 Prometheus 服务器,并配置目标为 Go 进程服务器的抓取作业。例如,假设我们的代码在 示例 6-7 中运行,我们可以使用 示例 6-9 中显示的一组命令来开始收集指标。

示例 6-9. 从终端运行 Prometheus 的最简单命令集,开始从 示例 6-7 收集指标
cat << EOF > ./prom.yaml
scrape_configs:
- job_name: "local"
  scrape_interval: "15s" ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) static_configs:
  - targets: [ "localhost:8080" ] ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) EOF
prometheus --config.file=./prom.yaml ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

1

为了演示目的,我可以将Prometheus 配置限制为单个抓取作业。首先要决定的是指定抓取间隔。通常,持续、高效的指标收集间隔约为 15 到 30 秒。

2

我们还提供一个指向我们精简的 Go 程序示例 示例 6-7 的目标。

3

Prometheus 只是一个用 Go 编写的单一二进制文件。我们可以通过多种方式安装它。在最简单的配置中,我们可以指定一个创建好的配置。启动后,UI 将在 localhost:9090 上可用。

在上述设置完成后,我们可以使用 Prometheus API 开始分析数据。最简单的方法是使用 Prometheus 查询语言(PromQL),其文档在这里这里有详细描述。通过如 示例 6-9 中所示的启动 Prometheus 服务器后,我们可以使用 Prometheus UI 查询和展示收集的数据。

例如,图 6-4 显示了简单查询的结果,该查询在时间轴上(从进程启动时刻起)获取了最新的延迟直方图数据(对应我们的 operation_duration_seconds 指标,表示成功操作)。这通常与我们在 示例 6-8 中看到的格式相匹配。

efgo 0604

图 6-4. PromQL 查询结果,显示了 Prometheus UI 中所有 operation_duration_​sec⁠onds_bucket 指标的简单查询图表化结果

要获取单个操作的平均延迟,我们可以使用某些数学操作,通过 operation_duration_seconds_sum 除以 oper⁠ation_duration_seconds_count 的速率。我们使用 rate 函数确保在许多进程和它们的重启中准确的结果。rate 将 Prometheus 计数器转换为每秒速率。¹³ 然后我们可以使用 / 来除以这些度量的速率。这种平均查询的结果显示在 图 6-5 中。

efgo 0605

图 6-5. PromQL 查询结果,代表由示例 6-7 仪表化的平均延迟,在 Prometheus UI 中绘制

使用另一个查询,我们可以检查总操作数,甚至更好地检查使用 increase 函数在我们的 operation_duration_​sec⁠onds_count 计数器上的每分钟速率,如 图 6-6 所示。

efgo 0606

图 6-6. PromQL 查询结果,代表系统中操作每分钟的速率,在 Prometheus UI 中绘制

在 Prometheus 生态系统中,还有许多其他功能、聚合方式和使用度量数据的方法。我们将在后续章节中详细介绍其中一些。

Prometheus 与这种特定的抓取技术的惊人之处在于,拉取度量允许我们的 Go 客户端非常轻量和高效。因此,Go 进程不需要执行以下操作:

  • 在内存或磁盘上缓冲数据样本、跨度或日志

  • 维护信息(并自动更新!)关于发送潜在数据的位置

  • 如果度量后端暂时宕机,实现复杂的缓冲和持久逻辑

  • 确保一致的样本推送间隔

  • 关于度量负载的认证、授权或 TLS 的任何了解

此外,当以这样的方式拉取数据时,可观察性体验更好:

  • 度量用户可以轻松控制从中心位置的抓取间隔、目标、元数据和记录。这使得度量使用更简单、更实用,通常也更经济。

  • 更容易预测这种系统的负载,这使得在需要扩展收集管道的情况下更容易做出反应。

  • 最后但并非最不重要的,拉取度量允许您可靠地了解应用程序的健康状况(如果我们无法从中抓取度量,那么它很可能是不健康的或宕机)。我们通常也知道度量的最后一个样本是哪个(陈旧度)。¹⁴

就像一切事物一样,都存在一些权衡。每个拉取、尾随或抓取信号都有其缺点。观察力拉取式系统的典型问题包括:

  • 从短暂的进程(例如 CLI 和批处理作业)中拉取数据通常更具挑战性。¹⁵

  • 并非每个系统架构都允许入口流量。

  • 通常更难确保所有信息安全地落入远程位置(例如这种拉取方式不适合审计)。

Prometheus 指标的设计旨在减少缺点并利用拉取模型的优势。我们使用的大多数指标都是计数器,这意味着它们只会增加。这使得 Prometheus 可以跳过进程的几次抓取,但最终在较大的时间窗口内(如几分钟内)仍能准确统计每个指标的数字。

正如之前提到的,最终,指标(作为数值)是我们在评估效率时所需的内容。一切都是关于比较和分析数字。这就是为什么指标的可观察性信号是收集所需信息的一种极好的方式。我们将在“宏基准测试”和“效率根本原因分析”中广泛使用这一信号。它简单、实用,生态系统庞大(几乎可以找到所有类型软件和硬件的指标导出器),成本通常较低,且在人类用户和自动化(例如警报)中都运作良好。

指标的可观察性信号,特别是在 Prometheus 数据模型中,适用于聚合信息的仪表化。我们讨论了其优势,但有些限制和缺点也很重要。所有的缺点都源于一个事实,即我们通常不能将预聚合数据缩小到聚合之前的状态,例如单个事件。对于指标,我们可能知道有多少请求失败,但对于发生的单个错误的确切堆栈跟踪、错误消息等我们是不清楚的。我们通常拥有的最精细的信息是错误类型(例如状态码)。这使得我们能向指标系统提出的问题的范围较小,比起捕获所有原始事件而言。另一个可能被认为是缺点的重要特征是指标的基数必须保持较低。

高指标基数

基数表示我们指标的唯一性。例如,想象一下在示例 6-7 中,我们将一个唯一的错误字符串注入,而不是使用error_type标签。每个新的标签值都会创建一个可能是短暂的唯一指标。只有一个或少数样本的指标更多代表一个原始事件,而非随时间的聚合。不幸的是,如果用户试图将类似事件的信息推送到设计用于指标的系统(如 Prometheus),这往往是昂贵且缓慢的。

将更多基础数据推送到为指标设计的系统是非常诱人的。这是因为想要从这些类似信号般便宜和可靠的指标中学到更多是很自然的。避免这样做,通过指标预算、记录规则和允许列表重标记来保持低基数。如果希望捕获诸如确切错误消息或系统中单个特定操作的延迟等唯一信息,请切换到基于事件的系统,如日志和追踪!

无论是从日志、追踪、性能分析还是度量信号中收集的信息,我们在前几章已经涉及了一些指标,例如每秒使用的 CPU 核心、堆上分配的内存字节或每个操作使用的居住内存字节。因此,让我们详细讨论其中一些,谈论它们的语义、我们应该如何解释它们、潜在的细粒度以及使用刚学到的信号来举例说明的示例代码。

没有可观测的银弹!

指标非常强大。然而,正如您在本章中所学到的,日志和追踪也为我们提供了巨大的机会,通过专门的工具提高效率观测体验,使我们能够从中衍生指标。在本书中,您将看到我使用所有这些工具(以及我们尚未涵盖的性能分析)来提高 Go 程序的效率。

实用系统会捕获适合您用例的每个可观测性信号的足够信息。不太可能构建仅基于指标、仅基于追踪或仅基于性能分析的系统!

效率指标的语义

可观测性感觉像是一个广阔而深远的主题,需要多年才能掌握和设置。行业不断发展,创建新解决方案并不总是有帮助的。然而,一旦我们开始为像效率工作这样的具体目标使用可观测性,理解将会更加容易。让我们具体讨论一下哪些可观测性要素对于开始测量我们关心的资源消耗和延迟(例如 CPU 和内存)至关重要。

指标作为数字值与指标可观测性信号

“指标”中,我们讨论了指标的可观测性信号。在这里,我们讨论了对效率工作有用的特定指标语义。澄清一下,我们可以以各种方式捕获这些具体指标。我们可以使用指标的可观测性信号,但我们也可以从其他信号(如日志、追踪和性能分析)中衍生它们!

每个指标都可以由两个因素定义:

语义

那个数字的含义是什么?我们如何测量?使用什么单位?我们称之为什么?

细粒度

这些信息有多详细?例如,它是每个唯一操作的吗?它是这个操作的结果类型(成功与错误)吗?每个 goroutine 吗?每个进程吗?

测量语义和粒度都严重依赖于仪器设备。本节将重点定义典型度量衡的语义、粒度以及示例仪器,用于追踪我们软件资源消耗和延迟。理解我们将要操作的具体测量是至关重要的,以有效地使用我们将在“基准测试级别”和“Go 中的性能分析”学习的基准和分析工具。在迭代这些语义时,我们将揭示需要注意的常见最佳实践和陷阱。出发吧!

延迟

如果我们想提高程序执行特定操作的速度,我们需要测量延迟。延迟意味着操作从开始到成功或失败所需的持续时间。因此,我们在第一眼看起来需要的语义似乎非常简单——我们通常希望“完成软件操作所需的时间量”。我们的度量通常会以延迟持续时间经过的时间为名,并使用所需的单位。但魔鬼藏在细节中,正如您将在本节中了解的那样,测量延迟容易出错。

典型延迟测量的首选单位取决于我们所测量的操作类型。如果我们测量非常短的操作,比如压缩延迟或操作系统上下文切换延迟,我们必须关注细粒度的纳秒。在典型现代计算机中,纳秒也是我们可以依赖的最细粒度计时单位。这就是为什么 Go 标准库time.Timetime.Duration结构以纳秒计量时间的原因。

一般来说,软件操作的典型测量几乎总是以毫秒、秒、分钟或小时为单位。这就是为什么通常足以以秒为单位测量延迟,作为浮点值,达到纳秒级粒度。使用秒还有另一个优势:它是一个基本单位。使用基本单位通常是自然和一致的,适用于许多解决方案。¹⁶ 在这里一致性至关重要。如果可以避免,你不希望在系统的一个部分以纳秒为单位测量,另一个部分以秒为单位,再另一个部分以小时为单位。在尝试猜测正确单位或编写单位转换时,很容易因为数据混淆而得出错误的结论。

在“示例:测量延迟的仪器化”中的代码示例中,我们已经提到了许多使用各种可观测信号仪器化延迟的方法。让我们扩展示例 6-1 至示例 6-10,展示确保尽可能可靠地测量延迟的重要细节。

示例 6-10. 单一操作的手动和最简单的延迟测量,可能会出错,并且需要准备和清除阶段
prepare()

for i := 0; i < xTimes; i++ {
    start := time.Now() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    err := doOperation()
    elapsed := time.Since(start) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

    // Capture 'elapsed' value using log, trace or metric...

    if err != nil { /* Handle error... */ }
}

tearDown()

1

我们尽可能接近我们的doOperation调用的开始时间来捕获start时间。这确保在start和操作开始之间不会出现意外的延迟,这些延迟可能会误导我们后续对此度量结果的结论。这是有意设计的,应该排除我们必须为测量的操作做的任何潜在准备或设置。让我们明确地将这些作为另一个操作进行测量。这也是为什么你应该避免在start和操作调用之间添加任何换行(空行)。结果,下一个程序员(或者经过一段时间的你自己)不会在其中添加任何内容,忘记你添加的仪表化工具。

2

同样重要的是,我们要尽快使用time.Since辅助函数捕获finish时间,以便不会捕获到任何不相关的持续时间。例如,类似于排除prepare()时间,我们希望排除任何潜在的closetearDown()持续时间。此外,如果你是一个高级的 Go 程序员,你的直觉总是在某些函数完成时检查错误。这是至关重要的,但我们应该出于仪表化的目的在捕获延迟之后进行。否则,我们可能会增加未注意到我们的仪表化而在我们测量和time.Since之间添加不相关语句的风险。此外,在大多数情况下,你希望确保测量成功和失败操作的延迟,以了解程序正在做什么的完整图片。

较短的延迟更难以可靠地测量

测量操作延迟的方法如 示例 6-10 所示,对于在 0.1 微秒(100 纳秒)以下完成的操作效果不佳。这是因为获取系统时钟数字、分配变量以及进一步计算time.Now()time.Since函数可能也需要时间,对于这样短的测量来说是相当显著的。¹⁷ 此外,正如我们将在 “实验的可靠性” 中学到的那样,每次测量都存在一定的变化。延迟越短,这种噪音的影响就越大。¹⁸ 这也适用于跟踪跨度测量延迟。

测量非常快速函数的一个解决方案是由 Go 基准测试所使用,正如 示例 6-3 中所展示的,我们通过执行多次操作来估算每次操作的平均延迟。更多详细信息请参见 “微基准测试”。

时间是无限的;测量时间的软件结构却不是!

在测量延迟时,我们必须意识到软件中时间或持续时间测量的限制。不同类型可以包含不同范围的数字值,并非所有类型都能包含负数。例如:

  • time.Time 只能测量从 1885 年 1 月 1 日¹⁹到 2157 年之间的时间。

  • time.Duration 类型可以测量大约在您的“起点”之前 290 年到“起点”之后 290 年之间的时间(以纳秒为单位)。

如果要测量超出这些典型值以外的事物,您需要扩展这些类型或使用自己的类型。最后但同样重要的是,Go 存在 闰秒问题 和操作系统时间偏差的问题。在某些系统上,time.Duration(单调时钟)也会在计算机休眠(例如笔记本电脑或虚拟机暂停)时停止,这将导致错误的测量结果,因此请注意这一点。

我们讨论了一些典型的延迟度量语义。现在让我们转向粒度问题。我们可以决定在我们的流程中测量操作 A 或 B 的延迟。我们可以测量一组操作(例如事务)或其单个子操作。我们可以跨多个流程收集此数据,也可以仅查看一个流程,这取决于我们想要实现的目标。

要使其更加复杂,即使我们选择单个操作作为我们测量延迟的粒度,该单个操作也具有许多阶段。在单个进程中,这可以由堆栈跟踪表示,但对于具有某些网络通信的多进程系统,我们可能需要建立额外的边界。

让我们以一些程序作为例子,正如前一章中解释的 Caddy HTTP Web 服务器,通过一个简单的 REST HTTP 调用来检索 HTML 作为我们的示例操作。如果我们在生产中的云上安装这样一个 Go 程序来为客户端(例如某人的浏览器)提供我们的 REST HTTP 调用,我们应该测量哪些延迟?我们可以测量延迟的示例粒度如图 6-7 中所示的 Figure 6-7。

efgo 0607

图 6-7. 我们可以在与用户的 Web 浏览器通信的 Go Web 服务器程序中测量的示例延迟阶段

我们可以概述五个示例阶段:

绝对(总)客户端端延迟

延迟精确地从用户在浏览器中 URL 输入框中按下回车的时刻开始,直到检索到整个响应,加载内容并且浏览器渲染完毕。

HTTP 客户端端延迟(响应时间)

从客户端开始写入 HTTP 请求的第一个字节,直到客户端接收到响应的所有字节。这不包括客户端之前(例如 DNS 查询)或之后(在浏览器中渲染 HTML 和 JavaScript)发生的任何事情。

HTTP 服务器端延迟

延迟是从服务器接收到客户端的 HTTP 请求的第一个字节,直到服务器完成写入 HTTP 响应的所有字节。如果我们在 Go 中使用 HTTP 中间件模式 进行测量,通常是我们正在测量的内容。

服务器端延迟(服务时间)

HTTP 请求解析和响应编码无关的服务器端计算延迟。延迟是从解析 HTTP 请求开始到开始编码和发送 HTTP 响应的时刻。

服务器端函数延迟

单个服务器端函数计算的延迟,从调用开始到函数工作完成并返回参数在调用者函数上下文中。

这些只是我们在 Go 程序或系统中测量延迟时可以使用的许多排列组合之一。我们应该为优化选择哪个?哪个更重要?事实证明,它们每一个都有其用例。我们应该使用哪种延迟度量粒度以及何时取决于我们的目标,如“实验可靠性”中所述的测量准确性,以及我们想要专注的元素,如“基准测试级别”中所讨论的。为了理解整体情况并找出瓶颈,我们必须同时测量几个不同的粒度。正如“根本原因分析,但是效率”中讨论的,像追踪和分析这样的工具可以帮助解决这个问题。

无论您选择哪种度量粒度,请理解并记录您所测量的内容!

如果我们从测量中得出错误的结论,就会浪费大量时间。很容易忘记或误解我们正在测量的粒度的哪些部分。例如,您可能认为自己在测量服务器端延迟,但是慢速客户端软件引入了您没有包括在度量中的延迟。因此,您可能试图找出服务器端的瓶颈,而潜在的问题可能在另一个进程中。²⁰ 为了避免这些错误,请理解、记录并明确您的仪器。

在“示例:为延迟进行仪器化”中,我们讨论了如何收集延迟。我们提到了通常在 Go 生态系统中用于效率需要的两种主要测量方法。这两种方式通常是最可靠且最便宜的(在执行负载测试和基准测试时非常有用)。

  • 对于单独功能和单一进程测量,使用基本的日志记录“微基准”。

  • 例如,示例 6-7 等度量标准适用于涉及多个进程的大型系统的宏观测量。

特别是在第二种情况下,正如前面提到的,我们必须多次测量单个操作的延迟,以获得可靠的效率结论。我们没有每个操作的原始延迟数据——我们必须选择一些聚合方式。在示例 6-2 中,我们提出了一个简单的平均聚合机制在仪表化内部。使用度量仪表化,这将变得非常容易。只需创建两个计数器:一个用于延迟的sum和一个用于操作的count。我们可以用这两个度量值来计算平均值(算术平均)来评估收集到的数据。

不幸的是,平均值过于简单化了聚合方式。我们可能会错过大量关于我们延迟特性的重要信息。在“微基准”中,我们可以用平均值进行基本统计(这是 Go 基准测试工具在使用的方法),但在衡量我们软件在更复杂系统中的效率时,我们必须谨慎。例如,想象一下,我们想要提高一个操作的延迟,该操作过去大约需要 10 秒。我们使用我们的 TFBO 流程进行了潜在的优化。我们想在宏观层面评估效率。在我们的测试期间,系统在 5 秒内执行了 500 次操作(更快!),但有 50 次操作的延迟非常慢,达到了 40 秒。假设我们坚持使用平均值(8.1 秒)。那么,我们可能会错误地得出结论,认为我们的优化成功了,忽视了我们的优化可能导致的潜在大问题,导致 9%的操作延迟非常慢。

这就是为什么在百分位数中测量特定度量指标(如延迟)非常有帮助。这就是示例 6-7 仪表化的目的,用于我们的延迟测量的度量直方图类型。

大多数度量更适合视为分布而不是平均值。例如,对于延迟 SLI [服务水平指标],一些请求将被快速服务,而其他请求则不可避免地需要更长的时间——有时更长得多。简单的平均值可能会掩盖这些尾延迟以及它们的变化。(...) 使用百分位数指标允许您考虑分布的形状及其不同的属性:高阶百分位数,如第 99 或第 99.9,显示可能的最坏情况值,而使用第 50 百分位数(也称为中位数)强调典型情况。

C. Jones 等人,网站可靠性工程, “服务级别目标”(O’Reilly,2016)

我在示例 6-8 中提到的直方图指标非常适合延迟测量,因为它计算了多少操作适合特定的延迟范围。在示例 6-7 中,我选择了指数桶 0.001, 0.01, 0.1, 1, 10, 100。(参见²¹)最大的桶应该代表您在系统中预期的最长操作持续时间(例如,超时)。(参见²²)

在“度量”中,我们讨论了如何使用 PromQL 这种度量标准。对于直方图类型的度量和我们的延迟语义,理解这一点的最佳方法是使用 histogram_quantile 函数。请参阅图 6-8 中的中位数示例输出和图 6-9 中的第 90 分位数示例输出。

efgo 0608

图 6-8. 我们在示例 6-7 中对每种错误类型的操作的五十分位数(中位数)。

efgo 0609

图 6-9. 我们在示例 6-7 中对每种错误类型的操作的延迟的九十分位数。

这两个结果可以为我所测量的程序得出有趣的结论。我们可以观察到几件事情:

  • 一半的操作通常快于 590 毫秒,而 90% 的操作快于 1 秒。因此,如果我们的 RAER(“资源感知效率要求”)声明 90% 的操作应在 1 秒内完成,这可能意味着我们不需要进一步优化。

  • 失败于 error_type=error1 的操作明显较慢(很可能在该代码路径中存在某种瓶颈)。

  • 大约在 17:50 UTC,我们可以看到所有操作的延迟略有增加。这可能意味着一些副作用或环境变化导致我的笔记本操作系统为我的测试分配了较少的 CPU。(参见²³)

这种测量和定义的延迟可以帮助我们确定我们的延迟是否足够满足我们的要求,以及我们所做的任何优化是否有帮助。它还可以帮助我们使用不同的基准测试和瓶颈查找策略找到导致速度变慢的部分。我们将在第七章中探讨这些内容。

根据典型的延迟度量定义和示例仪器化,让我们继续移动到我们可能希望在效率旅程中测量的下一个资源:CPU 使用情况。

CPU 使用情况

在第四章中,您学习了我们在执行 Go 程序时如何使用 CPU。我还解释了我们查看 CPU 使用情况以减少 CPU 驱动的延迟(参见²⁴)和成本,以及在同一台机器上运行更多进程的能力。

各种度量指标允许我们测量程序 CPU 使用的不同部分。例如,借助 Linux 工具如proc文件系统perf,我们可以测量我们的Go 程序的未命中和命中率,CPU 分支预测命中率以及其他低级别的统计信息。但是,对于基本的 CPU 效率,我们通常关注 CPU 周期、指令或使用时间:

CPU 周期

每个 CPU 核心上执行程序线程指令所使用的总 CPU 时钟周期数。

CPU 指令

我们程序线程在每个 CPU 核心上执行的总 CPU 指令数。在某些来自RISC 体系结构的 CPU 上(例如 ARM 处理器),这可能等于周期数,因为一个指令总是需要一个周期(摊销成本)。然而,在 CISC 体系结构(例如 AMD 和 Intel x64 处理器)上,不同的指令可能会使用额外的周期。因此,计算 CPU 必须完成某些程序功能所需的指令数可能更加稳定。

无论是周期还是指令,都非常适合比较不同的算法。这是因为它们的噪声较小,例如:

  • 它们不依赖于 CPU 核心在程序运行期间的频率。

  • 内存提取的延迟,包括不同的缓存、未命中和 RAM 延迟

CPU 时间

我们的程序线程在每个 CPU 核心上执行的时间(以秒或纳秒为单位)。正如您将在“Off-CPU 时间”中了解到的那样,这段时间与我们程序的延迟不同(更长或更短),因为 CPU 时间不包括 I/O 等待时间和操作系统调度时间。此外,我们程序的 OS 线程可能同时在多个 CPU 核心上执行。有时我们还会使用 CPU 时间除以 CPU 容量,通常称为 CPU 使用率。例如,1.5 秒的 CPU 使用率意味着我们的程序平均需要一个 CPU 核心执行 1 秒,第二个核心执行 0.5 秒。

在 Linux 上,CPU 时间通常分为用户时间和系统时间:

  • 用户时间表示程序在用户空间执行 CPU 上的时间。

  • 系统时间是 CPU 在内核空间上代表用户执行某些函数的时间,例如像read这样的系统调用。

通常情况下,在更高级别,比如容器中,我们没有所有三个度量的奢侈条件。我们大多数时候必须依赖 CPU 时间。幸运的是,CPU 时间通常是一个足够好的度量,用来追踪我们的 CPU 执行工作负载所需的情况。在 Linux 上,从进程启动时算起获取当前 CPU 时间的最简单方法是访问 /proc/<PID>/stat(其中PID表示进程 ID)。我们在线程级别上也有类似的统计信息,例如 /proc/<PID>/tasks/<TID>/stat(其中TID表示线程 ID)。这正是像pshtop这样的实用程序所使用的。²⁵

pshtop工具确实可能是当前测量 CPU 时间的最简单工具。然而,我们通常需要评估我们正在优化的完整功能所需的 CPU 时间。不幸的是,“Go Benchmarks”没有提供每个操作的 CPU 时间(仅延迟和分配)。您可以从stat文件中获取该数字,例如,使用procfs Go 库以编程方式获取,但我建议另外两种主要方法:

  • CPU 分析,详见“CPU”。

  • Prometheus 指标仪表化。接下来让我们快速看一下这种方法。

在示例 6-7 中,我展示了一个注册自定义延迟指标的 Prometheus 仪器。添加 CPU 时间度量也非常简单,但 Prometheus 的客户端库已经为此构建了帮助程序。推荐的方法在示例 6-11 中呈现。

示例 6-11. 注册有关您的进程用于 Prometheus 的proc stat仪器信息
import (
    "net/http"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/collectors"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func ExampleCPUTimeMetric() {
    reg := prometheus.NewRegistry()
    reg.MustRegister(
        collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
    ) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    go func() {
        for i := 0; i < xTimes; i++ {
             err := doOperation()
             // ...
        }
    }()

    err := http.ListenAndServe(
        ":8080",
        promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
    )
    // ...
}

1

您唯一需要做的就是使用之前提到的/proc stat文件注册collectors.NewProcessCollector来获取 Prometheus 的 CPU 时间度量。

collectors.ProcessCollector提供多个指标,如pro⁠cess_​open_fdsprocess_max_fdsprocess_start_time_seconds等等。但我们感兴趣的是process_cpu_seconds_total,它是从我们程序开始运行以来使用的 CPU 时间计数器。使用 Prometheus 处理此任务的特别之处在于它周期性地从我们的 Go 程序中收集此指标的值。这意味着我们可以查询 Prometheus 以获取特定时间窗口内的进程 CPU 时间,并将其映射到实时。我们可以使用rate函数的持续时间来获取 CPU 时间每秒的平均速率。例如,rate(process_cpu_sec⁠onds_​total{}[5m])将给出我们的程序在过去五分钟内的平均每秒 CPU 时间。

您将在基于此类度量标准的示例 CPU 时间分析中找到,见“理解结果和观察”。但是,现在,我愿意向您展示一个有趣且常见的情况,即process_cpu_seconds_total有助于缩小主要效率问题的范围。想象一下,您的机器只有两个 CPU 核心(或者我们限制我们的程序使用两个 CPU 核心),您运行要评估的功能,然后看到您的 Go 程序的 CPU 时间率看起来像图 6-10。

多亏了这个视图,我们可以知道labeler进程正在经历 CPU 饱和状态。这意味着我们的 Go 进程需要比可用的 CPU 时间更多。有两个信号告诉我们 CPU 饱和状态:

  • 典型的“健康”CPU 使用是波动的(例如,如本书后面图 8-4 所示)。这是因为典型应用程序不太可能一直使用相同数量的 CPU。然而,在图 6-10 中,我们看到了五分钟内相同的 CPU 使用情况。

  • 因此,我们永远不希望我们的 CPU 时间接近 CPU 限制(在我们的情况中是两个)。在图 6-10 中,我们可以清楚地看到 CPU 限制周围的轻微波动,这表明 CPU 完全饱和。

efgo 0610

图 6-10. labeler Go 程序的 Prometheus CPU 时间图(我们将在“宏基准”章节中使用)经过一次测试后

知道我们的 CPU 饱和时至关重要。首先,这可能会给人错误的印象,即当前 CPU 时间是进程需要的最大值。此外,这种情况也会显著减慢我们程序的执行时间(增加延迟)或者完全使其停滞。这就是为什么基于 Prometheus 的 CPU 时间指标,正如您在这里学到的那样,对我来说在了解这种饱和情况方面至关重要。这也是在分析程序效率时必须首先找出的事情之一。当发生饱和时,我们必须给该进程更多的 CPU 核心,优化 CPU 使用,或者减少并发性(例如,限制它能够同时处理的 HTTP 请求数量)。

另一方面,CPU 时间让我们了解到可能被阻塞的相反情况。例如,如果你期望 CPU 密集型功能以 5 个 goroutine 运行,并且看到 CPU 时间为 0.5(占一个 CPU 核心的 50%),这可能意味着 goroutine 被阻塞了(关于此更多内容见“Off-CPU 时间”)或整个机器和操作系统都很忙。

现在让我们看一下内存使用指标。

内存使用

正如我们在第五章中学到的,关于我们的 Go 程序如何使用内存有各种复杂的层次。这就是为什么实际物理内存(RAM)使用是最棘手的测量之一,并归因于我们的程序。在像虚拟内存、分页和共享页面这样的操作系统内存管理机制上,每个内存使用指标只能是估计。虽然不完美,但这是我们必须处理的,所以让我们简要看一下哪种方法最适合 Go 程序。

我们 Go 进程的内存使用信息主要来自两个来源:Go 运行时堆内存统计和操作系统关于内存页面的信息。让我们从进程内运行时统计开始。

运行时堆统计

正如我们在“Go 内存管理”中所学到的,Go 程序虚拟内存的堆段可以作为内存使用的足够代理。这是因为对于典型的 Go 应用程序,大多数字节都分配在堆上。此外,这种内存也从不从 RAM 中逐出(除非启用交换)。因此,我们可以通过查看堆大小有效评估我们功能的内存使用。

我们通常最感兴趣的是评估执行某种操作所需的内存空间或内存块数。为了尝试估计这一点,我们通常使用两种语义:

  • 堆上的总字节或对象分配使我们能够查看内存分配,而不经常非确定性 GC 影响。

  • 堆上当前正在使用的字节或对象的数量。

先前的统计数据非常准确且快速访问,因为 Go 运行时负责堆管理,所以它跟踪我们需要的所有信息。在 Go 1.16 之前,通过runtime.ReadMemStats函数是以编程方式访问这些统计数据的推荐方式,尽管出于兼容性原因它仍然可用,但不幸的是,它需要 STW(停止世界)事件来收集所有内存统计信息。从 Go 1.16 开始,我们应该全部使用提供有关 GC、内存分配等许多廉价收集见解的runtime/metrics包。此包的示例用法用于获取内存使用度量在 Example 6-12 中介绍。

示例 6-12。最简单的代码打印总堆分配的字节和当前使用的字节
import(
    "fmt"
    "runtime"
    "runtime/metrics"
)

var memMetrics = []metrics.Sample{
    {Name: "/gc/heap/allocs:bytes"}, ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    {Name: "/memory/classes/heap/objects:bytes"},
}

func printMemRuntimeMetric() {
    runtime.GC() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    metrics.Read(memMetrics) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

    fmt.Println("Total bytes allocated:", memMetrics[0].Value.Uint64()) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    fmt.Println("In-use bytes:", memMetrics[1].Value.Uint64())
}

1

要从runtime/metrics读取样本,我们必须首先通过引用所需的度量名称来定义它们。不同 Go 版本中可能会有不同(主要是添加的)度量的完整列表,并且您可以在pkg.go.dev上查看具有描述的列表。例如,我们可以获取堆中对象的数量。

2

内存统计在 GC 运行后立即记录,因此我们可以触发 GC 以获取有关堆的最新信息。

3

metrics.Read填充我们样本的值。如果您只关心最新值,可以重用相同的样本切片。

4

这两个度量都是uint64类型,因此我们使用Uint64()方法来检索值。

编程访问此信息对于本地调试目的很有用,但在每次优化尝试中并不可持续。这就是为什么在社区中,我们通常看到其他访问这些数据的方式:

  • Go 基准测试,在“Go 基准测试”中解释

  • 堆分析,在“堆”中解释

  • Prometheus 度量仪器

要将 runtime/metric 注册为 Prometheus 指标,我们可以在 Example 6-11 中添加一行:reg.MustRegister(collectors.NewGoCollector())。Go 收集器默认公开各种内存统计信息(参见 各种内存统计)。出于历史原因,这些统计信息映射到 MemStats Go 结构体,因此在 Example 6-12 中定义的指标的等价物将是go_mem⁠stats_​heap_alloc_bytes_total作为计数器,以及go_memstats_heap_alloc_bytes作为当前使用的计量表。我们将在 “Go e2e Framework” 中展示 Go 堆统计的分析。

不幸的是,堆统计仅仅是一个估算值。我们的 Go 程序堆越小,内存效率可能越好。但是,如果你添加一些像使用显式的mmap系统调用进行大量的堆外存储器分配或者数千个大堆栈 goroutine 的有意机制,那可能会在你的机器上导致内存溢出,但这在堆统计中并不反映出来。类似地,在 “Go Allocator” 中,我解释了只有堆空间的一部分被分配到物理内存的罕见情况。

尽管有这些不利因素,堆分配仍然是现代 Go 程序中测量内存使用效果最好的方法。

操作系统内存页统计

我们可以检查 Linux 操作系统每个线程跟踪的数字,以了解更真实但更复杂的内存使用统计。与 “CPU Usage” 类似,/proc/*<PID>*/statm 提供了以页面为单位的内存使用统计。更精确的数字可以从每个内存映射统计中检索,我们可以在 /proc/*<PID>*/smaps 中看到这些统计信息(参见 “OS Memory Mapping”)。

这种映射中的每一页可能具有不同的状态。一页可能已经在物理内存上分配,也可能没有。有些页面可能会在多个进程之间共享。有些页面可能在物理内存中分配,并且作为已使用内存进行记账,但被程序标记为“空闲”(参见 “Garbage Collection” 中提到的MADV_FREE释放方法)。有些页面甚至可能不会在smaps文件中计入,例如,因为它是 文件系统 Linux 缓存缓冲区的一部分。因此,我们对以下指标中观察到的绝对值应持怀疑态度。在许多情况下,操作系统在释放内存时都是懒惰的;例如,程序使用的部分内存被以最佳方式缓存,并且只要有其他程序需要,它们就会立即释放。

我们可以从操作系统获取一些关于我们进程的典型内存使用指标:

VSS

虚拟集大小代表为程序分配的页面数(或字节,取决于仪表)。这些指标并不是很有用,因为大多数虚拟页面从未分配在 RAM 上。

RSS

住宅集大小表示驻留在 RAM 中的页面(或字节)数量。请注意,不同的度量可能以不同的方式考虑这一点;例如,cgroups 的 RSS 指标不包括单独跟踪的文件映射内存。

PSS

比例集大小代表了共享内存页面平均分配给所有用户的内存。

WSS

工作集大小估计了我们程序当前用于执行工作的页面(或字节)数量。它最初由Brendan Gregg 引入作为热点,频繁使用的内存——程序的最小内存需求。

思路是,一个程序可能已经分配了 500 GB 的内存,但在几分钟内,可能只使用了 50 MB 来进行某些局部计算。理论上,其余的内存可以安全地转移到磁盘上。

存在许多 WSS 的实现,但我看到最常见的是使用cadvisor 解释,利用cgroup 内存控制器计算 WSS,计算公式为 RSS(包括文件映射)加上一部分缓存页面(用于磁盘读取或写入),减去inactive_file条目——因此一段时间内未被触及的文件映射。它不包括非活动匿名页面,因为典型的操作系统配置无法将匿名页面转移到磁盘上(交换已禁用)。

在实际中,RSS 或 WSS 用于确定我们 Go 程序的内存使用情况。其中一个高度依赖于同一台机器上的其他工作负载,并遵循 RAM 使用扩展到所有可用空间的流程,正如“我们是否有内存问题?”中所述。每个的有用性取决于当前的 Go 版本和提供这些指标的仪器。根据我的经验,使用最新的 Go 版本和 cgroup 指标,RSS 指标往往提供更可靠的结果。²⁶ 不幸的是,无论准确与否,像Kubernetes 用于触发驱逐(例如,OOM)的系统中仍然使用 WSS,因此我们应该使用它来评估可能导致 OOM 的内存效率。

鉴于我专注于基础架构 Go 程序,我倾向于使用名为cadvisor 的度量导出器,将 cgroup 指标转换为 Prometheus 指标。我将在“Go e2e 框架”中详细解释其使用方法。它允许分析像container_memory_rss + container_memory_mapped_filecontainer_memory_working_set_bytes这样的指标,这在社区中是常用的。

摘要

现代可观察性提供了一套技术,对我们的效率评估和改进至关重要。然而,一些人认为这种主要设计给 DevOps、SRE 和云原生解决方案使用的可观察性无法适用于开发者用例(过去被称为应用性能监控[APM])。

我认为同样的工具可以用于开发人员(用于效率和调试之旅)以及系统管理员、运维人员、DevOps 和 SRE,以确保他人交付的程序运行有效。

在本章中,我们讨论了三个最初的可观测性信号:度量、日志和追踪。然后,我们通过 Go 语言示例讲解了这些的仪器化。最后,我解释了我们将在后续章节中使用的延迟、CPU 时间和内存使用量的常见语义。

现在是学习如何利用效率可观测性进行数据驱动决策的时候了。首先,我们将专注于如何模拟我们的程序,以评估不同层次的效率。

¹ 你们中的一些人可能会问为什么我坚持使用“可观测性”这个词,而不提及监控。在我看来,我必须同意我的朋友Björn Rabenstein的观点,即监控和可观测性之间的差异过于受到营销需求的驱动。有人可能会说,可观测性如今已经变得毫无意义。理论上,监控意味着回答已知的未知问题(已知问题),而可观测性允许了解未知的未知问题(未来可能会遇到的任何问题)。在我看来,监控是可观测性的一个子集。在本书中,我们将保持务实。让我们专注于如何在实践中利用可观测性,而不是使用理论概念。

² 第四个信号,性能分析,最近开始被一些人视为一种可观测信号。这是因为直到最近,行业才意识到持续收集性能分析的价值和必要性。

³ 作为一个最近的例子,我们可以提到这个仓库,通过 eBPF 探针收集信息,并尝试搜索流行的函数或库。

⁴ 在本书中,我试图建立围绕优化和效率的有益流程,这些流程旨在提前获得标准问题。这些聚合信息通常对我们来说已经足够了。

⁵ 鉴于 Go 的兼容性保证,即使社区同意改进它,我们也无法在 Go 2.0 之前进行更改。

⁶ 一个供他人引入的非可执行模块或包。

⁷ 记录日志的 Go 语言库很多。go-kit 提供了足够好的 API,能够在我迄今为止帮助的所有 Go 项目中满足我们所有需要的日志记录。这并不意味着 go-kit 没有缺陷(例如,很容易忘记必须为键值对逻辑提供偶数个参数)。此外,Go 社区还有一个关于标准库中的结构化日志(slog 包)的提案。欢迎使用任何其他库,但请确保它们的 API 简单、可读且有用。同时确保你选择的库不会引入效率问题。

⁸ 这是一种典型模式,允许进程将有用信息打印到标准输出并将日志保留在 stderr Linux 文件中。

⁹ 尾部抽样是一种逻辑,延迟决定是否在事务结束时排除或抽样跟踪,例如,仅在我们知道其状态码之后。尾部抽样的问题在于你的工具可能已经假定所有跨度将被抽样。

¹⁰ 我与 Prometheus 团队共同维护这个库。在编写本书时,client_golang 是最常用的 Go 语言度量客户端 SDK,有超过 53,000 个开源项目使用它。它是免费开源的。

¹¹ 使用全局的 prometheus.DefaultRegistry 是一种典型模式。但请不要这样做。我们试图摆脱这种可能会引起许多问题和副作用的模式。

¹² 在进程拆除时,始终检查错误并执行优雅的终止。查看在Thanos 项目中的生产级使用,该项目利用运行 goroutine 辅助程序

¹³ 请注意,在 rate 类型的度量标准上执行可能会产生不正确的结果。

¹⁴ 相反,对于推送式系统,如果没有看到预期的数据,很难判断是发送方宕机还是发送管道宕机。

¹⁵ 查看我们在KubeCon EU 2022的演讲,讨论了这类情况。

¹⁶ 这就是为什么Prometheus 生态系统建议使用基本单位

¹⁷ 例如,在我的机器上,time.Nowtime.Since 大约需要 50-55 纳秒。

¹⁸ 这就是为什么最好执行数千甚至更多次相同的操作,测量总延迟,并通过操作数进行平均。这就是 Go 基准测试所做的,正如我们将在“Go 基准测试”中学到的。

¹⁹ 你知道这个日期之所以被选中,仅仅是因为回到未来第二部吗?

²⁰ 根据我的经验,一个显著的例子是测量具有大响应的 REST 服务端延迟或带有流式响应的 HTTP/gRPC 服务端延迟。服务端延迟不仅取决于服务器本身,还取决于网络和客户端多快能够消耗这些字节(并在TCP 控制流中写回确认数据包)。

²¹ 现在,如果你想要使用 Prometheus,直方图中的桶的选择是手动的。然而,Prometheus 社区正在研究稀疏直方图,这些直方图具有动态的桶数,可以自动调整。

²² 更多关于使用直方图的内容可以在这里阅读。

²³ 很有道理。我在测试期间大量使用了我的网络浏览器,这证实了我们将在“实验可靠性”中讨论的知识。

²⁴ 作为提醒,我们可以通过多种方式来改善程序功能的延迟,而不仅仅是通过优化 CPU 使用率。我们可以通过并发执行来提高延迟,通常会增加总 CPU 时间。

²⁵ 还有一个有用的procfs Go library,可以通过编程方式检索stats文件数据的数字。

²⁶ 其中一个原因是在 cadvisor 中存在的问题,其中包括一些仍可回收的内存。

第七章:数据驱动的效率评估

在上一章中,你学会了如何使用不同的可观察信号来观察我们的 Go 程序。我们讨论了如何将这些信号转换为数值,或者说指标,以有效地观察和评估程序的延迟和资源消耗。

不幸的是,知道如何测量运行程序的当前或最大消耗或延迟,并不能保证正确评估应用程序的整体效率。我们在这里缺少的是实验部分,这可能是优化中最具挑战性的部分:如何使用第六章提到的可观察工具触发值得测量的情况!

测量的定义

我发现“测量”这个动词非常不精确。我看到这个词被过度使用来描述两件事情:执行实验的过程和从中收集数值数据。

在本书中,每当你读到“测量”过程时,我都遵循计量学(测量科学)中使用的定义。我确切地指的是使用仪器来量化当前发生的事情(例如事件的延迟,或者所需的内存量)或者在给定时间窗口内发生的事情。导致我们测量的事件(我们在基准测试中模拟的或自然发生的)是一个单独的话题,在本章中进行讨论。

在本章中,我将介绍实验和测量效率的艺术。我将主要关注数据驱动的评估,通常称为基准测试。在我们跳到第八章编写基准测试代码之前,这章将帮助你理解最佳实践。这些实践在第九章中同样非常重要,该章重点讨论性能分析。

我们首先从复杂性分析开始,这是一种较少依赖经验的评估解决方案效率的方式。然后,我将解释《基准测试的艺术》中的基准测试。我们将其与功能测试进行比较,并澄清一个普遍的成见:“基准测试总是虚假的”。

后续在“实验的可靠性”中,我们将讨论实验的可靠性,无论是基准测试还是性能分析。我将提供基本规则,以避免因收集糟糕数据而浪费时间(或金钱),并做出错误的结论。

最后,在“基准测试水平”中,我将向您介绍完整的基准测试策略。在前几章中,我已经使用基准测试来提供解释 CPU 或内存资源行为的数据。例如,在“一致的工具”中,我提到 Go 工具提供了一个标准的基准测试框架。但是,我想在本章教给您的基准测试技能超越此框架,它只是在“微基准测试”中讨论的众多工具之一。有许多不同的方法来评估我们 Go 代码的效率。了解何时使用何种方法至关重要。

让我们从介绍基准测试以及这些测试的关键方面开始。

复杂性分析

我们并非总能拥有指导我们通过某种解决方案效率的经验数据的奢侈。你对更好的系统或算法的想法可能尚未实施,并且在我们进行基准测试之前,需要大量的努力才能实现它。此外,我提到了在“定义 RAER 的示例”中对复杂性估计的需求。

这可能与我们在“优化挑战”中学到的内容相矛盾(“程序员在估计确切资源消耗时声名狼藉”),但有时工程师依赖理论分析来评估程序。一个例子是当我们在算法级别评估优化时(来自“优化设计级别”)。开发人员和科学家经常使用复杂性分析来比较并决定哪种算法可能更适合解决某些带有特定约束条件的问题。更具体地说,他们使用渐近符号(通常称为“大 O”复杂性)。很可能,您已经听说过它们,因为它们在任何软件工程面试中都经常被询问。

然而,要完全理解渐近符号,您必须了解“估算”效率复杂性意味着什么,以及它是什么样子!

“估算”效率复杂性

我在“资源感知效率要求”中提到,我们可以将 CPU 时间或任何资源的消耗表示为与特定输入参数相关的数学函数。通常,我们谈论运行时复杂性,它告诉我们使用特定代码和环境执行某个操作所需的 CPU 时间。然而,我们还有空间复杂性,它可以描述执行该操作所需的内存、磁盘空间或其他空间需求。

例如,让我们以我们的Sum函数为例,来自示例 4-1。我可以证明这样的代码具有估算的空间复杂性(表示堆分配),其函数如下,其中 N 是输入文件中的整数数量:

s p a c e ( N ) = ( 848 + 3 . 6 N ) + ( 24 + 24 N ) + ( 2 . 8 N ) b y t e s = 872 + 30 . 4 N b y t e s

知道详细的复杂性很重要,但通常找到真正的复杂性函数是不可能或很困难的,因为涉及的变量太多。然而,我们可以尝试估计这些变量,特别是对于像内存分配这样更确定的资源,可以通过简化变量来进行。例如,前述方程只是一个估算,采用了一个仅有一个参数——整数的数量。当然,这个代码还取决于整数的大小,但我假设整数约为 3.6 字节长(这是从我的测试输入统计得出的数据)。

“估计”复杂性

正如我在这本书中试图教给你的那样——在措辞上要准确。

我这些年来一直误以为复杂性总是指大 O 渐近复杂性,其实复杂性也存在,在某些情况下非常有用。至少我们应该意识到它的存在!

不幸的是,很容易将其与渐近复杂性混淆,因此我建议称关注常数的那种为“估计”复杂性。

我是如何找到这个复杂性方程的?这并不是一件简单的事情。我不得不分析源代码,进行一些栈逃逸分析,运行多个基准测试,并使用性能分析(你将在本章和下两章学到的所有内容)来发现这些复杂性。

这只是一个例子!

别担心。要评估或优化您的代码,您不需要进行如此详细的复杂性分析,特别是这么详细的分析。我这样做是为了表明这是可能的,并且可以得到什么结果,但是还有更多务实的方法可以快速评估效率并找出下一个优化点。您将在第十章中看到示例流程。

搞笑的是,在 TFBO 流程的末端,当你大幅优化程序的某一部分时,你可能对问题空间有了详细的认识,以至于你能够迅速找到这样的复杂性。然而,对你代码的每个版本都这样做则是浪费的。

解释收集复杂性并将其映射到源代码的过程可能很有用,如例子 7-1 所示。

例 7-1. 例子 4-1 的复杂性分析
func Sum(fileName string) (ret int64, _ error) {
   b, err := os.ReadFile(fileName) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   if err != nil {
      return 0, err
   }

   for _, line := range bytes.Split(b, []byte("\n")) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
      num, err := strconv.ParseInt(string(line), 10, 64) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
      if err != nil {
         return 0, err
      }

      ret += num
   }

   return ret, nil
}

1

我们可以将复杂方程中的 848 + 3.6 * N部分与将文件内容读入内存的操作相关联。我使用的测试输入非常稳定——整数的位数不同,但平均有 2.6 位数字。每行添加一个新行(\n)字符意味着每行大约有 3.6 字节。由于ReadFile返回的是包含输入文件内容的字节数组,因此我们可以说我们的程序对于由b切片指向的字节数组需要确切的 3.6 * N字节。848 字节的常量来自于在os.ReadFile函数中分配的各种对象,例如为b切片分配的切片值(24 字节),这些逃逸出堆。通过空文件进行基准测试并进行分析即可发现这个常量。

2

正如您将在第十章中了解的那样,bytes.Split在分配和运行时延迟方面都相当昂贵。然而,我们可以将大部分分配归因于此部分,因此复杂度的 24 + 24 * N部分。这是“主要”部分,因为它是最大的常量(24)乘以输入大小。原因是为了返回[][]byte数据结构而进行的分配。虽然我们不会复制底层字节数组(我们与从os.ReadFile中共享缓冲区),但是为分配的空的[]byte切片总共需要 24 * N的堆,再加上[][]byte切片头部的 24 字节。如果N数量级为十亿级别,这将是一个巨大的分配(22GB 用于十亿个整数)。

3

最后,正如我们在“数值、指针和内存块”中学到的,并且正如我们将在“优化 runtime.slicebytetostring”中揭示的那样,我们在这一行上也有很多分配。一开始并不明显,但string(line)所需的内存(始终是副本)正在逃逸到堆中。¹ 这是复杂度中 2.8 * N的一部分,因为我们平均会为 2.6 位数字进行 N 次此类转换。剩下的 0.2 * N的来源是未知的。²

希望通过这个分析,你能明白复杂度的含义。也许你已经看到这种知识的有用性。也许你已经看到了许多我们将在第十章中尝试的优化机会!

渐进复杂度与大 O 符号

渐近复杂度忽略了实现的开销,特别是硬件或环境方面的。相反,它专注于渐近数学分析:运行时间或空间需求与输入大小的增长速度。这允许基于可伸缩性对算法进行分类,这通常对于研究复杂问题的算法的研究者非常重要(这些问题通常需要大量输入)。例如,在 图 7-1 中,我们看到了典型函数的简要概述,并对算法的典型复杂度进行了评论。请注意,这里的“坏”复杂度并不意味着没有更好的算法——有些问题无法以更快的方式解决。

efgo 0701

图 7-1. 来自 https://www.bigocheatsheet.com 的大 O 复杂度图表。阴影表示通常问题的效率评估。

我们通常使用大 O 符号来表示渐近复杂度。据我所知,唐纳德·克努斯在他 1976 年的文章中尝试清晰地定义了三个符号(O, Ω, Θ)(3)。

口头上,O(f(n)) 可以理解为“最多为 f(n) 的阶数”;Ω(f(n)) 可以理解为“至少为 f(n) 的阶数”;Θ(f(n)) 可以理解为“正好为 f(n) 的阶数”。

唐纳德·克努斯,《“大欧米伽、大欧米伽和大 Theta”》(https://oreil.ly/yeFpW)

短语“按照 f(N) 的顺序”意味着我们对精确的复杂度数字不感兴趣,而更关注近似值:

上界(O)

大 Oh 意味着该函数的渐近复杂度不能比 f(n) 更差。有时也用于反映最坏情况,如果其他输入特征重要(例如,在排序问题中,我们通常讨论元素的数量,但有时输入已经排序也很重要)。

紧密界限(Θ)

大 O 记号代表确切的渐近函数,有时也代表平均典型情况。

下界(Ω)

大 Omega 意味着该函数的渐近复杂度不能比 f(n) 更好。有时也代表最佳情况。

例如,快速排序 排序算法根据输入的排序方式和选择的枢轴点,最佳和平均运行时间复杂度为 N * logN,因此 Ω(N * logN) 和 Θ(N * logN),尽管最坏情况是 O(N²)。

行业并非总是正确使用大 O 符号。

一般来说,在面试、讨论和教程中,您会看到人们使用大 Oh(O)来描述典型情况下应使用大 Theta(Θ)。例如,我们经常说快速排序是 O(N * logN),这并不正确,但在许多情况下,我们会接受这个答案。也许人们试图通过简化这个话题来使其更易理解。我将在这里尽量更准确,但您始终可以用 Θ 替换 O(但不能反过来)。

对于我们在 示例 4-1 中的算法,渐近空间复杂度是线性的:

s p a c e ( N ) = 872 + 30 . 4 * N b y t e s = Θ ( 1 ) + Θ ( N ) b y t e s = Θ ( N ) b y t e s

在渐近分析中,像 1、872 和 30.2 这样的常数并不重要,尽管在实践中,如果我们的代码分配了 1 MB(Θ(N))或 30.4 MB,这可能会有所影响。

请注意,我们不需要精确的复杂度来找出渐近复杂度。这就是问题的关键:精确的复杂度取决于太多变量,特别是当涉及运行时间复杂度时。通常,我们可以根据算法伪代码或描述来学习找到理论上的渐近复杂度。这需要一些实践,但是想象一下,如果我们没有实现 示例 7-1,而是设计了一个算法。例如,文件中所有整数的朴素求和算法可以描述如下:

  1. 我们将文件内容读入内存,其渐近空间复杂度为 Θ(N),其中 N 是整数或行数的数量。由于我们读取了 N 行,这也具有 Θ(N) 的运行时间复杂度。

  2. 我们将内容分割成子切片。如果我们原地执行,这意味着 Θ(N)。否则,从理论上讲,它是 Θ(1)。这是一个有趣的案例,正如我们在精确复杂度中看到的那样,尽管在原地执行,开销为 24 * N,这表明 Θ(N)。在这两种情况下,运行时间复杂度都是 Θ(N),因为我们必须遍历所有行。

  3. 对于每个子切片(空间复杂度 Θ(1) 和运行时间 Θ(N)):

    1. 我们解析整数。从技术上讲,这不需要额外的堆空间,假设整数可以保留在堆栈上。如果我们关联到行数和数字限制在范围内,其运行时间也应该是 Θ(1)。

    2. 我们将解析的值添加到一个包含部分和的临时变量中:Θ(1) 的运行时间和 Θ(1) 的空间。

通过这样的分析,我们可以得出空间复杂度为 Θ(N) + Θ(1) + Θ(N) * Θ(1),因此为 Θ(N)。我在第二步还提到了运行时间复杂度,合并后为 Θ(N) + Θ(N) + Θ(N) * Θ(1),因此也是线性的 Θ(N)。

一般来说,这样一个Sum算法在渐进上是相当容易评估的,但在许多情况下并不是微不足道的。这需要一些实践和经验。如果有自动工具能够检测到这样的复杂性,我会感到非常高兴。过去有一些有趣的尝试,但实际上它们的代价太高了。⁴也许有一种方法可以实现某种算法来评估伪代码的复杂性,但这现在是我们的工作!

应用实例

坦率地说,我一直对“复杂性”这个话题持怀疑态度。也许我在大学时错过了关于它的讲座,⁵但每当有人让我确定某个算法的复杂性时,我总是感到失望。我相信这只是在技术面试中用来捉弄候选人的伎俩,在实际软件开发中几乎没有任何用处。

第一个问题是不精确——当人们问我要确定复杂性时,他们指的是大 O 符号的渐进复杂性。此外,在付费工作期间,如果我通常可以使用线性算法而不是哈希映射在数组中搜索元素,代码仍然足够快吗?此外,更有经验的开发人员因为我的复杂的链表与更好的插入复杂性可能只是一个更简单的数组带有appends而拒绝我的合并请求。最后,我了解了所有那些因隐藏常数成本或其他注意事项而在实践中未使用的具有令人难以置信的渐进复杂性的快速算法。⁶

我认为我的大部分挫败感来源于对行业刻板印象和简化的误解和误用。我特别惊讶的是,不少工程师竟然愿意进行这种“估算”复杂性。也许我们常常因为估计超越渐进复杂性的困难而感到失落或不知所措。对我来说,阅读旧的编程书籍是一种启发——它们在大多数优化示例中都使用了这两种复杂性!

程序的主for循环执行了N-1次,并包含一个内部循环,内部循环本身执行了N次;因此,程序所需的总时间将受到与成比例的项的主导。观察到片段 A1 的帕斯卡运行时间约为 47.0N² 微秒。

乔恩·路易斯·本特利,《编写高效程序》

当您尝试评估或优化需要更高效率的算法和代码时,了解其预估复杂性和渐进复杂性是有实际价值的。让我们看一些使用案例。

如果您了解精确的复杂性,您无需测量即可知道预期的资源需求

在实践中,我们很少从一开始就有精确的复杂度,但想象一下有人给我们这样的复杂度。这在像容量规划这样需要了解在各种负载(例如不同输入)下运行系统成本的任务中是一个巨大的胜利。

例如,示例 7-1 中Sum的朴素实现使用了多少内存?事实证明,即使没有任何基准测试,我也可以使用 872 + 30.4 * N 字节的空间复杂度来告诉,例如:

  • 对于一百万个整数,我的代码将需要 30,400,872 字节,即 30.4 MB,如果我们使用1,000 倍数,而不是 1,024。⁷

  • 对于两百万个整数,它将需要 60.8 MB。

如果我们执行快速微基准测试(别担心,我会在这里和第八章中解释如何进行基准测试),结果将呈现在示例 7-2 中。

示例 4-1 的基准分配结果分别具有一百万个元素和两百万个元素的输入。
name (alloc/op)    Sum1M        Sum2M
Sum                30.4MB ± 0%  60.8MB ± 0%

name (alloc/op)    Sum1M        Sum2M
Sum                800k ± 0%    1600k ± 0%

基于这两个结果,我们的空间复杂度相当准确。⁸

你不太可能总是能找到完整、准确的实际复杂度。但通常对此复杂度进行非常高水平的估计就足够了,例如,Sum 函数在示例 7-1 中的空间复杂度为 30 * N 字节足够详细。

它告诉我们代码是否存在任何简单的优化方法。

有时候,我们不需要详细的经验数据就能知道我们存在效率问题。⁹这很棒,因为这些技术可以告诉我们优化我们程序的难易程度。在我们进入重型基准测试之前,了解这样一个快速的效率评估对我来说是非常重要的。

例如,当我在示例 4-1 中编写Sum的朴素实现时,我预计会编写一个具有Θ(N)空间复杂度(渐近)的算法。然而,我预计它的实际复杂度大约是 3.5 * N,因为我将整个文件内容读入了内存。只有当我运行像示例 7-2 这样的基准测试后,我才意识到我的朴素实现是多么糟糕,内存使用几乎比预期多了 10 倍(30.5 MB)。这种对实际复杂度的预期估计与实际结果之间的差异通常是我们需要改进效率时的一个很好的指示。

其次,如果我的算法空间复杂度为线性,对于如此简单的功能来说已经是一个坏迹象。我的算法将为大量输入使用极大量的内存。根据要求,这可能没问题,或者如果我们希望扩展这个应用程序,这可能意味着真正的问题。¹⁰如果现在不是问题,应该承认和记录最大预期输入大小,因为这可能对将来使用此函数的人来说会有所惊讶!

最后,假设测量结果完全偏离算法预期的复杂性。这可能表明存在内存泄漏,如果你有正确的工具(正如我们将在“不泄露资源”中讨论的那样),通常很容易修复。

三个明确表明我们在浪费内存空间的迹象

  • 理论空间复杂度(渐进和估计)与使用基准测试测量得到的实际空间复杂度之间的差异可以立即告诉你是否有意外情况。

  • 与用户(或调用者)输入相关的显著空间复杂度是一个坏迹象,可能意味着未来可能存在的可扩展性问题。

  • 如果程序使用的总内存随时间不断增长而且从不减少,很可能表明存在内存泄漏问题。

它帮助我们评估作为优化算法的更好想法

复杂性优化的另一个惊人用例是快速评估算法优化而不实现它们。对于我们的Sum示例,我们不需要极端的算法技能来知道我们不需要在内存中缓冲整个文件。如果我们想节省内存,我们应该能够有一个用于解析目的的小缓冲区。让我们描述一个改进的算法:

  1. 我们打开输入文件而不读取任何内容。

  2. 我们创建了一个 4 KB 的缓冲区,因此我们至少需要 4 KB 的内存,这仍然是一个恒定的量(Θ(1))。

  3. 我们以 4 KB 的块读取文件。对于每个块:

    1. 我们解析数字。

    2. 我们将其添加到临时部分和。

理论上,这样改进的算法应该给出约 4 KB 的空间复杂度,因此O(1)。因此,我们的示例 4-1 对 100 万个整数可以使用少 7,800 倍的空间!因此,我们可以在不实现的情况下说,算法级别的这种优化将非常有益,并且你将在“优化内存使用”中看到其效果。

进行这种复杂性分析可以快速评估你的改进想法,而不需要完整的 TFBO 循环!

有时候更糟糕也是更好的!

如果我们决定使用更好的渐进或理论复杂性来实现算法,请不要忘记使用基准代码级别进行评估!在设计算法时,我们通常会优化渐进复杂性,但在编写代码时,我们优化这种渐进复杂性的常数。

如果没有良好的测量,你可能会按照大 O 复杂度实现一个好算法,但由于代码效率低下,做效率优化而不是改进!

它告诉我们瓶颈在哪里,算法的哪个部分是关键的。

最后,特别是当映射到源代码中,详细的空间复杂度如 示例 7-1 ,是确定效率瓶颈的绝佳方式。我们可以看到,常数 24 是其中最大的,它来自于 bytes.Split 函数,我们将在 第十章 中首先进行优化。然而,在实践中,性能分析可以更快地产生数据驱动的结果,因此我们将在 第九章 中重点关注这种方法。

总结一下,对复杂性的广泛知识以及将基本测量与理论渐近结合起来的能力教会了我,复杂性可能是有用的。如果正确使用,它可以是更理论效率评估的优秀工具。然而,正如你所见,真正的价值在于我们将经验测量与理论结合起来时。记住这一点,让我们更深入地了解基准测试!

基准测试的艺术

在 TFBO 流程中评估效率是至关重要的,如图 3-5 中的第 4 步 Figure 3-5 所示。通过研究、静态分析和 Big O 表示法对运行时复杂度的算法级别进行效率评估通常是一个复杂的问题,有多种实现方式。

通过进行理论分析和估计代码效率,我们可以评估很多内容。然而,在许多情况下,最可靠的方法是亲自动手,运行一些代码并看看实际情况。正如我们在 “优化挑战” 中所学到的,我们不擅长估算我们代码的资源消耗,因此经验评估允许我们在评估中减少猜测的数量。¹¹ 理想情况下,我们什么都不假设,并使用专门测试过程验证效率,这些测试专注于效率而不是正确性。我们称这些测试为 基准测试

基准测试与压力和负载测试

对于基准测试有许多替代名称,如压力测试、性能测试和负载测试。然而,它们通常指的是同样的内容,为保持一致性,本书中将统一使用基准测试这一术语。

一般来说,基准测试是我们软件或系统的有效效率评估方法。抽象地说,基准测试的过程由四个核心部分组成,我们将其逻辑描述为一个简单函数:

B e n c h m a r k = N * ( E x p e r i m e n t + M e a s u r e m e n t s ) + C o m p a r i s o n

在任何基准测试的核心是实验和测量周期:

实验

模拟软件特定功能以了解其效率行为的行为。我们可以将实验范围限定到单个 Go 函数或 Go 结构,甚至是复杂的分布式系统。例如,如果您的团队开发 Web 服务器,这可能意味着启动 Web 服务器并执行具有真实数据的单个 HTTP 请求,用户可能会使用这些数据。

测量

在第六章中,我们讨论了如何准确测量延迟和各种资源的消耗。在整个实验过程中可靠地观察我们的软件是至关重要的,以便在实验结束时得出有意义的结论。以我们的 Web 服务器为例,这可能意味着测量不同层次上操作的延迟(例如客户端和服务器的延迟),以及我们的 Web 服务器的内存消耗。

我们基准测试过程中独特的部分是,实验和测量周期必须执行N次,并在最后进行比较阶段。

测试迭代次数(N)

N是我们必须执行的测试迭代次数,以在结果方面建立足够的信心。运行的确切次数取决于许多因素,我们将在“实验的可靠性”中讨论这些因素。一般来说,我们需要在更高的信心和成本或等待时间之间取得平衡,以避免过多的迭代次数。

比较

最后,在基准测试的定义中,我们有比较方面,这使我们能够了解我们的软件效率的提升、阻碍以及与期望(RAER)的距离。

在许多方面,您可能会注意到基准测试与我们进行的验证正确性的测试(后文称为功能测试)相似。因此,许多测试实践也适用于基准测试。接下来让我们来看看这一点。

与功能测试比较

与我们熟悉的某些东西进行比较是学习的最佳方式之一。因此,让我们比较基准测试和功能测试。在方法论或实践方面是否有什么可以重用的东西?在本章中,您将了解到功能测试和基准测试之间可以共享许多内容。例如,有一些相似的方面:

  • 为形成测试用例的最佳实践(例如,边缘案例)、表驱动测试和回归测试

  • 将测试分割成unit, integration, e2e,并在生产中进行测试(更多信息请参见“基准测试级别”)

  • 连续测试的自动化

不幸的是,我们也必须意识到重大差异。通过基准:

我们必须有不同的测试用例和测试数据

这可能很诱人,但我们不能重复使用与用于正确性测试的单元或集成测试相同的测试数据(输入参数、潜在伪造、数据库中的测试数据等)。这是因为目标不同。在正确性测试中,我们倾向于从功能角度考虑不同的边缘案例(例如,故障模式)。而在效率测试中,边缘案例通常专注于触发不同的效率问题(例如,大请求与许多小请求)。我们将在“重现生产”中讨论这些内容。

对于大多数系统而言,程序员应当监控输入数据以符合程序将在生产中遇到的数据。请注意,通常的测试数据通常不符合此要求:虽然测试数据被选择以执行代码的所有部分,但是分析[和基准]数据应被选择为其“典型性”。

乔恩·路易斯·本特利,《编写高效程序》

拥抱性能非确定性

现代软件和硬件由复杂优化层组成。这可能导致在执行基准测试时出现非确定性条件的变化,这可能意味着结果也将是不确定的。我们将在“实验的可靠性”中进一步扩展,但这就是为什么我们通常重复测试迭代周期数百次,甚至成千上万次(我们的N组件),以增加对我们观察的信心。这里的主要目标是确定我们的基准测试有多重复。如果方差过高,我们知道我们不能信任结果,必须降低方差。这就是为什么我们在基准测试中依赖统计数据的原因,这确实很有帮助,但也很容易误导他人和自己。

可重复性:确保在所有配置上进行基准测试相同操作,并且度量在多次测试运行中可重复。经验法则是变异率高达 5%通常是可以接受的。

鲍勃·克兰布里特,《谎言、该死的谎言和基准测试:什么构成一个良好的性能度量》

编写和运行起来更加昂贵

正如您可以想象的那样,我们需要执行的迭代次数增加了运行成本和基准测试的复杂性,包括计算成本和开发人员用于创建这些基准测试和等待的时间。但与正确性测试相比,这并不是唯一的额外成本。为了触发效率问题,特别是对于大型系统的负载测试,我们必须耗尽不同系统的容量,这意味着仅仅为了测试而购买大量的计算能力。

这就是为什么我们必须专注于实用的优化过程,只关心必要的效率。还有一些方法可以通过使用战术性的、隔离功能的微基准测试,而避免使用全面的宏基准测试,正如在“基准测试水平”中讨论的那样。

期望不那么具体

正确性测试总是以一些断言结束。例如,在 Go 测试中,我们检查函数的结果是否具有预期值。如果不是,我们使用t.Errort.Fail来指示测试应该失败(或者使用一行代码,例如testutil.Oktestutil.Equals)。

如果我们在进行基准测试时也能做到同样就太棒了——断言延迟和资源消耗是否未超过 RAER。不幸的是,在微基准测试的结尾并不能简单地执行if maxMemoryConsumption < 200 * 1024 * 1024。结果的高变异性,难以将延迟和资源消耗隔离到我们测试的单个功能中,以及其他在“实验可靠性”中提到的问题,使得自动化断言过程变得困难。通常情况下,必须依靠人工或非常复杂的异常检测或断言软件来判断结果是否可接受。希望在未来会看到更多使这一过程变得更加简便的工具。

为了增加难度,我们可能会对更大的 API 和功能性引入 RAER。但是,如果 RAER 说整个 HTTP 请求的延迟应低于 20 秒,这对于涉及此请求的单个 Go 函数(成千上万个函数中的一个)意味着什么?我们在使用该函数的微基准测试中应该期待多少延迟?这个问题没有一个确切的答案。

我们更关注相对结果而不是绝对数字!

在基准测试中,我们通常不断言绝对值。相反,我们专注于将结果与某些基准(例如,在我们代码更改之前的先前基准)进行比较。通过这种方式,我们可以知道是否改进或对单个组件的效率产生了负面影响,而不必关注整体情况。这通常足够在单元微基准测试级别上使用。

解释了基准测试的基本概念之后,让我们在下一节中解决其中的一个悬而未决问题——将基准测试与谎言联系在一起的刻板印象。不幸的是,对于这种关系,存在充分的理由。让我们深入探讨一下,看看我们如何判断我们或他人进行的基准测试是否可信。

基准测试会说谎

有一个扩展到一个著名短语的说法指出,我们可以按从最好到最差的顺序排列以下词语:“谎言、该死的谎言和基准测试”。

对性能的兴趣并未被计算机供应商忽视。几乎每个供应商都宣传其产品更快或者“性价比更高”。所有这些性能营销都引发了一个问题:“这些竞争对手怎么都声称自己是最快的呢?”事实上,计算机性能是一个复杂的现象,谁最快完全取决于用来得出特定简化结论的具体方法。

亚历山大·卡尔顿,“谎言、该死的谎言和基准测试”

在基准测试中作弊确实很普遍。在竞争激烈的市场中,基准测试的效率结果具有重要意义。用户面对太多选择,所以简化比较到一个简单的问题,“哪个是最快的解决方案?”或“哪一个最具可伸缩性?”是决策者的共同做法。因此,基准测试演变成了一个被欺骗的游戏化系统。事实上,效率评估非常复杂且昂贵,因此误导性结论很容易逃脱追究。有很多公司、供应商和个人在基准测试中撒谎。¹² 然而,需要强调的是,并非所有情况都是有意或者恶意的。在大多数情况下,作者并非故意报告误导性结果。对于人类大脑来说,被统计谬误和悖论误导是再正常不过的了。

基准测试不会说谎;我们只是误解了结果!

有很多种方式会导致我们从基准测试中得出错误的结论。如果无意中发生,后果可能很严重——通常会造成大量时间和金钱的浪费。如果是有意为之……好吧,谎言终究难以长久。

我们可能会因为人为错误、在与我们及我们问题无关的条件下进行的基准测试,或者简单的统计误差而被基准测试误导。基准测试结果本身并不会说谎;我们可能只是在测量错误的东西!

解决方法是成为那些基准测试的审慎消费者或开发者,并学习数据科学的基础知识。我们将在“实验的可靠性”中讨论常见的错误和解决方案。

为了克服基准测试中自然发生的一些偏见,行业经常提出一些标准和认证。例如,为了确保公平的燃油经济效率评估,美国所有轻型车辆都要求由美国环境保护署(EPA)进行燃油经济结果测试。类似地,在欧洲,为了应对汽车制造商测试和实际情况之间的 40%差距,欧盟采用了全球统一轻型车辆测试循环和程序。对于硬件和软件,许多独立组织设计了特定要求的一致基准测试。SPECPercona HammerDB是众多例子中的两个。

要克服谎言和诚实错误,我们必须专注于理解什么因素使基准测试不可靠,以及我们可以做些什么来提高质量。这是解释我们将在第八章讨论的许多基准实践的基础知识。让我们在下一节中做这件事。

实验的可靠性

TFBO 周期需要时间。无论我们在哪个级别评估和优化效率,都需要花费大量时间来实施基准测试、执行它们、解释结果、查找瓶颈并尝试新的优化。如果我们的所有或部分努力由于不可靠的评估而被浪费,这是令人沮丧的。

正如在解释基准测试谎言时提到的那样,有许多原因会导致基准测试误导我们。有一组常见的挑战值得我们注意。

瓶颈分析也是如此!

在本章中,我们可能会讨论基准测试,因此实验主要允许我们衡量我们的效率(延迟或资源消耗),但类似的可靠性问题也适用于围绕效率的其他实验或测量。例如,我们对 Go 程序进行剖析以找出瓶颈,详细讨论在第九章。

我们可以概述基准测试可靠性面临的三个常见挑战:人为错误、我们的实验与生产环境的相关性,以及现代计算机的非确定性效率。我们将在接下来的几节中详细讨论这些问题。

人为错误

优化和基准测试例程,正如今天的情况所示,涉及开发人员大量的手动工作。我们需要运行具有不同算法和代码的实验,同时关注再现生产和性能的非确定性。由于手动性质,这容易出现人为错误。

很容易迷失在我们已经尝试过的优化、为调试目的添加的代码和需要保存的内容之间。同样,很容易混淆代码版本与基准测试结果归属以及您已经证明错误的假设。

我们的许多基准测试问题往往是由于粗心和缺乏组织引起的。不幸的是,我也犯了许多这些错误!例如,当我以为我在基准测试优化 X 时,我在看到基准测试结果没有显著差异后就放弃了它。几个小时后,我才注意到我测试了错误的代码,而优化 X 是有帮助的!

幸运的是,有一些方法可以减少这些风险:

保持简单。

尽可能用最小的迭代次数进行与效率相关的代码更改。如果你试图同时优化代码的多个元素,很可能会混淆你的基准测试结果。你可能会忽略其中一个优化会限制你感兴趣的方面的效率。

同样地,尝试将复杂的部分隔离为可以单独优化和分析的较小部分(分而治之)。

知道你正在进行基准测试的软件版本。

这可能微不足道,但值得重复——使用软件版本控制!如果你尝试不同的优化,将它们分别提交并分布在不同的分支中,这样你就可以在需要时回到以前的版本。不要因为忘记在一天结束时提交你的工作而丧失你的优化努力。¹³

这也意味着你必须严格控制刚刚基准测试过的代码版本。即使是对看似无关的语句进行小小的重排,也可能影响代码的效率,因此请始终在原子迭代中基准测试你的程序。这也包括你的代码所需的所有依赖项,例如在你的go.mod文件中列出的依赖。

知道你正在使用的基准测试版本。

此外,记得对基准测试本身的代码进行版本控制!避免比较不同基准测试实现的结果,即使变更很小(增加额外的检查)。

编写脚本来执行这些基准测试,并对它们进行相同的配置和版本控制,这也是不迷失的好方法。在第八章中,我提到了一些关于以声明方式共享基准测试选项的最佳实践,以便你将来自己和团队的其他成员使用。

保持你的工作组织良好和结构化。

记录笔记,设计自己的一致工作流程,并明确你试验的代码版本。跟踪依赖版本,并以一致的方式明确跟踪所有基准测试结果。最后,清楚地与他人沟通你的发现。

你的代码在不同的代码尝试中也应该保持干净。保持所有最佳实践,如DRY,不要保留注释掉的代码,隔离测试之间的状态等。

对“看似太好而不可信”的基准测试结果保持怀疑态度。

如果您无法解释为什么您的代码突然运行更快或者使用更少的资源,那么您肯定在基准测试时做错了什么。很诱人地庆祝,接受它并继续前进,而不进行双重检查是不明智的。

检查常见问题,例如您的基准测试用例是否触发错误而不是成功运行(见“为正确性测试您的基准!”)或者编译器是否优化了您的微基准测试(见“编译器优化与基准测试”)。

在我们的工作中稍微有些懒散是健康的。¹⁴ 然而,在错误的时刻懒惰可能会显著增加未知因素和风险,使程序效率优化变得更加困难。

现在让我们看看可靠基准测试的第二个关键元素,即相关性。

复制生产环境

这可能是显而易见的,但我们并不是为了在我们的开发机器上运行得更快或者消耗更少的资源而优化软件。我们优化的目的是确保软件在对我们业务重要的目标地点上具有足够高效的执行能力,也就是所谓的生产

生产环境可能意味着您部署的生产服务器环境(如果您构建后端应用程序),或者客户设备,如 PC、笔记本电脑或智能手机(如果您构建面向最终用户的应用程序)。因此,通过增强其相关性来显著提高所有基准的效率评估的质量是完全可行的。我们可以通过尽力模拟(复制)生产的情况和环境条件来实现这一点。特别是:

生产条件

生产环境的特性。例如,生产机器将为我们的程序专门分配多少 RAM 和何种类型的 CPU?它使用哪个操作系统版本?我们的程序将使用哪些版本和类型的依赖关系?

生产工作负载

我们的程序将处理的数据以及必须处理的用户流量的行为。

或许我们应该首先做的是围绕软件目标目的地收集需求,最好以书面形式记录在我们的 RAER 中。如果没有它,我们无法正确评估我们软件的效率。同样,如果您看到供应商或独立实体完成的基准测试,请检查基准条件是否与您的生产和要求相匹配。通常情况下,它们并不匹配,为了完全信任它,我们应该尝试在我们这边复制这样的基准测试。

假设我们大致知道我们软件的目标生产环境是什么样的,我们可以开始设计我们的基准流程、测试数据和用例。坏消息是,在我们的开发或测试环境中,完全复制生产的每一个方面是不可能的。总会存在差异和未知因素。生产环境将会有很多不同的原因:

  • 即使我们运行与生产环境相同类型和版本的操作系统,也无法复制操作系统的动态状态,这会影响效率。事实上,在同一台本地机器上的两次运行之间,我们无法完全复制这种状态!这个挑战通常被称为非确定性性能,并且我们将在“性能的非确定性”中讨论它。

  • 经常复制所有可能发生的生产工作负载会太昂贵(例如,复制所有生产流量并将其通过测试集群进行分叉)。

  • 当开发终端用户应用程序时,存在太多不同的硬件、依赖软件版本和情况的排列组合。例如,想象一下您创建了一个 Android 应用程序——即使我们限制自己只使用过去两年内制造的智能手机,仍然有大量智能手机型号可能运行您的软件。

好消息是,我们不需要复制生产环境的所有方面。相反,通常足以表示可能限制我们工作负载的产品的关键特性。我们可能从开发的起始阶段就了解到这一点——但是随着时间、实验和宏观基准测试(参见“宏观基准测试”)甚至生产,您将了解哪些是重要的。

例如,想象一下,你开发了负责将本地文件上传到远程服务器的 Go 代码,用户在上传大文件时注意到不可接受的延迟。基于此,我们用于复制这一问题的基准应该是:

  • 专注于涉及大文件的测试用例。不要试图优化大量不同的小文件、所有不同的错误情况以及潜在的加密层,如果这些不代表大多数生产用户正在使用的内容。相反,务实地专注于基准测试,关注您当前的目标。

  • 请注意,您的本地基准测试并未复制可能在生产环境中看到的潜在网络延迟和行为。您代码中的一个 bug 可能仅在网络缓慢时导致资源泄漏,这可能在您的机器上很难复制。为了进行这些优化,值得根据“基准测试级别”中所述,将基准测试移动到不同的级别。

模拟生产的“特性”并不一定意味着与生产中存在的相同数据集和工作负载!对于我们之前的例子,您不需要创建 200GB 的测试文件并使用它们来基准测试您的程序。在许多情况下,您可以从相对较大的文件开始,例如 5MB,然后是 10MB,并结合复杂性分析,推断出在 200GB 级别会发生什么。这将使您能够更快、更便宜地优化这些情况。

通常情况下,尝试精确复制特定工作负载将会过于困难和低效。基准测试通常是工作负载的一种抽象。在将工作负载抽象为基准测试的过程中,需要捕捉工作负载的关键方面,并以准确映射的方式表示它们。

亚历山大·卡尔顿,《谎言、该死的谎言和基准测试》

总结一下,当试图评估效率或复现效率退化时,请注意您的测试设置与生产环境之间的差异。并非所有差异都值得复现,但首要步骤是了解这些差异及其如何影响我们基准测试的可靠性!现在让我们看看还能做些什么来提高基准测试实验的信心。

性能的非确定性

效率优化中可能面临的最大挑战是现代计算机的“非确定性性能”。这意味着所谓的噪音,也就是我们实验结果的变化性,是由于影响我们在第四章和第五章学到的效率的各层高复杂性。因此,效率特性通常是不可预测的,极易受环境副作用的影响。

例如,让我们考虑 Go 代码中的单个语句,a += 4。不管这段代码在什么条件下执行,假设我们是唯一使用 a 变量的内存的用户,a += 4 的结果始终是确定性的——a 的值加上 4。这是因为,在几乎所有情况下,很难影响正确性。您可以让计算机极度加热或冷却,可以摇晃它,可以在操作系统中安排数百万个同时进程,并且可以使用支持该硬件的任何支持类型的操作系统的任何 CPU 版本。除非您做一些极端的事情,如影响内存中的电信号,或者让计算机停电,否则 a += 4 操作始终会给我们相同的结果。

现在让我们想象一下,我们有兴趣了解我们的 a += 4 操作如何影响更大程序的延迟。乍一看,延迟评估应该很简单——这只需要一个 CPU 指令(例如,ADDQ)和一个 CPU 寄存器,因此摊销成本应该与您的 CPU 频率一样快,例如,对于 3 GHz CPU 的平均值为 0.3 纳秒。

然而,在实际应用中,开销永远无法摊销,并且在单次运行中从不静态,使该语句的延迟高度不确定。正如我们在 第四章 中学到的,如果寄存器中没有数据,CPU 必须从 L 缓存中获取,可能需要一纳秒。如果 L 缓存中包含 CPU 需要的数据,我们的单个语句可能需要 50 纳秒。假设操作系统正忙于运行数百万其他进程,则我们的单个语句可能需要毫秒级的时间。请注意,我们正在讨论的是单个指令!从更大的尺度来看,如果这种噪声累积,我们可以积累以秒计量的可测量差异。

要小心。几乎所有的东西都可能影响我们操作的延迟。繁忙的操作系统、硬件元件的不同版本,甚至是同一公司制造的不同 CPU 可能导致不同的延迟测量。靠近笔记本电脑 CPU 或电池模式的环境温度可以触发 CPU 频率的热调节,上下波动。在极端情况下,甚至对着电脑大声喊叫都可能影响效率!¹⁶ 运行程序时,我们拥有的复杂性和层次越多,效率测量就越脆弱。远程设备、个人电脑以及使用像容器或虚拟机这样的共享基础设施的公共云提供商(例如 AWS 或 Google),同样存在类似的问题。¹⁷

效率评估的脆弱性如此普遍,以至于我们必须在每次基准测试尝试中都预期它。因此,我们必须接受它,并将对这些风险的缓解嵌入到我们的工具中。

在减少非确定性性能之前,您可能想要做的第一件事是检查此问题是否影响您的基准测试。通过计算结果的方差(例如,使用标准偏差)来验证测试的可重复性。我将在“理解结果”中解释一个好工具,但通常您可以在眼前就看到它。

例如,如果您运行实验一次,看到它在 4.05 秒内完成,而其他运行时间从 3.01 到 6.5 秒不等,您的效率评估可能不准确。另一方面,如果方差很小,您可以更加自信地确认您的基准测试的相关性。因此,首先检查您基准测试的可重复性。

不要过度使用统计学

诱人的是接受高方差,要么去除极端结果(异常值),要么取所有结果的平均值。您可以应用非常复杂的统计学方法来找到某些概率下的效率数字 (链接)。增加基准运行次数也可以使您的平均数更稳定,因此您更有信心。

实践中,有更好的方法来首先减少不稳定性。统计学在我们无法进行稳定测量或无法验证所有样本(例如,我们不能对全球所有人类进行轮询以了解使用的智能手机数量)时非常有用。在进行基准测试时,我们对稳定性的控制比最初想象的要多。

我们可以遵循许多最佳实践,以确保我们的效率测量更可靠,减少潜在的非确定性性能影响:

确保在进行基准测试的机器处于稳定状态。

对于大多数依赖比较的基准测试来说,重要的不是我们进行基准测试的条件,只要它们是稳定的(机器状态在基准测试期间或之间不变)。不幸的是,通常有三种因素会妨碍机器的稳定性:

后台线程

正如你在第四章中学到的那样,难以在机器上隔离进程。即使是一个看似很小的单个进程也足以让你的操作系统和硬件变得忙碌,从而改变你的效率测量结果。例如,你可能会对一个浏览器标签或 Slack 应用程序使用了多少内存和 CPU 时间感到惊讶。在公共云上,这种影响可能更为隐蔽,因为我们可能会看到来自我们不拥有的不同虚拟操作系统的进程对我们产生影响。

热量调节

高端 CPU 在负载下温度显著上升。CPU 被设计为可以承受如 80–110°C 这样的相对高温,但也有其极限。如果风扇不能迅速冷却硬件,操作系统或固件将限制 CPU 周期,以避免组件熔化。特别是在像笔记本电脑或智能手机这样的远程设备上,当环境温度较高、设备暴露在阳光下或者散热风扇被遮挡时,很容易触发热量调节。

电源管理

同样地,设备可以限制硬件速度以降低功耗。这通常在带有省电模式的笔记本电脑和智能手机上可见。

在共享基础设施上要格外谨慎。

在稳定的云提供商上购买专用虚拟机进行基准测试并不是一个坏主意。我们提到了“吵闹的邻居”问题,但如果操作得当,云有时比你的桌面机器更能够在基准测试期间运行各种交互式软件。

使用云资源时,请确保选择与供应商签订最佳可能的、严格的服务质量(QoS)合同。例如,避免选择更便宜的突发型或可预留的虚拟机,这些设计上容易受到基础设施不稳定性和“吵闹的邻居”的影响。

避免使用持续集成(CI)流水线,特别是那些来自免费层次(如GitHub Action或其他提供者)的流水线。尽管它们仍然是方便和廉价的选择,但它们设计用于必须最终完成的正确性测试(而不是尽可能快地完成),并动态扩展以满足用户需求以最小化成本。这无法提供基准测试所需的严格和稳定的资源分配。

要注意基准机器的限制。

要注意你的机器规格。例如,如果你的笔记本电脑只有 6 个 CPU 核心(使用超线程技术可以达到 12 个虚拟核心),不要实施需要比你可用于测试的 CPU 数量更多的基准测试用例。此外,对于通用机器上的六个物理核心 CPU,可能有意义的是仅使用四个 CPU 进行基准测试,以确保为操作系统和后台进程留出空间。¹⁹

同样地,要注意其他资源(如内存)的限制。例如,不要运行接近最大内存容量的基准测试,因为内存压力、更快的垃圾回收和内存碎片可能会减慢机器上所有线程的速度,包括操作系统!

将实验运行更长时间。

减少基准测试运行之间方差的最简单方法之一是稍微延长基准测试时间。这使我们能够最小化我们可能在基准测试开始阶段看到的基准测试开销(例如,CPU 缓存预热阶段)。这在统计上也给了我们更多的信心,表明平均延迟或资源消耗指标显示了当前效率水平的真实模式。这种方法需要时间,并依赖于非平凡的统计学,易于统计谬误,因此请谨慎使用,并最好尝试之前提到的建议。

总之,请注意可能导致混淆的潜在人为错误。确保你的实验与你和你的开发团队的生产最终目标相关。最后,测量你的实验的可重复性,以评估是否可以依赖它们的结果。当然,基准测试运行之间或基准测试运行与生产设置之间总会存在一些差异。然而,遵循这些建议,你应该能够将它们降低到安全的 2-5%的方差水平以下。

或许你来到这一章是为了学习如何进行 Go 基准测试。我迫不及待地希望在下一章为你逐步解释如何进行这些测试!然而,Go 基准测试并不是我们在经验评估工具中拥有的全部。因此,学会何时选择 Go 基准测试,何时退而求其次使用不同的基准测试方法是至关重要的。我将在下一节中概述这一点。

基准测试级别

在第六章中,我们讨论了寻找延迟和资源使用度量标准,这些标准将允许我们进行可靠的测量。但在前一节中,我们了解到这可能只是成功的一半。按照定义,基准测试需要一个实验阶段,这将触发应用程序的某种情况或状态,这对于测量是有价值的。

在我们开始实验之前,有一点值得一提。评估我们软件新版本效率的天真且可能最简单的解决方案是将其提供给我们的客户,并在“生产”使用过程中收集我们的指标数据。这非常好,因为我们不需要模拟或重现任何东西。本质上,客户在我们的软件上执行“实验”部分,我们只是测量他们的体验。我们可以称之为“源头监控”或“生产监控”。不幸的是,这里存在一些挑战:

  • 计算机系统是复杂的。正如我们在 “复制生产环境” 中所学到的,效率取决于许多环境因素。要真正评估我们的新软件版本是否具有更好或更差的效率,我们必须了解所有这些“测量”条件。然而,当它在客户机器上运行时,收集所有这些信息是不经济的。²¹ 没有这些信息,我们无法得出任何有意义的结论。此外,许多用户会选择退出任何报告功能,这意味着我们对发生的事情知之甚少。

  • 即使我们收集了可观察性信息,也不能保证问题再次发生。不能保证客户会执行复制旧问题的所有步骤。统计上讲,所有有意义的情况总会发生,但实际上这种情况发生的时间间隔太长。例如,假设一个特定路径 /compute 的 HTTP 请求导致效率问题。我们修复了并部署到生产环境。但如果在接下来的两周内没有人使用这条路径会怎样呢?这里的反馈循环可能会非常长。

反馈循环

反馈循环是一个从修改代码开始并以围绕这些变化的观察结束的循环。

反馈循环越长,开发成本就越高。开发人员的沮丧情绪也经常被低估。在极端情况下,开发人员会不可避免地采取捷径,忽略重要的测试或基准测试实践。

要克服这一点,我们必须投资于能够在最短时间内提供尽可能可靠反馈的实践。

  • 最后,如果我们依赖用户来“基准测试”我们的软件,通常为时已晚。如果太慢,我们可能已经失去了他们的信任。可以通过 金丝雀发布 和功能标志来缓解这一问题,²² 但理想情况下,我们在将软件发布到生产环境之前就能捕捉到效率问题。

生产监控至关重要,特别是当您的软件每周 7 天、每天 24 小时运行时。此外,像在您的错误跟踪器中观察效率趋势和用户反馈这样的手动监控,在效率评估的最后一步也是有用的。我们在这里讨论的测试策略确实会有漏洞,因此保持生产监控作为最后的验证手段是有意义的。但作为独立的效率评估,生产监控是相当有限的。

幸运的是,我们有更多的测试选项来验证效率。话不多说,让我们来看看不同级别的效率测试。如果我们把它们都放在一个单一的图表上,根据实施和维护所需的努力以及各个测试的有效性进行比较,它可能看起来像 图 7-2。

efgo 0702

图 7-2。关于设置和维护难度(水平轴)与实际上一个给定类型的测试效果有多有效(垂直轴)的效率和正确性测试方法的类型。

在图 7-2 中展示的方法哪些被成熟的软件项目和公司使用?答案是所有方法。让我解释一下。

生产环境中的基准测试

生产环境测试实践之后,我们可以使用实际的生产系统来评估效率。这可能意味着雇佣“测试驱动者”(即 Beta 用户),他们将在其设备上运行我们的软件,创建真实的使用情况并报告问题。在你的公司将开发的软件作为 SaaS 销售时,生产环境中的基准测试也非常有用。对于这些情况,只需创建自动化(例如批处理作业或微服务),定期或每次发布后,使用预定义的测试用例集来进行基准测试,这些测试用例模拟真实用户功能(例如模拟用户流量的 HTTP 请求)。尤其是由于你控制生产环境,你可以减轻生产监控的缺点。你可以了解环境条件,快速回退,使用功能标志,进行金丝雀发布等等。

生产环境中的基准测试有限的使用

不幸的是,这种测试实践面临许多挑战:

  • 当你将软件作为 SaaS 运行时,这一切都会更容易。否则,情况会变得更加困难,因为开发人员无法快速回退或修复潜在的影响。

  • 你必须确保服务质量(QoS)。这意味着你不能使用极端的负载进行基准测试,因为你需要确保不会影响——例如,导致拒绝服务(DoS)——你的生产环境。

  • 在这种模型中,开发人员的反馈循环相当长。例如,你需要完全发布你的软件才能对其进行基准测试。

另一方面,如果你可以接受这些限制,正如在图 7-2 中所示,生产环境中的基准测试可能是最有效和可靠的测试策略。这最终是我们能够接近真实生产使用的最佳方法,从而降低不准确结果的风险。创建和维护这类测试的工作量相对较小,假设我们已经有了生产监控。我们不需要模拟数据,环境,依赖关系等。我们可以重用现有的监控工具,这样你需要保持集群运行。

宏观基准测试

在生产环境中测试或基准测试是可靠的,但在那时发现问题是昂贵的。这就是为什么行业在开发的早期阶段引入了测试的原因。其好处是我们可以仅通过原型评估效率,而原型可以更快地生成。我们称这个级别上的测试为“宏观基准测试”。

与在生产中进行基准测试相比,宏基准测试在测试可靠性和反馈速度之间提供了很好的平衡。实际上,这意味着在模拟环境中构建你的 Go 程序,并在那些需要的所有依赖项中进行基准测试。例如,对于客户端应用程序,这可能意味着购买一些示例客户设备(例如,如果我们构建移动应用程序,则可能是智能手机)。然后,在某些应用程序发布中,重新安装你的 Go 程序到这些设备上,并彻底进行基准测试(最好使用一些自动化套件)。

对于类似 SaaS 的使用案例,这可能意味着创建生产集群的副本,通常称为“测试”或“暂存”环境。然后,为了评估效率,在这些环境中构建你的 Go 程序,部署方式与生产环境相同,并对其进行基准测试。我们还将简要讨论更简单的方法,例如使用e2e框架,你可以在单个开发机器上运行,而无需像 Kubernetes 这样复杂的编排系统。我将在“宏基准测试”中简要解释这两种方法。

宏基准测试有很多好处:

  • 它们非常可靠和有效(但不如在生产中进行基准测试那么多)。

  • 将这样的宏基准测试委托给独立的 QA 工程师,因为你可以将你的 Go 程序视为一个“封闭的盒子”(以前被称为“黑盒子”——不需要理解它是如何实现的)。

  • 你不会对生产产生任何影响。

正如在图 7-2 中所示,这种方法的缺点在于构建和维护这样一个基准测试套件所需的工作量。通常,这意味着复杂的配置或代码来自动化所有这些工作。此外,在许多情况下,我们 Go 程序的任何功能更改意味着我们必须重新构建复杂的宏基准测试系统的部分。因此,这样的宏基准测试适用于具有稳定 API 的更成熟项目。此外,反馈周期仍然相当长。我们还必须限制同时进行的基准测试数量。自然而然地,我们有一定数量的测试集群,与其他团队成员共享以节约成本。这意味着我们必须协调这些基准测试。

微基准测试

幸运的是,我们有一种更敏捷的基准测试方法!我们可以遵循分而治之的优化模式。与其查看整个系统或 Go 程序的效率,我们可以以开放式盒子(以前称为“白盒子”)的方式处理我们的程序,并将程序功能划分为较小的部分。然后,我们可以使用我们将在第九章中学到的性能分析来识别对整体解决方案效率贡献最大的部分(例如,使用最多的 CPU 或内存资源或对延迟增加最多)。然后,我们可以通过编写小的单元测试,例如微基准测试,只针对这个小部分在隔离状态下评估程序的效率。Go 语言提供了一个本地基准测试框架,您可以使用与单元测试相同的工具运行:go test。我们将在“微基准测试”中讨论使用这种实践。

微基准测试可能是最有趣的写作,因为它们非常敏捷,并且可以快速反馈我们的 Go 函数、算法或结构的效率。您可以快速在您喜爱的 IDE 中运行这些基准测试,甚至是在您的(即使是小型的!)开发者机器上。您可以在 10 分钟内实现这样的基准测试,接下来的 20 分钟内执行它,然后拆除或完全更改它。这种方法成本低廉,迭代便宜,就像单元测试一样。您还可以将其视为更可重复使用的开发工具——编写更复杂的微基准测试,作为整个团队可以使用的小部分代码的验收基准。

不幸的是,敏捷性带来了许多权衡。例如,假设您错误地识别了程序的效率瓶颈。在这种情况下,您可能会因为程序的某些部分的本地微基准测试仅花费了 200 毫秒而感到高兴。然而,当您的程序部署后,它可能仍然会导致效率问题(并违反 RAER)。此外,有些问题只有在运行所有代码组件时才能看到(类似于集成测试)。测试数据的选择也是非常重要的。在许多情况下,我们无法模仿依赖关系,使其能够重现某些效率问题,因此我们必须做一些假设。

在进行微基准测试时,请不要忘记大局

对于代码中的瓶颈部分进行简单而有意的优化并看到明显改善并不罕见。例如,在优化后,我们的微基准测试可能表明,我们的函数现在每次操作只分配了 2 MB,而不是 400 MB。思考了代码的这部分后,您可能会有很多其他关于优化这 2 MB 分配的想法!因此,您可能会想要学习和优化它。

这是一个风险。很容易固守单个微基准的原始数字并深入优化的兔子洞,引入更多复杂性并消耗宝贵的工程时间。

在这种情况下,我们最有可能对庞大的 200 倍提升感到满意,并采取一切措施使其部署。如果我们希望进一步提高我们所关注的路径的性能,则我们所测试的代码路径的瓶颈现在可能已经转移到其他地方!

您应该使用哪个级别?

正如您可能已经注意到的那样,没有“最佳”的基准测试类型。每个阶段都有其目的并且是必需的。每个扎实的软件项目最终都应该有一些微基准测试,有一些宏基准测试,并可能在生产中对某些功能部分进行基准测试。这可以通过查看一些开源项目来确认。有许多示例,但只需选择两个:

您要在您所工作的软件项目中添加哪些基准测试以及何时添加基于需求和成熟度而定。在早期开发周期中,向项目添加大量基准测试是不实际的。当 API 不稳定且详细需求正在变化时,基准测试也需要相应更改。事实上,如果我们花时间编写(以及后来维护)尚未在功能上证明其有用性的项目的基准测试,这可能对项目有害。

采用这种(聪明的)懒惰方法:

  1. 如果利益相关者对可见的效率问题感到不满,请对生产中解释的第九章执行瓶颈分析,并将微基准(见“微基准”)添加到成为瓶颈的部分。优化后,可能会出现另一个瓶颈部分,因此必须添加新测试。一直做到您对效率感到满意,或者进一步优化程序变得太困难或昂贵。它将有机生长。

  2. 当建立正式的 RAER 时,确保您更终端地测试效率可能会很有用。然后您可能希望投资于手动,然后自动的宏基准(见“宏基准”)。

  3. 如果你真的关心准确和务实的测试,并且控制你的“生产”环境(适用于 SaaS 软件),请考虑在生产环境中进行基准测试。

不要担心“基准”代码覆盖率!

对于功能测试,通过确保测试代码覆盖率较高来衡量项目质量是很流行的。²³

永远不要试图衡量你的程序有多少部分有基准测试!理想情况下,你只应该为你想要优化的关键位置实施基准测试,因为数据表明它们(或曾经是)瓶颈。

有了这个理论,你应该知道你可以使用哪些基准测试水平,以及为什么没有银弹。尽管如此,基准测试已经成为我们软件效率故事的一部分,Go 语言在这里也不例外。我们无法在没有实验和测量的情况下进行优化。然而,在这个阶段花费的时间要注意。编写、维护和执行基准测试需要时间,所以按需遵循懒惰的方法,在适当的级别和只有在需要时添加基准测试。

总结

这些测试的可靠性问题也许是开发人员、产品经理和利益相关者将效率努力降低到最低程度的最大原因之一。你认为我在哪里找到所有这些小的最佳实践来提高可靠性呢?在我工程职业生涯的开始阶段,我与我的团队花了大量时间进行仔细的负载测试和基准测试,只是意识到这对环境的关键因素意味着什么。例如,我们的合成工作负载并未提供真实的负载。

这种情况甚至会使专业开发人员和产品经理感到沮丧。不幸的是,我们通常更倾向于为计算浪费支付更多而不是投资于优化工作。这就是为什么确保我们进行的实验、负载测试和规模测试尽可能可靠以更快地实现我们的效率目标至关重要的原因!

在本章中,你学到了通过我们称之为基准测试的经验性实验来建立可靠效率评估的基础。

我们讨论了基本的复杂性分析,它可以帮助优化我们的旅程。我提到了基准测试和功能测试之间的区别,以及为什么如果我们误解它们,基准测试会误导我们。你学到了在实验周期中真正重要的常见可靠性问题,以及行业中常见的基准测试水平。

我们终于准备好学习如何在上述所有层面上实施这些基准测试,所以让我们立刻开始吧!

¹ 这对于 Go 1.20 中的这个特定的ParseInt函数来说已经固定了,得益于一个惊人的改进,但你可能会在任何其他函数中感到惊讶!

² 只有在我们的程序中进行大量字符串复制时才会出现。也许它来自一些内部的字节池?

³ 这些“O-notations”分别被称为大 O 或 Oh、Omega 和 Theta。他还定义了“o-notations”(o,ω),意思是严格的上限或下限,因此“这个函数增长比f(N)慢,但不完全是f(N)”。在实践中,我们并不经常使用 o-notations。

⁴ 我会将它们归类为“蛮力”——它们会对不同的输入进行许多基准测试,并尝试逼近增长函数。

⁵ 我不会感到惊讶——我在大学第二年就开始了全职 IT 工作。

⁶ 例如,快速排序的复杂度比其他算法更糟糕,但平均而言它是最快的。或者像Coppersmith-Winograd这样的矩阵乘法算法有一个被大 O 符号隐藏的大常数系数,这使得它只对我们现代计算机来说太大的矩阵值得做。

⁷ 小心:不同的工具使用不同的转换;例如,pprof使用 1,024 的乘数,而benchstat使用 1,000 的乘数。

⁸ 我非常惊讶我们可以构建如此准确的空间复杂度,并对堆上的每个字节进行如此准确的内存基准测试和分析。感谢 Go 社区和pprof社区为此辛勤工作!

⁹ 这并不意味着我们应该立即修复这些问题!相反,如果你知道问题会影响你的目标,比如用户满意度或 RAER 要求,那么始终进行优化。

¹⁰ 有时,有相对简单的方法可以改变我们的代码以流式传输并使用外部内存算法,以确保内存使用稳定。

¹¹ 不幸的是,我们仍然必须有些猜测——更多内容请参见“实验的可靠性”。没有什么能给我们 100%的保证。然而,对于我们开发者来说,基准测试可能是确保我们开发的软件足够高效的最佳方式。

¹² 例如,汽车制造商在排放基准上作弊手机厂商在硬件基准上作弊(有时会导致被流行的Geekbench列出禁止)。在软件世界中,我们通过不公平的基准测试进行各种供应商之间的持续战斗。谁创建它们往往是结果列表中最快的之一。

¹³ 一些优秀的 IDE 还具有额外的 本地历史记录,如果你忘记在 git 仓库中提交更改。

¹⁴ 懒惰实际上对工程师是有好处的!但它必须是务实的、高效的、合理的懒惰,而不是纯粹基于我们当时的情绪。

¹⁵ 除非我们为运行在类似硬件上的其他开发人员编写软件。

¹⁶ 工程师 Brendan Gregg 演示了 大声喊叫对服务器硬盘的 I/O 延迟产生严重影响,因为振动的缘故。

¹⁷ 一个完全不同虚拟机的工作负载影响我们工作负载的情况通常被称为 noisy neighbor situation。这是一个严重的问题,云提供商不断努力应对,具体效果取决于服务和提供者。

¹⁸ 这就是为什么你不会看到我解释类似 RunParallel 的微基准选项。一般来说,同时运行多个基准函数可能会扭曲结果。因此,我建议避免使用此选项。

¹⁹ 您还可以将 CPU 核心完全用于您的基准测试;考虑使用 cpuset 工具

²⁰ 我在撰写 第十章 时遇到了这个问题。我在一个相对寒冷的日子一次性运行了一些基准测试。下周英国却遭遇了热浪。在如此炎热的日子里,我无法继续利用过去的基准测试结果进行优化工作,因为我的所有代码运行速度慢了 10%!我不得不重新做所有的实验,以公平地比较实现方式。

²¹ 从某种意义上说,这就是为什么将产品作为 SaaS 销售在软件领域如此吸引人。你的“生产”在你的地盘上,更容易控制用户体验并验证一些效率优化。

²² 功能标志是可以通过 HTTP 调用动态更改的配置选项,而无需重新启动服务。这使得可以更快地回滚新功能,有助于在生产环境中进行测试或基准测试。对于功能标志,我依赖于优秀的 go-flagz 库。我还会密切关注新的 CNCF 项目 OpenFeature,旨在在此领域提供更标准的接口。

²³ 我个人对这种方法并不是特别喜欢。并非代码的每个部分都同样重要进行测试,也不是每个东西都值得测试。此外,工程师们倾向于将这个系统变成游戏,只是为了提高覆盖率编写测试,而不是专注于以最快的方式找出代码中的潜在问题(降低开发成本)。

第八章:基准测试

希望你的 Go IDE 已准备好并热身待命!现在是时候对我们的 Go 代码进行压力测试,以了解在第七章中提到的微观和宏观水平上的效率特征。

在本章中,我们将从“微基准”开始,介绍微基准的基础知识,并介绍 Go 本地基准测试。接下来,我将解释如何使用benchstat等工具解释输出。然后,我将讨论我学到的微基准方面和技巧,这些对微基准的实际使用非常有用。

在本章的后半部分,我们将介绍“宏基准”,由于其大小和复杂性,宏基准很少在编程书籍中讨论。在我看来,宏基准与微基准一样关键,因此每个关心效率的开发人员都应该能够使用这种测试级别。接下来,在“Go 端到端框架”中,我们将通过使用容器完全编写的宏测试的完整示例进行介绍。我们将讨论结果和过程中的常见可观察性。

不再多言,让我们直接进入评估代码较小部分效率的最敏捷方式,即微基准测试。

微基准

如果基准测试专注于单个、孤立功能的单一代码片段,并且在单个进程中运行的小段代码,那么可以称之为微基准测试。您可以将微基准测试视为用于评估为单个组件或算法级别进行的优化效率的工具(在“优化设计级别”中讨论)。任何更复杂的东西可能会在微观水平上进行基准测试时面临挑战。我指的更复杂的是,例如尝试对以下内容进行基准测试可能会有挑战性:

  • 同时进行多个功能。

  • 长时间运行的功能(超过 5 至 10 秒)。

  • 更大的多结构组件。

  • 多进程功能。如果在我们的测试过程中不会旋转太多的 goroutine(例如超过一百个),则接受多 goroutine 功能。

  • 需要比中等开发机器更多资源才能运行的功能(例如,分配 40GB 内存来计算答案或准备测试数据集)。

如果您的代码违反了这些要素之一,您可能需要将其拆分为更小的微基准,或者考虑使用不同框架的宏基准(参见“宏基准”)。

保持微基准微小

我们在微观水平上一次进行基准测试的越多,实施和执行这类基准测试就需要越多的时间。这导致了连锁后果——我们尝试使基准测试更可重用,并花费更多时间在其上构建更多的抽象。最终,我们试图使它们更稳定并且更难更改。

这是一个问题,因为微基准是为了敏捷性而设计的。我们经常更改代码,因此我们希望能够快速更新基准而不受阻碍。因此,您可以快速编写它们,保持简单,并进行更改。

此外,Go 基准测试不具备(也不应具备!)复杂的可观测性,这也是保持其简洁的另一个原因。

基准定义意味着对微基准进行验证,以确认您的程序是否符合某些功能的高级用户RAER,例如,“此 API 的 p95 应低于一分钟。” 换句话说,通常不适合回答需要绝对数据的问题。因此,在编写微基准时,我们应该专注于与某个基线或模式相关的答案,例如:

学习关于运行时复杂度

微基准是了解 Go 函数或方法在某些维度上效率行为的绝佳方法。例如,输入和测试数据的不同份额和大小如何影响延迟?分配是否随着输入大小的增加而无限增长?您选择的算法的常数因子和开销是多少?

由于快速反馈循环,轻松地手动调整测试输入并查看您的函数在各种测试数据和情况下的效率是很容易的。

A/B 测试

A/B 测试是通过在程序版本 A 上执行相同测试,然后在版本 B 上执行不同(理想情况下)仅有一个事物(例如,您重用了一个片)。它们可以告诉我们我们更改的相对影响。

微基准是评估代码、配置或硬件的新更改是否可能影响效率的好方法。例如,假设我们知道某些请求的绝对延迟为两分钟,并且我们知道其中 60% 的延迟是由我们开发的某个 Go 函数引起的。在这种情况下,我们可以尝试优化此函数,并在之前和之后进行微基准。只要我们的测试数据可靠,如果优化后,我们的微基准显示我们的优化使我们的代码快了 20%,整个系统也将快 18%。

有时,延迟微基准的绝对数值可能并不重要。例如,如果我们的微基准在我们的机器上显示每个操作 900 毫秒,那么在另一台笔记本电脑上,它可能显示 500 毫秒。重要的是,在同一台机器上,尽可能少地更改环境并在一次又一次的基准测试之后,版本 A 和 B 之间的延迟更高或更低。正如我们在“重现生产”中学到的那样,这种关系在您将在那些版本上进行基准测试的任何其他环境中可能是可重现的。

在 Go 中实现和运行微基准测试的最佳方法是通过其内置于 go test 工具中的本地基准测试框架。它经过实战考验,集成到测试流程中,具有原生支持性能分析的能力,您可以在 Go 社区中看到许多基准测试的示例。我已经在 Example 6-3 中提到了围绕 Go 基准测试框架的基础知识,我们在 Example 7-2 的输出中看到了一些预处理的结果,但现在是深入细节的时候了!

Go 基准测试

创建 Go 中的微基准测试 首先要创建一个具有特定签名的特定函数。Go 工具并不挑剔——一个函数必须满足三个要素才能被视为基准测试:

  • 创建函数的文件必须以 _test.go 后缀结尾。¹

  • 函数名必须以区分大小写的 Benchmark 前缀开头,例如 BenchmarkSum

  • 函数必须有一个 *testing.B 类型的函数参数。

在 “复杂度分析” 中,我们讨论了 Example 4-1 代码的空间复杂度。在 第十章 中,我将向您展示如何优化这段代码以满足几个不同的需求。如果没有 Go 基准测试,我将无法成功地进行这些优化。我用它们来获取分配数量和延迟的估计数据。现在让我们看看基准测试的具体过程。

Go 基准测试命名约定

我尝试在 Go 测试框架中的所有函数类型(如基准测试 (Benchmark<NAME>),测试 (Test<NAME>),模糊测试 (Fuzz<NAME>),和示例 (Example<NAME>)) 的 <NAME> 部分上遵循一致的命名模式。这个想法很简单:

  • 将一个测试命名为 BenchmarkSum 表示测试 Sum 函数的效率。BenchmarkSum_withDuplicates 表示相同的测试,但后缀(注意它以小写字母开头)告诉我们我们在测试的某些条件。

  • BenchmarkCalculator_Sum 表示对 Calculator 结构体中的 Sum 方法进行测试。如上所述,如果我们对同一方法有更多的测试,可以添加后缀以区分不同情况,例如 BenchmarkCalculator_Sum_withDuplicates

  • 此外,您可以添加输入大小作为另一个后缀,例如 BenchmarkCalculator_Sum_10M

鉴于 Example 4-1 中的 Sum 是一个专用的简短函数,一个好的微基准测试就足以说明其效率。因此,我在 sum_test.go 文件中创建了一个名为 BenchmarkSum 的新函数。但在做任何其他操作之前,我添加了所需的大多数基准测试的原始模板,正如 Example 8-1 中所示。

示例 8-1. Go 基准测试的核心元素
func BenchmarkSum(b *testing.B) {
    b.ReportAllocs() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    // TODO(bwplotka): Add any initialization that is needed.

    b.ResetTimer() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    for i := 0; i < b.N; i++ { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        // TODO(bwplotka): Add tested functionality.
    }
}

1

选择性方法,告诉 Go 基准测试提供分配的数量和分配的总量。这等同于在运行测试时设置-benchmem标志。虽然在理论上它可能会为测量的延迟增加微小的开销,但仅在非常快的函数中才能看到。实际上,我很少需要移除分配追踪,所以我总是保持开启。通常情况下,即使您期望任务只对 CPU 敏感,查看分配数量也是有用的。正如在“内存相关性”中提到的,某些分配可能会令人惊讶!

2

在大多数情况下,我们不希望基准测试初始化测试数据、结构或模拟的依赖所需的资源。要在“外部”时钟延迟和分配跟踪之外执行此操作,请在实际基准测试之前重置计时器。如果我们没有任何初始化,我们可以将其删除。

3

这个精确的for循环序列与b.N是任何 Go 基准测试的强制元素。永远不要更改它或删除它!类似地,永远不要在您的函数中使用循环中的i。这可能会在开始时令人困惑,但要运行您的基准测试,go test可能会多次运行BenchmarkSum以找到合适的b.N,具体取决于我们如何运行它。默认情况下,go test将尝试至少运行这个基准测试 1 秒钟。这意味着它将使用b.N等于 1 m 来执行我们的基准测试一次,仅评估单次迭代持续时间。基于此,它将尝试找到使整个BenchmarkSum至少执行 1 秒钟的最小b.N

我想要进行基准测试的Sum函数接受一个参数——包含要求和的整数列表的文件名。正如我们在“复杂性分析”中讨论的那样,示例 4-1 中使用的算法取决于文件中整数的数量。在这种情况下,空间和时间复杂度是O(N),其中N是整数的数量。这意味着Sum与单个整数相比,将比包含数千个整数的Sum更快并且分配更少的内存。因此,输入选择将显着改变效率结果。但是我们如何找到适合基准测试的正确测试输入呢?不幸的是,这并没有单一答案。

我们基准测试的测试数据和条件选择

通常,我们希望使用尽可能小(因此速度最快且成本最低!)的数据集,这将为我们提供足够的知识和对程序效率特征模式的信心。另一方面,它应该足够大,以触发用户可能遇到的潜在限制和瓶颈。正如我们在“复制生产”中提到的,测试数据应尽可能模拟生产工作负载。我们追求“典型性”。

但是,如果我们的功能在特定输入时存在严重问题,我们也应该在基准测试中包含这些内容!

为了使事情变得更加困难,我们还受到微基准测试数据大小的限制。通常情况下,我们希望确保这些基准测试能够在几分钟内以最大效率运行,并在我们的开发环境中获得最佳的敏捷性和最短的反馈循环。值得欣慰的是,有方法可以找到程序的某些效率模式,使用比潜在生产数据集小几倍的数据集运行基准测试,并推断可能的结果。

例如,在我的机器上,Example 4-1 大约需要 78.4 毫秒来求和 200 万个整数。如果我用 100 万个整数进行基准测试,需要 30.5 毫秒。根据这两个数字,我们可以有一定的信心⁴,认为我们的算法平均需要大约 29 纳秒来求和一个整数。⁵如果我们的需求分析和需求规范(RAER)指定,例如,我们必须在 30 秒内对 20 亿个整数求和,我们可以假设我们的实现速度太慢,因为 29 纳秒 * 20 亿大约是 58 秒。

出于这些原因,我决定在 Example 4-1 的基准测试中坚持使用 200 万个整数。这是一个足够大的数字,可以展示一些瓶颈和效率模式,但又足够小,可以保持我们的程序相对快速(在我的机器上,它可以在 1 秒内执行大约 14 次操作。)⁶目前,我创建了一个testdata目录(在编译中排除),并手动创建了一个名为test.2M.txt的文件,其中包含 200 万个整数。使用测试数据和 Example 8-1,我添加了要测试的功能,如 Example 8-2 所示。

Example 8-2. 用于评估Sum函数效率的最简单的 Go 基准测试
func BenchmarkSum(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = Sum("testdata/test.2M.txt")
    }
}

要运行这个基准测试,我们可以使用go test命令,在我们的机器上安装 Go之后就可以使用了。go test允许我们运行所有指定的测试、模糊测试或基准测试。对于基准测试,go test有许多选项,允许我们控制它如何执行我们的基准测试,并在运行后生成什么样的结果。让我们通过示例选项,展示在 Example 8-3 中。

Example 8-3. 我们可以使用的示例命令来运行 Example 8-2
$ go test -run '^$' -bench '^BenchmarkSum$' ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
$ go test -run '^$' -bench '^BenchmarkSum$' -benchtime 10s ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
$ go test -run '^$' -bench '^BenchmarkSum$' -benchtime 100x ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
$ go test -run '^$' -bench '^BenchmarkSum$' -benchtime 1s -count 5 ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

1

此命令执行一个具有显式名称BenchmarkSum的单个基准测试函数。您可以使用RE2 正则语言来过滤您想要运行的测试。请注意-run标志严格匹配没有功能测试。这是为了确保不运行任何单元测试,从而可以专注于基准测试。空的-run标志意味着将执行所有单元测试。

2

使用-benchtime,我们可以控制我们的基准测试应执行多长时间或多少次迭代(功能操作)。在本例中,我们选择尽可能多的迭代次数来适应 10 秒的时间间隔。⁷

3

我们可以选择将-benchtime设置为确切的迭代次数。这种做法较少见,因为作为微基准测试用户,您希望专注于快速反馈循环。当指定迭代次数时,我们无法知道测试何时结束,以及是否需要等待 10 秒或 2 小时。这就是为什么通常更喜欢限制基准测试时间的原因,如果迭代次数太少,则可以稍微增加-benchtime中的数字,或者更改基准实现或测试数据。

4

我们还可以使用-count标志重复基准测试周期。这样做非常有用,因为它允许我们计算运行之间的方差(使用“理解结果”中解释的工具)。

选项的完整列表非常长,您可以随时使用go help testflag列出它们。

通过 IDE 运行 Go 基准测试

几乎所有现代 IDE 都允许我们简单点击 Go 基准测试功能,并从 IDE 中执行它。因此,请随意操作。只需设置正确的选项,或者至少了解默认的选项有哪些!

我使用 IDE 触发初始的一秒钟基准测试运行,但对于更复杂的情况,我更喜欢使用传统的 CLI 命令。它们易于使用,并且很容易与他人分享测试运行配置。最后,使用您感觉最舒适的工具!

对于我的Sum基准测试,我创建了一个有用的单行命令,其中包含我需要的所有选项,见示例 8-4。

示例 8-4. 用于基准测试的单行 shell 命令,见示例 4-1
$ export ver=v1 && \ ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    go test -run '^$' -bench '^BenchmarkSum$' -benchtime 10s -count 5 \
        -cpu 4 \ ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        -benchmem \ ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        -memprofile=${ver}.mem.pprof -cpuprofile=${ver}.cpu.pprof \ ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    | tee ${ver}.txt ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)

1

编写复杂脚本或框架来将结果保存在正确位置、创建比较结果的自动化等是非常诱人的。在许多情况下,这是一个陷阱,因为 Go 基准测试通常是短暂且易于运行的。尽管如此,我决定添加少量的 bash 脚本来确保我的基准测试产生的工件具有我稍后可以引用的相同名称。当我使用优化的新代码版本进行基准测试时,我可以手动调整ver变量的不同值,如v2v3v2-with-streaming,以便进行后续比较。

2

有时,如果我们通过并发代码来优化延迟,就像在 “使用并发优化延迟” 中那样,重要的是控制允许基准测试使用的 CPU 核心数量。这可以通过 -cpu 标志来实现。它设置了正确的 GOMAXPROCS 设置。正如我们在 “性能非确定性” 中提到的,确切值的选择高度依赖于生产环境的外观以及开发机器有多少个 CPU。

3

如果我们的优化在分配极大量的内存时没有优点,那么优化延迟就没有意义,就像我们在 “内存相关性” 中学到的那样。根据我的经验,内存分配比 CPU 使用造成的问题更多,因此我始终试图注意使用 -benchmem

4

如果您运行微基准测试并看到您不满意的结果,您的第一个问题可能是什么导致了减速或高内存使用。这就是为什么 Go 基准测试内置支持分析的原因,详见 第 9 章。我比较懒,通常保持这些选项默认开启,类似于 -benchtime。因此,我总是可以深入分析性能分析文件,找到引起可疑资源使用的代码行。与 -benchtimeReportAllocs 类似,默认情况下关闭它们,因为它们会轻微增加延迟测量。然而,通常可以安全地将它们保持打开,除非您在测量超低延迟操作(数十纳秒)时。尤其是 -cpuprofile 选项在后台会增加一些分配和延迟。

5

默认情况下,go test 将结果打印到标准输出。但是,为了可靠地进行比较,不至于在结果与运行时迷失方向,我建议将它们保存在临时文件中。我建议使用 tee 同时写入文件和标准输出,这样您可以跟踪基准测试的进展。

有了基准实现、输入文件和执行命令,现在是执行我们的基准测试的时候了。我在我的机器上的测试文件目录中执行了 示例 8-4,32 秒后完成。它创建了三个文件:v1.cpu.pprofv1.mem.pprofv1.txt。在本章中,我们对最后一个文件最感兴趣,因此您可以了解如何读取和理解 Go 基准测试输出。我们将在下一节中进行。

理解结果

每次运行后,go test基准测试以一致的格式打印结果。⁹ 示例 8-5 展示了在 示例 8-4 上执行的输出运行,用于 示例 4-1 中呈现的代码。

示例 8-5。由示例 8-4 命令产生的v1.txt文件的输出
goos: linux ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) goarch: amd64
pkg: github.com/efficientgo/examples/pkg/sum
cpu: Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz
BenchmarkSum-4    67    79043706 ns/op    60807308 B/op    1600006 allocs/op ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) BenchmarkSum-4    74    79312463 ns/op    60806508 B/op    1600006 allocs/op
BenchmarkSum-4    66    80477766 ns/op    60806472 B/op    1600006 allocs/op
BenchmarkSum-4    66    80010618 ns/op    60806224 B/op    1600006 allocs/op
BenchmarkSum-4    74    80793880 ns/op    60806445 B/op    1600006 allocs/op
PASS
ok     github.com/efficientgo/examples/pkg/sum    38.214s

1

每次基准运行都会捕获有关环境的基本信息,如架构、操作系统类型、我们运行基准的包以及机器上的 CPU。不幸的是,正如我们在“实验的可靠性”中讨论的那样,还有许多其他可能值得捕获的元素¹⁰,这些元素可能会影响基准。

2

每一行代表一个单独的运行(即使你使用-count=1运行基准,也只会有一行)。每行包含三列或更多列,具体数量取决于基准的配置,但顺序是一致的。从左到右,我们有:

  • 带有表示可用 CPU 数量的后缀的基准名称(理论上¹¹)。这告诉我们可以期待并发实现。

  • 此基准运行的迭代次数。请注意这个数字;如果太低,其他列中的数字可能不反映实际情况。

  • -benchtime除以运行次数得到的每个操作的纳秒数。

  • 每个操作在堆上分配的字节数。正如您在第五章中学到的,请记住,这并不告诉我们在其他段(如手动映射、缓存和堆栈)中分配了多少内存!只有在设置了-benchmem标志(或ReportAllocs)时才会出现此列。

  • 每个操作在堆上的分配次数(仅在设置了-benchmem标志时出现)。

  • 可选地,您可以使用b.ReportMetric方法报告每个操作的自定义指标。参见此示例。这将显示为进一步的列,并可以类似地与后面解释的工具进行聚合。

如果你运行示例 8-4 并且很长时间看不到输出,可能意味着你的微基准的第一次运行需要这么长时间。如果你的-benchtime是基于时间的,go test会快速检查运行单次迭代所需的时间,以找到估计的迭代次数。

如果花费太多时间,除非您想运行 30 分钟以上的测试,否则可能需要优化基准设置,减少数据量,或将微基准拆分为更小的功能。否则,您将无法实现所需的数百或数十次迭代。

如果你看到初始输出(goosgoarchpkg和基准名称),表示单次迭代运行已完成,并且适当的基准测试已经开始。

示例 8-5 中呈现的结果可以直接阅读,但存在一些挑战。首先,数字是基本单位的—一开始看不出我们是否分配了 600 MB、60 MB 还是 6 MB。如果我们将我们的延迟转换为秒也是一样。其次,我们有五个测量结果,那么我们选择哪一个?最后,我们如何比较第二个微基准结果与进行优化的代码?

幸运的是,Go 社区创建了另一个 CLI 工具 benchstat,它可以执行更多处理和统计分析,以便更轻松地评估一个或多个基准测试结果。因此,它已经成为最受欢迎的解决方案,用于近年来呈现和解释 Go 微基准测试结果。

您可以使用标准的 go install 工具安装 benchstat,例如,go install golang.org/x/perf/cmd/benchstat@latest。安装完成后,它将存在于您的 \(GOBIN 或 *\)GOPATH/bin* 目录中。然后您可以使用它来呈现我们在 示例 8-5 中得到的结果;请参阅 示例 8-6 中的示例用法。

示例 8-6. 在 示例 8-5 中呈现的结果上运行 benchstat
$ benchstat v1.txt ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) name   time/op
Sum-4  79.9ms ± 1% ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) name   alloc/op
Sum-4  60.8MB ± 0%

name   allocs/op
Sum-4   1.60M ± 0%

1

我们可以使用包含 示例 8-5 的 v1.txt 运行 benchstatbenchstat 可以解析 go test 工具的格式,从同一代码版本上执行一次或多次基准测试。

2

对于每个基准测试,benchstat 计算所有运行的平均值,并且±跨运行的方差(在这种情况下为 1%)。这就是为什么多次运行 go test 基准测试非常重要(例如,使用 -count 标志);否则,仅进行一次运行,方差将显示一个误导性的 0%。运行更多测试允许我们评估结果的重复性,正如我们在 “性能不确定性” 中讨论的那样。运行 benchstat --help 查看更多选项。

一旦我们对测试运行有信心,我们可以称之为基准结果。我们通常希望通过与我们的基线比较来评估代码的效率,使用新的优化。例如,在 第十章 中,我们将优化 Sum 函数,其中一个优化版本将快两倍。我通过将可见的 Sum 函数更改为 示例 4-1 中的 ConcurrentSum3(代码在 示例 10-12 中呈现)来找到这一点。然后我运行了在 示例 8-2 中实现的基准测试,使用与 示例 8-4 中显示的完全相同的命令,只是将 ver=v1 更改为 ver=v2 以生成 v2.txtv2.cpu.pprofv2.mem.pprof

benchstat 帮助我们计算方差并提供人类可读的单位。但还有另一个有用的功能:比较不同基准运行的结果。例如,示例 8-7 显示了我如何检查朴素和改进并发实现之间的差异。

示例 8-7. 运行 benchstat 比较 v1.txt 和 v2.txt 的结果
$ benchstat v1.txt v2.txt ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) name   old time/op    new time/op    delta
Sum-4    79.9ms ± 1%    39.5ms ± 2%  -50.52%  (p=0.008 n=5+5) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) name   old alloc/op   new alloc/op   delta
Sum-4    60.8MB ± 0%    60.8MB ± 0%     ~     (p=0.151 n=5+5)

name   old allocs/op  new allocs/op  delta
Sum-4     1.60M ± 0%     1.60M ± 0%   +0.00%  (p=0.008 n=5+5)

1

使用两个文件运行 benchstat 可以启用比较模式。

2

在比较模式下,benchstat 提供了一个增量列,显示两个平均值之间的增量,以百分比显示,如果显著性测试失败则显示 ~。默认显著性测试是 曼-惠特尼 U 检验,可以使用 -delta-test=none 禁用该测试。显著性测试是一种额外的统计分析,计算 p 值,默认情况下应小于 0.05(可通过 -alpha 进行配置)。它在方差之上(在 ± 之后)为我们提供额外信息,用于安全比较结果。n=5+5 表示两个结果的样本量(两次基准运行均使用 -count=5 进行)。

多亏了 benchstat 和 Go 基准测试,我们可以相对自信地说,我们的并发实现速度约快了 50%,并且不会影响分配。

细心的读者可能会注意到,分配大小未通过 benchstat 的显著性测试(p 大于 0.05)。我可以通过使用更高的 -count 运行基准测试来改善这一点(例如,8 或 10 次)。

我特意让这个显著性测试失败,以向您展示有时可以应用常见的推理。两个结果都表明分配了大约 60.8 MB,方差极小。我们可以明确地说,这两种实现使用了类似的内存量。我们是否在乎一个实现使用少了几 KB 还是多了几 KB?可能不会,所以我们可以跳过 benchstat 的显著性测试,验证我们是否可以信任增量。没必要在这里花费更多时间!

分析微基准测试可能会在初期时让人感到困惑,但希望使用 benchstat 提供的流程教会您如何评估不同实现的效率,而无需拥有数据科学学位!总体而言,在使用 benchstat 时,请记住:

  • 运行多于一次的测试(-count)以便识别噪音。

  • 检查 ± 后的方差数字是否不高于 3–5%。特别注意小数值的方差。

  • 若要依赖于具有较高方差的结果之间的准确增量,请检查显著性测试(p 值)。

考虑到这一点,让我们来看看在您日常使用 Go 基准测试工作中可能非常有用的一些常见高级技巧!

微基准测试的技巧与窍门

微基准测试的最佳实践通常来自于您自己的错误,并且很少与他人分享。让我们通过提到一些值得注意的 Go 微基准测试的常见方面来打破这种局面。

方差过大

正如我们在“性能非确定性”中学到的,了解我们测试的方差是至关重要的。如果微基准之间的差异超过,比如说,5%,这表明可能存在潜在的噪音,我们可能不能完全依赖这些结果。

在准备“使用并发优化延迟”时,我遇到了这种情况。在进行基准测试时,我的结果具有过大的方差,正如 benchstat 的结果所示。那次运行的结果在示例 8-8 中呈现。

示例 8-8. benchstat 表示延迟结果方差较大
name   time/op
Sum-4  45.7ms ±19% ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) name   alloc/op
Sum-4  60.8MB ± 0%

name   allocs/op
Sum-4   1.60M ± 0%

1

19% 的方差相当可怕。我们应该忽略这样的结果,并在做出任何结论之前稳定基准。

在这种情况下我们能做些什么呢?我们已经在“性能非确定性”中提到了一些事情。我们应该考虑延长基准测试时间,重新设计我们的基准,或者在不同的环境条件下运行它。在我的情况下,我不得不关闭浏览器,并将 -benchtime 从 5 秒增加到 15 秒,以在示例 8-7 中达到 2% 的方差运行。

找到您的工作流程

在“Go 基准测试”中,您跟随我通过微观层面的效率评估周期。当然,这可能会有所不同,但通常基于 git 分支,可以总结如下:

  1. 我检查是否存在我要测试的现有微基准实现。如果不存在,我将创建一个。

  2. 在我的终端中,我执行类似于示例 8-4 的命令多次运行基准测试(5–10 次)。我将结果保存到类似 v1.txt 的文件中,保存配置文件,并将其视为我的基准。

  3. 我评估 v1.txt 的结果,检查资源消耗是否大致符合我对实现和输入大小的理解。为了确认或拒绝,我执行第九章中解释的瓶颈分析。在这个阶段,我可能会为不同的输入执行更多的基准测试以获取更多信息。这大致告诉我是否有一些简单的优化空间,我是否应该投资于更危险和有意识的优化,或者是否应该转向不同层次的优化。

  4. 假设存在一些优化空间,我创建一个新的git 分支并实现它。

  5. 遵循 TFBO 流程,我首先测试了我的实现。

  6. 我提交更改,使用相同的命令运行基准测试函数,并将其保存为,例如,v2.txt

  7. 我使用 benchstat 比较结果,并调整基准或优化,以达到最佳结果。

  8. 如果我想尝试不同的优化,我会创建另一个git分支或在同一分支上构建新的提交,并重复这个过程(例如,生成v3.txtv4.txt等)。这使我可以在一次尝试让我悲观的情况下返回到先前的优化。

  9. 我在我的笔记、提交消息或存储库变更集(例如,拉取请求)中记录发现,并丢弃我的.txt结果(过期日期!)。

这个流程对我来说效果很好,但你可能想尝试不同的流程!只要它对你没有困惑,是可靠的,并且遵循我们在“效率感知开发流程”中讨论的 TFBO 模式,就可以使用它。还有许多其他选择,例如:

  • 你可以使用终端历史记录来跟踪基准测试结果。

  • 对于相同功能,您可以创建具有不同优化的不同函数。然后,如果您不想在此处使用git,可以在基准函数中交换要使用的函数。

  • 使用git stash而不是提交。

  • 最后,您可以遵循Dave Cheney 流程,该流程使用go test -c命令将测试框架和代码构建为单独的二进制文件。然后,您可以保存此二进制文件并执行基准测试,而无需重新构建源代码或保存您的测试结果。¹²

我建议尝试不同的流程,了解哪种对你最有帮助!

我建议避免为我们的本地微基准工作流编写过于复杂的自动化(例如,复杂的 bash 脚本来自动化一些步骤)。微基准测试应该更具交互性,您可以手动挖掘您关心的信息。编写复杂的自动化可能意味着比必要的更多开销和更长的反馈周期。但是,如果这对您有效,请继续!

测试您的基准测试是否正确!

我们在基准测试中最常见的错误之一是评估不提供正确结果的功能的效率。由于有意的优化性质,很容易引入破坏我们代码功能的错误。有时,优化失败的执行很重要,¹³但这应该是一个明确的决定。

TFBO 中的“测试”部分,解释在“效率感知开发流程”中,并非偶然。我们的重点应该是为我们的Sum函数编写一个单元测试,例如单元测试示例可以看作是 Example 8-9。

示例 8-9. 用于评估Sum函数正确性的单元测试示例
// import "github.com/efficientgo/core/testutil"

func TestSum(t *testing.T) {
    ret, err := Sum("testdata/input.txt")
    testutil.Ok(t, err)
    testutil.Equals(t, 3110800, ret)
}

有了单元测试,可以确保在正确配置 CI 后,当我们向主存储库提交我们的更改(可能通过拉取请求 [PR])时,我们会注意到我们的代码是否正确。因此,这已经提高了我们优化工作的可靠性。

但是,我们仍然可以做一些事情来改进这个过程。如果您仅在最后一个开发步骤中进行测试,您可能已经进行了所有基准测试和优化的努力,而没有意识到代码是错误的。这可以通过在每次基准测试运行之前手动运行 Example 8-10 中的单元测试来减轻,例如 Example 8-2 中的代码。这有所帮助,但仍然存在一些轻微的问题:

  • 在我们进行更改后,再运行另一个东西是很烦人的。因此,跳过运行功能测试的手动流程以节省时间并实现更快的反馈循环是非常诱人的。

  • 该函数在单元测试中可能经过了充分测试,但在如何调用函数以及基准测试中存在差异。

  • 另外,正如您在 “与功能测试的比较” 中学到的那样,对于基准测试,我们需要不同的输入。新的东西意味着制造错误的新地方!例如,在为本书准备基准测试时,在 Example 8-2 中,我在文件名中意外地写错了一个字母(testdata/test2M.txt 而不是 testdata/test.2M.txt)。当我运行我的基准测试时,它通过了,但结果的延迟非常低。事实证明,Sum 除了因文件不存在而失败外,什么也没做。因为在 Example 8-2 中,我为简单起见忽略了所有错误,我错过了这些信息。只是直觉告诉我,我的基准测试运行得太快了,以至于不真实,所以我双重检查了Sum的实际返回情况。

  • 在更高负载下进行基准测试时,可能会出现新的错误。例如,由于机器上文件描述符的限制,我们可能无法打开另一个文件,或者我们的代码没有清理磁盘上的文件,因此由于磁盘空间不足而无法对文件进行更改。

幸运的是,解决这个问题的简单方法是在基准测试迭代中添加快速的错误检查。它看起来像 Example 8-10。

例如 8-10. 用于评估带有错误检查的Sum函数效率的 Go 基准测试
func BenchmarkSum(b *testing.B) {
    for i := 0; i < b.N; i++ {
       _, err := Sum("testdata/test.2M.txt")
        testutil.Ok(b, err) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    }
}

1

断言每次迭代循环中Sum不会返回错误。

需要注意的是,基准测试之后我们获得的效率指标将包括由 testutil.Ok(b, err) 调用引入的延迟,即使没有错误。这是因为我们在 b.N 循环中调用此函数,因此它会增加一定的开销。

我们应该接受这种开销吗?这与包括 -benchmem 和测试生成的问题相同,这也可能会增加一些小的噪音。如果我们尝试对非常快速的操作进行基准测试(比如毫秒级的快速操作),这种开销是不可接受的。然而,对于大多数基准测试来说,这样的断言不会改变您的基准测试结果。甚至可以认为这种错误断言将存在于生产中,因此应该包含在效率评估中。¹⁵ 就像 -benchmem 和性能分析一样,我几乎在所有微基准测试中添加了这种断言。

在某些方面,我们仍然容易出错。也许对于大输入,Sum 函数在不返回错误的情况下无法提供正确的答案。就像所有测试一样,我们永远不会消除所有错误 —— 在编写、执行和维护额外测试的努力与信心之间必须保持平衡。由您决定有多少信任您的工作流程。

如果您希望为了更多的信心选择前述案例,您可以添加一个检查,将返回的总和与预期结果进行比较。在我们的情况下,添加 testutil.Equals(t, <expected number>, ret) 不会增加太多开销,但通常对于微基准测试来说,这样做更昂贵,因此不合适。出于这些目的,我创建了一个小的 testutil.TB 对象,允许您运行单次迭代的微基准测试。这使得它在正确性方面始终保持最新,这在更大的共享代码库中尤为具有挑战性。例如,对我们的 Sum 基准测试进行持续测试可能看起来像 示例 8-11。¹⁶

示例 8-11. 用于评估 Sum 函数效率的可测试 Go 基准测试
func TestBenchSum(t *testing.T) {
    benchmarkSum(testutil.NewTB(t))
}

func BenchmarkSum(b *testing.B) {
    benchmarkSum(testutil.NewTB(b))
}

func benchmarkSum(tb testutil.TB) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    for i := 0; i < tb.N(); i++ { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        ret, err := Sum("testdata/test.2M.txt")
        testutil.Ok(tb, err)
        if !tb.IsBenchmark() {
            // More expensive result checks can be here.
            testutil.Equals(tb, int64(6221600000), ret) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        }
    }
}

1

testutil.TB 是一个接口,允许将函数作为基准测试和单元测试运行。此外,它允许我们设计我们的代码,以便其他函数执行相同的基准测试,例如,带有额外的性能分析,如 示例 10-2 所示。

2

tb.N() 方法返回基准测试中的 b.N,允许正常的微基准测试执行。它返回 1 以执行单元测试的一个测试运行。

3

现在,我们可以将可能更昂贵的额外代码(例如更复杂的测试断言)放入基准测试无法达到的空间,这要归功于 tb.IsBenchmark() 方法。

总之,请测试您的微基准测试代码。这将节省您和您的团队的时间。此外,它可以对抗不需要的编译器优化,详见 “编译器优化与基准测试”。

与团队分享基准测试(以及未来的自己)

一旦完成 TFBO 周期并对下一个优化迭代感到满意,就是提交新代码的时候了。与你的小型个人项目相比,与团队分享你发现或取得的成果更为重要。当有人提出优化更改时,在生产代码中只看到优化并且只有一个小小的描述:“我对此进行了基准测试,速度提高了 30%。” 这对多种原因都不理想:

  • 对于审阅者而言,在没有看到你使用的实际微基准测试代码之前很难验证基准测试的有效性。审阅者不应不信任你所说的,而是很容易犯错误、忽略副作用或错误地进行基准测试。¹⁷ 例如,输入必须是某个特定大小才能触发问题,或者输入不反映预期的用例。只有通过另一个人查看你的基准测试代码才能验证这一点。这在我们远程与团队合作和开源项目中尤为重要,强大的沟通至关重要。

  • 一旦合并,任何涉及此代码的其他更改可能会意外引入效率退化。

  • 如果你或其他任何人想尝试改进相同的代码部分,他们除了重新创建基准测试并经历与你在拉取请求中所做的相同努力外别无选择,因为先前的基准测试实现已经消失(或存储在你的计算机上)。

解决方案在于尽可能提供有关实验细节、输入和基准测试实现的上下文。当然,我们可以以某种形式提供这些文档(例如,在拉取请求描述中),但没有比将实际的微基准测试与你的生产代码一起提交更好的方式!然而,在实践中,这并不简单。在分享微基准测试之前,值得添加一些额外的内容。

我优化了我们的Sum函数并解释了我的基准测试过程。然而,你不希望为了向团队(和未来的自己)解释你所做的优化而写一整章!相反,你可以像在示例 8-12 中呈现的那样提供一个单独的代码片段,这就足够了。

示例 8-12. 用于评估并发实现Sum函数的良好文档化、可重复使用的 Go 基准测试。
// BenchmarkSum assesses `Sum` function. ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
// NOTE(bwplotka): Test it with a maximum of 4 CPU cores, given we don't allocate
// more in our production containers.
//
// Recommended run options:
/*
export ver=v1 && go test \
    -run '^$' -bench '^BenchmarkSum$' \
    -benchtime 10s -count 5 -cpu 4 -benchmem \
    -memprofile=${ver}.mem.pprof -cpuprofile=${ver}.cpu.pprof \
  | tee ${ver}.txt ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) */
func BenchmarkSum(b *testing.B) {
   // Create 7.55 MB file with 2 million lines.
   fn := filepath.Join(b.TempDir(), "/test.2M.txt")
   testutil.Ok(b, createTestInput(fn, 2e6)) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      _, err := Sum(fn)
      testutil.Ok(b, err) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
   }
}

1

对于一个简单的基准测试来说可能会感到有些过度,但良好的文档显著提高了你和你的团队的基准测试的可靠性。在评论中提及关于这个基准测试的任何令人惊讶的事实、数据集选择、条件或先决条件。

2

我建议用建议的方式对基准进行评论,描述如何运行这个基准测试,而不是强迫什么。未来的你或你的团队成员会感谢你!

3

提供您打算运行基准测试的确切输入。您可以为单元测试创建一个静态文件并将其提交到您的代码库中。不幸的是,基准测试的输入通常太大而无法提交到您的源代码库(例如git)。为此,我创建了一个小的createTestInput函数,可以生成动态数量的行。注意使用b.TempDir(),它创建一个临时目录,并在使用后需要手动清理。¹⁸

4

因为你希望将来重复使用此基准,并且其他团队成员也将使用它,所以确保其他人不要测量错误的内容,在基准测试中甚至要测试基本的错误模式是有意义的。

由于 b.ResetTimer() 的存在,即使输入文件的创建相对较慢,延迟和资源使用在基准测试结果中也不会显现出来。但是,如果你反复运行该基准测试,可能会感到不太愉快。而且,在多次运行该基准测试后,你会多次经历到这种慢速度。正如我们在“Go 基准测试”中学到的那样,Go 可以多次运行基准测试以找到正确的N值。如果初始化时间太长并影响到你的反馈循环,你可以添加代码在文件系统上缓存测试输入。参见 Example 8-13 如何使用简单的os.Stat来实现这一点。

Example 8-13. 执行一次且在磁盘上缓存的基准测试示例
func lazyCreateTestInput(tb testing.TB, numLines int) string {
    tb.Helper() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    fn := fmt.Sprintf("testdata/test.%v.txt", numLines)
    if _, err := os.Stat(fn); errors.Is(err, os.ErrNotExist) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        testutil.Ok(tb, createTestInput(fn, numLines))
    } else {
        testutil.Ok(tb, err)
    }
    return fn
}

func BenchmarkSum(b *testing.B) {
    // Create a 7.55 MB file with 2 million lines if it does not exist.
    fn := lazyCreateTestInput(tb, 2e6)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _, err := Sum(fn)
        testutil.Ok(b, err)
   }
}

1

t.Helper 告诉测试框架,当出现潜在错误时要指出调用lazyCreateTestInput的行。

2

os.Stat 如果文件存在则停止执行 createTestInput。在更改输入文件的特性或大小时要小心。如果不改变文件名,则运行这些测试的人可能会得到输入的旧版本的缓存。然而,如果输入文件的创建慢于几秒钟,那么这种小风险是值得的。

这样的基准测试提供了有关基准实现、目的、输入、运行命令和先决条件的优雅而简洁的信息。此外,它允许您和您的团队以极少的工作量复制或重用相同的基准测试。

运行不同输入的基准测试

了解我们的实现在不同大小和类型的输入下效率如何通常是很有帮助的。有时我们可以手动更改代码中的输入并重新运行基准测试,但有时我们希望为同一段代码编写针对源代码中不同输入的基准测试(例如供团队以后使用)。表格测试非常适合这些用例。通常,我们在功能测试中看到这种模式,但在微基准测试中也可以使用,正如 Example 8-14 中所述。

示例 8-14。使用通用模式与b.Run的表格基准测试
func BenchmarkSum(b *testing.B) {
    for _, tcase := range []struct { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
       numLines int
    }{
        {numLines: 0},
        {numLines: 1e2},
        {numLines: 1e4},
        {numLines: 1e6},
        {numLines: 2e6},
    } {
        b.Run(fmt.Sprintf("lines-%d", tcase.numLines), func(b *testing.B) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
            b.ReportAllocs() ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

            fn := lazyCreateTestInput(tb, tcase.numLines)

            b.ResetTimer()
            for i := 0; i < b.N; i++ { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
                _, err := Sum(fn)
                testutil.Ok(b, err)
            }
        })
    }
}

1

内联的匿名结构体片段在这里效果很好,因为您不需要在任何地方引用此类型。随意在此处添加任何字段以根据需要映射测试案例。

2

在测试用例循环中,我们可以运行b.Run来告诉go test有一个子基准。如果您将空字符串""作为名称,go test将使用数字作为测试案例的标识。我决定将一些行作为每个测试案例的唯一描述。测试案例标识将作为后缀添加,因此BenchmarkSum/<test-case>

3

对于这些测试,go test会忽略在b.Run之外的任何b.ReportAllocs和其他基准方法,因此确保在这里重复它们。

4

常见的陷阱是意外地使用b,不是内部函数创建的闭包中的b。如果您试图避免遮蔽b变量,并为内部的*testing.B使用不同的变量名,例如,b.Run("", func(b2 *testing.B),这种问题很常见。这些问题很难调试,因此我建议始终使用相同的名称,例如b

令人惊讶的是,我们可以使用与示例 8-4 中呈现的相同推荐的run命令来进行非表格测试。然后,benchstat处理的示例运行输出看起来像示例 8-15。

示例 8-15。benchstat对示例 8-14 测试结果的输出
name                 time/op
Sum/lines-0-4        2.79µs ± 1%
Sum/lines-100-4      8.10µs ± 5%
Sum/lines-10000-4     407µs ± 6%
Sum/lines-1000000-4  40.5ms ± 1%
Sum/lines-2000000-4  78.4ms ± 3%

name                 alloc/op
Sum/lines-0-4          872B ± 0%
Sum/lines-100-4      3.82kB ± 0%
Sum/lines-10000-4     315kB ± 0%
Sum/lines-1000000-4  30.4MB ± 0%
Sum/lines-2000000-4  60.8MB ± 0%

name                 allocs/op
Sum/lines-0-4          6.00 ± 0%
Sum/lines-100-4        86.0 ± 0%
Sum/lines-10000-4     8.01k ± 0%
Sum/lines-1000000-4    800k ± 0%
Sum/lines-2000000-4   1.60M ± 0%

我发现表格测试非常适合快速了解应用程序的预估复杂性(在“复杂性分析”中讨论)。然后,了解更多信息后,我可以将案例数减少到真正能触发我们过去遇到的瓶颈的案例。此外,将这样的基准测试提交到我们团队的源代码中,将增加其他团队成员(包括您自己!)重复使用它并运行项目中所有重要案例的微基准测试的机会。

微基准测试与内存管理

微基准测试的简单性带来了许多好处,但也有缺点。其中一个最令人惊讶的问题是go test基准测试中报告的内存统计信息并不详尽。不幸的是,鉴于 Go 语言中的内存管理实现(在“Go 内存管理”中讨论),我们无法通过微基准测试复制我们 Go 程序的所有内存效率方面。

正如我们在示例 8-6 中看到的,Sum的朴素实现在示例 4-1 中分配了约 60 MB 的堆内存,用于计算 200 万个整数的总和。这告诉我们的内存效率比我们想象的要少。它只告诉我们三件事:

  • 我们在微基准结果中经历的某些延迟不可避免地来自于进行如此多分配的事实(我们可以通过配置文件确认它有多重要)。

  • 我们可以将该分配数量和大小与其他实现进行比较。

  • 我们可以将分配的数量和大小与预期的空间复杂度进行比较(“复杂度分析”)。

不幸的是,基于这些数字的任何其他结论都属于估计范畴,只有在我们运行“宏基准”或“生产中的基准测试”时才能验证。原因很简单——基准测试没有专门的 GC 调度,因为我们希望尽可能地模拟生产环境。它们按照生产代码中的正常调度运行,这意味着在我们的基准测试的 100 次迭代期间,GC 可能运行 1,000 次、10 次,或者在快速基准测试中根本不会运行!因此,任何手动触发runtime.GC()的尝试也是不理想的选择,因为这不是它在生产环境中运行的方式,可能会与正常的 GC 调度冲突。

结果,微基准不会给我们一个清晰的概念和以下的内存效率问题:

GC 延迟

正如我们在“Go 内存管理”中学到的,堆越大(堆中的对象越多),GC 的工作量就越大,这总是会导致增加的 CPU 使用率,或者更频繁的 GC 周期(即使使用公平的 25% CPU 使用率机制)。由于非确定性的 GC 和快速的基准操作,我们很可能不会在微基准水平上看到 GC 的影响。¹⁹

最大内存使用量

如果一个单个操作分配了 60 MB,这是否意味着执行一次这样的操作的程序在我们的系统中需要不多不少约 60 MB 的内存?不幸的是,出于前面提到的同样原因,我们无法通过微基准测试来确定。

可能我们的单个操作并不需要所有对象的完整持续时间。这可能意味着内存的最大使用量仅为例如 10 MB,尽管有 60 MB 的分配数量,因为 GC 实际上可以多次执行清理操作。

甚至你可能会遇到相反的情况!特别是对于示例 4-1,在整个操作期间大部分内存是保留的(它保存在文件缓冲区中——我们可以从性能分析中看出,详见“Go 性能分析”)。此外,GC 可能无法快速清理内存,导致下一个操作在原始 60 MB 基础上再分配 60 MB,总共需要 OS 提供 120 MB。如果我们对操作进行更大的并发,情况可能会更糟。

遗憾的是,前述问题经常出现在我们的 Go 代码中。如果我们能在微基准测试中验证这些问题,那么判断我们是否能更好地重用内存(例如,通过“内存重用和池化”)或者我们应该直接减少分配并减少到什么水平,将会更容易。不幸的是,为了确切地判断,我们需要转向“宏基准测试”。

然而,如果我们假设通常情况下更多的分配可能会引起更多问题,那么微基准测试分配信息将非常有用。这就是为什么在我们的微优化周期中仍然专注于减少分配数量或分配空间非常有效的原因。然而,我们需要承认的是,仅仅从微基准测试中得出的这些数字可能无法完全让我们对最终的 GC 开销或最大内存使用量是否可接受或有问题产生完全的信心。我们可以尝试估计这一点,但在我们转向宏级别来评估它之前,我们不会确切知道。

编译器优化与基准测试

微基准测试和编译器优化之间存在非常有趣的“元”动态,有时会引起争议。了解这个问题、潜在的后果以及如何缓解它们是值得的。

在进行微基准测试时,我们的目标是尽可能高置信度地评估我们生产代码中的小部分效率(考虑到可用时间和问题约束)。因此,Go 编译器将我们的“Go 基准测试”功能视为任何其他生产代码。编译器对代码的所有部分执行相同的 AST 转换、类型安全、内存安全、死代码消除和优化规则,正如在“理解 Go 编译器”中讨论的那样——没有针对基准测试的特殊例外。因此,我们在复制所有生产条件,包括编译阶段。

这个前提很好,但阻碍这一哲学的是微基准测试有点特殊。从运行时进程的角度来看,这段代码在生产环境执行和我们想要了解生产代码效率时有三个主要区别:

  • 没有其他用户代码在同一进程中同时运行。²⁰

  • 我们在循环中调用相同的代码。

  • 我们通常不使用输出或返回参数。

这三个元素可能看起来没有什么大的区别,但正如我们在“CPU 和内存墙问题”中学到的,现代 CPU 由于不同的分支预测和 L-cache 局部性等原因,在这些情况下已经可以以不同的方式运行。此外,你可以想象一个足够智能的编译器,它也会根据这些情况调整机器码!

这个问题在使用 Java 编程时特别明显,因为某些编译阶段是在运行时完成的,这要归功于成熟的即时编译(JIT)编译器。因此,Java 工程师在进行基准测试时必须 非常小心,并使用特殊的 框架 来确保模拟生产条件,包括预热阶段和其他技巧,以增加基准测试的可靠性。

在 Go 语言中,情况更简单。编译器不如 Java 的成熟,并且没有 JIT 编译。尽管 JIT 连规划都没有,但某种形式的 运行时依赖分析编译器优化(PGO) 正在 Go 的考虑中,这可能会使我们的微基准在未来变得更加复杂。时间会告诉我们。

然而,即使我们专注于当前的编译器,它有时也会对我们的基准测试代码应用不希望的优化。已知问题之一称为 死代码消除。让我们考虑一个表示 population count 指令 的低级函数以及 示例 8-16 中的简单微基准。²¹

示例 8-16. popcnt 函数的微基准,其影响受到编译器优化的影响
const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
   x -= (x >> 1) & m1
   x = (x & m2) + ((x >> 2) & m2)
   x = (x + (x >> 4)) & m4
   return (x * h01) >> 56
}

func BenchmarkPopcnt(b *testing.B) {
   for i := 0; i < b.N; i++ {
      popcnt(math.MaxUint64) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   }
}

1

在原始问题 #14813 中,函数的输入取自 uint64(i),这是一个巨大的反模式。你永远不应该从 b.N 循环中使用 i'!我想专注于这个例子中令人惊讶的编译器优化风险,因此让我们想象一下,我们想评估 popcnt 在可能的最大无符号整数上的效率(使用 math.MaxInt64 来获取它)。这也将使我们遇到下面提到的一个意外行为。

如果我们对此基准进行一秒钟的执行,我们将会得到令人略感担忧的输出,如 示例 8-17 所示。

示例 8-17. BenchmarkPopcnt 基准测试的输出来自 示例 8-16
goos: linux
goarch: amd64
pkg: github.com/efficientgo/examples/pkg/comp-opt-away
cpu: Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz
BenchmarkPopcnt
BenchmarkPopcnt-12     1000000000          0.2344 ns/op ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) PASS

1

每当你看到你的基准测试执行十亿次迭代(go test 能做的最大迭代次数),你就知道你的基准测试是错误的。这意味着我们将看到一个循环开销,而不是我们正在测量的延迟。这可能是由于编译器优化掉你的代码或者测量某些太快而无法用 Go 基准测试测量的东西(例如单条指令)。

发生了什么?第一个问题是,Go 编译器内联了popcnt代码,进一步的优化阶段检测到没有其他代码使用内联计算的结果。编译器检测到如果移除这部分代码不会改变可观察行为,因此省略了内联的代码部分。如果我们在go buildgo test时使用-gcflags=-S列出汇编代码,你会注意到没有代码负责执行popcnt后面的语句(我们运行了一个空循环!)。这也可以通过运行GOSSAFUNC=BenchmarkPopcnt go build并在浏览器中打开ssa.html来确认,这会更交互地列出生成的汇编代码。我们可以通过使用-gcflags=-N运行测试来验证这个问题,该标志关闭所有编译器优化。执行或查看汇编将显示明显的差异。

第二个问题是,我们基准测试的所有迭代都使用相同的常数——最大的无符号整数来运行popcnt。即使没有发生代码消除,通过内联,Go 编译器足够聪明以预计算某些逻辑(有时称为内部函数)。popcnt(math.MaxUint64)的结果始终是 64,无论我们运行多少次和在何处运行它;因此,机器代码将简单地使用64而不是在每次迭代中计算popcnt

通常,在基准测试中有三种实用的对抗编译器优化的对策:

转向宏观层面。

在宏观层面上,同一二进制中没有特殊的代码,因此我们可以同时用于基准测试和生产代码的同一机器代码。

在微基准测试更复杂的功能。

如果编译器优化影响,可能是在过低的层面上优化 Go。

我个人没有受到编译器优化的影响,因为我倾向于在更高层次的功能上进行微基准测试。如果您像示例 8-16 这样微小的函数进行基准测试,通常会内联并且速度快几纳秒,期望 CPU 和编译器效果对您的影响更大。对于更复杂的代码,编译器通常不会像为基准测试目的内联或调整机器代码。更大的宏基准测试中的指令数量和数据也更有可能打破 CPU 的分支预测器和缓存局部性,就像在生产环境中一样。²²

在微基准测试中躲避编译器。

如果你想对像示例 8-16 这样微小的函数进行基准测试,没有其他方法可以混淆编译器的代码分析。通常有效的方法是使用导出的全局变量。它们在当前每个包的 Go 编译逻辑下很难预测²³,或者使用 runtime.KeepAlive,这是告诉编译器“这个变量被使用”的新方法(这是告诉 GC 在堆上保留这个变量的副作用)。//go:noinline 指令可以阻止编译器内联函数,但不建议在生产环境中使用,因为你的代码可能会被内联和优化,而我们也希望对其进行基准测试。

如果我们想要改进示例 8-16 中展示的 Go 基准测试,我们可以像示例 8-18 中所示,添加 Sink 模式²⁴和全局变量作为输入。这在 Go 1.18 中与 gc 编译器兼容,但不太可能适应未来 Go 编译器的改进。

示例 8-18. Sink 模式和可变输入计数器策略可以避免微基准测试中不必要的编译器优化。
var Input uint64 = math.MaxUint64 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
var Sink uint64 ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

func BenchmarkPopcnt(b *testing.B) {
    var s uint64

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
       s = popcnt(Input) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    }
    Sink = s
}

1

全局 Input 变量掩盖了 math.MaxUint64 是常数的事实。这迫使编译器不懒惰,并在我们的基准迭代中进行工作。这有效是因为编译器无法确定在运行时之前或期间是否会有其他人改变这个变量。

2

Sink 是一个类似的全局变量,用于隐藏我们函数值从未被使用的事实,所以编译器不会假设它是死代码。

3

注意,我们不直接给全局变量赋值,因为这可能会增加我们基准测试的额外开销,这是更昂贵的

多亏示例 8-18 中介绍的技术,我可以评估在我的机器上执行这样一个操作大约需要 1.6 纳秒。不幸的是,虽然我得到了一个稳定的、(人们希望)现实的结果,但对这种低级代码的效率评估是脆弱且复杂的。击败编译器或禁用优化是相当有争议的技术——它们违背了基准代码应尽可能接近生产代码的理念。

别到处放置 Sinks!

这一部分可能会让人感到复杂和困惑。当我最初了解到这些复杂的编译影响时,我在所有我的微基准测试中放置了一个 sink 或者只是为了避免潜在的省略问题而断言错误。

这是不必要的。务实些,对于无法解释的基准测试结果要保持警惕(如在“人为错误”中提到的)并添加这些特殊的对策。

就我个人而言,我宁愿不看到在真正需要它们之前,到处都出现了 Sink。在许多情况下,它们不会被需要,并且代码没有它们更加清晰。我的建议是等到基准测试被明确优化掉,然后再添加它们。Sink 的细节可能取决于上下文。例如,如果你有一个返回 int 的函数,将它们求和然后将结果分配给全局变量是可以的。

Russ Cox(rsc),“基准测试与死代码消除”,电子邮件线程

总结一下,要注意编译器如何影响你的微基准测试。这种情况并不经常发生,特别是如果你在合理的水平上进行基准测试,但当发生时,你现在应该知道如何减少这些问题的影响。我的建议是不要过于依赖微基准测试。相反,除非你是一位对特定用例中 Go 代码的超高性能感兴趣的经验丰富的工程师,否则应该通过测试更复杂的功能来提升测试水平。幸运的是,你将要处理的大部分代码可能太复杂,不会触发与 Go 编译器的这种“战斗”。

宏观基准测试

编程书籍通常不会详细描述比微观层面更大规模的性能和优化主题中的基准测试。这是因为在宏观层面进行测试对开发者来说是一个灰色地带。通常,这是专门的测试团队或质量保证工程师的责任。然而,对于后端应用程序和服务,这样的宏观基准测试涉及经验、技能以及处理许多依赖关系、编排系统和通常更大的基础设施所需的工具。因此,这样的活动过去属于运维团队、系统管理员和 DevOps 工程师的领域。

然而,事情正在发生变化,特别是对于基础设施软件,这是我的专业领域。云原生生态系统使得基础设施工具对开发者更加可访问,使用诸如Kubernetes、容器和Site Reliability Engineering (SRE)等标准和技术。此外,流行的微服务架构允许将功能部分分解为具有清晰 API 的更小程序。这使开发者可以更多地负责他们的专业领域。因此,在过去的几十年里,我们看到开发者在所有层面上更容易地测试(和运行)软件的趋势。

参与影响你的软件的宏观基准测试!

作为开发者,参与对你的软件进行测试,即使是在宏观层面,也是极具洞察力的。发现软件的 bug 和减速问题可以清晰地确定优先级。此外,如果你在你控制或熟悉的环境中发现了这些问题,更容易调试或找到瓶颈,确保快速修复或优化。

我想打破提到的惯例,并向您介绍一些有效的宏基准测试所需的基本概念。特别是对于后端应用程序,当涉及到高层次的准确效率评估和瓶颈分析时,开发者们有更多的说法。因此,让我们利用这一事实,讨论一些基本原则,并通过go test提供一个宏基准测试的实际示例。

基础知识

正如我们在“基准测试层次”中学到的,宏基准测试侧重于在产品级别(应用程序、服务或系统)测试代码,接近您的功能和效率要求(如“效率要求应被正式化”所述)。因此,我们可以将宏基准测试与集成或端到端(e2e)功能测试进行比较。

在本节中,我将主要关注基准测试服务器端、多组件的 Go 后端应用程序。原因有三:

  • 那是我的专长。

  • 这是典型的用 Go 语言编写的应用程序目标环境。

  • 这种应用通常涉及与非平凡基础设施和许多复杂依赖项的协作。

特别是最后两项使我更倾向于专注于后端应用程序,因为其他类型的程序(CLI、前端、移动端)可能需要较少复杂的架构。尽管如此,所有类型的程序都会重复使用本节的某些模式和经验教训。

例如,在“微基准”中,我们评估了我们 Go 代码中Sum函数的效率(示例 4-1),但该函数可能是更大产品或服务的瓶颈。想象一下,我们团队的任务是开发和维护一个名为labeler的更大微服务,该服务使用了Sum

labeler将在容器中运行,并连接到一个对象存储²⁵,其中包含各种文件。每个文件的每一行可能包含数百万个整数(与我们Sum问题中的输入相同)。labeler的任务是在用户调用 HTTP GET方法/label_object时返回一个标签—指定对象的元数据和一些统计信息。返回的标签包含属性,如对象名称、对象大小、校验和等等。其中一个关键的标签字段是对象中所有数字的总和。²⁶

你首先学习如何评估较小的Sum函数在微观层面上的效率,因为它更简单。在产品层面上情况要复杂得多。这就是为什么在宏观层面上进行可靠的基准测试(或瓶颈分析)时,需要注意一些差异和额外组件。让我们来看看它们,如图 8-1 中所示。

efgo 0801

图 8-1. 宏基准测试所需的常见元素,例如,用于对labeler服务进行基准测试的。

与我们的Sum微基准测试的具体差异可以概述如下:

我们的 Go 程序作为一个独立的进程

由于“Go Benchmarks”,我们了解到Sum函数的效率并可以对其进行优化。但是,如果代码的另一部分现在成为流程中更大的瓶颈呢?这就是为什么我们通常希望在宏观层面上对我们的 Go 程序进行基准测试的原因。这意味着以与生产环境中相似的方式和配置运行该过程。但不幸的是,这也意味着我们不能再运行go test基准测试框架,因为我们是在进程级别上进行基准测试。

依赖项,例如对象存储

宏基准测试的关键元素之一是我们通常希望分析整个系统的效率,包括所有关键依赖项。当我们的代码可能依赖于依赖项的某些效率特征时,这一点尤为重要。在我们的labeler示例中,我们使用对象存储,这通常意味着在网络上传输字节。如果对象存储通信是延迟或资源消耗的主要瓶颈,那么优化Sum可能没有太多意义。通常有三种处理宏观依赖的方式:

  • 我们可以尝试使用真实的依赖关系(例如,在我们的示例中,将在生产中使用的确切对象存储提供者,与相似的数据集大小)。如果我们希望测试整个系统的端到端效率,这通常是最好的方法。

  • 我们可以尝试实现或使用一个fake或适配器来模拟生产问题。然而,这往往需要太多的精力,而且很难模拟慢速 TCP 连接或服务器的确切行为。

  • 我们可以实现我们依赖项的最简单的仿真,并评估我们程序的隔离效率。在我们的示例中,这可能意味着运行本地的开源对象存储,如Minio。它不会反映出我们可能在生产依赖项中遇到的所有问题,但它会为我们的程序的问题和开销提供一些估算。我们将在“Go e2e Framework”中使用这种方法以保持简单。

    可观察性

    我们无法在宏观层面上使用“Go Benchmarks”,因此我们没有内置支持用于延迟、分配和自定义指标。因此,我们必须提供自己的可观察性和监控解决方案。幸运的是,在第六章中,我们已经讨论了用于 Go 程序的仪表和可观察性,我们可以在宏观层面上使用这些内容。在“Go e2e Framework”中,我将向您展示一个具有内置支持的框架,用于开源项目Prometheus,允许收集延迟、使用情况和自定义基准指标。您可以通过其他工具(如跟踪、日志记录和连续分析)增强此设置,以更轻松地调试功能和效率问题。

    负载测试器

    走出 Go 基准测试框架的另一个后果是触发实验案例逻辑的缺失。Go 基准测试执行我们的代码所需的次数和参数。在宏观层面上,我们可能希望使用这种服务,就像用户使用 HTTP REST API 用于像labeler这样的网络服务。这就是为什么我们需要一些理解我们的 API 并将其调用所需次数和参数的负载测试器代码。

    您可以实现自己的负载测试器来模拟用户流量,但这很可能容易出错。²⁷ 有一些方法可以使用更先进的解决方案如 Kafka 来“分叉”或重放生产流量到测试产品中。也许最简单的解决方案是选择一个开源项目,比如k6,它专为负载测试目的设计并经过实战检验。我将在“Go e2e Framework”中展示使用 k6 的示例。

    持续集成(CI)和持续部署(CD)

    最后,我们很少在本地开发机器上运行更复杂系统的宏基准测试。这意味着我们可能希望投资于自动化,以安排负载测试并部署所需版本的组件。

通过这样的架构,我们可以在宏观层面进行效率分析。我们的目标与我们为“微基准”设定的类似,只是在更复杂的系统上,比如 A/B 测试和了解系统功能的空间和运行时复杂性。然而,考虑到我们更接近用户如何使用我们的系统,我们也可以将其视为接受测试,以验证 RAER 的效率。

理论很重要,但在实践中是什么样子呢?不幸的是,使用 Go 执行宏基准测试没有一致的方法,因为它高度依赖于您的使用案例、环境和目标。然而,我想提供一个关于labeler的实用且快速的宏基准示例,我们可以在本地开发机上使用 Go 代码执行!所以让我们深入下一节。

Go 端到端框架

后端宏基准测试并不总是意味着使用与生产环境相同的部署机制(例如 Kubernetes)。然而,为了减少反馈循环,我们可以尝试在开发者机器或小型虚拟机(VM)上执行所有必需的依赖项、专用负载测试器和可观察性的宏基准测试。在许多情况下,这可能会为您在宏观层面提供足够可靠的结果。

对于实验,您可以在您的机器上手动部署“Basics”中提到的所有元素。例如,您可以编写一个 bash 脚本或Ansible runbook。然而,由于我们是 Go 开发人员,希望提高代码的效率,那么在您的代码旁边实现这样一个基准测试如何呢?

为此,我想向您介绍 e2e Go 框架,它允许使用 Go 代码和 Docker 容器在单台机器上运行交互式或自动化实验。容器是一个概念,允许在安全隔离的沙箱环境中运行进程,同时重用主机的内核。在这个概念中,我们在预定义的容器镜像中执行软件。这意味着我们必须预先构建(或下载)要运行的软件的所需镜像。或者,我们可以构建我们自己的容器镜像,并添加像我们的 Go 程序的预构建二进制文件,例如 labeler

在任何操作系统上,容器都不是第一等公民。相反,它可以使用现有的 Linux 机制构建,例如 cgroupsnamespaces 和 Linux 安全模块(LSMs)。Docker 提供了容器引擎的一个实现,以及其他一些选择。²⁸ 多亏像 Kubernetes 这样的编排系统,容器在大型云原生基础设施中也被广泛使用。

要利用容器的所有优势,每个容器只运行一个进程!将更多进程(例如本地数据库)放入一个容器中是诱人的。但这违背了观察和隔离容器的初衷。像 Kubernetes 或 Docker 这样的工具是为每个容器设计的单一进程,因此将辅助进程放在 sidecar 容器中。

让我们来完成一个完整的宏基准实现,分为两部分,示例 8-19 和 8-20,评估我们在“基础知识”中介绍的 labeler 服务的延迟和内存使用。为了方便起见,我们的实现可以作为正常的 go test 脚本化和执行,通过 t.Skip构建标签来手动执行,或者在不同的周期比功能测试更频繁地执行。³⁰

例 8-19. 在交互模式下运行宏基准的 Go 测试(第一部分)
import (
    "testing"

    "github.com/efficientgo/e2e"
    e2edb "github.com/efficientgo/e2e/db"
    e2einteractive "github.com/efficientgo/e2e/interactive"
    e2emonitoring "github.com/efficientgo/e2e/monitoring"
    "github.com/efficientgo/core/testutil"
    "github.com/thanos-io/objstore/providers/s3"
)

func TestLabeler_LabelObject(t *testing.T) {
    e, err := e2e.NewDockerEnvironment("labeler") ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    testutil.Ok(t, err)
    t.Cleanup(e.Close)

    mon, err := e2emonitoring.Start(e) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    testutil.Ok(t, err)
    testutil.Ok(t, mon.OpenUserInterfaceInBrowser()) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

    minio := e2edb.NewMinio(e, "object-storage", "test") ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    testutil.Ok(t, e2e.StartAndWaitReady(minio))

    labeler := e2e.NewInstrumentedRunnable(e, "labeler"). ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
        WithPorts(map[string]int{"http": 8080}, "http").
        Init(e2e.StartOptions{
            Image: "labeler:test", ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/6.png)
            LimitCPUs: 4.0,
            Command: e2e.NewCommand(
                "/labeler",
                "-listen-address=:8080",
                "-objstore.config="+marshal(t, client.BucketConfig{
                    Type: client.S3,
                    Config: s3.Config{
                        Bucket:    "test",
                        AccessKey: e2edb.MinioAccessKey,
                        SecretKey: e2edb.MinioSecretKey,
                        Endpoint:  minio.InternalEndpoint(e2edb.AccessPortName),
                        Insecure:  true,
                    },
                }),
            ),
        })
    testutil.Ok(t, e2e.StartAndWaitReady(labeler))

1

e2e 项目是一个 Go 模块,允许创建端到端测试环境。目前支持在Docker 容器中运行(任何语言的)组件,这样可以在文件系统、网络和可观察性方面进行清洁隔离。容器之间可以互相通信,但不能连接主机。相反,主机可以通过映射的 localhost 端口与容器连接。

2

e2emonitoring.Start 方法启动 Prometheus 和cadvisor。后者将我们容器相关的 cgroups 转换为 Prometheus 指标格式,以便收集它们。Prometheus 还将自动收集使用 e2e.New​InstrumentedRunnable 启动的所有容器的指标。

3

对于资源使用和应用程序指标的交互式探索,我们可以调用 mon.OpenUserInterfaceInBrowser(),这将在我们的浏览器中打开 Prometheus UI(如果在桌面上运行)。

4

Labeler 使用对象存储依赖项。如 “基础知识” 中所述,我通过专注于 labeler Go 程序的效率,而不受远程对象存储的影响,来简化了这个基准测试。因此,本地的 Minio 容器是合适的。

5

最后,现在是启动我们的 labeler Go 程序在容器中的时候了。值得注意的是,我设置容器 CPU 限制为 4(由 Linux cgroups 强制执行),以确保我们的本地基准测试不会使我的机器所有 CPU 都饱和。最后,我们注入对象存储配置以连接到本地的 minio 实例。

6

我使用了本地构建的 labeler:test 镜像。我经常在 Makefile 中添加一个脚本来生成这样的镜像,例如 make docker。你可能会忘记使用你想要基准测试的所需 Go 程序版本来构建镜像,所以要注意你正在测试的内容!

例 8-20. 在交互模式下运行宏基准测试的 Go 测试(第二部分)
    testutil.Ok(t, uploadTestInput(minio, "object1.txt", 2e6)) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    k6 := e.Runnable("k6").Init(e2e.StartOptions{
        Command: e2e.NewCommandRunUntilStop(),
        Image: "grafana/k6:0.39.0",
    })
    testutil.Ok(t, e2e.StartAndWaitReady(k6))

    url := fmt.Sprintf(
        "http://%s/label_object?object_id=object1.txt",
        labeler.InternalEndpoint("http"),
    )
    testutil.Ok(t, k6.Exec(e2e.NewCommand(
        "/bin/sh", "-c", `cat << EOF | k6 run -u 1 -d 5m - ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) import http from 'k6/http'; ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png) import { check, sleep } from 'k6';

export default function () {
    const res = http.get('`+url`');
    check(res, { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png) 'is status 200': (r) => r.status === 200,
        'response': (r) =>
            r.body.includes(
    '{"object_id":"object1.txt","sum":6221600000,"checksum":"SUUr'
            ),
    });
    sleep(0.5)
}
EOF`)))

    testutil.Ok(t, `e2einteractive.RunUntilEndpointHit()`) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
}

1

我们必须上传一些测试数据。在我们的简单测试中,我们上传了一个有两百万行的单个文件,使用了我们在 “Go 基准测试” 中使用的类似模式。

2

我选择 k6 作为我的负载测试工具。k6 作为一个批处理作业工作,因此我首先必须创建一个长时间运行的空容器。然后,我可以在 k6 环境中执行新进程,以在我的 labeler 服务上施加所需的负载。作为一个 shell 命令,我将负载测试脚本作为 k6 CLI 的输入传递。我还指定了我想要的虚拟用户数 (-u--vus)。VUS 表示在脚本中指定的运行负载测试功能的工作程序或线程数。为了保持我们的测试和结果简单,现在让我们暂时保持一个用户,以避免同时的 HTTP 调用。-d--duration 的简短标志)类似于我们在 “Go 基准测试” 中的 -benchtime 标志。关于如何使用 k6 的更多提示请见这里

3

k6 接受用简单 JavaScript 编写的负载测试逻辑。我的负载测试很简单。对我想要基准测试的 labeler 路径进行 HTTP GET 调用。我选择每次 HTTP 调用后休眠 500 毫秒,以便 labeler 服务器在每次调用后有时间清理资源。

4

类似于 “为正确性测试你的基准测试!”,我们必须测试输出。如果我们触发了 labeler 代码或宏基准实现中的错误,我们可能在测量错误的东西!使用 check JavaScript 函数允许我们断言预期的 HTTP 状态码和输出。

5

我们可能想在这里添加自动断言规则,以便在延迟或内存使用在某个阈值内时通过这些测试。然而,正如我们在 “功能测试比较” 中学到的,要找到可靠的效率断言是困难的。相反,我建议通过更互动的方式了解我们 labeler 的效率。e2einteractive.RunUntilEndpointHit() 将会在你访问到打印出的 HTTP URL 之前停止 go test 基准测试。这使我们能够探索所有输出和我们的可观察性信号,例如 Prometheus 中关于 labeler 和测试的收集指标。

代码片段可能很长,但与其编排的多个事物相比,相对来说是比较小而且可读的。另一方面,它必须描述一个相当复杂的宏基准测试,以便在一个可靠的基准测试中配置和调度五个进程,且具有丰富的容器和内部 Go 指标。

保持容器镜像的版本化!

确保对确定性版本的依赖项进行基准测试非常重要。这就是为什么应该避免使用 :latest 标签,因为经常在不知不觉中更新它们是很常见的。此外,当依赖版本发生变化时,可能(或者可能不会!)对结果产生影响,在第二次基准测试后才意识到这一点会相当令人沮丧。

你可以通过你的 IDE 或者简单的 go test . -v -run TestLabeler_LabelObject 命令来启动 示例 8-19 的基准测试。一旦 e2e 框架创建了一个新的 Docker 网络,就启动 Prometheus、cadvisor、labelerk6 容器,并将它们的输出流到你的终端。最后执行 k6 负载测试。在指定的五分钟后,我们应该会打印出关于我们测试功能正确性和延迟的汇总统计结果。当我们访问打印出的 URL 时,测试将停止,并移除所有容器和 Docker 网络。

宏基准测试的持续时间

在 “Go 基准测试” 中,通常只需要运行 5 到 15 秒的基准测试就足够了。为什么我选择运行五分钟的宏负载测试呢?主要有两个原因:

  • 通常,我们要对更复杂的功能进行基准测试,希望花更多的时间和迭代来稳定所有系统组件。例如,正如我们在 “微基准测试与内存管理” 中学到的,微基准测试并不能准确反映 GC 对我们代码的影响。通过宏基准测试,我们运行一个完整的 labeler 进程,因此我们想看看 Go GC 如何处理 labeler 工作。然而,要查看 GC 的频率、影响以及最大内存使用量,我们需要在压力下更长时间地运行我们的程序。

  • 为了在生产环境中实现可持续且更经济的可观察性和监控,我们避免过于频繁地测量应用程序的状态。这就是为什么推荐的 Prometheus 收集(抓取)间隔大约在 15 到 30 秒左右。因此,我们可能希望通过几个收集周期来运行我们的测试,以获得准确的测量结果,同时与生产环境共享相同的可观察性。

在接下来的章节中,我将详细介绍此实验给我们带来的输出及可能的观察。

理解结果与观察

正如我们在 “理解结果” 中所看到的,实验只是成功的一半。另一半是正确解读结果。在运行约七分钟的 Example 8-19 后,我们应该看到类似 Example 8-21 的 k6 输出³¹。

示例 8-21. 在使用 k6 进行一次虚拟用户(VUS)7 分钟测试的宏基准输出的最后 24 行。
running (5m00.0s), 1/1 VUs, 476 complete and 0 interrupted iterations
default   [ 100% ] 1 VUs  5m00.0s/5m0s
running (5m00.4s), 0/1 VUs, 477 complete and 0 interrupted iterations
default ✓ [ 100% ] 1 VUs  5m0s
✓ is status 200
✓ response
checks....................: 100.00% ✓ 954      ✗ 0 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
data_received.............: 108 kB  359 B/s
data_sent.................: 57 kB   191 B/s
http_req_blocked..........: avg=9.05µs  min=2.48µs  med=8.5µs    max=553.13µs
    p(90)=11.69µs p(95)=14.68µs
http_req_connecting.......: avg=393ns   min=0s      med=0s       max=187.71µs
http_req_duration.........: avg=128.9ms min=92.53ms med=126.05ms max=229.35ms ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    p(90)=160.43ms p(95)=186.77ms ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
{ expected_response:true }: avg=128.9ms min=92.53ms med=126.05ms max=229.35ms
    p(90)=160.43ms p(95)=186.77ms
http_req_failed...........: 0.00%   ✓ 0        ✗ 477
http_req_receiving........: avg=60.17µs min=30.98µs med=46.48µs  max=348.96µs
    p(90)=95.05µs  p(95)=124.73µs
http_req_sending..........: avg=35.12µs min=11.34µs med=36.72µs  max=139.1µs
    p(90)=59.99µs  p(95)=67.34µs
http_req_waiting..........: avg=128.81ms min=92.45ms med=125.97ms max=229.22ms
    p(90)=160.24ms p(95)=186.7ms
http_reqs.................: 477     1.587802/s ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
iteration_duration........: avg=629.75ms min=593.8ms med=626.51ms max=730.08ms
    p(90)=661.23ms p(95)=687.81ms
iterations................: 477     1.587802/s ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
vus.......................: 1       min=1      max=1
vus_max...................: 1       min=1      max=1

1

检查这一行以确保您测量了成功的调用!

2

http_req_duration 是如果我们想要追踪总 HTTP 请求延迟的最重要的测量值。

3

还需要注意我们所做的总调用次数(迭代次数越多,可靠性越高)。

从客户端的角度来看,k6 的结果可以告诉我们有关不同 HTTP 阶段的吞吐量和延迟的情况。看起来,只需一个“worker”调用我们的方法并等待 500 毫秒,我们就达到了每秒约 1.6 次调用 (http_reqs) 和平均客户端延迟为 128.9 毫秒 (http_req_duration)。正如我们在 “延迟” 中所学到的,尾延迟对于延迟测量可能更为重要。为此,k6 也计算了百分位数,这表明 90% 的请求 (p90) 比 160 毫秒快。我们在 “Go Benchmarks” 中了解到,这个过程中涉及的 Sum 函数平均需要 79 毫秒,这意味着它占据了平均延迟甚至总体 p90 延迟的大部分。如果我们关心优化延迟,我们应该尝试优化 Sum。我们将学习如何通过像性能分析这样的工具在 第九章 中验证这一百分比并识别其他瓶颈。

我们应该检查的另一个重要结果是我们运行的方差。我希望k6能提供开箱即用的方差计算,因为没有它很难判断我们的迭代是否重复。例如,我们看到最快的请求花了 92 毫秒,而最慢的则花了 229 毫秒。这看起来令人担忧,但首次请求需要更长时间是正常的。要确定,我们需要两次进行相同的测试并测量平均值和百分位数方差。例如,在我的机器上,同一 5 分钟测试的下一次运行给了我一个平均值为 129 毫秒和p90为 163 毫秒的结果,这表明方差很小。但最好将这些数字汇总到电子表格中,并计算标准偏差以找出方差百分比。也许可以有一个像benchstat这样的快速 CLI 工具,可以给我们类似的分析。这很重要,因为相同的“实验可靠性”方面适用于宏基准。如果我们的结果不可重复,我们可能需要改进我们的测试环境,减少未知数,或者进行更长时间的测试。

k6的输出并不是我们拥有的一切!具有良好使用监控和可观测性的宏基准,如 Prometheus 的美妙之处在于,我们可以评估和调试许多效率问题和疑问。在示例 8-19 的设置中,我们有仪表化工具,它通过cadvisor提供给我们有关容器和进程的cgroup指标,通过labeler Go 运行时提供内置进程和堆指标,并且我在labeler代码中手动仪表化了应用程序级别的 HTTP 指标。因此,根据我们的目标和 RAER(见“效率感知开发流程”),我们可以检查我们关心的使用指标,例如我们在“效率指标语义学”中讨论的指标以及更多。

让我们看看我运行后在 Prometheus 中可以看到的一些指标可视化。

服务器端延迟

在我们的本地测试中,我们使用本地网络,因此服务器和客户端的延迟几乎没有差异(我们在“延迟”中讨论过这种差异)。然而,更复杂的宏基准测试可能会在不同服务器或远程设备的不同地理位置引入网络开销,我们可能希望或不希望在结果中考虑这一点。如果我们不想考虑,我们可以查询 Prometheus 获取服务器处理我们/label_object路径的平均请求持续时间,如图 8-2 所示。

efgo 0803

图 8-2. 将http_request_duration_seconds直方图总和除以计数速率以获取服务器端延迟

结果确认了我们在示例 8-21 中所看到的。观察到的平均延迟大约是 0.12 到 0.15 秒,具体取决于时刻。该指标来自我在 Go 中使用prometheus/client_golang手动创建的 HTTP 中间件。

Prometheus 率持续时间

请注意,在这次宏基准测试的查询中,我使用了 [1m] 范围向量来作为 Prometheus 计数器。这是因为我们只运行了 5 分钟的测试。使用 15 秒的抓取间隔,1 分钟应该有足够的样本使得rate有意义,同时也能看到我指标值的更多细节,具有一次性的分钟窗口粒度。

当涉及到服务器端的百分位数时,我们依赖于分桶直方图。这意味着结果的准确性取决于最近的分桶。在示例 8-21 中,我们看到结果在 92 毫秒到 229 毫秒之间,其中p90等于 136 毫秒。在基准测试时,分桶如下定义:在labeler中为0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120, 240, 360, 720。因此,我们只能说 90% 的请求比 300 毫秒更快,如图 8-3 所示。

efgo 0804

图 8-3。使用http_request_duration_seconds直方图来计算/label_object请求的p90分位数

要获得更精确的结果,我们可能需要手动调整分桶,或者使用即将推出的 Prometheus 2.40 版本中的新稀疏直方图特性。默认的分桶在我们不关心请求是否在 100 毫秒或 300 毫秒内处理时效果良好,但如果突然变成 1 秒,我们就关心了。

CPU 时间

延迟是一回事,但 CPU 时间可以告诉我们 CPU 需要多少时间来完成其工作,并且并发量能起到多大帮助,以及我们的进程是 CPU 绑定还是 I/O 绑定。我们还可以知道是否为当前进程负载提供了足够的 CPU。正如我们在第四章中学到的,我们的迭代的更高延迟可能是 CPU 饱和的结果——我们的程序使用了所有可用的 CPU 核心(或接近极限),从而减慢了所有 goroutine 的执行。

在我们的基准测试中,我们可以使用 Go 运行时的process_cpu_seconds_total计数器或者cadvisorcontainer_cpu_usage_seconds_total计数器来找到这个数字。这是因为labeler是其容器中唯一的进程。两个指标看起来相似,后者在图 8-4 中展示。

efgo 0805

图 8-4。使用container_cpu_usage_seconds_total计数器评估labeler的 CPU 使用情况

值在 0.25–0.27 CPU 秒之间波动,这表示labeler在此负载所需的 CPU 时间。我将labeler限制在 4 个 CPU 核心,但它最多只使用了单个 CPU 的 27%。这意味着,很可能 CPU 并未饱和(除非在同一时刻有很多嘈杂的邻居运行,这在延迟数字中会看到)。每秒 270 毫秒的 CPU 时间似乎是一个合理的值,考虑到我们的请求平均需要 128.9 毫秒,之后,k6等待了 500 毫秒。这使我们得出 20%³³的负载测试时间,因此,k6实际上正在向labeler请求一些工作,这些工作可能不全都用于 CPU,还可能用于 I/O 时间。我们当前版本中的labeler /label_object执行是顺序的,但也有一些后台任务,比如监听信号、度量收集、GC 和 HTTP 后台 goroutine。再次查看“Go 中的性能分析”,这是准确了解 CPU 使用情况的最佳方法。

内存

在“微基准测试”中,我们了解了Sum分配了多少内存,但Sum并不是labeler需要执行的唯一逻辑。因此,如果我们想评估labeler的内存效率,我们需要查看在基准测试期间收集的进程或容器级内存指标。此外,我们在“微基准测试与内存管理”中提到,只有在宏观水平上,我们才有机会了解更多关于 GC 影响和我们labeler进程的最大内存使用情况。

查看在图 8-5 中呈现的堆指标,我们可以观察到单个/label_object使用了相当数量的内存。在看到示例 8-7 中Sum函数微基准测试结果显示每次迭代使用 60.8 MB 后,这并不出乎意料。

这一观察显示了可能导致问题的 GC 事件。在k6的单个“worker”(VUS)中,如果Sum是主要瓶颈,labeler应该永远不需要超过约 61 MB 的活动内存。然而,我们可以看到,在 2 个抓取(30 秒)后和然后 1 个抓取后,内存增加到了 118 MB。很可能是在第二个调用开始之前,GC 没有释放上一个 HTTP /label_object调用的内存。如果考虑到峰值,总体最大堆大小稳定在约 120 MB 左右,这应该告诉我们没有即时的内存泄漏问题。³⁴

efgo 0806

图 8-5. 使用go_memstats_heap_alloc_bytes指标评估labeler堆使用情况

不幸的是,正如我们在 “操作系统内存管理” 和 “内存使用情况” 中所学到的,堆使用的内存只是 Go 程序使用的 RAM 空间的一部分。操作系统为 goroutine 栈、手动创建的内存映射以及内核缓存(例如文件访问)保留更多页面的物理内存空间。当我们查看在 Figure 8-6 中呈现的容器级 RSS 指标时,我们可以看到这一点。

efgo 0807

图 8-6. 使用 container_memory_rss 游标评估 labeler 的物理 RAM 使用

幸运的是,RSS 方面也没有什么意外。活跃的内存页面大致与堆的大小相当,并在测试结束后返回到更小的水平。因此,我们可以评估 labeler 在这个负载下大约需要 130 MB 的内存。

总结一下,我们评估了宏观层面上的延迟和资源效率。在实践中,我们可以根据磁盘、网络、I/O 设备、数据库使用等效率目标评估更多内容。在我们的测试中,k6 的配置很简单——单个工作者和顺序调用带有暂停。让我们在下一节中探索其他变体和可能性。

常见的宏基准测试工作流程

在 “Go e2e Framework” 的示例测试中,您可以了解如何配置示例负载测试工具,接入依赖项,并设置和使用高效分析的实用观测性。此外,您可以根据效率目标扩展这样的本地 e2e 测试。例如:

  • 使用多个工作者对系统进行负载测试,以评估在维持所需的 p90 延迟的同时维持给定请求每秒(RPS)速率所需的资源量。

  • 运行 k6 或其他负载测试工具,在不同的位置模拟真实客户端流量。

  • 在远程服务器上部署宏基准测试,可能与您的生产环境使用相同的硬件。

  • 在远程位置部署依赖项;例如,在我们的 labeler 示例中,使用 AWS S3 服务 替代本地对象存储实例。

  • 扩展您的宏测试和服务到多个副本,以检查流量是否可以正确负载平衡,以便系统的效率保持可预测性。

与 “找到您的工作流” 相似,您应该找到适合您进行此类实验和分析的工作流程。例如,对于我和与我合作的团队来说,在设计和使用类似 “Go e2e Framework” 的宏基准测试过程可能如下所示:

  1. 作为团队,我们计划宏基准测试元素、依赖关系,我们想要基准测试的方面以及我们想要施加的负载。

  2. 我确保labeler和宏观基准代码处于干净的代码状态。我将所有更改提交,以便知道我正在测试什么和用什么基准测试。假设我们最终得到一个如“Go e2e 框架”中的基准测试。

  3. 在开始基准测试之前,我会创建一个共享的 Google 文档³⁷,并记录所有实验细节,如环境条件和软件版本。

  4. 我执行基准测试以评估给定程序版本的效率:

    • 我通过在 Goland IDE 中启动go test并等待负载测试完成来运行我的宏观基准测试,例如使用 Go e2e 框架(见“Go e2e 框架”)。

    • 我确认没有功能错误存在。

    • 我将k6的结果保存到 Google 文档。

    • 我收集我想关注的资源的有趣观察结果,例如堆和 RSS 以评估内存效率。我捕捉屏幕截图并粘贴到我的 Google 文档中³⁸。最后,我记录我所做的所有结论。

    • 可选地,我收集“Go 中的性能分析”过程的配置文件。

  5. 如果发现允许我找到代码优化,我会实施并将其保存为新的git提交。然后再次进行基准测试(见第 5 步),并将新结果保存到同一 Google 文档的不同版本下,以便稍后比较我的 A/B 测试。

前述工作流程使我们能够分析结果并得出效率评估,根据创建的文档可以制定假设。链接确切的基准测试,理想情况下已提交到源代码,使他人能够重现相同的测试以验证结果或进行进一步的基准测试和测试。再次强调,只要您关心“实验的可靠性”中提到的元素,您可以自由使用任何您需要的实践。没有单一一致的宏观基准测试流程和框架,这完全取决于软件类型、生产条件以及您希望投入以确保产品效率的价格。

还值得一提的是,宏观基准测试与“生产环境中的基准测试”并不相距甚远。您可以在宏观基准测试中重用许多元素,例如负载测试工具和可观察性工具,以进行与生产环境的基准测试(反之亦然)。这种互操作性使我们能够节省构建和学习新工具的时间。在生产环境中执行基准测试的主要区别在于,要确保生产用户的质量——通过确保新软件版本在不同测试和基准测试水平上的基本质量,或者利用 Beta 测试人员或金丝雀部署。

摘要

恭喜!通过本章,您现在应该了解如何实际进行微基准测试和宏基准测试,这是理解我们是否需要进一步优化软件、如何优化以及优化的程度的核心方法。此外,微基准测试和宏基准测试在与效率相关的软件开发的其他方面,如容量规划和可伸缩性方面,也是无价的。³⁹

在我的软件开发日常工作中,我在很大程度上依赖于微基准测试和宏基准测试。由于微层级快速反馈循环,我经常针对关键路径中较小的函数执行这些测试,以决定实现应该如何进行。它们易于编写,也易于删除。

宏基准测试需要更多的投资,因此我特别建议创建和执行这些基准测试:

  • 在更大的特性或发布后,作为整个系统的 RAER 评估的验收测试。

  • 在调试和优化触发效率问题的回归或事件时。

参与微基准测试和宏基准测试的实验对效率评估有用,并在 “6. 找出主要的瓶颈” 中进行。然而,在该基准测试期间,我们还可以对 Go 程序进行分析,以推断主要的效率瓶颈。让我们在下一章中看看如何实施!

¹ 对于更大的项目,我建议添加 _bench_test.go 后缀,以便更容易发现基准测试。

² 在 testing package 的示例文档 中有很好的解释。

³ 如果完全删除 b.N,Go 基准测试将尝试增加 N 的数量,直到整个 BenchmarkSum 至少需要 1 秒。没有 b.N 循环,我们的基准测试将永远不会超过 1 秒,因为它不依赖于 b.N。这样的基准测试将在 b.N 等于 10 亿次迭代时停止,但仅执行单次迭代,基准测试结果将是错误的。

⁴ 正如前面提到的,微基准测试总是基于某些假设;我们无法在如此小的测试中模拟所有情况。

⁵ 请注意,一个具有单个整数的基准测试绝对不会花费 29 纳秒。这个数字是我们在更多整数的情况下看到的延迟时间。

⁶ 请注意,未来版本中更改我们程序和基准测试的测试数据是可以接受的。通常,随着时间的推移,我们的优化使得我们的测试数据集“太小”,因此如果需要进一步优化,我们可以随时间增加它以发现不同的问题。

⁷ 正如之前所解释的,需要注意完整的基准测试过程可能会超过 10 秒,因为 Go 框架会尝试找到正确的迭代次数。测试结果的差异越大,测试可能会持续的时间也可能越长。

⁸ 你还可以在逗号后提供多个数字。例如,-cpu=1,2,3 将使用 GOMAXPROCS 分别设置为 1、2 和 3 来运行测试。

⁹ 可以通过查看 BenchmarkResult 类型 来探索该格式的内部表示。

¹⁰ 例如,Go 版本、Linux 内核版本、同时运行的其他进程、CPU 模式等等。不幸的是,几乎无法完整列出所有因素。

¹¹ Go 测试框架不会检查有多少 CPU 可用于该基准测试。正如你在 第四章 中学到的,CPU 是在其他进程之间公平共享的,因此在我的情况下,四个 CPU 并未完全保留给基准测试。此外,对 runtime.GOMAXPROCS 的程序化更改在这里不会反映出来。

¹² 确保严格控制用于构建这些二进制文件的 Go 版本。使用不同 Go 版本构建的测试二进制文件可能会导致误导性结果。例如,你可以构建一个二进制文件,并在其名称末尾添加源代码版本的 git 哈希。

¹³ 这对于处理错误非常频繁的分布式系统和用户面向的应用程序特别重要,它是正常程序生命周期的一部分。例如,我经常使用的代码对于数据库写入速度很快,但在运行失败时分配了极大量的内存,导致级联失败。

¹⁴ 在我的基准测试中,在我的机器上,仅这条指令就需要 244 纳秒,并且不分配任何字节。

¹⁵ 性能分析,详见 “Go 中的性能分析”,还可以帮助确定你的基准测试对这些开销的影响程度。

¹⁶ 请注意,TB 是我自己的发明,不是 Go 社区常见或推荐的用法,因此请谨慎使用!

¹⁷ 事实上,我们甚至不能完全信任自己!第二个仔细的审阅者总是一个好主意。

¹⁸ 请注意,t.TempDirb.TempDir 方法每次调用时都会创建一个新的唯一目录!

¹⁹ 对于更长的微基准测试,你可能会看到 GC 延迟。一些教程还建议运行无 GC 的微基准测试(使用GOGC=off),但我发现这在实践中并不有用。理想情况下,应该转向宏观水平,以理解全面影响。

²⁰ 除非你使用我在“性能不确定性”中不推荐的并行选项。

²¹ 这个函数的理念源于了不起的大卫的tutorialissue 14813,并进行了一些修改。

²² 我并不打算阻止对超低级函数进行微基准测试。你依然可以比较各种事物,但要注意,实际生产数据可能会让你感到意外。

²³ 这并不意味着未来的 Go 编译器不能更智能化,并考虑优化全局变量。

²⁴ sink模式在 C++中也因为相同的原因而流行。

²⁵ 对象存储是廉价的云存储,具有简单的 API 用于上传对象和读取它们或它们的字节范围。它以对象的形式处理所有数据,这些对象具有类似文件路径的特定 ID。

²⁶ 你可以在labeler package中找到简化的微服务代码。

²⁷ 一个常见的陷阱是实现效率低下的负载测试代码。有风险是,你的应用程序之所以不能达到你想要的吞吐量,仅仅是因为客户端发送的流量不够快!

²⁸ 这个领域迅速扩展,有两个独立的规范(CRI 和 OCI),以及容器生态系统各个部分的各种实现。在这里详细了解更多。

²⁹ 这经常被低估了。创建可重用的仪表板、了解你的仪表化工具和指标含义,需要不少的工作量。如果我们的本地测试和生产环境共享相同的指标和其他信号,可以节省大量时间,并提高我们的可观察性质量。

³⁰ 你可以自行运行这段代码,或者探索e2e框架如何配置所有组件这里

³¹ 也有一种方法可以直接将这些结果推送到Prometheus

³² 参见labeler使用的示例代码

³³ 128.9 毫秒除以 128.9+500 毫秒,以确定负载测试器处于主动负载测试的时间比例。

³⁴ 查看 go_goroutines 也有助于分析。如果我们看到明显的趋势,可能会忘记关闭一些资源。

³⁵ 解决方案是使用计数器。对于内存来说,这意味着使用现有的 rate(go_memstats_alloc_bytes_total[1m]) 并将其除以 GC 释放的字节的速率。不幸的是,Prometheus Go 收集器没有公开这样的指标。Go 允许我们获取这些信息,因此未来可能会添加这些信息。

³⁶ 对于更大的测试,考虑确保负载测试器具有足够的资源。对于 k6,请参阅此指南

³⁷ 其他任何媒介,如 Jira 的票务评论或 GitHub 的问题也可以。只需确保能够轻松粘贴截图,这样就不会有太多麻烦,也不会因为截图是为哪个实验而出现错误的情况!

³⁸ 不要只是首先制作所有的截图,然后延迟描述它们直到稍后。尝试在谷歌文档中对每个观察结果进行迭代,因为稍后会忘记捕捉时的情况。此外,我曾看到许多情况是认为截图已保存在我笔记本电脑的本地目录中,然后丢失了所有的基准结果。

³⁹ 在Martin Kleppmann 的书《设计数据密集型应用:可靠、可扩展和可维护系统背后的大思想》中有很好的解释(O’Reilly)。

第九章:数据驱动的瓶颈分析

程序员通常很难猜测代码的哪些部分是主要的资源消耗者。程序员往往修改一段代码,期望能节省大量时间,结果发现几乎没有任何区别,因为该代码很少被执行。

乔恩·路易斯·本特利,《编写高效程序》

提高我们的 Go 程序效率的关键步骤之一是了解延迟或资源使用的主要来源。因此,我们应该有意识地首先集中精力放在对最贡献(瓶颈或热点)最大的代码部分进行优化,以获得最大的优化价值。

很容易根据我们在软件开发中的经验来估计哪部分代码是最昂贵或计算速度太慢的。我们可能已经见过类似的代码片段导致效率问题。例如,“哦,我在 Go 中使用了链表,速度太慢了,这一定是它!”或者“我们在这里创建了很多新的切片,我觉得这就是我们的瓶颈,让我们重复使用一些吧。”我们可能仍然记得可能带来的痛苦或压力。不幸的是,基于这些感觉的结论通常是错误的。每个程序、用例和环境都是不同的。软件可能在其他地方遇到问题。快速而可靠地揭示那一部分是至关重要的,这样我们就知道在哪里投入优化的努力。

幸运的是,我们不需要猜测。我们可以收集适当的数据!Go 提供并整合了非常丰富的工具,供我们用于瓶颈分析。我们将从介绍一些工具的“效率根本原因分析”开始我们的旅程。然后,我会向你介绍“Go 中的性能分析”,你将了解到pprof生态系统。这个性能分析基础相当受欢迎,但如果你不了解基础知识,很难理解其结果。工具、报告和视图的文档很少,所以我会花几节课描述原则和常见的表示方式。在“捕捉性能分析信号”中,你将学习如何仪表化和收集分析数据。在“常见的性能分析仪表化”中,我将解释一些我们现在可以在 Go 中使用的重要现有分析。最后,我们将探讨一些“技巧和窍门”,包括最近流行的“连续性能分析”技术!

这是我在研究和准备内容时学到很多的章节之一。这也是我更加兴奋地与你分享这些知识的原因!让我们从根本原因分析及其与瓶颈分析的关系开始吧。

效率根本原因分析

瓶颈分析过程与 原因分析根本原因分析 工程师在系统事件或测试失败后执行的过程没有区别。事实上,效率问题导致了许多这类事件,例如,HTTP 请求超时因 CPU 饱和而发生。因此,在我们的系统或程序的瓶颈分析过程中,最好装备自己具有类似的心态和工具。

对于具有多个进程的更复杂系统,调查可能会非常复杂,有许多症状¹、误导²或甚至多个瓶颈。

第六章 中的工具在瓶颈分析中始终是无价的。通过资源使用率周围的指标,我们可以缩小到什么时候和哪个进程分配或使用了最多的内存或 CPU 时间等。通过详细的日志记录,我们可以为每个阶段提供额外的延迟测量。通过跟踪,我们可以分析请求路径,并找出哪个进程,有时甚至是程序功能³对整个操作的延迟贡献最大。

另一种天真的方法是试错流程。我们总是可以通过逐个禁用某些代码部分的方式进行手动实验,以检查是否能够重现效率错误。然而,对于大型系统来说,这在实践中可能是不可行的。可能有更好的方法来确定导致广泛资源使用或高延迟的主要贡献者。这种方法可以在几秒钟内告诉我们准确的代码行责任所在。

这种方便的信号称为 profiling,通常被描述为可观察性的第四支柱。让我们在下一节详细探讨 profiling。

在 Go 中的 Profiling

Profiling 是一种动态代码分析形式。您可以在应用程序运行时捕获应用程序的特征,然后使用这些信息来识别如何使您的应用程序更快、更高效。

“Profiling Concepts”,Google Cloud 文档

Profiling 是一个完美的概念,用于表示由程序中特定代码行引起的某物(例如,经过的时间、CPU 时间、内存、goroutines 或数据库中的行)的确切使用情况。根据我们所寻找的内容,我们可以比较不同代码行或按函数⁴或文件分组的某物的贡献。

根据我的经验,分析是 Go 社区中最成熟的调试方法之一。它丰富、高效,并且对每个人都是可访问的,Go 标准库提供了六种默认的分析实现,社区创建的分析器,以及易于构建的自定义分析器。令人惊奇的是,所有这些分析文件可能有不同的含义,并且涉及不同的资源,但它们的表示遵循相同的约定和格式。这意味着无论你想探索堆(见 “堆”)、goroutine(见 “Goroutine”)还是 CPU(见 “CPU”),你都可以使用相同的可视化和分析工具和模式。

毫无疑问,我们应该对 pprof 项目 表示感谢(pprof代表性能分析)。市面上有很多性能分析工具。Linux 有 perf_events (perf 工具),FreeBSD 有 hwpmc,还有 DTrace,等等。pprof 的特别之处在于,它建立了一种通用的表示、文件格式和可视化工具,用于性能分析数据。这意味着你可以使用前述的任何工具,或者从头开始在 Go 中实现一个分析器,并且使用相同的工具和语义来分析这些分析数据。

分析器

分析器是一种软件,它可以收集某个资源(或时间)的堆栈跟踪和使用情况,然后将其保存为一个分析文件。配置、安装或者被仪器化的分析器可以被称为分析器仪器。

让我们在下一节深入讨论 pprof

pprof 格式

最初的 pprof 工具是 Google 内部开发的 Perl 脚本。根据版权标头,开发可能可以追溯到 1998 年。它于 2005 年作为 gperftools 的一部分首次发布,并于 2010 年添加到 Go 项目中。2014 年,Go 项目用 Raul Silvera 的 Go 实现替换了基于 Perl 的版本的 pprof 工具,这个实现在 Google 内部已经在使用。这个实现于 2016 年作为一个独立项目重新发布。从那时起,Go 项目一直在供应这个上游项目的副本,并定期更新。

Felix Geisendörfer, “Go 的 pprof 工具和格式”

许多编程语言如 Go 和 C++,以及像 Linux 的 perf 这样的工具,都可以利用 pprof 格式,因此更值得深入了解。为了真正理解分析,让我们快速创建一个自定义的分析器来跟踪我们 Go 程序中当前打开的文件。程序可以同时持有的文件描述符数量是有限的。如果我们的程序遇到这样的问题,文件描述符分析可能有助于找出程序中负责打开最多描述符的部分。⁵

对于这样基本的配置文件分析,我们不需要实现任何pprof编码或跟踪代码。相反,我们可以使用标准库实现的简单runtime/pprof.Profile结构。它允许创建记录所需类型当前使用对象的计数和源的配置文件。pprof.Profile非常简单且有些受限,⁶ 但是它非常适合我们开始配置文件分析的旅程。

在示例 9-1 中介绍了基本的配置文件分析器示例。

示例 9-1. 使用pprof.Profile功能实现文件描述符配置文件分析
package fd

import (
    "os"
    "runtime/pprof"
)

var fdProfile = pprof.NewProfile("fd.inuse") ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

// File is a wrapper on os.File that tracks file descriptor lifetime.
type File struct {
    *os.File
}

// Open opens a file and tracks it in the `fd` profile`.
func Open(name string) (*File, error) {
    f, err := os.Open(name)
    if err != nil {
        return nil, err
    }
    fdProfile.Add(f, 2) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    return &File{File: f}, nil
}

// Close closes files and updates profile.
func (f *File) Close() error {
    defer fdProfile.Remove(f.File) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    return f.File.Close()
}

// Write saves the profile of the currently open file
// descriptors into a file in pprof format.
func Write(profileOutPath string) error {
    out, err := os.Create(profileOutPath)
    if err != nil {
        return err
    }
    if err := fdProfile.WriteTo(out, 0); err != nil { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
        _ = out.Close()
        return err
    }
    return out.Close()
}

1

pprof.NewProfile 被设计为全局变量使用。它使用提供的名称注册配置文件,名称必须是唯一的。在这个例子中,我使用fd.inuse名称来指示跟踪正在使用的文件描述符的配置文件。

不幸的是,这种全局注册约定有一些缺点。如果导入两个创建我们不想使用的配置文件的包,或者它们使用常见名称注册配置文件,我们的程序将会崩溃。另一方面,全局模式允许我们使用pprof.Lookup("fd.inuse")从不同包中获取创建的配置文件。它还自动与net/http/pprof处理程序配合使用,详见“捕获配置文件分析信号”。对于我们的示例,这效果很好,但我通常不建议在任何严肃的自定义配置文件分析器中使用全局约定。

2

为了记录活动文件描述符,我们提供了一个Open函数,模仿os.Open函数。它打开一个文件并记录它。它还包装了os.File,因此我们知道它何时关闭。Add方法记录对象。第二个参数告诉我们在堆栈跟踪中跳过多少次调用。堆栈跟踪用于记录配置文件在进一步的pprof格式中的位置。

我决定使用Open函数作为示例创建的参考,所以我必须跳过两个堆栈帧。

3

当文件关闭时,我们可以移除对象。请注意,我正在使用相同的内部*os.File,所以pprof包可以跟踪并找到我打开的对象。

4

标准 Go 配置文件提供了一个WriteTo方法,它将完整的pprof文件的字节写入提供的写入器。然而,通常我们希望将其保存到文件中,所以我添加了Write方法。

许多标准配置文件,如稍后在“常见配置文件工具”中提到的那些,都是透明地进行配置的。例如,我们不必以不同的方式分配内存就可以在堆配置文件中看到它(请参阅“堆”)。对于像我们这样的自定义配置文件,分析器必须在我们的程序中手动进行配置。例如,我创建了TestApp,模拟一个打开了 112 个文件的应用程序。使用示例 9-1 中的代码在示例 9-2 中呈现了fd.inuse配置文件的代码。

示例 9-2. TestApp代码通过fd.inuse配置文件保存配置文件的最后一部分到fd.pprof文件中
package main

// import "github.com/efficientgo/examples/pkg/profile/fd"

type TestApp struct {
    files []io.ReadCloser
}

func (a *TestApp) Close() {
    for _, cl := range a.files {
        _ = cl.Close() // TODO: Check error. ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    }
    a.files = a.files[:0]
}

func (a *TestApp) open(name string) {
    f, _ := fd.Open(name) // TODO: Check error. ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    a.files = append(a.files, f)
}

func (a *TestApp) OpenSingleFile(name string) {
    a.open(name)
}

func (a *TestApp) OpenTenFiles(name string) {
    for i := 0; i < 10; i++ {
        a.open(name)
    }
}

func (a *TestApp) Open100FilesConcurrently(name string) {
    wg := sync.WaitGroup{}
    wg.Add(10)
    for i := 0; i < 10; i++ {
        go func() {
            a.OpenTenFiles(name)
            wg.Done()
        }()
    }
    wg.Wait()
}

func main() {
    a := &TestApp{}
    defer a.Close()

    // No matter how many files we opened in the past...
    for i := 0; i < 10; i++ {
        a.OpenTenFiles("/dev/null") ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        a.Close()
    }

    // ...after the last Close, only files below will be used in the profile.
    f, _ := fd.Open("/dev/null") // TODO: Check error.
    a.files = append(a.files, f)

    a.OpenSingleFile("/dev/null")
    a.OpenTenFiles("/dev/null")
    a.Open100FilesConcurrently("/dev/null")

    if err := fd.Write("fd.pprof"); err != nil { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
        log.Fatal(err)
    }
}

1

我们使用fd.Open函数打开文件,这会作为打开文件的副作用录入到配置文件中。

2

我们始终需要确保当我们不再需要文件时将其关闭。这样可以节省资源(如文件描述符),更重要的是,刷新任何已缓冲的写入,并记录文件不再使用。

3

为了演示我们的分析工作,我们首先打开 10 个文件并关闭它们,重复 10 次。我们使用/dev/null作为我们的虚拟测试文件。

4

最后,我们使用某种方式链接的方法创建了 110 个文件。然后,我们以我们的fd.inuse配置文件的形式拍摄了这种情况的快照。我使用.pprof文件扩展名来命名这个文件(Go 文档使用.prof),但从技术上讲,它是一个经过gzip程序压缩的 protobuf 文件,因此通常使用.pb.gz文件扩展名。使用您发现更易读的内容。

在示例 9-2 中代码中正在发生的事情可能看起来很简单。然而,在实践中,我们的 Go 程序的复杂性可能会使我们想知道什么代码段会创建那么多没有关闭的文件。在创建的fd.pprof中保存的数据应该能够回答这个问题。我们在 Go 社区中称之为pprof格式,简单地是一个使用.proto语言定义的、官方在google/pprof项目的proto文件中定义的 gzipped 的 protobuf(二进制格式)文件。这种格式使用了模式。

要快速学习pprof模式及其基元,让我们看看fd.pprof文件在示例 9-2 中产生了什么。打开(正在使用)和总文件描述符图的高级表示在图 9-1 中呈现。

图 9-1 展示了以 pprof 格式存储的对象及这些对象包含的一些核心字段(还有更多)。正如您可能注意到的,这种格式旨在提高效率,具有许多间接引用(通过整数 ID 引用其他内容)。出于简单起见,我在图表中省略了这些细节,但所有字符串也通过字符串表进行了内部化

efgo 0901

图 9-1. 以 pprof 格式表示的开放(使用中)和总文件描述符的高级表示

pprof 格式以称为 Profile 的单个根对象开始,其中包含以下子对象:

Mappings

并非每个程序在二进制文件中都有调试符号。例如,在 “理解 Go 编译器” 中,我们提到 Go 默认包含它们,以提供指向源代码的可读堆栈跟踪。但是,某些编译二进制文件的人可能会删除此信息,以显著减小二进制文件的大小。如果没有符号,pprof 文件可以使用堆栈帧(位置)的地址。然后,这些地址将通过后续工具动态转换为准确的源代码行,这个过程称为符号化。映射允许指定如何将地址映射到二进制文件,如果在后续步骤中动态提供。

不幸的是,如果您需要一个二进制文件,则必须从收集配置文件的相同源代码版本和体系结构构建它。这通常非常棘手。例如,当我们从远程服务获取配置文件(更多信息请参见 “捕获分析信号”),我们在分析配置文件的机器上可能没有相同的二进制文件。

幸运的是,我们可以将所有必需的元数据存储在 pprof 文件中,因此不需要符号化。这是从 Go 1.9 开始用于标准配置文件的内容,因此我将跳过解释符号化技术。

Locations

位置是代码行(或其地址)。为方便起见,位置可以指向定义它的函数以及源代码文件名。位置本质上代表堆栈帧。

Functions

如果二进制文件中存在调试符号,则函数结构包含有关定义位置的函数的元数据。

ValueTypes

这告诉我们在我们的配置文件中有多少维度。每个位置可能负责使用某些值。值类型定义了单位以及该值的含义。我们的 示例 9-1 配置文件只有 fd.inuse 类型,因为当前简单的 pprof.Profile 不允许添加更多维度;但是为了演示,图 9-1 包含两种类型,表示总计数和当前计数。

贡献

pprof 格式的分析文件不限制分析值的含义。定义测量值语义是实现的责任。例如,在 Example 9-1 中,我将其定义为配置文件快照时存在的打开文件数。对于其他“Common Profile Instrumentation”,该值可能表示其他内容:CPU 上花费的时间、分配的字节数或在特定位置执行的 goroutine 数量。始终澄清您的分析值的含义!

通常,大多数分析值告诉我们代码中的每个部分使用某种资源或时间的量。这就是为什么在解释样本分析值时,我坚持使用贡献这个动词。

Samples

给定某个值类型的某个值的测量或测得贡献的给定堆栈跟踪。为了表示堆栈跟踪(调用序列),样本列出了从堆栈跟踪顶部开始的所有位置 ID。重要的细节是,样本必须具有等于我们定义的值类型数(及顺序)的确切数量的值。我们还可以为样本附加标签。例如,我们可以附加在该堆栈跟踪中打开的示例文件名。“Heap”使用它显示平均分配大小。

更多元数据

类似于捕获的数据,可以在分析对象中包括捕获配置文件的时间、数据跟踪持续时间(如果适用)以及一些过滤信息。其中最重要的字段之一是period字段,它告诉我们配置文件是否已抽样。我们在 Example 9-2 中跟踪所有已插入的Open调用,因此我们的period等于一。

带有所有这些组件,pprof 数据模型设计非常出色,具有描述软件任何方面的性能分析数据。它还与统计配置文件很好地配合,该配置文件捕获了发生的一小部分所有事件的数据。

在 Example 9-2 中,跟踪已打开的文件不会对应用程序造成太大的开销。也许在极端生产案例中,对每个文件打开和关闭调用AddRemove,并在每个文件打开和关闭时映射对象,可能会减慢一些关键路径。然而,对于像“CPU”或“Heap”这样的复杂分析情况,情况要糟糕得多。对于对我们程序的 CPU 使用情况进行分析的 CPU 分析情况,跟踪每个单个周期中执行的确切指令是不切实际的(也是不可能的)。这是因为,对于每个周期,我们需要捕获一个堆栈跟踪并将其记录在内存中,就像我们在第四章中了解到的那样,这可能单独花费数百个 CPU 周期。

这就是为什么必须对 CPU 分析进行抽样的原因。这与内存等其他分析类似。正如你将在“Heap”中了解到的那样,我们对其进行抽样,因为跟踪所有单个分配将增加显著的开销,并减慢程序中的所有分配速度。

幸运的是,即使是高度采样的配置文件,分析也非常有用。按设计,分析主要用于瓶颈分析。按定义,瓶颈是使用某些资源或时间最多的东西。这意味着无论我们捕获了 100%,10% 还是甚至 1% 使用例如 CPU 时间的事件,统计上,使用 CPU 最多的代码应仍位于顶部,具有最大的使用数字。这就是为什么更昂贵的配置文件总是以某种方式进行采样,这使得 Go 开发人员可以几乎在所有程序中安全地预先启用配置文件。它还使得讨论“持续配置文件”中的持续配置文件实践成为可能。

统计分析不是 100% 精确

在采样的配置文件中,您可能会错过部分贡献。

类似 Go 这样的分析工具具有复杂的缩放机制,试图找到缺少分配的概率并进行调整,通常是足够精确的。

然而,这些仅仅是近似值。有时我们会错过一些在我们的配置文件中具有较小分配的代码位置。有时真实分配比估计的略大或略小。

确保在 pprof 配置文件中检查 period 信息(详见“go tool pprof Reports”),并注意配置文件中的采样以得出正确的结论。不要因为您的基准分配数字与配置文件中的数字不完全匹配而感到惊讶和担忧。只有当我们获得周期等于一(100% 采样)的配置文件时,我们才能完全确定绝对数字。

在解释 pprof 标准的基础上,让我们看看可以用这种 .pprof 文件做些什么。幸运的是,我们有许多理解此格式并帮助我们分析分析数据的工具。

使用 go tool pprof 进行报告

有许多工具(和网站!)可以用来解析和分析 pprof 文件。由于其清晰的模式,您还可以轻松地编写自己的工具。然而,目前最流行的是 google/pprof 项目,它实现了 pprof CLI 工具。同样的工具也被Go 项目供应,这使得我们可以通过 Go CLI 使用它。例如,我们可以使用 go tool pprof -raw fd.pprof 命令以半人类可读格式报告所有 pprof 相关字段,如示例 9-3 中所示。

示例 9-3. 使用 Go CLI 的配置文件的原始调试输出
go tool pprof -raw fd.pprof
PeriodType: fd.inuse count
Period: 1 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) Time: 2022-07-29 15:18:58.76536008 +0200 CEST
Samples:
fd.inuse/count
        100: 1 2
         10: 1 3 4
          1: 5 4
          1: 6 4
Locations
1: 0x4b237b M=1 main.(*TestApp).open example/main.go:23 s=0
    main.(*TestApp).OpenTenFiles example/main.go:33 s=0
2: 0x4b25cd M=1 main.(*TestApp).Open100FilesConcurrently.func1 (...)
3: 0x4b283a M=1 main.main example/main.go:64 s=0
4: 0x435b51 M=1 runtime.main /go1.18.3/src/runtime/proc.go:250 s=0
5: 0x4b26f2 M=1 main.main example/main.go:60 s=0
6: 0x4b2799 M=1 main.(*TestApp).open example/main.go:23 s=0
    main.(*TestApp).OpenSingleFile example/main.go:28 s=0
    main.main example/main.go:63 s=0
Mappings
1: 0x400000/0x4b3000/0x0 /tmp/go-build3464577057/b001/exe/main  [FN]

1

-raw 输出目前是发现捕获配置文件时使用的最佳方式。使用 head 实用工具可以查看包含此信息的前几行,这对于大型配置文件特别有用,例如 go tool pprof -raw fd.pprof | head

原始输出可以显示关于配置文件中包含的数据的一些基本信息,并帮助创建 图 9-1 中的图表。但是,有更好的方法来分析更大的配置文件。例如,如果运行 go tool pprof fd.pprof,它将进入一个交互模式,允许您检查不同的位置并生成各种报告。我们不会在本书中涵盖此模式,因为现在有一种几乎可以完成交互模式所有工作的更好方式——Web 查看器!

运行 Web 查看器的最常见方式是通过 Go CLI 在您的机器上运行本地服务器。使用 -http 标志指定要侦听的地址和端口。例如,运行 go tool pprof -http :8080 fd.pprof ⁷ 命令将在浏览器中打开 Web 查看器网站⁸,显示在 示例 9-2 中获取的配置文件。您将看到的第一页是基于给定的 fd.pprof 配置文件渲染的有向图(参见“图形”)。但在那之前,让我们熟悉一下 Web 界面中提供的顶部导航菜单,如 图 9-2 所示⁹。

efgo 0902

图 9-2. pprof 网页界面上的顶部导航

从左侧开始,顶部灰色覆盖菜单有以下按钮和输入框:¹⁰

视图

允许您选择相同配置文件数据的不同视图(报告)。我们将在下面的小节中详细介绍所有六种视图类型。它们都从稍微不同的角度显示配置文件,并有其各自的目的;您可能会偏好不同的视图。它们是从位置层次结构(堆栈跟踪)生成的,可以从 图 9-1 的样本中重建。

样本

由于我们只有一种样本值类型(具有 count 单位的 fd.inuse 类型),所以此菜单选项在 图 9-2 中不可见,但对于具有更多类型的配置文件,样本菜单允许我们选择要使用的样本类型(一次只能使用一个)。这通常出现在堆配置文件中。

精化

此菜单仅在图形和顶部视图中有效(参见“图形”和“顶部”)。它允许将图形或顶部视图过滤到特定的兴趣位置:图中的节点和顶部表中的行。对于具有数百个或更多位置的非常复杂的配置文件,这一功能尤其有用。要使用它,请单击一个或多个图形节点或顶部表中的行以选择位置。然后点击“精化”,选择是要关注、忽略、隐藏还是显示它们。

“关注”和“忽略”控制通过所选节点或行的样本的可见性,允许您关注或忽略完整的堆栈跟踪。“隐藏”和“显示”仅控制节点或行的可见性,而不影响样本。

可以使用go tool pprof CLI中的 -focus 和其他标志来应用相同的过滤。此外,"REFINE" > "Reset" 选项会将我们带回非过滤视图,如果切换到不支持精细选项的视图,则仅持续 "Focus" 值。

当你想要查找特定代码路径的确切贡献时,"Focus" 和 "Ignore" 是非常有用的。另一方面,当你想要向某人展示图形或作为文档以获得更清晰的视图时,可以使用 "Hide" 和 "Show"。

如果你试图在脑中将代码与配置文件相关联,不要使用这些选项,因为你可能会在你的分析旅程初期容易混淆。

"CONFIG"

你从"REFINE"选项中使用的细化设置会保存在 URL 中。但是,你可以将这些设置保存到特殊的命名配置(以及用于图形视图的缩放选项)。点击"CONFIG" > "Save As …",然后选择你将要使用的配置。"Default" 配置的作用类似于 "REFINE" > "Reset"。配置保存在 <os.UserConfigDir>/pprof/settings.json 中。在我的 Linux 机器上,它位于 ~/.config/pprof/settings.json。如果你切换到其他视图,此选项也仅在 "Top" 和 "Graph" 视图上有效,并在自动切换到其他视图时更改为 "Default"。

DOWNLOAD

此选项下载了与go tool pprof中使用的相同配置文件。如果某人在远程服务器上公开了 Web 视图,并且你希望保存远程配置文件,则此选项非常有用。

搜索正则表达式

你可以使用RE2 正则表达式语法来搜索感兴趣的样本,可以通过位置的函数名称、文件名或对象名称进行设置 "Focus" 选项。在某些视图中,如 "Top"、"Graph" 和 "os.ReadFile",接口在你输入表达式时还会突出显示匹配的样本。

二进制名称和样本类型

在右上角是一个链接,带有所选的二进制名称和样本值类型。你可以点击此菜单项,打开一个小弹窗,快速查看关于配置文件、视图和正在运行选项的统计信息。例如,当你点击链接并启用一些 "REFINE" 选项时,可以看到 Figure 9-2 的内容。

在深入了解pprof工具中的不同视图之前,我们必须理解"Flat" 和 "Cumulative"(简称 "Cum")对于某些位置粒度的重要概念。

每个pprof视图显示一个或多个位置的 "Flat" 和 "Cumulative" 值:

  • "Flat" 表示某个节点对资源或时间使用的直接责任。

  • "Cumulative" 是直接间接贡献的总和。间接意味着这些位置并没有直接创建任何资源(或者在任何时候未被直接使用),但可能调用了一个或多个执行此操作的函数。

使用代码示例最好详细解释这些定义。让我们使用 示例 9-2 中的 main() 函数的一部分,这在 示例 9-4 中介绍。

示例 9-4. 示例 9-2 的片段解释了 Flat 和累积值
func main() { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    // ...

    f, _ := fd.Open("/dev/null") // TODO: Check error. ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    a.files = append(a.files, f) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

    a.OpenSingleFile("/dev/null")
    a.OpenTenFiles("/dev/null") ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

    // ...
}

1

分析紧密地与堆栈跟踪结合在一起,表示导致特定样本的调用序列,因此在我们的情况下是打开文件。然而,我们可以聚合通过 main() 函数的所有样本来学习更多。在这种情况下,main() 函数的打开文件的 Flat 数是 1,Cum 数是 12。这是因为在主函数中,我们直接只打开了一个文件(通过 fd.Open);¹¹ 其余文件是通过链式(后代)函数打开的。

2

根据我们的 fd.pprof 文件,我们发现此代码行的 Flat 值是 1,Cum 值是 1。它直接打开了一个文件,并且没有间接地增加任何文件描述符的使用。

3

append 不贡献任何样本。因此,任何样本不应包含此代码行。

4

调用 a.OpenSingleFile 方法的代码行的 Flat 值为 0,Cum 值为 1。同样,a.OpenTenFiles 方法的 Flat 值为 0,Cum 值为 10。在 CPU 执行此程序行的时刻,它们都没有创建(尚未)任何文件。

我觉得 Flat 和 Cum 的名称相当令人困惑,因此在进一步的内容中将使用直接和累积这两个术语。这两个数字有助于比较代码的哪些部分对资源使用(或使用时间)有贡献。累积数字帮助我们了解哪个流程更昂贵,而直接值告诉我们潜在瓶颈的来源。

让我们浏览不同的视图,看看如何使用它们来分析在 示例 9-2 中获得的 fd.pprof 文件。

Top

首先是 VIEW 列表上的 Top 报告,显示了按功能分组的每个位置的统计表。fd.pprof 文件的视图显示在 图 9-3 中。

efgo 0903

图 9-3. Top 视图按直接值排序

每一行代表单个功能的直接和累积的打开文件的贡献,正如我们从 示例 9-4 中学到的那样,聚合了该功能内一个或多个行的使用情况。这称为函数粒度,可以通过 URL 或 CLI 标志进行配置。

我们已经定义了 Flat 和 Cum 列的值所代表的含义。此视图中的其他列包括:

Flat%

行的直接贡献百分比,占程序总贡献的比例。在我们的案例中,99.11%的打开文件描述符是直接由open方法创建的(共 112 个中的 111 个)。

总和%

第三列是从顶部到当前流的所有直接值的百分比对总贡献的百分比。例如,前两行直接负责所有 112 个文件描述符。此统计数据允许我们将注意力集中在可能对我们的瓶颈分析最重要的函数上。

累积%

行的累积贡献百分比,占程序总贡献的比例。

当涉及到 goroutines 时要小心

在某些与 goroutines 相关的情况下,累积值可能会具有误导性。例如,图 9-3 表明runtime.main累计打开了 12 个文件。然而,从示例 9-2 中可以看出,它还执行了Open100FilesConcurrently方法,然后作为新的 goroutine 执行Open100FilesConcurrently.func1(匿名函数)。我期望在图形中从runtime.mainOpen100FilesConcurrently.func1有一个链接,并且runtime.main的累积值为 112。

问题在于 Go 中每个 goroutine 的堆栈跟踪始终是分开的。因此,我们不能确定哪个 goroutine 创建了哪个 goroutine,这一点在我们查看 Goroutine 中的 goroutine 分析时会更加清楚。在分析程序瓶颈时,我们必须牢记这一点。

名称和内联

位置的函数名以及编译期间是否内联。在示例 9-2 中,openOpenSingleFile都足够简单,编译器可以将它们内联到父函数中。您可以通过在pprof命令中添加-noinlines标志或在 URL 参数中添加?noinlines=t来表示内联后的二进制情况。看到内联之前的情况仍然推荐,以更轻松地映射源代码发生的情况。

我们 Top 表中的行排序顺序是按直接贡献排序的,但我们可以通过-cum标志改变为按累积值排序。我们还可以单击表中每个标题以触发此视图中不同的排序。

Top 视图可能是查找负责使用您正在分析的资源或时间的函数(或文件或行,取决于所选择的粒度)的最简单和最快的方法。缺点是它不告诉我们这些行之间的确切链接,这些链接可以告诉我们哪些代码流(完整的堆栈跟踪)可能触发了使用情况。对于这种情况,使用下一节中解释的图形视图可能是值得的。

图形

图形视图是打开pprof工具网页界面时首先看到的东西。这并非没有原因——如果我们能够将事物可视化,人类的工作效率会更高,而不是从文本报告中进行解析和可视化。这也是我最喜欢的视图,特别是对于来自不太熟悉的代码库获取的性能分析。

要渲染图形视图,pprof工具会根据提供的 DOT 格式的性能分析文件生成一个图形化的有向无环图(DAG)。然后我们可以使用 -dot 标志与 go tool pprof,并使用其他渲染工具将其渲染为我们想要的格式,如 -svg-png-jpg-gif-pdf。另一方面,-http 选项会生成一个临时的 .svg 格式图形,并从中启动网页浏览器。从浏览器中,我们可以在图形视图中看到 .svg 可视化,并使用之前解释的交互式“REFINE”选项:缩放、放大和通过图形移动。我们 fd.pprof 格式的示例图形视图显示在图 9-4 中。

efgo 0904

图 9-4. 示例 9-2 的图形视图,使用函数粒度

我喜欢这个视图的原因在于它清晰地表示了程序中不同执行部分在资源或时间使用方面的关系(层次结构)。虽然可能很诱人,但你不能移动节点。你只能使用“REFINE”选项来隐藏或显示它们。在节点上悬停还会显示完整的包名称或代码行。

此外,这个图的每个方面都有其含义,有助于找到最昂贵的部分。让我们来看看图的属性:

节点

每个节点代表当前打开文件的函数的贡献。这就是为什么节点文本的第一部分显示了 Go 包和函数(或方法)。如果我们选择不同的粒度,我们将看到代码行或文件。节点的第二部分显示了直接和累计值。如果这些值中的任何一个非零,我们会看到该值对总贡献的百分比。例如,在图 9-4 中,我们看到 main.main() 节点(右侧)确认了我们在示例 9-4 中找到的数字。使用 pprof,我们记录了该函数的 1 个直接贡献和 12 个累计贡献。颜色和大小也告诉我们一些信息:

  • 节点的大小表示直接贡献。节点越大,直接使用的资源或时间越多。

  • 边框和填充颜色代表累计值。正常颜色是金色。大的正累计数会使节点变为红色。接近零的累计值会使节点变灰。

边缘

每条边代表函数(文件或行)之间的调用路径。调用不需要是直接的。例如,如果您使用 REFINE 选项,可以隐藏在两者之间调用的多个节点,导致边显示间接链接。边上的值表示该代码路径的累计贡献。边旁边的 inline 词告诉我们,该边所指向的调用已内联到调用者中。其他特性也很重要:

  • 边的粗细表示路径的累计贡献。边越粗,使用的资源越多。

  • 颜色显示相同。通常边是金色的。较大的正值将边着色为红色,接近零则为灰色。

  • 虚线边表示删除了某些连接位置,例如因为节点限制。¹²

有些节点可能会被隐藏!

如果在图形视图中未看到资源分析中每次贡献,不要感到惊讶。正如我之前提到的,大多数配置文件是抽样的。这意味着从结果配置文件中可能会漏掉少量贡献的位置。

第二个原因是 pprof 视图中的节点限制。默认情况下,为了可读性,最多显示80 个节点。您可以使用 -nodecount 标志来更改此限制。

最后,-edgefraction-nodefraction 设置会隐藏那些直接贡献比例低于指定值的边和节点。默认情况下(默认),节点比例为 0.005(0.5%),边比例为 0.001(0.1%)。

理论放一边,我们从 pprof 图形视图中可以学到什么?这个视图非常适合了解效率瓶颈及其来源。从 图 9-4 我们可以立即看出,最大的累计贡献者是 Open100FilesConcurrently,它似乎是一个新的 goroutine,因为它与 runtime/main 函数没有连接。首先优化该路径可能是个不错的主意。最多打开文件来自 OpenTenFilesopen。这告诉我们,这是该资源效率的关键路径。如果某些新功能要求在每次 open 调用时创建额外的文件,我们将看到我们的 Go 程序打开的文件描述符数量显著增加。

Graph 视图是了解应用程序不同功能如何影响程序资源使用的绝佳方法。这对于具有大型依赖项的复杂程序尤其重要,这些依赖项并非您的团队创建。事实证明,误解依赖库的正确使用方法很容易。不幸的是,这也意味着会有很多您不认识或不理解的函数名或代码行。参见 图 9-5,取自我们在 “使用并发优化延迟” 中优化的 Sum

这个结果也证明了在不同粒度之间切换的技能的重要性。只需向 URL 添加 ?g=lines,就可以切换到行粒度——这比使用 -lines 标志重新打开 go tool pprof 效果更好。

efgo 0905

图 9-5. 来自 示例 10-10 的 Graph 视图的片段,使用行粒度

接下来是 pprof 工具的最新功能——Flame Graph 视图,许多 Go 社区成员喜欢使用。让我们深入了解。

Flame Graph

pprof 中的 Flame Graph 视图(有时也称为冰柱图)受到 Brendan Gregg 的 工作 的启发,最初专注于 CPU 分析。

Flame Graph 是一种可视化栈跟踪(也称为调用堆栈)的图表,显示为倒置的冰柱布局。Flame Graph 通常用于可视化 CPU 分析器的输出,其中通过采样收集栈跟踪。

Brendan Gregg,《炎图》“The Flame Graphs”

fd.pprof 渲染的 Flame Graph 报告显示在 图 9-6 中。

段的颜色和顺序通常不重要

这取决于渲染 Flame Graph 的工具,但对于 pprof 工具来说,颜色和顺序在这里都没有意义。段通常按位置名称或标签值排序。

efgo 0906

图 9-6. 示例 9-2 的 Flame Graph 视图,具有函数粒度

pprof 是原始 Flame Graph 的倒置版本,其中每个重要的代码流形成一个单独的冰柱。这里主要关注的属性是矩形段的宽度,代表了 Graph 视图中的节点——在我们的案例中是函数。方块越宽,贡献的累积值就越大。您可以悬停在单个段上查看它们的绝对值和百分比累积值。单击每个块以聚焦于给定的代码路径。

与其关注冰柱的高度,不如查看位于当前段上方的调用层次结构。这里重要的是宽度,而不是高度。

在某种程度上,Flame Graph 通常受到更高级工程师的青睐,因为它更加紧凑。它允许对系统的最大瓶颈进行实用的洞察。它立即显示每个代码路径贡献的所有资源的百分比。一眼就能看出,在图 9-6 中,我们可以立即知道没有任何互动,Open100FilesConcurrently.func1是主要的打开文件瓶颈,大约使用了 90%的资源。Flame Graph 也非常适合显示是否存在任何主要的瓶颈。在某些情况下,许多小的贡献者一起可能生成大量使用。Flame Graph 将立即告诉我们这种情况。请注意,类似于图 9-4 视图,它可能会从视图中删除许多节点。如果单击右上角的二进制名称,将显示删除的节点数。

我们讨论过的三个视图之一——Top、Graph 或 Flame Graph——应该是查找程序效率最大瓶颈的第一关键点。请记住关于抽样,切换粒度以获取更多信息,并首先关注最大的瓶颈。但是,还值得简要提及另外三个视图:Peek、Source 和 Disassemble。让我们在下一节中看一下它们。

Peek、Source 和 Disassemble

另外三个视图——Peek、Source 和 Disassemble——不受粒度选项影响。它们都显示位置的原始行或地址级别,特别适用于想要回到源代码并专注于喜欢的 IDE 内部代码优化的情况。

Peek 视图提供了类似于 Top 视图的表格。唯一的区别在于,每个代码行都显示所有直接调用者及其在 Call 和 Calls% 列中的使用分布情况。这在有许多调用者的情况下非常有帮助,您可以缩小贡献最大的代码路径。

我最喜欢的工具之一是 Source 视图。它显示程序源代码上下文中的确切代码行。此外,它还显示前后几行。不幸的是,输出没有排序,因此您必须使用之前的视图来了解要关注的函数或代码行,并使用搜索功能来专注于您想要的内容。例如,我们可以看到直接映射到我们代码中的Open100FilesConcurrently的直接和累积贡献,如图 9-7 所示。

efgo 0907

图 9-7. 示例 9-2 的源视图,专注于Open100FilesConcurrently搜索

对我而言,源视图中有些特别之处。直接映射到代码语句中的打开文件描述符、分配点、CPU 时间等,在你的源代码中给出了比在图 9-4 中看到的一堆框更大的理解和认识。对于标准库代码,或者当你提供一个二进制文件(如为 Disassemble 视图所述),你还可以点击一个函数来显示它的汇编代码!

当我们分析代码的“复杂性分析”时,源视图非常有用。如果你不能完全理解代码的某个部分使用资源的原因,我建议使用源视图。

最后,Disassemble 视图对于高级分析非常有用。它提供源视图,但是在汇编级别上(参见“汇编”)。它允许检查围绕有问题的代码的编译详细信息。此视图需要提供从与你采取配置文件的程序相同的源代码构建的二进制文件。例如,对于我的情况,通过路径使用go tool pprof -http :8080 pkg/profile/fd/example/main fd.pprof提供静态构建的二进制文件来获取fd.inuse文件。¹³

目前,没有机制会检查你是否使用了正确的程序二进制文件来分析你的配置文件。因此,结果可能会偶然正确,也可能完全错误。在错误情况下的结果是不确定的,因此请确保提供正确的二进制文件!

pprof 工具是确认你关于应用程序效率和可能问题原因的初始猜测的绝佳方式,以数据驱动的方式。在本节中你获得的技能的惊人之处在于,所述的文本和pprof配置文件的视觉表示不仅仅被原生的pprof工具使用。类似的视图和技术也被许多其他分析工具和付费供应商服务所采用,比如Polar SignalsGrafana PhlareGoogle ProfilerDatadog 的持续性分析Pyroscope 项目等等!

很可能你的 Go IDE¹⁴也支持渲染和收集pprof配置文件。使用 IDE 非常好,因为它可以直接集成到你的源代码中,并通过位置实现平滑导航。但是,我更喜欢像Parca 项目这样基于go tool pprofpprof工具的云项目,因为我们经常需要在宏基准测试级别上进行分析(参见“宏基准测试”)。

当格式和可视化描述完成后,让我们深入了解如何从你的 Go 程序中获取配置文件。

捕获分析信号

最近,我们开始将性能分析视为 第四种可观察性信号。这是因为性能分析在很多方面与第六章中讨论的其他信号(如指标、日志记录和跟踪)非常相似。例如,与其他信号类似,我们需要仪表化和可靠的实验来获取有意义的数据。

我们讨论了如何编写 “pprof 格式” 的自定义仪表化,我们将介绍 Go 运行时中常见的现有性能分析器。然而,仅仅能够获取关于程序各种资源使用情况的概要是不够的——我们还需要知道如何触发能为我们提供所需效率瓶颈信息的情况。

幸运的是,我们已经通过 “实验的可靠性” 和 “基准测试水平” 来解释了可靠的实验。性能分析实践旨在与我们的基准测试过程自然集成。这使得我们的 TFBO 循环中的实际优化工作流得以顺利进行(“高效开发流程”):

  1. 我们在所需级别(微观、宏观或生产)上执行基准测试,以确保程序的效率。

  2. 如果我们对结果不满意,我们可以重新运行相同的基准测试,并在实验期间或结束时捕获性能概要,以找出效率瓶颈。

始终开启性能分析

您可以设计您的工作流程,以避免需要重新运行基准测试以捕获性能概要。在 “微基准测试” 中,我建议在大多数 Go 基准测试中始终捕获您的性能概要。在 “持续性能分析” 中,您将学习如何在宏观或生产级别上持续进行性能分析!

拥有仪表化和正确的实验(重用基准测试)是很好的。但我们还需要学习如何触发并将所选仪表化的概要传输到您在 “go tool pprof 报告” 中学到的工具进行分析。

我们需要了解用于我们想要使用的性能分析器的 API。正如我们在 第六章 中学到的,类似于其他信号,我们通常有两种主要的仪表化类型:自动仪表化和手动仪表化。关于前一种模型,有很多方法可以在不添加一行代码的情况下获取有关我们的 Go 程序的性能概要!使用像 eBPF 这样的技术,我们可以对我们的 Go 程序的几乎任何资源使用进行仪表化。许多开源项目、初创公司或成熟供应商正在致力于使这个领域变得更加可访问和易于使用。

然而,所有事情都是一种权衡。eBPF 技术仍处于早期阶段,仅在 Linux 上工作。在不同的 Linux 内核版本和非平凡的可维护性成本之间存在一些可移植性挑战。通常它也是一个通用解决方案,无法像我们现在使用更多手动的进程内分析工具那样提供相同的可靠性和能力来提供语义化、应用级别的分析。最后,这是一本关于 Go 编程语言的书,所以我很想分享如何创建、捕获和使用本地进程内分析工具。

使用仪器的 API 取决于其实现。例如,你可以编写一个分析器,每分钟或每次发生某个事件时(例如,捕获特定的 Linux 信号时),将分析结果保存到磁盘上。然而,通常在 Go 社区中,我们可以概述三种主要的触发和保存分析结果的模式:

通过编程方式触发

大多数你会看到并且在 Go 中使用的分析器都可以手动插入到你的代码中,在你希望保存分析结果时进行。这就是我在 Example 9-2 中使用的方法,用于捕获我们在 “go tool pprof Reports” 中分析的 fd.pprof 文件。典型的接口具有类似于 WriteTo(w io.Writer) error 的签名,它会从程序运行开始记录的样本。然后,以 pprof 格式将分析结果写入你选择的写入器(通常是文件)。

一些性能分析工具在开始记录样本时会设置一个明确的起点。例如,CPU 分析器(参见 “CPU”)有一个类似于 StartCPUProfile(w io.Writer) error 的签名来启动周期,然后使用 StopCPUProfile() 来结束分析周期。

这种使用分析的模式非常适合在开发环境中进行快速测试或在微基准测试代码中使用(参见 “Microbenchmarks”)。然而,通常开发者不会直接使用它。相反,他们经常将其用作构建两种其他模式的基础:Go 基准测试集成和 HTTP 处理程序:

Go 基准测试集成

正如我在 Example 8-4 中提供的一个 Go 基准测试的示例命令,你可以通过在 go test 工具中指定标志来获取微基准测试中的所有标准分析结果。几乎所有在 “Common Profile Instrumentation” 中解释的分析都可以通过 -memprofile-cpuprofile-blockprofile-mutexprofile 标志进行启用。除非你想在特定时刻触发分析,否则不需要将自定义代码放入你的基准测试中。目前还不支持自定义分析。

HTTP 处理程序

最后,HTTP 服务器是在宏观和生产级别捕获程序配置文件的最常见方法。这种模式对于默认接受 HTTP 连接进行后端 Go 应用程序特别有用。因此,为了正常使用,建议添加专门的 HTTP 处理程序用于分析和其他监控功能(例如 Prometheus 的/metrics端点)。接下来我们将探讨这种模式。

标准 Go 库为所有使用pprof.Profile结构的分析器提供了 HTTP 服务器处理程序,例如我们的示例 9-1 分析器或任何在“Common Profile Instrumentation”中解释的标准配置文件。您可以将这些处理程序添加到您的http.Server中,如示例 9-5 中所示,通过几行代码即可在您的 Go 程序中进行设置。

示例 9-5. 使用自定义和标准分析器创建 HTTP 服务器
import (
    "net/http"
    "net/http/pprof"

    "github.com/felixge/fgprof"
)

// ...

m := http.NewServeMux() ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
m.HandleFunc("/debug/pprof/", pprof.Index) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
m.HandleFunc("/debug/pprof/profile", pprof.Profile) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
m.HandleFunc("/debug/fgprof/profile", fgprof.Handler().ServeHTTP) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

srv := http.Server{Handler: m}

// Start server...

1

Mux 结构允许在特定 HTTP 路径上注册 HTTP 服务器处理程序。导入_ "net/http/pprof"将默认在全局默认 Mux (http.DefaultServeMux) 中注册标准配置文件。然而,我始终建议创建一个新的空 Mux,而不是使用全局 Mux,以明确注册的路径。这就是为什么在我的示例中我手动注册它们的原因。

2

pprof.Index 处理程序公开一个根 HTML 索引页面,列出快速统计信息,并链接到使用pprof.NewProfile注册的分析器。示例视图如图 9-8 所示。此外,此处理程序将每个按名称引用的分析器转发到相应的位置;例如,/debug/pprof/heap将转发到堆分析器(参见“Heap”)。最后,此处理程序添加到注册的profile行下的cmdlinetrace处理程序的链接,这些处理程序提供了进一步的调试能力。

3

标准的 Go CPU 不使用pprof.Profile,因此我们必须显式注册该 HTTP 路径。

4

相同的配置文件捕获方法可以用于第三方分析器,例如称为fgprof的“Off-CPU Time”分析器。

efgo 0908

图 9-8. 从示例 9-5 中创建的服务器的 debug/pprof/ 路径提供的 HTML 页面

如果您忘记分析器使用的名称或您的 Go 程序中有哪些分析器,索引页面非常有用。请注意,我们的自定义示例 9-1 分析器也在列表中(fd.inuse,具有 165 个文件¹⁵),因为它是使用pprof.NewProfile创建的。对于不导入包含示例 9-1 中代码的fd包的程序,这个索引页面将缺少fd.inuse行。

一个漂亮的调试页面并非 HTTP 处理程序的主要目的。它们的基本优势在于人工操作员或自动化可以从外部动态捕获配置文件,在宏观测试、事故或正常生产运行的最相关时刻触发它们。根据我的经验,我发现通过 HTTP 协议使用配置文件工具有四种方式:

  • 您可以在可见的 HTML 页面中点击所需配置文件工具的链接,例如图 9-8 中的heap。这将打开http://<address>/debug/pprof/heap?debug=1链接,该链接会打印当前时刻每个堆栈跟踪样本的数量——一个简化的文本格式内存配置文件。

  • 移除debug参数将以pprof格式下载所需的配置文件;例如,在浏览器中访问http://<address>/debug/pprof/heap链接将下载“堆”中解释的内存配置文件到本地文件。然后,您可以使用go tool pprof打开此文件,如我在“go tool pprof 报告”中所解释的那样。

  • 您可以直接将pprof工具指向配置文件工具的 URL,避免手动下载文件的过程。例如,如果在我们的终端中运行go tool pprof -http :8080 http://<address>/debug/pprof/heap,我们可以打开一个内存配置文件的 Web 配置文件查看器。

  • 最后,我们可以使用另一个服务器定期将这些配置文件收集到专用数据库中,例如使用PhlareParca项目,在“连续分析”中有详细解释。

总结一下,使用您认为对分析的程序更方便的工具。在微服务架构中,性能分析对理解复杂生产应用程序的效率非常有帮助,因此捕获配置文件的 HTTP API 模式通常是我使用的。Go 基准测试分析可能是最有用的微观级别分析。提到的访问模式在 Go 社区中很常见,但这并不意味着您不能创新并编写适合您工作流程的捕获流程。

为了解释“go tool pprof 报告”中的视图类型,pprof格式和自定义配置文件工具,我创建了最简单的文件描述符配置文件工具(示例 9-1)。幸运的是,我们不需要编写我们自己的配置工具来获得对常见机器资源的强大分析。Go 自带了几种标准的配置文件工具,由社区和全球用户广泛维护和使用。另外,我还会提到开源社区中一个有用的额外配置工具。让我们在下一节详细介绍这些内容。

常见的配置文件工具

在第 4 和第五章中,我解释了我们优化的两个主要资源——CPU 时间和内存。我还讨论了这些资源如何影响延迟。初次接触整个领域可能会令人生畏,鉴于其复杂性和在“实验的可靠性”中的关注点。这就是为什么理解 Go 具有哪些常见的 profiling 实现以及如何使用它们至关重要的原因。我们将从堆 profiling 开始。

heap profile,有时也被称为alloc profile,提供了一种可靠的方法来找出堆上分配的内存的主要贡献者(在“Go 内存管理”中解释)。然而,类似于“内存使用”中提到的go_mem⁠stats_heap指标,它只显示在堆上分配的内存块,而不包括在栈上分配的内存或自定义的mmap调用。尽管如此,Go 程序内存的堆部分通常是最大的问题所在;因此,堆 profile 在我的经验中通常非常有用。

您可以使用pprof.Lookup​("heap").WriteTo(w, 0)将堆 profile 重定向到io.Writer,在 Go benchmark 中使用-memprofile,或通过调用/debug/pprof/heap URL 与处理程序,如示例 9-5 所示。¹⁶

内存 profiler 必须具备高效性才能实现实际目的。这就是为什么heap profiler 是经过抽样的,并且深度集成到负责分配值、指针和内存块的Go 运行时分配器流程中的原因(参见“值、指针和内存块”)。抽样可以通过runtime.MemProfileRate变量(或GODEBUG=memprofilerate=X环境变量)进行控制,定义为每分配 512 KB 堆上内存时记录一个 profile 样本的平均字节数。默认情况下,Go 在每分配 512 KB 堆上内存时记录一个样本。

您应该选择哪种内存 profiling 率?

我建议不要更改 512 KB 的默认值。对于大多数 Go 程序的实际瓶颈分析来说,这个值已经足够低,并且便宜,因此我们总是可以使用它。

对于更详细的分析数值或者优化关键路径上较小的分配大小,考虑将其更改为一字节以记录程序中的所有分配情况。然而,这可能会影响应用程序的延迟和 CPU 时间(这将在 CPU profile 中可见)。尽管如此,在对内存进行重点测试时可能会很有效。

如果单个函数中有多个分配,通常有必要以lines粒度分析堆 profile(在 Web 查看器中添加&g=lines URL 参数)。例如,e2e框架中labeler的一个堆 profile 示例(见“Go e2e Framework”)在图 9-9 中展示。

efgo 0909

图 9-9. alloc_space 维度和 lines 粒度中来自 示例 4-1 中 labeler Sum 代码的 heap 剖析的放大图形视图

heap 剖析的独特之处在于它有四种值(样本)类型,您可以在新的“样本”菜单项中选择。当前选定的值类型显示在右上角。每种类型在不同情况下都很有用:

alloc_space

在这种模式下,样本值表示自程序启动以来在堆上按位置分配的总字节数。这意味着我们将看到过去分配的所有内存,但大部分可能已经被垃圾收集释放了。

在这里看到巨大的值并不奇怪!例如,如果程序运行时间较长,一个函数每分钟分配 100 KB,那么在 30 天后就会达到约 411 GB。看起来很可怕,但在这 30 天内,同一个应用程序可能只使用了最多 10 MB 的物理内存。

总历史分配量在代码中非常有用,因为它总共分配了过去最大的字节数,这可能导致程序使用的最大内存量问题。即使某些位置的分配量很小但非常频繁,这可能是垃圾收集的影响(参见“垃圾收集”)。alloc_space 对于发现过去分配了大空间的事件也非常有用。

例如,在图 9-9 中,我们看到 bytes.Split 函数累积内存使用了 78.6%。这种知识在“优化内存使用”的示例中将非常有价值。正如我们在“Go 基准测试”中已经看到的那样,分配的次数远远超过数据集,因此必须找到一种更廉价的内存解决方案来拆分字符串为行。

重置累积分配

我们无法以编程方式重置堆剖析器,例如从某个时刻开始记录分配。

然而,正如您将在“比较和聚合分析”中学到的,我们可以执行像减去 pprof 值这样的操作。例如,我们可以在时刻 A 捕获堆剖析,然后 30 秒后在时刻 B 捕获,创建一个“delta”堆剖析,显示这 30 秒内发生了哪些分配。

Go pprof HTTP 处理程序还有一个隐藏功能。在捕获 heap 剖析时,您可以添加 seconds 参数!例如,使用示例 9-5,您可以调用 http://<address>/debug/pprof/heap?seconds=30s 来远程捕获一个增量堆剖析!

alloc_objects

类似于 alloc_space,该值告诉我们分配的内存块数,而不是实际空间。这主要用于找出由频繁分配引起的延迟瓶颈。

inuse_space

这种模式显示了堆上当前分配的字节—在每个位置上分配的内存减去释放的内存。这种值类型非常适用于当我们想要在程序特定时刻找到内存瓶颈的情况。¹⁷

最后,这种模式非常适合查找内存泄漏。在分析中,不断分配而从未释放的内存将突出显示。

查找内存泄漏的源头

heap 分析显示分配内存块的代码,而不显示当前引用这些内存块的代码(例如变量)。要发现后者,我们可以使用分析当前形成的堆的 viewcore 工具。然而,这并不是一件简单的事情。

相反,首先尝试静态分析代码路径,找出可能被引用的创建结构的位置。但即便如此,在下一节中,先检查 goroutine 分析非常重要。我们将在 “不要泄露资源” 中讨论这个问题。

inuse_objects

这个值显示了堆上当前分配的内存块(对象)的数量。这对于揭示堆上活跃对象的数量非常有用,这反映了垃圾收集工作量(参见 “垃圾收集”)。大部分 CPU 密集型垃圾收集工作在必须遍历堆中对象的标记阶段进行。因此,拥有的对象越多,分配可能带来的负面影响越大。

对于每一位关注程序效率的 Go 开发者来说,掌握 heap 分析技巧至关重要。集中精力分析对内存分配空间贡献最大的代码即可,不必担心与其他可观察工具所用内存不相关的绝对数值(参见 “内存使用”)。在更高的内存分析频率下,您只能看到静态重要的内存分配部分。

Goroutine

goroutine 分析器能展示当前运行的 goroutine 数量以及它们正在执行的代码。这包括所有等待 I/O、锁、通道等的 goroutine。这个分析器不会对这个性能分析进行抽样,除了 系统 goroutine 外,所有的 goroutine 都会被捕获。¹⁸

类似于 heap 分析,我们可以通过 pprof.Lookup("goroutine").WriteTo(w, 0) 将此分析重定向至 io.Writer,或在 Go 基准测试中使用 -goroutineprofile,或通过处理程序调用 /debug/pprof/goroutine URL,例如 示例 9-5。对于具有大量 goroutine 或者关注程序每 10 毫秒延迟的 Go 程序来说,捕获 goroutine 分析的开销可能非常显著。

goroutine 配置文件的关键价值在于让您了解大多数代码 goroutine 正在做什么。在某些情况下,您可能会惊讶于程序需要执行某些功能所需的 goroutine 的数量。看到执行相同操作的大量(可能是增加的)goroutine 可能表明存在内存泄漏。

请记住,正如在 图 9-3 中提到的,对于 Go 开发人员来说,按设计,新 goroutine 与创建它的 goroutine 之间没有链接。¹⁹ 因此,在配置文件中看到的根位置始终是调用 goroutine 的第一个语句或函数。

我们的 labeler 程序的示例图形视图在 图 9-10 中展示。我们可以看到 labeler 并没有做太多事情。在放大视图中,我们可以看到只有 13 个 goroutine,而且没有一个位置是应用逻辑,只有性能分析器 goroutine、信号 goroutine 和几个 HTTP 服务器 goroutine 在轮询连接字节。这表明也许服务器正在等待 TCP 连接以接收传入请求。

efgo 0910

图 9-10. 来自 示例 4-1 中 labeler Sum 代码的 goroutine 性能的放大图形视图

但是,图 9-10 让你意识到几个通常可以在 goroutine 视图中找到的常见函数:

runtime.gopark

gopark 是一个内部函数,它使 goroutine 等待状态,直到外部回调将其重新启动工作。基本上,这是运行时调度器在等待事情(例如通道通信、网络 I/O 或有时互斥锁)时暂停(park)goroutine 的一种方式。

runtime.chanrecvruntime.chansend

如名称所示,goroutine 在 chanrecv 函数中接收消息或等待要发送到通道的内容。类似地,在 chansend 中,如果正在发送消息或等待通道有缓冲空间,则也是如此。

runtime.selectgo

如果 goroutine 正在等待或检查 select 语句 中的情况,则会看到这一点。

runtime.netpollblock

netpoll 函数 将 goroutine 设置为等待,直到从网络连接接收到 I/O 字节。

如您所见,即使您是第一次在配置文件中看到它们,也很容易追踪函数的含义。

CPU

我们对 CPU 进行配置文件以找出使用 CPU 时间最多的代码部分。减少这一点可以减少运行程序的成本,并实现更轻松的系统可扩展性。对于 CPU 绑定的程序,减少一些 CPU 使用率也意味着减少延迟。

证明分析 CPU 使用率非常困难。首先,CPU 在单个时刻可以执行大量操作 —— CPU 时钟每秒可以执行数十亿次操作。理解所有这些周期在程序代码中的分布情况很难跟踪,而不会显著减慢速度。多 CPU 核心程序使这个问题变得更加困难。

在撰写本书时,Go 1.19 提供了集成到 Go 运行时的 CPU 分析器。任何 CPU 分析器都会增加一些开销,因此不能简单地在后台运行。我们必须显式地为整个进程启动和停止它。与其他分析器一样,我们可以通过 pprof.StartCPUProfile(w)pprof.StopCPUProfile() 函数在程序中编程地执行这些操作。我们可以在 Go 基准测试中使用 -cpuprofile 标志,或者使用 Example 9-5 中处理程序的 /debug/pprof/profile?seconds=<integer> URL。

CPU 分析有其开始和结束

如果 profile HTTP 处理程序不会立即返回响应,就像其他配置文件一样,不要感到惊讶!HTTP 处理程序将启动 CPU 分析器,并在 seconds 参数中提供的秒数(如果未指定,则为 30 秒)内运行,然后才返回 HTTP 请求。

当前的实现是大量抽样的。当分析器启动时,它会调度特定于操作系统的定时器以在指定速率中断程序执行。在 Linux 上,这意味着使用 settimertimer_create 来为每个操作系统线程设置定时器,在 Go 运行时中监听 SIGPROF 信号。该信号中断 Go 运行时,然后获取正在执行的该操作系统线程上的当前堆栈跟踪。然后将样本排入预分配的环形缓冲区,pprof 写入器每 100 毫秒从中提取一次。²⁰

当前的 CPU 分析速率硬编码²¹ 为 100 Hz,因此理论上每 10 毫秒 CPU 时间(而不是实时)将记录每个操作系统线程的一个样本。未来有 计划 使该值可配置。

尽管 CPU 性能分析是最流行的效率工作流之一,但解决这个问题是复杂的。对于典型情况,它会为您提供良好的服务,但并非完美。例如,在某些操作系统(如 BSD²²)上存在已知问题,在 某些特定情况 下存在各种不准确性。在未来,我们可能会看到这一领域的一些改进,目前正在考虑使用 基于硬件的性能监视单元(PMUs)新提案

展示了labeler的 CPU 时间分布的示例 CPU 配置文件显示在图 9-11 中。考虑到由于较低的采样率而产生的不准确性,函数粒度视图可能会得出更好的结论。

efgo 0911

图 9-11. 来自示例 4-1 中labeler Sum代码的 30 秒 CPU 配置文件的火焰图视图,以functions粒度显示。

CPU 配置文件包含两种值类型:

样本

样本值指示在该位置观察到的样本数量。

CPU

每个样本值代表 CPU 时间。

从图 9-11,我们可以看到,如果我们想要优化labeler Go 程序的 CPU 时间或延迟,我们需要专注于什么。从火焰图视图中,我们可以概述五个主要部分:

io.Copy

由负责从本地对象存储复制文件的代码使用的此函数占据了 22.6%的 CPU 时间。也许我们可以利用本地缓存来节省这部分 CPU 时间。

bytes.Split

这在示例 4-1 中拆分行占据了 19.69%,因此可能需要检查这个函数是否有办法将其拆分为工作量较小的行。

gcBgMarkWorker

此函数占据了 15.6%,这表明堆上有大量存活对象。目前,垃圾回收占用了一部分 CPU 时间。

runtime.slicebytetostring

它表明非常重要的 CPU 时间(13.4%)用于将字节转换为字符串。通过源代码视图,我能够追踪到num, err := ⁠strconv.Par⁠seInt(string(line), 10, 64)行。这揭示了一种直接从字节片段解析整数的简单优化方法。

strconv.ParseInt

此函数使用了 12.4%的 CPU。我们可能希望检查是否可以通过编写我们自己的解析函数来消除任何不必要的工作或检查(提示:可以)。

结果表明,即使不完全准确,这样的 CPU 配置文件也是有价值的。我们将在“优化延迟”中尝试上述优化方法。

脱 CPU 时间

通常被忽视的是,大多数典型的 goroutine 大多数时间都在等待工作而不是在 CPU 上执行。这就是为什么在寻求优化程序功能延迟时,我们不能只看 CPU 时间的原因。²³ 对于所有程序,尤其是 I/O 密集型程序,您的进程可能会花费大量时间在睡眠或等待上。具体来说,我们可以定义组成整个程序执行的四个类别,这些类别在图 9-12 中展示。

efgo 0912

图 9-12. 进程执行时间组成²⁴

第一个观察是总执行时间比墙上时间长,所以在执行该程序时实际时间已过去。这不是因为计算机可以以某种方式减慢时间;而是因为所有的 Go 程序都是多线程的(或者在 Go 中甚至是多 goroutine 的),所以总测量的执行时间总会比实际时间长。我们可以概述四个执行时间的类别:

CPU 时间

我们的程序主动使用 CPU 的时间,如 “CPU” 中解释的那样。

区块时间

互斥体时间,加上我们的进程花费在 Go 通道通信中等待的时间(例如 <-ctx.Done(),如在 “Go Runtime Scheduler” 中讨论的那样),所以所有的同步原语。我们可以使用 block 分析器来分析该时间。默认情况下未启用,因此我们需要通过设置非零块分析率来启用它,使用 runtime.SetBlockProfileRate(int) 指定每个阻塞事件样本花费的纳秒数。然后我们可以在 Go 中使用 pprof.Lookup,在 Go 基准测试中使用 -blockprofile,或者使用 /debug/pprof/block HTTP 处理程序来捕获 contentiondelay 值类型。

互斥体时间

在锁竞争上花费的时间(例如,在 sync.RWMutex.Lock 中花费的时间)。与块分析类似,默认情况下是禁用的,可以使用 runtime.SetMutexProfileFraction(int) 启用。分数指定应跟踪的 1/*<fraction>* 锁竞争。类似地,我们可以在 Go 中使用 pprof.Lookup,在 Go 基准测试中使用 -mutexprofile,或者使用 /debug/pprof/mutex HTTP 处理程序来捕获 mutexdelay 值类型。

未追踪的 off-CPU 时间

睡眠的 goroutine,等待 CPU 时间,I/O(例如来自磁盘、网络或外部设备)、syscalls 等都不会被任何标准的性能分析工具追踪。为了发现这种延迟的影响,我们需要使用下面解释的不同工具。

我们是否必须在 off-CPU 时间中测量或找到瓶颈?

程序线程花费了大量的时间在 off-CPU 上。这就是为什么你的程序变慢的主要原因可能不是它的 CPU 时间。例如,假设你的程序执行花费了 20 秒,但它在等待来自数据库的答案花费了 19 秒。在这种情况下,我们可能希望查看数据库中的瓶颈(或在我们的代码中减轻数据库的慢速),而不是优化 CPU 时间。

通常建议使用追踪来找出我们功能的墙上时间(延迟)中的瓶颈。特别是,分布式追踪使我们能够将优化重点缩小到请求功能流程中耗时最多的部分。Go 具有内置的 追踪仪器,但它只对 Go 运行时进行仪器化,而不是我们的应用程序代码。然而,我们讨论了与云原生标准兼容的基本追踪仪器,如 OpenTelemetry 来实现应用级追踪。

还有一个令人惊叹的分析器叫做全面 Go 分析器 (fgprof),专注于追踪 CPU 和非 CPU 时间。虽然官方尚未推荐,且存在已知的限制,但我发现它在分析我的 Go 程序时非常有用。fgprof的分析结果可以通过示例 9-5 中提到的 HTTP 处理器公开。关于labeler服务的fgprof分析示例见图 9-13。

efgo 0913

图 9-13. 从示例 4-1 中labeler服务的 30 秒fgprof分析的火焰图视图,以functions粒度呈现

从分析结果中,我们可以快速得出结论,即大部分时间,labeler服务只是在等待信号中断或 HTTP 请求!如果我们有兴趣提高labeler能够服务的最大请求速率,我们可以快速发现问题并非出现在labeler本身,而是测试客户端未能以足够快的速度发送请求²⁵。

总结一下,在本节中,我介绍了在 Go 社区中使用的最常见的分析器实现方式²⁶。还有很多像 Linux perf和基于eBPF的封闭盒监控分析器,但它们超出了本书的范围。我更喜欢我提到的这些,因为它们是免费的(开源!),明确的,相对容易使用和理解。

现在让我们看看在分析 Go 程序时我发现有用的一些较少为人知的工具和实践。

小贴士

有三个更高级但非常有用的分析技巧,我希望您也能了解。这些技巧帮助我更有效地分析软件瓶颈。让我们逐一了解!

分享分析结果

通常,我们不会单独进行软件项目开发,而是在一个更大的团队中,大家分享责任并相互审查代码。分享就是关怀,因此类似于“与团队(和未来的自己)分享基准测试”,我们应该专注于与团队成员或其他感兴趣的人分享我们的瓶颈结果和发现。

在典型的工作流程中,我们下载或检查多个pprof分析结果。理论上,我们可以使用描述性命名以避免混淆,并使用 Google Drive 或 Slack 等文件共享解决方案发送它们。然而,这往往很麻烦,因为接收者必须下载pprof文件并在本地运行go tool pprof来分析。

另一种选择是分享性能分析的截图,但我们必须选择部分视图,这对其他人可能不够清晰。也许其他人希望使用不同的视图或值类型来分析性能分析。也许他们想要找到采样率或将性能分析缩小到某些代码路径。仅凭一张截图,您将错过所有这些互动功能。

幸运的是,一些网站允许我们保存pprof文件以供他人或将来使用,并且可以在不下载配置文件的情况下进行分析。例如,Polar Signals 公司提供了完全免费的 pprof.me 网站,正是允许这样做的地方。您可以上传您的分析文件(请注意,它将被公开分享!)并与团队成员分享链接,他们可以使用常见的go tools pprof报告视图来进行分析(参见“go tool pprof Reports”)。我经常和团队一起使用这个工具。

连续性能分析

在开源生态系统中,连续性能分析可能是 2022 年最流行的话题之一。它意味着在每个配置的时间间隔内自动收集我们的 Go 程序的有用分析数据,而不是手动触发。

在许多情况下,效率问题发生在程序运行的远程环境中的某个地方。也许这是因为过去对某些难以复现的事件作出响应而发生的。连续性能分析工具允许我们始终保持性能分析“开启”,并回顾性地查看过去的性能分析数据。

假设您看到资源使用率增加了——比如 CPU 使用率。然后您进行一次性分析以尝试找出是什么导致了资源的增加。连续性能分析本质上就是一直在做这个。(...) 当您有了这些数据后,您可以比较一个进程版本的整个生命周期与新部署的版本。或者您可以比较两个不同的时间点。比如说有一个 CPU 或内存峰值。我们实际上可以了解到我们的进程在代码行级别有何不同。这非常强大,它是在可观察性方面已经有用的其他工具的扩展,但它为我们运行中的程序提供了不同的视角。

Frederic Branczyk,《Grafana 的大帐篷:与 Frederic Branczyk 谈连续性能分析》

连续性能分析作为云原生开源社区中的第四个可观察性信号出现,但它并不新。这个概念最早在 2010 年由 Gang Ren 等人的研究论文《“Google-Wide Profiling: A Continuous Profiling Infrastructure For Data Centers”》中引入,证明了连续性能分析可以连续地针对生产工作负载进行使用,而不会带来重大开销,并有助于提高效率优化在 Google 中的应用。

最近我们看到了使这项技术更易于获取的开源项目。我个人已经使用连续性能分析工具数年来分析我们的 Go 服务,并且非常喜欢它!

您可以快速设置使用开源Parca 项目的连续分析。在许多方面,它类似于Prometheus 项目。Parca 是一个单一的 Go 程序二进制文件,使用我们在“捕获分析信号”中讨论的 HTTP 处理程序定期捕获配置文件并将其存储在本地数据库中。然后我们可以搜索配置文件、下载它们,甚至使用嵌入的 tool pprof 作为查看器来分析它们。

您可以在任何地方使用它:在您的生产环境、远程环境或可能在云中或笔记本电脑上运行的宏基准测试环境中设置连续分析可能是有意义的。在微基准测试级别上可能没有意义,因为我们在可能的最小范围内运行测试,这可以为基准测试的完整持续时间进行配置(参见“微基准测试”)。

将连续分析添加到我们在 Example 8-19 中的 labeler 宏基准测试中,只需要几行代码和一个简单的 YAML 配置,如 Example 9-6 中所示。

Example 9-6. 在 Example 8-19 中的 labeler 创建和 k6 脚本执行之间启动连续分析容器
labeler := ...

parca := e2e.NewInstrumentedRunnable(e, "parca").
    WithPorts(map[string]int{"http": 7070}, "http").
    Init(e2e.StartOptions{
        Image: "ghcr.io/parca-dev/parca:main-4e20a666", ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        Command: e2e.NewCommand("/bin/sh", "-c",
          `cat << EOF > /shared/data/config.yml && \
    /parca --config-path=/shared/data/config.yml
object_storage: ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png) bucket:
    type: "FILESYSTEM"
    config:
      directory: "./data"
scrape_configs: ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png) - job_name: "%s"
  scrape_interval: "15s"
  static_configs:
    - targets: [ '`+labeler.InternalEndpoint("http")+`' ]
  profiling_config:
    pprof_config: ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png) fgprof:
        enabled: true
        path: /debug/fgprof/profile
        delta: true
EOF
`),
        User:      strconv.Itoa(os.Getuid()),
        Readiness: e2e.NewTCPReadinessProbe("http"),
    })
testutil.Ok(t, e2e.StartAndWaitReady(parca))
testutil.Ok(t, e2einteractive.OpenInBrowser("http://"+parca.Endpoint("http"))) ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)

k6 := ...

1

e2e 框架在容器中运行所有工作负载,因此我们为 Parca 服务器这样做。我们使用从官方项目页面构建的容器镜像。

2

Parca 服务器的基本配置有两部分。首先是对象存储配置:我们希望将 Parca 的数据库内部数据文件存储在何处。Parca 使用FrostDB 列存储来存储调试信息和配置文件。为了方便起见,我们可以使用本地文件系统作为最基本的对象存储。

3

第二个重要的配置是抓取配置,它允许我们将某些端点作为目标来捕获分析。在我们的情况下,我仅将 labeler HTTP 端点放在本地网络上。我还指定每 15 秒获取一次配置文件。对于始终开启的生产环境使用,我建议更大的间隔,例如一分钟。

4

像堆、CPU、goroutine 阻塞和互斥锁这样的常见配置默认启用。然而,我们必须手动允许其他配置,比如在“Off-CPU 时间”讨论的 fgprof 配置。

5

一旦 Parca 启动,我们可以使用 e2einteractive 包打开 Parca UI,在 k6 脚本完成期间或之后查看我们的配置文件的视图式演示。

由于持续的配置文件,我们无需等待我们的基准测试(使用k6负载测试器)完成——我们可以直接跳转到我们的 UI,每隔 15 秒查看配置文件,实时!持续配置文件的另一个好处是,我们可以从每个配置文件随时间采集的所有样本值的总和中提取指标。例如,Parca 可以为随时间变化的labeler容器的堆内存使用情况提供图表,从周期性的heap inuse_alloc配置文件中获取(在“内存使用”讨论)。结果显示在图 9-14 中,应该非常接近“内存使用”中提到的go_memstats_heap_total度量。

efgo 0914

图 9-14. Parca UI 结果的屏幕截图,显示了随时间变化的labeler 图 9-9inuse_alloc配置文件。

您现在可以点击图中的样本,代表获取配置文件快照的时刻。由于连续形式,您可以选择最感兴趣的时间,也许是内存使用最高的时刻!一旦点击,就会显示该特定配置文件的火焰图,正如图 9-15 所示。

efgo 0915

图 9-15. 当您从图 9-4 中点击特定配置文件时,Parca UI 的火焰图(在 Parca 中称为冰柱图)的屏幕截图。

Parca 的维护者决定在火焰图中使用与go tool pprof工具不同的视觉风格,详见“火焰图”。然而,与配置空间中许多其他工具一样,它使用相同的语义。这意味着我们可以使用从go tool pprof特定工具中获取的分析技能,与 Parca 等不同的 UI 进行分析。

在个人资料视图中,我们可以下载所选的pprof文件。我们可以按照“分享配置文件”,筛选视图或选择不同视图来共享配置文件。我们还可以看到一个火焰图,代表选定时间段堆中函数对活动对象的贡献。我们无法手动轻松捕获这些内容。在图 8-5 中,我捕获了有趣事件发生后的配置文件,因此我必须使用alloc_space来显示从程序启动开始的总分配量。对于长时间运行的进程,此视图可能会非常嘈杂,并显示我不感兴趣的情况。更糟糕的是,进程在某些事件后可能已重新启动,如紧急情况或 OOM。在重新启动后进行这样的堆配置文件将毫无意义。类似的问题也会出现在其他只显示当前或特定时刻的配置文件上,如 goroutine、CPU 或自定义文件描述符配置文件。

这就是连续剖析极其有用的地方。它允许我们在发生有趣事件时捕获剖析,因此我们可以快速跳转到 UI 并分析效率瓶颈。例如,在 图 9-15 中,我们可以看到 bytes.Split 函数当前在堆上使用最多内存。

连续剖析的开销

捕获按需剖析会给正在运行的 Go 程序带来一些开销。然而,定期捕获多个剖析会使这种开销在应用程序运行期间持续存在,因此请确保您的剖析工具不会导致效率低于预期水平。

尝试了解在您的程序中剖析的开销。标准的默认 Go 剖析器的目标是不会为单个进程增加超过 5% 的 CPU 开销。您可以通过更改连续剖析间隔或剖析的采样来控制它。在大规模部署中,仅对多个相同副本中的一个进行剖析以分摊收集成本也是非常有用的。(来源:oreil.ly/yAACa

在我们的 Red Hat 基础设施 中,我们始终以一分钟的间隔运行连续剖析,并保留几天的剖析数据。

总之,我建议对那些您知道未来可能需要持续提高效率的实时 Go 程序进行连续剖析。Parca 是一个开源示例,但还有其他项目或供应商²⁷可以实现相同功能。但要小心,剖析可能会让人上瘾!

比较和聚合剖析

pprof 格式还有一个有趣的特性。按设计,它允许对多个剖析进行某些聚合或比较:

减法剖析

您可以从另一个剖析中减去一个剖析。这对于减少噪音并缩小您关心的事件或组件范围非常有用。例如,您可以在您的 Go 程序的一个运行中加载测试同时进行一些 AB 事件时,获取一个堆剖析剖析。然后,您可以从仅与 B 事件加载测试的同一 Go 程序的第二个堆剖析中减去堆剖析,以检查纯粹从 A 事件中的影响。go tool pprof 允许您使用 -base 标志从一个剖析中减去另一个剖析,例如 go tool pprof heap-AB.pprof -base heap-B.pprof

比较剖析

比较类似于减法;与删除匹配的样本值不同,它提供剖析之间的负数或正数增量。这对于在优化前后测量特定函数的贡献变化非常有用。您还可以使用go tool pprof通过 -diff_base 进行剖析比较。

合并剖析

社区中不太为人知的是,你可以将多个配置文件合并成一个!合并功能使我们能够将代表当前情况的多个配置文件结合在一起。例如,我们可以将数十个短 CPU 配置文件合并成一个跨较长时间段的 CPU 工作总配置文件。或者,我们可以合并多个堆配置文件到表示多个时间点所有堆对象的聚合配置文件中。

go tool pprof 不支持此操作。然而,你可以编写自己的 Go 程序来执行此操作,使用 google/pprof/profile.Merge 函数

我之前并不经常使用这些机制,因为在使用 go tool pprof 工具时,会因为与多个本地 pprof 文件混淆而感到困惑。当我开始使用像 Parca 这样的更高级的分析工具时,情况就改变了。正如你在 图 9-14 中看到的,有一个比较按钮用于比较两个特定的配置文件,以及一个合并按钮,用于将聚焦时间范围内的所有配置文件合并成一个配置文件。有了用户界面,选择要比较或聚合的配置文件变得更加容易和直观!

总结

对于 Go 的空间分析可能是微妙的,但一旦掌握了基础知识,利用起来并不难。在本章中,我们详细讨论了从常见分析器,通过捕获模式和 pprof 格式,到标准可视化技术的所有分析方面。最后,我们涉及了像连续分析这样的高级技术,我建议尝试一下。

先分析,后提问

我建议在适合你每日优化工作流程的任何形式中使用分析。在你已经从程序中捕获了配置文件之后,才问像是什么导致了你的代码减速或高资源使用等问题。

我相信这并不是这个领域创新的终点。由于像 pprof 这样的常见高效分析格式可以在不同工具和分析器之间实现互操作性,我们将看到更多工具、用户界面、有用的可视化,甚至与 第六章 中提到的不同可观察信号相关联。

此外,更多 eBPF 档案正在开源生态系统中出现,使得跨编程语言的分析更加便宜和统一。因此,请保持开放态度,并尝试不同的技术和工具,找出对你、你的团队或你的组织最有效的方法。

¹ 症状是我们看到的由某些潜在情况引起的影响,例如,OOM 是 Go 程序需要比允许的内存更多时的一个症状。症状的问题在于它们经常看起来像是根本原因,但可能有一个潜在的瓶颈导致它们。例如,导致 OOM 的进程高内存使用看起来可能是根本原因,但如果是由于依赖项未能快速处理请求,则也可能只是其他问题的症状。

² 红鲱鱼是意外的行为,结果证明对我们调查的总体主题并不构成问题。例如,在调查我们请求的较高延迟时,看到应用程序中的调试日志“开始处理请求”,却几个小时内没有看到“完成请求”,可能令人担忧。通常事实证明我们期望的“完成”日志消息未被实现,或者我们在日志系统中遗漏了它。事物经常会误导我们;这就是为什么在我们需要快速找到问题时,应该保持清洁和明确,避免可观察性和程序流程误导我们。

³ 通常,跟踪不提供完整的堆栈跟踪,只提供最重要的功能。这是为了限制跟踪的开销和成本。

⁴ 或方法,但在 Go 中处理方式相同。特别是在本章中,我会经常使用术语函数,指的是 Go 函数和方法。

⁵ Go 社区已经建议在标准库中包含这样的分析工具。然而,目前 该想法被拒绝了,因为理论上您可以通过关注os.Open的分配来跟踪打开的文件。

⁶ 使用pprof.Profile,我们只能跟踪对象。我们无法对像过去对象创建、I/O 使用等高级事物进行分析。我们也不能定制生成的pprof文件中的内容,如额外的标签、自定义抽样、其他值类型等。这样的自定义分析需要更多的代码,但由于像github.com/google/pprof/profile这样的 Go 包,实现起来仍然相对容易。

:80800.0.0.0:8080的简写,因此监听您计算机上所有网络接口。

⁸ 要运行此命令或生成图形,您需要在计算机上安装graphviz工具

⁹ 此指南适用于来自 Go 1.19 的 web 界面。目前没有迹象表明它会改变,但pprof工具可能会在后续的 Go 版本中进行增强或更新。

¹⁰ 您还可以将鼠标悬停在每个菜单项上,三秒后会显示一个简短的帮助弹出窗口。

¹¹ 从性能分析的角度来看,直接(Flat)的贡献由工具实现来决定。我们在示例 9-1 中的自定义代码将fd.Open函数视为文件描述符打开的时刻。不同的性能分析实现可能会以不同的方式定义“使用”的时刻(分配时刻、CPU 时间使用、等待锁打开等)。

¹² REFINE 隐藏选项使得线条保持实心。

¹³ 目前在pprof中这个视图存在一些 bug。当你缺少二进制文件时,UI 显示no matches found for regexp:。搜索也无法使用,但你可以使用内置浏览器搜索找到你想要的内容(例如,使用 Ctrl+F)。

¹⁴ 例如,在VSCodeGoLand中的插件。

¹⁵ 趣事是,165 这个数字有些过高。制作这张截图让我意识到我在labeler代码中有一个 bug。我没有关闭临时文件。

¹⁶ 通过/debug/pprof/alloc也可以获得相同的分析数据。唯一的区别是alloc分析器将alloc_space作为默认值类型。

¹⁷ 不幸的是,由于我在负载测试完成后拍摄了快照,代码对堆的当前贡献空间是极小的,不代表过去发生的任何有趣事件。在“持续分析”中,你会看到这个值类型更有用。

¹⁸ 参见优秀的goroutine 分析器概述

¹⁹ 从技术角度讲,Go 调度器记录了这些信息。当使用GODEBUG=tracebackancestors=X检索堆栈时,它可以向我们公开。

²⁰ 查看关于潜在 CPU 分析器下一个迭代的提案以获取详细描述。

²¹ 从技术角度讲,有一种非常巧妙的方法可以设置不同的 CPU 分析率。你可以在pprof.StartCPUProfile(w)之前调用runtime.SetCPUProfileRate()来设置你想要的率。pprof.StartCPUProfile(w)会尝试覆盖这个率,但由于这个 bug,它将会失败。只有在确信自己知道在做什么时才更改率——通常 100 Hz 是一个不错的默认值。大于 250–500 Hz 的值大多数操作系统定时器都不支持。

²² 查看此问题了解目前已知的有特定问题的操作系统列表。

²³ 事实上,即使 CPU 时间包括等待内存获取,如“CPU 和内存墙问题”所述。这些内容已包含在 CPU 分析中。

²⁴ 这个视图深受Felix 的优秀指南的启发。

²⁵ 这可以在示例 8-19 代码中得到确认,在k6s脚本中只有一个用户在 HTTP 调用之间等待 500 毫秒。

²⁶ 我跳过了在 Go pprof包中存在的threadcreate性能分析,因为它已知自 2013 年以来存在问题,并且未来修复的优先级较低。

²⁷ Phlare, Pyroscope, Google Cloud Profiler, AWS CodeGuru Profiler, 或者 Datadog continuous profiler,仅举几例。

第十章:优化示例

现在终于是时候将你从前几章中收集的所有工具、技能和知识应用到一些优化上了!在本章中,我们将尝试通过一些示例来加强务实的优化流程。

我们将尝试优化 示例 4-1 的天真实现。我将向您展示如何应用 TFBO(来自 “效率感知开发流程”)到三组不同的效率要求中。

优化/悲观化并不能很好地概括。一切都取决于代码,因此每次都要测量,不要做绝对的评判。

Bartosz Adamczewski,Tweet(2022)

我们将把我们的优化故事作为下一章中总结的一些优化模式的基础。了解过去数千次优化案例并不是非常有用的。每个案例都是不同的。编译器和语言会改变,所以任何“蛮力”尝试逐个尝试这些数千种优化并不现实。¹ 相反,我专注于为您提供知识、工具和实践,让您能够找到更高效的问题解决方案!

请不要专注于特定的优化,例如我应用的具体算法或代码更改。相反,请试着跟随我是如何得出这些改变的,我是如何首先找到需要优化的代码片段,以及我如何评估这些改变。

我们将从 “求和示例” 开始介绍三个问题。然后我们将进行Sum并在 “优化延迟”、“优化内存使用” 和 “利用并发优化延迟” 中进行优化。最后,我们将提及一些其他方法,可以在 “奖励:打破思维定式” 中实现我们的目标。让我们开始吧!

求和示例

在 第四章 中,我们介绍了一个简单的 Sum 实现,在 示例 4-1 中对文件中提供的大量整数求和。² 让我们利用你所学的所有知识,并用它来优化 示例 4-1。正如我们在 “资源有效性要求” 中学到的那样,我们不能“只是”优化——我们必须有一些目标。在本节中,我们将三次重复效率优化流程,每次都有不同的要求:

  • 最多只使用一个 CPU 的低延迟。

  • 最小内存使用量

  • 即使有四个 CPU 核心可用于工作负载,延迟也更低。

“较低”或“最小”这样的术语并不太专业。理想情况下,我们有一些更具体的数字来作为目标,像 RAER 这样的书面形式。快速的大 O 分析可以告诉我们,Sum的运行时复杂度至少是 O(N) ——我们必须至少重新访问所有行一次来计算总和。因此,像“Sum必须快于 100 毫秒”这样的绝对延迟目标是行不通的,因为它的问题空间取决于输入。我们总是可以找到足够大的输入来违反任何延迟目标。

处理这个问题的一种方法是指定一些假设和延迟目标下的最大可能输入。第二种方法是定义一个取决于输入的运行时复杂度的函数 ——所以吞吐量。让我们选择后者,并为示例 4-1 中的Sum指定摊销的延迟函数。我们可以在内存上做同样的事情。所以让我们更具体些。想象一下,对于我的硬件,一个系统设计利益相关者为示例 4-1 中的Sum制定了以下所需的目标:

  • 每行最多 10 纳秒的延迟(10 * N 纳秒),最多使用一个 CPU

  • 像上面那样的延迟和任何输入的堆内存最多分配 10 KB

  • 每行最多 2.5 纳秒的延迟(2.5 * N 纳秒),最多使用四个 CPU

如果我们无法达到这个目标怎么办?

由于问题的低估、新的需求或新的知识,我们最初设定的目标可能很难实现。这没问题。在许多情况下,我们可以尝试重新协商目标。例如,正如我们在“优化设计级别”中详细讨论的那样,每一次超越某一点的优化在时间、精力、风险和可读性方面的成本都会越来越高,所以增加更多的机器、CPU 或 RAM 可能更便宜。关键是大致估计这些成本,并帮助利益相关者决定什么对他们最好。

按照 TFBO 流程,在优化之前,我们首先要进行基准测试。幸运的是,我们已经讨论过在“Go 基准测试”中为Sum代码设计基准测试,所以我们可以继续使用示例 8-13 进行我们的基准测试。我使用了示例 10-1 中呈现的命令,对一个包含 200 万个整数的文件执行了 5 个 10 秒钟的基准测试,并限制为 1 个 CPU。

示例 10-1. 调用基准测试的命令
export ver=v1 && go test -run '^$' -bench '^BenchmarkSum$' \
    -benchtime 10s -count 5 -cpu 1 -benchmem \
    -cpuprofile=${ver}.cpu.pprof -memprofile=${ver}.mem.pprof | tee ${ver}.txt

使用示例 4-1,前述基准测试产生了以下结果:101 毫秒,分配了 60.8 MB 空间,并且每次操作分配了 1.60 百万次内存。因此,我们将以此为基准。

优化延迟

我们的要求很明确。我们需要使示例 4-1 中的Sum函数更快,以达到至少 10 * N 纳秒的吞吐量。基准结果显示我们的基准值是 50 * N 纳秒。是时候看看是否有快速的优化方法了!

在 “复杂度分析” 中,我分享了 Sum 函数的详细复杂性,清楚地概述了问题和瓶颈。然而,我使用了这一部分的信息来定义。现在,让我们忘记我们讨论过这样的复杂性,并试图从头开始找到所有信息。

最好的方法是使用 第九章 中解释的配置文件执行瓶颈分析。我在每个基准测试中捕获了 CPU 配置文件,所以我可以快速带来 CPU 时间的 Flame Graph,正如 图 10-1 所示。

efgo 1001

图 10-1. Example 4-1 CPU 时间的 Flame Graph 视图,函数粒度

分析配置文件为我们提供了情况的概述。我们看到四个明确的主要 CPU 时间使用贡献者:

  • bytes.Split

  • strconv.ParseInt

  • 运行时函数 runtime.slicebytetostr...,以 runtime.malloc 结尾,这意味着我们花费了大量 CPU 时间来分配内存

  • 运行时函数 runtime.gcBgMarkWorker,表示 GC 运行

CPU 配置文件提供了一个函数列表,我们可以查看并可能削减一些 CPU 使用率。然而,正如我们在 “Off-CPU Time” 中学到的,这里的 CPU 时间可能并不是一个瓶颈。因此,我们必须首先确认我们的函数是 CPU 绑定、I/O 绑定还是混合型。

一种方法是通过手动阅读源代码来完成这项工作。我们可以看到,在 Example 4-1 中唯一使用的外部介质是文件,我们用它来读取字节。代码的其余部分应只使用内存和 CPU 进行计算。

这使得这段代码成为混合型任务,但是多混合?我们应该从文件读取优化还是 CPU 时间开始?

发现这一点最好的方法是数据驱动的方式。我们来检查 CPU 和 off-CPU 的延迟情况,这得益于完整的 goroutine 配置文件(fgprof),在 Example 8-13 的基准测试中,我快速地用 fgprof 配置文件包装了我们的基准测试。

示例 10-2. 使用 fgprof 进行 Go 基准测试
// BenchmarkSum_fgprof recommended run options:
// $ export ver=v1fg && go test -run '^$' -bench '^BenchmarkSum_fgprof' \
//    -benchtime 60s  -cpu 1 | tee ${ver}.txt ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
func BenchmarkSum_fgprof(b *testing.B) {
    f, err := os.Create("fgprof.pprof")
    testutil.Ok(b, err)

    defer func() { testutil.Ok(b, f.Close()) }()

    closeFn := fgprof.Start(f, fgprof.FormatPprof)
    BenchmarkSum(b) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    testutil.Ok(b, closeFn())
}

1

为了获得更可靠的结果,我们必须进行长达 60 秒的测量。让我们测量 60 秒以确保。

2

为了重复使用代码并提高可靠性,我们可以执行相同的 Example 8-13 基准测试,只是用 fgprof 配置文件包装起来。

在经过 60 秒后,生成的 fgprof.pprof 配置文件显示在 图 10-2 中。

efgo 1002

图 10-2. Example 4-1 CPU 和 off-CPU 时间的 Flame Graph 视图,函数粒度

完整的 goroutine 配置文件确认我们的工作负载是 I/O(5%³)和 CPU 时间(大部分)。因此,虽然我们必须担心某些时候由文件 I/O 引入的延迟,但我们可以先优化 CPU 时间。所以让我们继续专注于最大的瓶颈:几乎占用Sum CPU 时间 36%的bytes.Split函数,如图 10-1 所示。

一次优化一件事情

多亏了图 10-1,我们找到了四个主要瓶颈。然而,在我们的第一次优化中,我选择集中在示例 10-3 中的最大瓶颈。

重要的是一次进行一种优化。感觉比起现在尝试优化我们所知的一切要慢,但实际上更有效。每种优化可能会影响其他优化,并引入更多未知因素。我们可以得出更可靠的结论,例如比较配置文件之间的贡献百分比。此外,为什么消除四个瓶颈,如果优化首先可能足以满足我们的需求?

优化bytes.Split

要找出 CPU 时间花在bytes.Split中的地方,我们必须试着理解这个函数做什么以及如何做到的。根据定义,它根据可能的多字符分隔符sep将一个大字节切片分割成更小的切片。让我们快速查看图 10-1 的配置文件,并专注于使用Refine选项的该函数。这将显示bytes.Index,并使用makesliceruntime.gcWriteBarrierDX等函数影响分配和垃圾收集。此外,我们还可以快速查看用于bytes.SplitgenSplit的 Go 源代码,以检查它是如何实现的。这应该给我们一些警告信号。也许bytes.Split做的事情可能对我们的情况不必要:

  • genSplit首先通过切片计算我们预期的切片数

  • genSplit分配一个二维字节切片来放置结果。这很可怕,因为对于一个大的 7.2 MB 字节切片,有 200 万行,它将分配一个有 200 万元素的切片。内存配置文件确认这一行分配了大量的内存。⁴

  • 然后它将使用我们在配置文件中看到的bytes.Index函数迭代两百万次。这是我们将收集字节直到下一个分隔符的两百万次。

  • bytes.Split中的分隔符是多字符的,需要更复杂的算法。然而,我们需要一个简单的,单行的换行符分隔符。

不幸的是,这种对成熟标准库函数的分析可能对于初学者 Go 开发者来说有些困难。这些 CPU 时间或内存使用中哪些是过度的,哪些不是?

始终帮助我回答这个问题的是回到算法设计阶段,尝试设计适合 Sum 问题的最简单分割线算法。当我们了解一个简单而高效的算法可能是什么样子,并对其满意时,我们可以开始挑战现有的实现。结果表明,有一个非常简单的流程可能适用于 例子 4-1。让我们在 例子 10-3 中详细了解一下。

示例 10-3. Sum2 是优化了 bytes.Split 的 CPU 瓶颈的 例子 4-1。
func Sum2(fileName string) (ret int64, _ error) {
    b, err := os.ReadFile(fileName)
    if err != nil {
        return 0, err
    }

    var last int ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    for i := 0; i < len(b); i++ {
        if b[i] != '\n' { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
            continue
        }
        num, err := strconv.ParseInt(string(b[last:i]), 10, 64)
        if err != nil {
            return 0, err
        }

        ret += num
        last = i + 1
    }
    return ret, nil
}

1

我们记录了最后一个看到的换行符的索引,再加一,以告知下一行从哪里开始。

2

bytes.Split 相比,我们可以硬编码一个新行作为我们的分隔符。在一个循环迭代中,同时重用 b 字节切片,我们可以找到完整的行,解析整数并执行求和。这种算法通常也被称为“原地”。

在得出任何结论之前,我们必须首先检查我们的新算法是否在功能上工作正常。在成功通过单元测试验证后,我使用 Sum2 函数而不是 Sum 运行了 例子 8-13 来评估其效率。结果乐观,耗时 50 毫秒,分配了 12.8 MB。与 bytes.Split 相比,我们可以完成的工作少 50%,同时内存减少了 78%。知道 bytes.Split 负责约 36% 的 CPU 时间和 78.6% 的内存分配,这样的改进告诉我们我们已完全消除了代码中的这一瓶颈!

标准函数可能并非所有情况都完美适用

前述的工作优化示例问为什么 bytes.Split 函数对我们不够优化。难道 Go 社区不能优化它吗?

答案是 bytes.Split 和其他标准或自定义的从互联网上可能导入的函数可能不如专门为您的需求量身定制的算法高效。这样一个流行的函数首先必须对许多您可能没有的边缘情况(例如多字符分隔符)可靠。这些通常针对可能比我们自己更复杂的情况进行了优化。

这并不意味着我们现在必须重新编写所有导入的函数。不,我们只需要意识到通过为关键路径提供量身定制的实现,可能会轻松获得效率提升的可能性。尽管如此,我们仍应使用已知并经过战斗测试的标准库代码。在大多数情况下,它已经足够好了!

我们的 例子 10-3 优化是最终版本吗?并不完全是 —— 虽然我们提高了吞吐量,但我们仍处于 25 * N 纳秒的标记之外。

优化 runtime.slicebytetostring

例子 10-3 基准测试的 CPU 分析应该为我们提供下一个瓶颈的线索,显示在 图 10-3 中。

efgo 1003

图 10-3. 示例 10-3 CPU 时间的 Flame Graph 视图,具有函数粒度

作为下一个瓶颈,让我们看看这个奇怪的 runtime.slicebytetostring 函数,它在大部分 CPU 时间中用于分配内存。如果我们在 Source 或 Peek 视图中查找它,它会指向示例 10-3 中的 num, err := strconv.ParseInt(string(b[last:i]), 10, 64) 行。由于这段 CPU 时间贡献没有计入 strconv ParseInt(一个单独的部分),它告诉我们,在调用 strconv ParseInt 之前必须执行它,然而在同一行代码中。唯一动态执行的事情是 b 字节切片的子切片和转换为字符串。进一步检查时,我们可以看到这里的字符串转换是昂贵的。⁵

有趣的是,string 本质上是一个没有 Cap 字段(字符串的容量始终等于长度)的特殊字节切片。因此,起初可能会感到惊讶的是,Go 编译器在这方面花费了这么多时间和内存。原因在于,string(<byte slice>) 相当于创建一个具有相同元素数量的新字节切片,将所有字节复制到一个新的字节中,然后从中返回字符串。复制的主要原因是,按设计,string 类型是不可变的,因此每个函数都可以在不用担心潜在竞争的情况下使用它。然而,有一种相对安全的方法可以将 []byte 转换为 string。我们在示例 10-4 中来做这个。

示例 10-4. Sum3 是优化了 CPU 瓶颈的字符串转换的示例 10-3
// import "unsafe"

func zeroCopyToString(b []byte) string {
    return *((*string)(unsafe.Pointer(&b))) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
}

func Sum3(fileName string) (ret int64, _ error) {
    b, err := os.ReadFile(fileName)
    if err != nil {
        return 0, err
    }

    var last int
    for i := 0; i < len(b); i++ {
        if b[i] != '\n' {
            continue
        }
        num, err := strconv.ParseInt(zeroCopyToString(b[last:i]), 10, 64)
        if err != nil {
            return 0, err
        }

        ret += num
        last = i + 1
    }
    return ret, nil
}

1

我们可以使用 unsafe 包从 b 中删除类型信息并形成 unsafe.Pointer。然后我们可以动态地将其转换为不同的类型,例如 string。这是不安全的,因为如果结构体的布局不相同,可能会出现内存安全问题或者非确定性的值。然而,[]bytestring 之间的布局是共享的,所以对我们来说是安全的。它在许多项目中的生产环境中使用,包括被称为yoloString的 Prometheus。

zeroCopyToString 允许我们将文件字节转换为 ParseInt 所需的字符串,几乎没有额外开销。在功能测试后,通过与 Sum3 函数相同的基准测试,我们可以确认这一点。好处显而易见——对于 200 万个整数,Sum3 只需 25.5 毫秒,并且分配了 7.2 MB 的空间。这意味着在 CPU 时间上,它比示例 10-3 快了 49.2%。内存使用也更好,我们的程序几乎精确分配了输入文件的大小——既不多也不少。

明智的权衡

使用不安全的、零拷贝的字节到字符串转换,我们进入了一个有意的优化领域。我们引入了潜在的不安全代码,并为我们的代码增加了更多非平凡的复杂性。虽然我们明确地将我们的函数命名为 zeroCopyToString,但我们必须仅在必要时才能证明并使用这样的优化。在我们的情况下,它帮助我们达到了效率目标,因此我们可以接受这些缺点。

我们足够快了吗?还不够。我们的吞吐量接近于 12.7 * N 纳秒。让我们看看是否还能进一步优化。

优化 strconv.Parse

再次,让我们从 示例 10-4 的最新 CPU 分析中看到我们可以尝试检查的最新瓶颈,如 图 10-4 所示。

efgo 1004

图 10-4. 示例 10-4 的 Flame Graph 视图,显示函数粒度的 CPU 时间

使用 strconv.Parse 进行 72.6% 的时间,如果能改进其 CPU 时间,我们可以获得很多好处。与 bytes.Split 类似,我们应该检查其性能和实现。遵循这两条路径,我们可以立即勾画出一些感觉像是过度工作的元素:

  • 我们在 ParseIntParseUint 中都检查了空字符串两次。在我们的性能分析中,这两者都显示出非常显著的 CPU 时间。

  • ParseInt 允许我们以不同的基数和位大小解析整数。但我们不需要这种通用功能或额外的输入来检查我们的 Sum3 代码。我们只关心十进制的 64 位整数。

这里的一个解决方案类似于 bytes.Split:找到或实现我们自己的 ParseInt 函数,专注于效率——只做我们需要的事情,不多做。标准库提供了 strconv.Atoi 函数,看起来很有前途。然而,它仍然需要字符串作为输入,这迫使我们使用不安全包中的代码。相反,让我们尝试自己快速实现一个版本。经过几轮测试和微基准测试我的新 ParseInt 函数⁶,我们得到了我们的求和功能的第四个迭代,展示在 示例 10-5 中。

示例 10-5. Sum4 是带有优化的 CPU 瓶颈的字符串转换的 示例 10-4
func ParseInt(input []byte) (n int64, _ error) {
    factor := int64(1)
    k := 0

    if input[0] == '-' {
        factor *= -1
        k++
    }

    for i := len(input) - 1; i >= k; i-- {
        if input[i] < '0' || input[i] > '9' {
           return 0, errors.Newf("not a valid integer: %v", input)
        }

        n += factor * int64(input[i]-'0')
        factor *= 10
    }
    return n, nil
}

func Sum4(fileName string) (ret int64, err error) {
    b, err := os.ReadFile(fileName)
    if err != nil {
        return 0, err
    }

    var last int
    for i := 0; i < len(b); i++ {
        if b[i] != '\n' {
            continue
        }
        num, err := ParseInt(b[last:i])
        if err != nil {
            return 0, err
        }

        ret += num
        last = i + 1
    }
    return ret, nil
}

我们整数解析优化的副作用是,我们可以将我们的 ParseInt 适配为从字节片段解析,而不是从字符串解析。因此,我们可以简化我们的代码,避免使用不安全的 zeroCopyToString 转换。经过测试和基准测试,我们看到 Sum4 达到了 13.6 毫秒,比 示例 10-4 减少了 46.66%,同时具有相同的内存分配。我们的求和函数的全面比较呈现在 示例 10-6 中,使用我们喜爱的 benchstat 工具。

示例 10-6. 在一个两百万行文件上运行 benchstat,查看所有四个迭代结果
$ benchstat v1.txt v2.txt v3.txt v4.txt
name \ (time/op)  v1.txt       v2.txt       v3.txt       v4.txt
Sum                101ms ± 0%    50ms ± 2%   25ms ± 0%   14ms ± 0% ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png) name \ (alloc/op) v1.txt       v2.txt       v3.txt       v4.txt
Sum               60.8MB ± 0%  12.8MB ± 0%  7.2MB ± 0%  7.2MB ± 0%

name \ (allocs/op) v1.txt       v2.txt       v3.txt       v4.txt
Sum                1.60M ± 0%   1.60M ± 0%  0.00M ± 0%  0.00M ± 0%

1

请注意,benchstat 可以四舍五入一些数字,以便与v1.txt中的大数字进行比较。v4.txt 的结果是 13.6 毫秒,而不是 14 毫秒,这在吞吐量计算中可能有所不同。

看起来我们的辛勤工作有了回报。通过当前的结果,我们实现了 6.9 * N 纳秒的吞吐量,这已经足够实现我们的第一个目标。但是,我们只检查了 200 万个整数。我们能确定相同的吞吐量能够在更大或更小的输入大小下保持吗?我们的大 O 运行时复杂度 O(N) 表明可以,但为了确保,我也用 1000 万个整数运行了相同的基准测试。67.8 毫秒的结果给出了 6.78 * N 纳秒的吞吐量。这多少证实了我们的吞吐量数字。

示例 10-5 中的代码并不是可能的最快或最节省内存的解决方案。可能有更多的优化算法或代码以进一步改进。例如,如果我们分析示例 10-5,我们将看到一个相对较新的段,表明总 CPU 时间的 14% 被使用。这是os.ReadFile的代码,在过去的配置文件中不太显眼,因为有其他瓶颈和我们未进行优化的一些内容。我们将在“如果可以的话进行预分配”中提到其潜在优化。我们还可以尝试并发(我们将在“使用并发优化延迟”中进行)。但是,由于只有一个 CPU,我们不能在这里期望太多的收益。

重要的是,此迭代中无需改进其他任何内容,因为我们已经实现了我们的目标。我们可以停止工作并宣布成功!幸运的是,我们不需要在优化流程中添加魔法或危险的非可移植技巧。只需要可读性和更容易进行的刻意优化即可。

优化内存使用

在第二种情况下,我们的目标是在保持相同吞吐量的同时集中在内存消耗上。假设我们的软件有一个新的商业客户需要在一台只有少量 RAM 的 IoT 设备上运行具有Sum功能的程序。因此,要求具有流式算法:无论输入大小如何,它在任何时刻只能使用 10 KB 堆内存。

这种要求乍一看似乎有些极端,因为示例 4-1 中的天真代码具有相当大的空间复杂度。如果一个有 1000 万行、36 MB 大小的文件需要示例 4-1 的 304 MB 堆内存,那么我们如何确保同一个文件(或更大!)能最多使用 10 KB 的内存?在我们开始担心之前,让我们先分析一下我们在这个主题上可以做些什么。

幸运的是,我们已经做了一些优化工作,作为副作用改进了内存分配。由于延迟目标仍然适用,让我们从示例 10-5 中的 Sum4 开始,这符合目标。Sum4 的空间复杂度似乎在 O(N) 左右。它仍然取决于输入大小,并且远未达到我们的 10 KB 目标。

转向流算法

让我们从图 10-5 中拉取Sum4基准的堆配置文件,以找出我们可以改进的地方。

efgo 1005

图 10-5. 示例 10-5 的堆分配的火焰图视图,具有函数粒度 (alloc_space)

内存配置文件非常无聊。第一行在示例 10-5 中分配了 99.6% 的内存。我们基本上将整个文件读入内存,以便可以在内存中迭代字节。即使我们在其他地方浪费了一些分配,也无法看到,因为由于从 os.ReadFile 中过度分配而无法看到它。有什么办法可以解决这个问题吗?

在我们的算法中,我们必须遍历文件中的所有字节;因此,我们最终必须读取所有字节。但是,我们不需要一次将所有字节都读入内存。从技术上讲,我们只需要一个足够大的字节切片来容纳整数的所有数字以进行解析。这意味着我们可以尝试设计外部内存算法以按块流式传输字节。我们可以尝试使用标准库中的现有字节扫描器——bufio.Scanner。例如,示例 10-7 中的 Sum5 实现就使用它来扫描足够的内存以读取和解析一行。

示例 10-7. Sum5 是使用 bufio.Scanner 的示例 10-5
func Sum5(fileName string) (ret int64, err error) {
    f, err := os.Open(fileName) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    if err != nil {
        return 0, err
    }
    defer errcapture.Do(&err, f.Close, "close file") ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

    scanner := bufio.NewScanner(f)
    for scanner.Scan() { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        num, err := ParseInt(scanner.Bytes())
        if err != nil {
            return 0, err
        }

        ret += num
    }
    return ret, scanner.Err() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
}

1

不是将整个文件读入内存,我们在此打开文件描述符。

2

我们必须确保在计算完成后关闭文件,以防资源泄漏。我们使用 errcapture 在延迟关闭的文件中通知可能的错误。

3

扫描器的 .Scan() 方法告诉我们是否达到文件末尾。如果还有字节要导致分割,则返回 true。分割基于 .Split 方法中提供的函数。默认情况下,我们想要的是 ScanLines

4

不要忘记检查扫描器的错误!使用这样的迭代器接口,很容易忘记检查其错误。

为了评估效率,现在更专注于内存,我们可以使用相同的示例 8-13 来进行Sum5。然而,考虑到我们过去的优化,我们已经接近可以合理测量的限度,考虑到工具在处理百万行输入文件时的准确性和开销。如果我们陷入微秒级的延迟中,由于仪器的准确性和基准测试工具的开销限制,我们的测量结果可能会有所偏差。因此,让我们将文件增加到 1000 万行。对于这个输入,在示例 10-5 中的基准化Sum4每次操作结果为 67.8 毫秒,内存分配 36 MB。带有扫描器输出的Sum5为 157.1 毫秒和 4.33 KB 每次操作。

就内存使用而言,这是很好的。如果我们看看实现,扫描器分配了初始的 4 KB,并将其用于读取行。如果行更长,它会根据需要增加这个值,但是我们的文件没有超过 10 位数字的情况,所以保持在 4 KB。不幸的是,扫描器对于我们的延迟要求来说并不快速。与Sum4相比,减慢了 131%,我们达到了 15.6 N 纳秒的延迟,这太慢了。我们必须再次优化延迟,知道我们还有大约 6 KB 可以分配,以便在 10 KB 的内存目标内。

优化 bufio.Scanner

我们能改进些什么?像往常一样,现在是时候检查示例 10-7 的源代码和性能分析了,参见图 10-6。

efgo 1006

图 10-6. 示例 10-7 的 CPU 时间图表,具有函数粒度

标准库中Scanner结构的评论给了我们一个提示。它告诉我们“Scanner适用于安全、简单的工作”ScanLines在这里是主要的瓶颈,我们可以用一个更高效的实现来替换它。例如,原始函数去除了回车(CR)控制字符,这对我们来说浪费了循环,因为我们的输入中没有它们。我设法提供了优化的ScanLines,它将延迟提高了 20.5%,达到了 125 毫秒,这仍然太慢了。

类似于之前的优化,可能值得编写一个自定义的流式扫描实现,而不是使用bufio.Scanner。示例 10-8 中的Sum6提供了一个潜在的解决方案。

示例 10-8. Sum6是带有缓冲读取的示例 10-5。
func Sum6(fileName string) (ret int64, err error) {
    f, err := os.Open(fileName)
    if err != nil {
        return 0, err
    }
    defer errcapture.Do(&err, f.Close, "close file")

    buf := make([]byte, 8*1024) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    return Sum6Reader(f, buf)
}

func Sum6Reader(r io.Reader, buf []byte) (ret int64, err error) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    var offset, n int
    for err != io.EOF {
        n, err = r.Read(buf[offset:]) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        if err != nil && err != io.EOF { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
            return 0, err
        }
        n += offset ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)

        var last int
        for i := range buf[:n] { ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/6.png)
            if buf[i] != '\n' {
                continue
            }
            num, err := ParseInt(buf[last:i])
            if err != nil {
                return 0, err
            }

            ret += num
            last = i + 1
        }

        offset = n - last
        if offset > 0 {
            _ = copy(buf, buf[last:n]) ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/7.png)
        }
    }
    return ret, nil
}

1

我们创建了一个单一的 8 KB 字节缓冲区用于读取。我选择了 8 KB 而不是 10 KB,以留出在我们 10 KB 限制内的一些余地。8 KB 对于操作系统页面为 4 KB 来说也是一个不错的数字,所以我们知道它只需要 2 页。

此缓冲区假设没有大于~8,000 位的整数。我们可以将其缩小得多,甚至降至 10,因为我们知道我们的输入文件没有超过 9 位数字(加上换行符)。然而,由于下一步解释的某些浪费,这将使算法变得更慢。此外,即使没有浪费读取,由于开销,8 KB 也比 1,024 次读取 8 字节快得多。

2

这一次,让我们将便捷的io.Reader接口背后的功能分离出来。这将允许我们在未来重用Sum6Reader。⁷

3

每次迭代中,我们从文件中读取接下来的 8 KB,减去offset字节。我们从offset字节后开始读取更多的文件字节,以留出尚未解析的数字的空间。如果我们读取的字节将一些数字分成多个部分,比如我们分两个不同的块读取...\n1234/n...

4

在错误处理中,我们排除了io.EOF哨兵错误,这表示我们到达文件末尾。对于我们来说,这不是错误——我们仍然希望处理剩余的字节。

5

我们必须从缓冲区处理的字节数恰好是n + offset,其中n是从文件中读取的字节数。文件结束的n可能小于我们请求的长度(buf的长度)。

6

我们在buf缓冲区中迭代n字节。⁸请注意,我们不会在整个切片上进行迭代,因为在err == io.EOF的情况下,我们可能读取的字节数少于 10 KB,因此我们只需要处理其中的n字节。在每次循环迭代中,我们处理在我们的 10 KB 缓冲区中找到的所有行。

7

我们计算offset,如果需要,我们将剩余的字节移到前面。这会在 CPU 中创建少量的浪费,但我们不会额外分配任何东西。基准测试将告诉我们这是否可以接受。

我们的Sum6代码变得有些庞大和复杂,因此希望它能够通过效率测试来证明复杂性的合理性。确实,在基准测试之后,我们看到它需要 69 毫秒和 8.34 KB。以防万一,让我们通过计算更大的文件——1 亿行,来对 Example 10-8 进行额外测试。对于更大的输入,Sum6产生 693 毫秒和约 8 KB。这给出了 6.9 * N纳秒的延迟(运行时复杂度)和约 8 KB 的空间(堆)复杂度,这符合我们的目标。

仔细的读者可能仍然在想我是否漏掉了什么。为什么空间复杂度是 8 KB,而不是 8 + x KB?对于 1 千万行文件,会分配一些额外的字节,对于更大的文件则会分配更多的字节。我们如何知道在某个时刻对于百倍大小的文件,内存分配不会超过 10 KB?

如果我们对 10 KB 分配目标非常严格和严密,我们可以尝试弄清楚发生了什么。最重要的是验证,除了文件大小之外没有任何增长分配的东西。这次,内存分析也是无价的,但为了全面理解事物,让我们确保通过在我们的 BenchmarkSum 基准测试中添加 runtime.MemProfileRate = 1 来记录所有分配。生成的分析显示在 图 10-7 中。

efgo 1007

图 10-7. 示例 10-8 内存的火焰图视图,具有函数粒度和 1 的配置文件速率

我们可以看到 pprof 包中的分配比我们的函数更多。这表明了通过分析本身的较大分配开销!但是,这并不能证明 Sum 除了我们的 8 KB 缓冲区之外没有在堆上分配其他任何东西。源视图证明是有帮助的,显示在 图 10-8 中。

efgo 1008

图 10-8. 示例 10-8 内存的源视图,使用 1 的配置文件速率进行 1,000 次迭代和 10 MB 输入文件的基准测试后

它显示 Sum6 只有一个堆分配点。我们还可以在不使用 CPU 分析的情况下进行基准测试,这现在为任何输入大小稳定分配了 8,328 个字节的堆。

成功!我们的目标已达成,我们可以转向最后的任务。各次迭代的概述结果显示在 示例 10-9 中。

示例 10-9. 对来自所有 3 次迭代的结果运行 benchstat,使用 1 千万行文件
$ benchstat v1.txt v2.txt v3.txt v4.txt
name \ (time/op)   v4-10M.txt   v5-10M.txt    v6-10M.txt
Sum                67.8ms ± 3%  157.1ms ± 2%  69.4ms ± 1%

name \ (alloc/op) v4-10M.txt   v5-10M.txt    v6-10M.txt
Sum               36.0MB ± 0%    0.0MB ± 3%   0.0MB ± 0%

name \ (allocs/op)  v4-10M.txt   v5-10M.txt    v6-10M.txt
Sum                 5.00 ± 0%     4.00 ± 0%    4.00 ± 0%

使用并发优化延迟

希望您已经准备好迎接最后的挑战:将我们的延迟进一步降低到每行 2.5 纳秒的水平。这次我们有四个 CPU 内核可用,因此我们可以尝试引入一些并发模式来实现它。

在 “何时使用并发” 中,我们提到了在代码中使用异步编程或事件处理需要并发的明确需求。我们谈到了在我们的 Go 程序中进行大量 I/O 操作时可以轻松获得的性能提升。然而,在本节中,我愿意向您展示如何通过使用并发来改进我们在 示例 4-1 中 Sum 的速度,其中包含两个典型的陷阱。由于严格的延迟要求,让我们采用已经优化过的 Sum 的版本。鉴于我们没有任何内存需求,并且 示例 10-5 中的 Sum4Sum6 稍慢一些,但代码行数较少,让我们从那里开始。

一个天真的并发

如往常一样,让我们提取 Example 10-5 的 CPU 分析,显示在 图 10-9 中。

efgo 1009

Example 10-5 的 Graph 视图,显示了函数细粒度的 CPU 时间

正如你可能注意到的那样,Example 10-5 的大部分 CPU 时间来自于 ParseInt(47.7%)。由于我们又回到了程序开头对整个文件的读取,所以程序的其余部分严格受限于 CPU。因此,即使只有一个 CPU,我们也不能指望使用并发技术获得更好的延迟。然而,考虑到我们有四个 CPU 核心可用,我们现在的任务是找到一种方法,尽可能少地在 goroutine 之间协调,均匀地分割解析文件内容的工作。让我们探索三种优化 Example 10-5 的并发方法。

我们首先要做的事情是找到可以同时独立进行的计算——不会相互影响的计算。因为求和是可交换的,数字加法的顺序无关紧要。朴素的并发实现可以将整数从字符串中解析出来,并原子地将结果添加到共享变量中。让我们在 Example 10-10 中探索这个相当简单的解决方案。

例子 10-10. 对 Example 10-5 的朴素并发优化,每行为计算创建一个新的 goroutine
func ConcurrentSum1(fileName string) (ret int64, _ error) {
    b, err := os.ReadFile(fileName)
    if err != nil {
        return 0, err
    }

    var wg sync.WaitGroup
    var last int
    for i := 0; i < len(b); i++ {
        if b[i] != '\n' {
            continue
        }

        wg.Add(1)
        go func(line []byte) {
            defer wg.Done()
            num, err := ParseInt(line)
            if err != nil {
                // TODO(bwplotka): Return err using other channel.
                return
            }
            atomic.AddInt64(&ret, num)
        }(b[last:i])
        last = i + 1
    }
    wg.Wait()
    return ret, nil

在成功完成功能测试后,现在是进行基准测试的时候了。与之前的步骤类似,我们可以简单地将 Sum 替换为 ConcurrentSum1,并将 -cpu 标志更改为 4,以解锁四个 CPU 核心。不幸的是,结果并不太理想——对于 200 万行的输入,每个操作大约需要 540 毫秒和 151 MB 的分配空间!比更简单、非并发的 Example 10-5 多花了大约 40 倍的时间。

使用分发的工作方法

查看 图 10-10 中的 CPU 分析结果,以了解原因。

efgo 1010

Example 10-10 的 Flame Graph 视图,显示了函数细粒度的 CPU 时间

Flame Graph 明确显示了由 runtime.scheduleruntime.newproc 所指示的 goroutine 创建和调度开销。以下是 Example 10-10 太过朴素且在我们的情况下不推荐的三个主要原因:

  • 并发工作(解析和添加)速度太快,无法证明 goroutine 的开销(无论在内存还是 CPU 使用上)是值得的。

  • 对于较大的数据集,我们可能会创建数百万个 goroutine。虽然 goroutine 相对较便宜且我们可以有数百个,但鉴于只有四个 CPU 核心可执行,调度器可能会出现延迟问题。

  • 我们的程序的性能将是不确定的,这取决于文件中的行数。由于我们会像外部文件的行数一样生成多个 goroutine(这超出了我们程序的控制),因此可能会遇到无界并发的问题。

这不是我们想要的,所以让我们改进我们的并发实现。我们可以从这里尝试多种方法,但让我们试图解决我们注意到的三个问题。我们可以通过给每个 goroutine 分配更多的工作来解决问题一。由于加法也是可结合和累积的,我们可以将工作分组成多行,在每个 goroutine 中解析和添加数字,并将部分结果添加到总和中。这自动有助于解决问题二。分组工作意味着我们将调度更少的 goroutine。问题是,每组行的最佳数量是多少?两个?四个?一百个?

大部分答案可能取决于我们希望在我们的进程中使用多少 goroutine 和可用的 CPU 数量。还有第三个问题——无界并发。在这里的典型解决方案是使用工作模式(有时称为 goroutine 池)。在这种模式下,我们事先确定了一些 goroutine 的数量,并一次性调度它们。然后我们可以创建另一个 goroutine,它将工作均匀地分发。让我们看一个在 示例 10-11 中实现该算法的例子。你能预测这个实现会更快吗?

Example 10-11. 并发优化 示例 10-5 的方法,保持一组有限的 goroutine 来计算一组行。使用另一个 goroutine 进行行的分发。
func ConcurrentSum2(fileName string, workers int) (ret int64, _ error) {
    b, err := os.ReadFile(fileName)
    if err != nil {
        return 0, err
    }

    var (
        wg     = sync.WaitGroup{}
        workCh = make(chan []byte, 10)
    )

    wg.Add(workers + 1)
    go func() {
        var last int
        for i := 0; i < len(b); i++ {
            if b[i] != '\n' {
                continue
            }
            workCh <- b[last:i]
            last = i + 1
      }
        close(workCh) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        wg.Done()
    }()

    for i := 0; i < workers; i++ {
        go func() {
            var sum int64
            for line := range workCh { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
                num, err := ParseInt(line)
                if err != nil {
                    // TODO(bwplotka): Return err using other channel.
                    continue
                }
                sum += num
            }
            atomic.AddInt64(&ret, sum)
            wg.Done()
        }()
    }
    wg.Wait()
    return ret, nil
}

1

记住,发送方通常负责关闭通道。即使我们的流程不依赖它,始终在使用后关闭通道是一个好的实践。

2

要注意常见的错误。for _, line := range <-workCh 有时也会编译通过,看起来逻辑上没有问题,但是这是错误的。它将等待从 workCh 通道接收的第一个消息,并迭代接收到的字节片。相反,我们希望迭代消息。

测试通过,所以我们可以开始进行基准测试。不幸的是,平均而言,这个使用 4 个 goroutine 的实现每次完成一个操作需要 207 毫秒(使用 7 MB 空间)。仍然比简单的顺序执行的 示例 10-5 慢了 15 倍。

无协调的工作方法(分片)

这次有什么问题?让我们来研究一下 图 10-11 中展示的 CPU 分析报告。

efgo 1011

图 10-11. 示例 10-11 的火焰图视图,显示了 CPU 时间与函数粒度

如果您看到这样的性能分析,它应立即告诉您,并发开销再次过大。我们仍然看不到实际工作,比如解析整数,因为这项工作比开销多得多。这次开销是由三个元素引起的:

runtime.schedule

负责调度 goroutines 的运行时代码。

runtime.chansend

在我们的情况下,等待锁以发送到我们的单个通道。

runtime.chanrecv

chansend相同,但等待从接收通道读取。

因此,解析和添加比通信开销更快。基本上,协调和工作的分发比工作本身需要更多的 CPU 资源。

我们在这里有多种改进选项。在我们的情况下,我们可以尝试消除分发工作的努力。我们可以通过无需协调的算法来实现这一点,该算法将工作负载均匀分布到所有 goroutines 中。这是无需协调的,因为没有通信来协商分配给每个 goroutine 的工作的部分。由于文件大小是预先知道的,因此我们可以使用某种启发式方法将每个文件部分分配给每个 goroutine 工作者。让我们看看如何在示例 10-12 中实现这一点。

示例 10-12. 对示例 10-5 的并发优化,维护了一组有限的 goroutines,计算线组。线条被分片而无需协调。
func ConcurrentSum3(fileName string, workers int) (ret int64, _ error) {
    b, err := os.ReadFile(fileName)
    if err != nil {
        return 0, err
    }

    var (
        bytesPerWorker = len(b) / workers
        resultCh       = make(chan int64)
    )

    for i := 0; i < workers; i++ {
        go func(i int) {
            // Coordination-free algorithm, which shards
            // buffered file deterministically.
            begin, end := shardedRange(i, bytesPerWorker, b) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

            var sum int64
            for last := begin; begin < end; begin++ {
                if b[begin] != '\n' {
                    continue
                }
                num, err := ParseInt(b[last:begin])
                if err != nil {
                    // TODO(bwplotka): Return err using other channel.
                    continue
                }
                sum += num
                last = begin + 1
            }
            resultCh <- sum
        }(i)
    }

    for i := 0; i < workers; i++ {
        ret += <-resultCh
    }
    close(resultCh)
    return ret, nil
}

1

为了清晰起见,未提供shardedRange。此函数将输入文件的大小分割成bytesPerWorker(我们的情况下是四个)个片段,然后将第i个片段分配给每个工作器。您可以在这里查看完整的代码。

测试也通过了,所以我们确认了示例 10-12 在功能上是正确的。但是速度呢?是的!基准测试显示每个操作需要 7 毫秒和 7 MB,几乎比顺序执行的示例 10-5 快了一倍。不幸的是,这使我们的吞吐量达到了 3.4 * N 纳秒,远低于我们的目标 2.5 * N

流式分片工作器方法

让我们再次在图 10-12 中进行分析,看看是否可以轻松改进一些内容。

CPU 分析显示,我们的 goroutines 完成的工作占用了大部分 CPU 时间。然而,大约 10%的 CPU 时间用于读取所有字节,我们也可以尝试并发执行。乍一看,这项工作似乎并不看好。然而,即使我们去除所有 10%的 CPU 时间,10%的性能提升只能给我们带来 3.1 * N 纳秒的数字,还不够。

efgo 1012

图 10-12. 示例 10-12 的火焰图视图,以函数粒度查看 CPU 时间

然而,我们在这里必须保持警惕。正如您所想象的,读取文件并不是一个 CPU 绑定的工作,因此也许实际的实时 CPU 时间花费在占总 CPU 时间的 10%的os.ReadFile上,这使得它成为我们优化的更好选择。就像在“优化延迟”中所述,让我们进行一个带有fgprof分析的基准测试!结果的完整协程分析呈现在图 10-13 中。

efgo 1013

图 10-13. 示例 10-12 的火焰图视图完整协程分析与函数粒度

fgprof分析显示,如果我们尝试并发读取文件,可以显著减少延迟,因为当前实际时间大约耗费了 50%!这更具前景,所以让我们尝试将文件读取移动到工作协程中。示例实现在示例 10-13 中展示。

示例 10-13. 并发优化示例 10-12,同时使用单独的缓冲区从文件读取
func ConcurrentSum4(fileName string, workers int) (ret int64, _ error) {
    f, err := os.Open(fileName)
    if err != nil {
        return 0, err
    }
    defer errcapture.Do(&err, f.Close, "close file")

    s, err := f.Stat()
    if err != nil {
        return 0, err
    }

    var (
        size           = int(s.Size())
        bytesPerWorker = size / workers
        resultCh       = make(chan int64)
    )

    if bytesPerWorker < 10 {
        return 0, errors.New("can't have less bytes per goroutine than 10")
    }

    for i := 0; i < workers; i++ {
        go func(i int) {
            begin, end := shardedRangeFromReaderAt(i, bytesPerWorker, size, f)
            r := io.NewSectionReader(f, int64(begin), int64(end-begin)) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

            b := make([]byte, 8*1024)
            sum, err := Sum6Reader(r, b) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
            if err != nil {
                // TODO(bwplotka): Return err using other channel.
            }
            resultCh <- sum
        }(i)
    }

    for i := 0; i < workers; i++ {
        ret += <-resultCh
    }
    close(resultCh)
    return ret, nil
}

1

我们不是在内存中拆分输入文件的字节,而是告诉每个协程可以从文件中读取哪些字节。我们可以做到这一点,多亏了SectionReader,它返回一个只允许从特定部分读取的读取器。在shardedRangeFrom​Rea⁠derAt 中有一些复杂性,以确保我们读取所有行(我们不知道文件中的换行符在哪里),但可以使用这里提出的相对简单的算法来完成。

2

我们可以重用示例 10-8 来完成此任务,因为它知道如何使用任何io.Reader实现,所以在我们的示例中,既有*os.File也有*io.SectionReader

让我们评估该代码的效率。最终,在所有这些工作之后,示例 10-13 对于 200 万行操作每次仅需惊人的 4.5 毫秒,对于 1000 万行则为 23 毫秒。这将我们带入了~2.3 * N 纳秒吞吐量,这符合我们的目标!成功迭代的延迟和内存分配的全面比较呈现在示例 10-14 中。

示例 10-14. 对四次迭代结果运行benchstat,使用 200 万行文件
name \ (time/op)   v4-4core.txt  vc3.txt      vc4.txt
Sum-4              13.3ms ± 1%   6.9ms ± 6%   4.5ms ± 3%

name \ (alloc/op)  v4-4core.txt  vc3.txt      vc4.txt
Sum-4              7.20MB ± 0%   7.20MB ± 0%  0.03MB ± 0%

总结一下,我们通过三个展示不同目标优化流程的练习。我还有一些可能的并发模式,可以利用我们的多核机器。总的来说,我希望您能看到基准测试和分析在整个旅程中有多么关键!有时候结果可能会让您惊讶,因此请始终确认您的想法。

然而,有另一种创新解决这些练习的方式,可能适用于某些用例。有时,它使我们能够避免过去三个部分中进行的巨大优化工作。让我们来看看!

奖励:超越传统思维

鉴于本章设定的挑战性目标,我花了大量时间优化和解释在示例 4-1 中的原始 Sum 实现。这展示了一些优化思路、实践以及我在优化工作中使用的一般心理模型。但是艰苦的优化工作并不总是答案——达成目标的方式有很多。

例如,如果我告诉你有一种方法可以达到摊销的运行时复杂度仅为几纳秒,并且零分配(只需再加四行代码)?我们来看看示例 10-15。

示例 10-15. 向示例 4-1 添加最简单的缓存
var sumByFile = map[string]int64{} ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

func Sum7(fileName string) (int64, error) {
    if s, ok := sumByFile[fileName]; ok {
        return s, nil
    }

    ret, err := Sum(fileName)
    if err != nil {
        return 0, err
    }

    sumByFile[fileName] = ret
    return ret, nil
}

1

sumByFile 表示缓存的最简存储方式。还有大量生产读取缓存实现可供考虑。我们可以编写自己的实现,保证协程安全。如果需要更复杂的驱逐策略,我建议使用HashiCorp 的 golang-lru甚至更优化的Dgraph 的 ristretto。对于分布式系统,应使用像MemcachedRedis或对等缓存解决方案如groupcache的分布式缓存服务。

功能测试通过了,基准显示出惊人的结果——对于一亿行文件,我们看到了 228 纳秒和 0 字节的分配!当然,这个例子非常简单。我们的优化之旅不可能总是这么轻松。简单缓存是有限的,如果文件输入经常变化,则无法使用。但如果我们可以呢?

要聪明,不要愚蠢。也许我们不需要对示例 4-1 进行优化,因为相同的输入文件经常被使用。为每个文件缓存单个求和值是廉价的——即使我们有百万个这样的文件,我们也可以使用几兆字节的缓存全部缓存。如果不是这种情况,也许文件内容经常重复,但文件名是唯一的。在这种情况下,我们可以计算文件的校验和,并基于此进行缓存。这比将所有行解析为整数要快。

着眼于目标,要聪明和创新。例如,如果有一种聪明的解决方案可以避免艰苦、持续一周的深度优化工作,那么这种优化可能就不值得了!

总结

我们成功了!我们通过从“效率感知开发流程”中引入的 TFBO 流程优化了示例 4-1 最初的原始实现。在需求的指导下,我们成功地改进了 Sum 代码:

  • 我们将运行时复杂度从大约 50.5 * N 纳秒(其中 N 是行数)优化到了 2.25 * N。这意味着大约比之前快了 22 倍,尽管原始和大多数优化算法都是线性的(我们优化了 O(N) 的常数)。

  • 我们将空间复杂度从约 30.4 * N字节降低到 8 KB,这意味着我们的代码原本具有 O(N)的渐进复杂度,但现在具有常量空间复杂度。这意味着新的Sum代码对用户更加可预测,对垃圾收集器更加友好。

总之,有时效率问题需要一个漫长而仔细的优化过程,就像我们为Sum所做的一样。另一方面,有时候,你可以找到快速而实用的优化思路,迅速实现你的目标。无论如何,我们都从本章的练习中学到了很多(包括我自己!)。

让我们转向本书的最后一章,在这一章中,我们将总结我们在练习中看到的一些学习和模式,以及我从社区经验中看到的一些内容。

¹ 例如,我已经了解到一个strconv.ParseInt优化将在 Go 1.20 中推出,这将改变朴素的示例 4-1 的内存效率,而无需我进行任何优化。

² 如果你对我使用的输入文件感兴趣,请参见我用于生成输入的代码

³ 在图 10-2 中有一个小段显示ioutil.ReadFile的延迟,占所有样本的 0.38%。当我们展开ReadFile时,syscall.Read(我们可以假设是 I/O 延迟)占据 0.25%,考虑到sum.BenchmarkSum_fgprof占据了整体壁钟时间的 4.67%(其余由基准测试和 CPU 分析占据)。计算出来(0.25 * 100%)/ 4.67 等于 5.4%。

⁴ 我们可以进一步检查使用“Heap”性能分析,根据我的测试,每次操作的总 60.8 MB 分配中,有 78.6%被bytes.Split占用!

⁵ 我们可以从性能分析中的runtime.slicebytetostring函数名推断出这一点。我们还可以将这一行拆分为三行(第一行进行字符串转换,第二行进行子切片,第三行调用解析函数),并再次进行性能分析,以确保。

⁶ 在基准测试中,我还发现我的ParseInt对于Sum测试数据比strconv.Atoi快 10%。

⁷ 有趣的是,仅仅添加一个新的函数调用和接口,在我的机器上每次操作都会减慢程序 7%,证明我们已经处于非常高的效率水平。然而,考虑到可重用性,也许我们可以承受这种减速。

⁸ 有趣的是,如果我们将这行代码替换为技术上更简单的循环,比如 for i := 0; i < n; i++ {,代码会慢 5%!不要将其视为规则(始终测量!),因为这可能取决于你的工作负载,但看到没有第二个参数的 range 循环在这里更有效是很有趣的。

⁹ 我们在 “Go Runtime Scheduler” 中讨论了同步原语。

第十一章:优化模式

通过我们在过去 10 章学到的一切,现在是时候了解我在开发高效 Go 代码时发现的各种模式和常见陷阱了。正如我在第十章中提到的,优化建议并不通用。然而,考虑到此时此刻你应该知道如何有效评估代码变更,指出在某些情况下提高效率的常见模式是无害的。

成为一个审慎的 Go 开发者

记住,你在这里看到的大多数优化想法都是经过深思熟虑的。这意味着我们必须有充分的理由将它们添加进来,因为它们需要开发者花费时间来正确实现并在未来维护。即使你了解了一些常见的优化方法,也要确保它们能提高你特定工作负载的效率。

不要把这一章当作严格的手册,而是把它当作你没有考虑过的潜在选择列表。然而,始终坚持我们在前几章学到的可观察性、基准测试和性能分析工具,以确保你所做的优化是实用的,遵循YAGNI,并且是必要的。

我们将从“常见模式”开始,我将描述一些高级优化模式,这些模式可以从第十章的优化示例中看到。然后,我将向您介绍“三 R 优化方法”,这是 Go(和 Prometheus)社区的一个优秀的内存优化框架。

最后,在“不泄露资源”,“如果可能,预分配”,“使用数组时过度使用内存”和“内存重用和池化”中,我们将逐一介绍一套特定的优化、技巧和我在开始优化 Go 代码旅程时希望知道的陷阱!我选择了最常见的那些值得注意的。

让我们从常见的优化模式开始。其中一些我在之前的章节中使用过。

常见模式

如何找到优化?在进行基准测试、性能分析和代码研究之后,这个过程需要我们找出一个更好的算法、数据结构或代码,以便更高效地运行。当然,这说起来容易做起来难。

一些实践和经验是有帮助的,但我们可以概述一些在我们的优化旅程中重复出现的模式。现在让我们逐一介绍编程社区和文献中看到的四种通用模式:做更少的工作,以及为了效率而交换功能,为了时间而交换空间,为了空间而交换时间。

做更少的工作

我们应该首先关注避免不必要的工作。特别是在“优化延迟”中,通过删除大量不必要的代码,我们多次改进了 CPU 时间。这可能看起来过于简单,但这是我们经常忽视的强大模式。如果代码的某些部分非常关键并且需要优化,我们可以查看瓶颈(例如,在“go tool pprof 报告”中,我们在源代码视图中看到的大量贡献行)并检查我们是否可以:

跳过不必要的逻辑

我们能移除这行吗?例如,在“优化延迟”中,strconv.ParseInt 有很多检查,在我们的实现中并不需要。我们可以利用我们的假设和要求,削减不严格需要的功能。这还包括我们可以早期清理的潜在资源或任何资源泄漏(参见“不要泄漏资源”)。

通用实现

对于编程问题,使用通用解决方案非常诱人。我们经常训练自己发现模式,而编程语言提供了许多抽象和面向对象的范式来复用更多的代码。

正如我们在“优化延迟”中看到的,虽然bytes.Splitstrconv.ParseInt函数设计良好,使用安全,并且功能丰富,但它们可能并不总适合于关键路径。“通用”具有许多缺点,效率通常是首要受害者。

做一次事情

这已经完成了吗?也许我们已经在其他地方循环遍历了同一个数组,所以我们可以更多地“原地”处理,就像在示例 10-3 中所做的那样。

可能有些情况下,我们验证某个不变量,即使之前已经验证过它。或者我们“只是为了确保”再次排序,但当我们仔细检查代码时,它已经排序了。例如,在 Thanos 项目中,当合并不同的指标流时,我们可以进行“k 路归并”,而不是天真地合并并重新排序,因为每个流都以词典顺序提供指标,这是不变的。

另一个常见的例子是内存重用。例如,我们可以创建一个小缓冲区并重复使用它,就像在示例 10-8 中一样,而不是每次需要时都创建一个新的。我们还可以使用缓存或者“内存重用和池化”。

利用数学来少做一些工作

使用数学是减少我们需要做的工作的一个了不起的方法。例如,为了计算通过 Prometheus API 检索的样本数,我们不需要解码块并迭代所有样本来计数。相反,我们通过块的大小除以平均样本大小来估算样本数。

利用已有的知识或预计算的信息

许多 API 和函数被设计为智能并自动化某些工作,即使这意味着做更多工作。一个例子是预分配的可能性,在“如果可以的话,预分配”中讨论过。

在另一个更复杂的例子中,我们使用的minio-go对象存储客户端可以上传任意的 io.Reader 实现。然而,该实现在上传前需要计算校验和。因此,如果我们没有提供读取器中可用字节的总预期大小,minio-go 将使用额外的 CPU 循环和内存来缓冲整个,可能是几十亿字节大的对象。所有这些只是为了计算有时必须提前发送的校验和。另一方面,如果我们注意到这一点,并且已经有了总大小的信息,通过 API 提供这些信息可以显著提高上传效率。

这些元素看起来是专注于 CPU 时间和延迟,但我们可以将其用于内存或任何其他资源的使用。例如,在示例 11-1 中展示了“少做更多”意味着专注于更低内存使用的小例子。

示例 11-1. 使用空结构优化的判断切片中是否有重复元素的函数。使用“泛型”。
func HasDuplicatesT comparable bool {
    dup := make(map[T]any, len(slice))
    for _, s := range slice {
        if _, ok := dup[s]; ok {
            return true
        }
        dup[s] = "whatever, I don't use this value"
    }
    return false
}

func HasDuplicates2T comparable bool {
    dup := make(map[T]struct{}, len(slice))
    for _, s := range slice {
        if _, ok := dup[s]; ok {
            return true
        }
        dup[s] = struct{}{} ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    }
    return false
}

1

因为我们不使用 map 值,我们可以使用 struct{} 声明,它不使用内存。由于这个原因,我的机器上 HasDuplicates2 的速度快了 22%,而对于包含 100 万个元素的 float64 切片,内存分配减少了 5 倍。在我们不关心值的地方也可以使用相同的模式。例如,对于用于同步 goroutine 的通道,我们可以使用 make(chan struct{}) 来避免我们不需要的不必要空间。

通常,我们的程序中总是有减少一些工作的余地。我们可以利用分析来检查所有昂贵的部分及其与问题相关性,从而利用这一优势。通常我们可以删除或转换成更便宜的形式,从而提高效率。

要有战略眼光!

有时,现在做少量工作意味着以后会做更多工作或使用更多资源。我们可以对此进行战略性处理,并确保我们的本地基准测试不会忽略其他重要的权衡。这个问题在“内存重用和池化”中得到了突出展示,宏基准测试结果与微基准测试相反。

牺牲功能以换取效率

在某些情况下,为了提高效率,我们不得不谈判或移除某些功能。在“优化延迟”中,通过在文件中移除对负整数支持,我们可以改善 CPU 时间。在不需要这一要求的情况下,我们可以在示例 10-5 的 ParseInt 函数中移除对负号的检查!也许这个功能并不常用,可以用更便宜的执行来替代!

这也是为什么在项目中接受所有可能的功能通常不太可持续的原因。在许多情况下,额外的 API、额外的参数或功能可能会给关键路径带来显著的效率损失,如果我们仅将功能限制到最低限度,这些损失可以避免。¹

以空间换时间

如果我们通过减少不必要的逻辑、功能和泄漏来限制程序的工作,我们还能做些什么呢?通常,我们可以转向使用时间更少但在存储方面(如内存、磁盘等)花费更多的系统、算法或代码。让我们一起探讨一些可能的变更:²

预先计算结果

而不是每次计算相同昂贵的函数,我们可以尝试预先计算它并将结果存储在某个查找表或变量中。

如今,看到编译器适应这样的优化非常普遍。编译器通过交换编译器延迟和程序代码空间来实现更快的执行。例如,像 10*1024*102420 * time.Seconds 这样的语句可以由编译器预先计算,因此它们在运行时不必计算。

但是可能存在更复杂的函数语句情况,编译器无法为我们预先计算。例如,在某些条件下我们可以使用 regexp.Must​Com⁠pile("…​").MatchString(,这在关键路径上。也许在那些频繁使用的代码中,创建一个变量 pattern := regexp.Must​Com⁠pile("…​") 并在 pattern.MatchString( 上操作会更有效率。此外,某些加密算法提供了预计算方法,可以加速执行。

缓存

当计算结果严重依赖于输入时,为只有在偶尔使用的一个输入预先计算结果并不是很有帮助。相反,我们可以像我们在示例 4-1 中所做的那样引入缓存。编写我们的缓存解决方案是一项非常重要的工作,应该谨慎进行。³有许多缓存策略,其中最流行的是最近最少使用(LRU)策略,根据我的经验。

扩展数据结构

我们通常可以改变数据结构,使得某些信息可以更容易地访问,或者通过向结构中添加更多信息来实现。例如,我们可以将文件描述符旁边存储文件大小,而不是每次都查询文件大小。

此外,我们可以在我们的结构中保持元素的映射,这样在我们已有的切片旁边,我们可以更轻松地去重或查找元素(类似于我在示例 11-1 中所做的去重映射)。

解压缩

压缩算法非常适合节省磁盘或内存空间。然而,任何压缩——例如,字符串内部化、gzip、zstd等——都会带来一些 CPU(因此,时间)开销,因此当时间就是金钱时,我们可能希望摆脱压缩。但要小心,因为启用压缩可以提高程序的响应延迟,例如在慢网络中用于消息时。因此,为了减少消息大小,以便我们可以通过更少的网络数据包发送更多内容,可能会更快。

理想情况下,决策是经过深思熟虑的。例如,也许我们知道根据 RAERs,我们的程序仍然可以使用更多内存,但我们未能达到延迟目标。在这种情况下,我们可以检查是否有什么可以添加、缓存或存储的内容,以便在程序中花费更少的时间。

用时间换空间

如果在执行期间我们可以节省一些延迟或额外的 CPU 时间,但内存不足,我们可以尝试前面提到的相反规则,用空间换时间。这些方法通常完全与“用时间换空间”中的方法相反:压缩和编码更多内容、从结构体中删除额外字段、重新计算结果、移除缓存等。

用时间换空间或用空间换时间优化并不总是直观的。

有时为了节省内存资源的使用,我们必须先分配更多!

例如,在“使用数组过多的内存”和“内存重用和池化”中,我提到了一些情况,即使看起来更费力,分配更多内存或明确复制内存也更好。因此,从长远来看,这可以节省更多内存空间。

总之,考虑这四个通用规则作为可能优化的更高级模式。现在让我向您介绍“三 R”,这对我在效率开发任务中指导一些优化工作非常有帮助。

三 R 优化方法

三 R 技术是减少浪费的一种卓越方法。它通常适用于所有计算机资源,但通常用于生态目的以减少实际浪费。由于这三个要素——减少、重复使用和回收利用——我们可以减少对地球环境的影响,并确保可持续生活。

在 2018 年的FOSDEM上,我看到了Bryan Boreham 令人惊叹的演讲,他描述了如何使用这种方法来减轻内存问题。事实上,三 R 方法对抗内存分配尤为有效,这是内存效率和 GC 开销问题的最常见根源。因此,让我们探讨每个“R”组件及其如何帮助。

减少分配

直接影响节奏的尝试(例如,使用GOGCGOMEMLIMIT)与与收集器的同情无关。这确实是关于在每次收集之间或收集过程中完成更多工作。通过减少任何工作部分添加到堆内存的分配量或数量来影响它。

William Kennedy,《Go 中的垃圾收集:第一部分——语义》(https://oreil.ly/DVdNm)

几乎总有减少分配的空间——寻找浪费!我们的代码放在堆上的对象数量减少的一些方法显而易见(例如合理的优化,如我们在示例 1-4 中看到的切片预分配)。

然而,其他优化需要一定的权衡——通常需要更多的 CPU 时间或更不可读的代码,例如:

  • 字符串国际化,我们通过提供一个字典并使用一个更小、无指针的整数字典来避免对string类型进行操作,从而避免分配。

  • 不安全的转换(从[]bytestring(反之亦然),无需复制内存)可能会节省分配,但如果操作不当可能会在堆中保留更多内存(在示例 11-15 中讨论)。

  • 确保变量不逃逸到堆中也可以被认为是减少分配的一种努力。

减少分配方式有很多种。我们之前已经提到了一些。例如,当工作量减少时,通常可以减少分配!另一个提示是在所有优化设计层面上寻找减少分配的方法(“优化设计层次”),而不仅限于代码。在大多数情况下,算法必须首先改变,这样我们才能在移至代码层之前大幅提升空间复杂性。

内存重复使用

重复使用也是一种有效的技术。正如我们在“垃圾收集”中学到的那样,Go 运行时已经以某种方式重用了内存。尽管如此,还有一些方法可以显式地重用对象,如变量、切片或映射,以便在每次循环中重复操作而不是在每次循环中重新创建它们。我们将在“内存重用和池化”中讨论一些技术。

再次利用所有优化设计层次(参见“优化设计层次”)。我们可以选择重用内存的系统或算法设计;例如,请参阅“转向流式算法”。系统级别上“重用”的另一个优化示例是 TCP 协议。它提供保持连接以便重用,这也有助于建立新连接所需的网络延迟。

重复使用时要小心

诱人地将这个技巧理解字面上——许多人尝试尽可能地重用每一点东西,包括变量。正如我们在“值、指针和内存块”中学到的那样,变量是需要一些内存的盒子,但通常是在堆栈上,因此如果需要的话,我们不应该害怕创建更多的变量。相反,过度使用变量可能导致难以找到的错误,特别是当我们遮蔽变量时。

对复杂结构进行重复使用有时也非常危险,原因有两个:⁴

  • 在第二次使用之前重置复杂结构的状态通常并不容易(而不是分配一个新的结构,这会创建一个确定的空结构)。

  • 我们不能同时使用这些结构,这可能限制进一步的优化或使我们感到惊讶,并引发数据竞争。

回收

如果我们使用任何内存,回收是我们程序中必须具备的最小要求。幸运的是,在我们的 Go 代码中,我们不需要额外的东西,因为内置的 GC 负责将未使用的内存回收给操作系统,除非我们使用像“mmap Syscall”或其他非堆内存技术这样的高级工具。

然而,如果我们无法更少“减少”或“重复”更多内存,有时我们可以优化我们的代码或 GC 配置,以便垃圾收集的回收更有效。让我们来看看一些提高回收效率的方法:

优化分配对象的结构。

如果我们不能减少分配的数量,也许我们可以减少对象中指针的数量!然而,由于像timestringslices这样的常见结构包含指针,避免使用指针并不总是可能的。尤其是string看起来不像,但实际上它只是一个特殊的[]byte,这意味着它有一个指向字节数组的指针。在极端情况下,在特定条件下,将[]string改为offsets []intbytes []byte可能值得,以创建一个无指针的结构!

另一个普遍的例子是在实现被序列化到不同字节格式(如 JSON、YAML 或protobuf)的数据结构时,很容易生成指针密集的结构。诱人之处在于对嵌套结构使用指针,以允许字段的可选性(区分字段是否已设置)。一些代码生成引擎(如Go protobuf 生成器)默认将所有字段作为指针。对于较小的 Go 程序来说这没问题,但如果我们使用大量对象(这在网络消息中很常见),我们可能考虑尝试从这些数据结构中移除指针(许多生成器和序列化程序提供了这个选项)。

减少结构中指针的数量对于 GC 更好,并且可以使我们的数据结构更加 L1 缓存友好,减少程序的延迟。它还增加了编译器将数据结构放在堆栈而不是堆上的机会!

然而,主要的缺点是当你按值传递该结构时会有更多的开销(在 “Values, Pointers, and Memory Blocks” 中提到的复制开销)。

GC 调优

我在 “Garbage Collection” 中提到了两种 Go GC 的调优选项:GOGCGOMEMLIMIT

调整 GOGC 选项,将其从默认的 100% 值调整可能会有时对程序效率产生积极影响。根据需要,使下一次 GC 收集提前或延迟可能是有利的。不幸的是,需要大量基准测试来找到合适的数值。此外,不能保证这种调优在应用程序的所有可能状态下都能良好工作。而且,如果代码的关键路径经常变动,这种技术的可持续性很差。每次更改都需要进行另一次调优会话。这就是为什么像 Google 和 Uber 这样的大公司会投资于自动调整 GOGC 的工具,以在运行时自动进行调整!

GOMEMLIMIT 是在 GOGC 之上可以调整的另一个选项。这是一个相对较新的 GC 选项,用于在堆接近或超过期望的软内存限制时更频繁地运行 GC。

查看 更详细的 GC 调优指南,带有交互式可视化。

手动触发 GC 并手动释放 OS 内存

在极端情况下,我们可能希望尝试使用 runtime.GC() 手动触发 GC 收集。例如,在分配了大量内存并且不再引用它的操作之后,我们可能希望手动触发 GC。请注意,手动触发 GC 通常是一种强烈的反模式,特别是在库中,因为它具有全局效果。⁵

在堆外分配对象

我们提到尝试先在堆栈上分配对象而不是在堆上。但是堆栈和堆并不是我们唯一的选择。有方法可以在堆外分配内存,因此它不由 Go 运行时负责管理。

我们可以通过我们在 “mmap Syscall” 中学到的显式 mmap 系统调用来实现。一些人甚至尝试通过 CGO 调用像 jemalloc 这样的 C 函数。参见 使用 CGO 调用 C 函数像 jemalloc

虽然可能,我们需要认识到这样做可以与重新实现 Go 分配器的部分相比,更不用说处理手动分配和缺乏内存安全性。这是我们可能想尝试的最后一件事,以获得终极高性能的 Go 实现!

光明的一面是,这个领域正在不断改进。在撰写本书时,Go 团队批准并实施了一个令人兴奋的提案,背后是GOEXPERIMENT=arena环境变量。它允许从 GC 管理的堆之外的内存连续区域(arena)中分配一组对象。因此,我们将能够在需要时显式地隔离、跟踪和快速释放该内存(例如,在处理 HTTP 请求时),而不必等待或支付垃圾收集周期的费用。关于arena的特殊之处在于,它旨在在您意外使用以前未使用的内存时使程序崩溃,从而确保一定程度的内存安全性。一旦发布,我迫不及待地想开始使用它——这可能意味着更安全、更易于使用的离堆优化。

在尝试任何回收改进我们的生产代码之前,必须对所有这些优化的效果进行基准测试和测量。其中一些如果在没有进行广泛测试的情况下使用,可能被认为是棘手且不安全的。

总结一下,记住三个 R 方法,最好按照同样的顺序:减少,重用和回收。现在让我们深入了解一些我在实践中看到的常见 Go 优化。其中一些可能会让你感到意外!

不要泄漏资源

资源泄漏是降低我们 Go 程序效率的常见问题。当我们创建某些资源或后台 goroutine,并在使用后希望释放或停止它时,却意外地遗留下来。这在小规模上可能不明显,但迟早会变成一个难以调试的大问题。我建议无论如何都要清理自己创建的东西,即使你期望在下一个周期退出程序!⁶

“这个程序有内存泄漏!”

不是每一种更高的内存利用行为都可以被视为泄漏。例如,我们可能会在某些操作中“浪费”更多的内存,导致堆使用量的突增,但在某个时刻会被清除。

从技术上讲,泄漏只有当程序的负载相同时(例如,长时间运行的服务的相同数量的 HTTP 流量),我们使用无限量的资源(例如,磁盘空间,内存,数据库中的行数),最终耗尽时才会发生。

在泄漏和浪费的边缘存在意外的不确定性内存使用情况。这些有时被称为伪内存泄漏,我们将在“使用数组过多地消耗内存”中讨论其中一些。

或许我们可能会认为内存应该是这一规则的例外情况。堆栈内存会自动移除,并且 Go 中的垃圾回收动态地移除堆上分配的内存。⁷ 除了停止引用并等待(或触发)完整的 GC 循环外,没有办法触发内存块的清理。但是,不要被这个所欺骗。有很多情况下,Go 开发人员编写的代码会泄漏内存,尽管最终会进行垃圾回收!

我们的程序泄漏内存有几个原因:

  • 我们的程序频繁创建自定义的 mmap 系统调用,却从不关闭它们(或者关闭比创建慢)。这通常会导致进程或者机器的 OOM。

  • 我们的程序调用了太多嵌套函数,通常是无限或大型递归。我们的进程将因此出现堆栈溢出错误而退出。

  • 我们引用了一个长度很小的 slice,但忘记了它的容量非常大,如 “过度使用数组的内存” 中所解释的那样。

  • 我们的程序在堆上不断创建内存块,这些内存块总是被执行范围内的某些变量引用。这通常意味着我们泄漏了 goroutines 或者无限增长的 slices 或 maps。

当我们知道内存泄漏的位置时,修复起来很容易,但是要找出它们并不容易。通常是在应用程序崩溃后才会得知泄漏问题。如果没有像 “持续剖析” 中的高级工具,我们就必须希望通过本地测试来重现问题,但这并非总是可能的。

即使在过去的堆剖析中,在泄漏期间,我们只能看到分配内存块的代码中的内存,而看不到当前引用它的代码。⁸ 一些内存泄漏,特别是由泄漏的 goroutine 导致的,可以通过 goroutine 缩小范围来缩小,但并非总是如此。

幸运的是,一些最佳实践可以预防我们泄漏任何不可压缩的资源(例如磁盘空间、内存等),避免那种痛苦的泄漏分析。请将本节中的建议视为我们始终关注和合理优化的内容。

控制你的 Goroutine 的生命周期

每当在程序中使用 go 关键字启动 goroutine 时,都必须知道该 goroutine 将如何以及何时退出。如果你不知道答案,那就可能会导致内存泄漏。

Dave Cheney,《“不要在不知道如何停止它的情况下启动 goroutine”》(https://oreil.ly/eZKzr)

Goroutines 是一种优雅而干净的并发编程框架,但也有一些缺点。其中一个是每个 Goroutine 完全与其他 Goroutine 隔离(除非我们使用显式的同步范式)。在 Go 运行时中没有中央调度,我们可以调用它,并且例如要求关闭当前 Goroutine 创建的 Goroutines(甚至检查它创建了哪一个)。这不是框架成熟度的不足,而是允许 Goroutines 非常高效的设计选择。作为一种权衡,我们必须实现可能会在工作完成时停止它们的代码,或者更具体地说,停止 Goroutine 内部的代码(唯一的方法!)。

解决方案决不是创建一个 Goroutine 并将其留在那里没有严格的控制,即使我们认为计算速度很快。相反,在调度 Goroutines 时,考虑两个方面:

如何停止它们

我们应该始终问自己,Goroutine 何时会完成。它会自动完成,还是我必须使用上下文、通道等触发完成(如后续示例中的示例)?例如,如果请求被取消,我应该能够中止 Goroutine 的长时间执行吗?

我的函数应该等待 Goroutine 完成吗?

我的代码是否希望继续执行而不等待 Goroutines 完成?通常答案是否定的,您应该等待 Goroutine 停止,例如使用 sync.WaitGroup(例如在 示例 10-10 中)、errgroup 或优秀的 run.Group 抽象。

有许多情况下,感觉可以放心地让 Goroutines “最终” 停止,但实际上,不等待它们会带来危险后果。例如,考虑异步计算某些数字的 HTTP 服务器处理程序,参见 示例 11-2。

示例 11-2. 并发函数中常见泄漏的展示
func ComplexComputation() int { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    // Some computation...

    // Some cleanup...
    return 4
}

func Handle_VeryWrong(w http.ResponseWriter, r *http.Request) {
    respCh := make(chan int)

    go func() { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        defer close(respCh) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        respCh <- ComplexComputation()
    }()

    select { ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    case <-r.Context().Done():
        return ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
    case resp := <-respCh:
        _, _ = w.Write([]byte(strconv.Itoa(resp)))
        return
    }
}

1

小函数模拟较长的计算过程。想象一下,完成所有计算大约需要两秒钟。

2

想象一个调度异步计算的处理程序。

3

我们的代码不依赖于某人关闭通道,但作为良好的实践,发送者关闭它。

4

如果取消操作发生,则立即返回。否则,我们等待结果。乍看之下,上述代码看起来并不太糟糕。它似乎我们控制了调度 Goroutine 的生命周期。

5

不幸的是,细节隐藏在更多的信息中。我们只在一个好的情况下控制生命周期(即没有发生取消时)。如果我们的代码到达这一行,我们在这里做了一些不好的事情。我们无需关心 goroutine 生命周期而返回。我们没有停止它。我们没有等待它。更糟糕的是,这是一个永久泄漏,即带有ComplexCalculation的 goroutine 将被饿死,因为没有人从respCh通道读取。

虽然 goroutine 看起来受到控制,但并非在所有情况下都是如此。这种有漏洞的代码在 Go 代码库中很常见,因为需要非常详细的关注来不遗漏每一个小的边缘情况。由于这些错误,我们倾向于推迟在我们的 Go 中使用 goroutine,因为很容易出现这样的泄漏。

泄漏最糟糕的部分是,我们的 Go 程序可能会在很长时间内幸存下来,直到有人注意到这些泄漏的不良影响。例如,周期性运行Handle_VeryWrong并定期取消它最终会导致这个 Go 程序 OOM,但如果我们仅偶尔取消并定期重新启动我们的应用程序,如果没有良好的可观察性,我们可能永远不会注意到它!

幸运的是,有一个神奇的工具可以让我们在单元测试级别发现这些泄漏。因此,我建议在每个使用并发代码的单元(或测试文件)中使用泄漏测试。其中之一是来自 Uber 的goleak,其基本用法在示例 11-3 中展示。

示例 11-3. 在示例 11-2 代码中进行泄漏测试
func TestHandleCancel(t *testing.T) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    defer goleak.VerifyNone(t) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

    w := httptest.NewRecorder()
    r := httptest.NewRequest("", "https://efficientgo.com", nil)

    wg := sync.WaitGroup{}
    wg.Add(1)

    ctx, cancel := context.WithCancel(context.Background())
    go func() {
        Handle_VeryWrong(w, r.WithContext(ctx))
        wg.Done()
    }()
    cancel()

    wg.Wait()
}

1

让我们创建测试来验证取消行为。这是可能触发泄漏的地方。

2

要验证 goroutine 泄漏,只需在我们的测试顶部延迟goleak.VerifyNone。它会在测试结束时运行,并且如果有任何意外的 goroutine 仍在运行,则测试失败。我们还可以使用goleak.VerifyTestMain方法来验证整个包的测试。

运行这样的测试会导致测试失败,并输出示例 11-4 中的内容。

示例 11-4. 两次失败运行示例 11-3 的输出
=== RUN   TestHandleCancel
    leaks.go:78: found unexpected goroutines:
        [Goroutine 8 in state sleep, with time.Sleep on top of the stack:
        goroutine 8 [sleep]: ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        time.Sleep(0x3b9aca00)
           /go1.18.3/src/runtime/time.go:194 +0x12e
        github.com/efficientgo/examples/pkg/leak.ComplexComputation()
           /examples/pkg/leak/leak_test.go:107 +0x1e
        github.com/efficientgo/examples/pkg/leak.Handle_VeryWrong.func1()
           /examples/pkg/leak/leak_test.go:117 +0x5d
        created by github.com/efficientgo/examples/pkg/leak.Handle_VeryWrong
           /examples/pkg/leak/leak_test.go:115 +0x7d
        ]
--- FAIL: TestHandleCancel (0.44s)
=== RUN   TestHandleCancel
    leaks.go:78: found unexpected goroutines:
        [Goroutine 21 in state chan send, with Handle_VeryWrong.func1 (...):
        goroutine 21 [chan send]: ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        github.com/efficientgo/examples/pkg/leak.Handle_VeryWrong.func1()
           /examples/pkg/leak/leak_test.go:117 +0x71
        created by github.com/efficientgo/examples/pkg/leak.Handle_VeryWrong
           /examples/pkg/leak/leak_test.go:115 +0x7d
        ]
--- FAIL: TestHandleCancel (3.44s)

1

我们看到测试结束时仍在运行的 goroutine 以及它们正在执行的操作。

2

如果我们在取消后等待几秒钟,我们会发现 goroutine 仍在运行。但是,这次它正在等待从respCh读取,这永远不会发生。

这种边缘情况泄漏的解决方案是修复示例 11-2 代码。所以让我们看看在示例 11-5 中似乎解决了问题的两个潜在解决方案,但仍然以某种方式泄漏!

示例 11-5. (仍然)泄漏的处理程序。这次留下的 goroutine 最终会停止。
func Handle_Wrong(w http.ResponseWriter, r *http.Request) {
    respCh := make(chan int, 1) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    go func() {
        defer close(respCh)
        respCh <- ComplexComputation()
    }()

    select {
    case <-r.Context().Done():
        return
    case resp := <-respCh:
        _, _ = w.Write([]byte(strconv.Itoa(resp)))
        return
    }
}

func Handle_AlsoWrong(w http.ResponseWriter, r *http.Request) {
    respCh := make(chan int, 1)

    go func() {
        defer close(respCh)
        respCh <- ComplexComputationWithCtx(r.Context()) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    }()

    select {
    case <-r.Context().Done():
        return
    case resp := <-respCh:
        _, _ = w.Write([]byte(strconv.Itoa(resp)))
        return
    }
}

func ComplexComputationWithCtx(ctx context.Context) (ret int) {
    var done bool
    for !done && ctx.Err == nil {
        // Some partial computation...
    }

    // Some cleanup... ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    return ret
}

1

这段代码与示例 11-2 中的 HandleVeryWrong 唯一的区别在于我们创建了一个带有一个消息缓冲区的通道。这使得计算 goroutine 可以将一个消息推送到这个通道而不必等待某人读取它。如果我们取消并等待一段时间,那么“遗留”的 goroutine 最终将完成。

2

为了使事情更有效率,我们甚至可以实现一个接受上下文的 ComplexComputationWithCtx,当计算被取消且不再需要时取消它。

3

很多上下文取消的函数在上下文被取消时并不会立即结束。也许会定期检查上下文,或者需要一些清理来恢复取消的更改。在我们的情况下,我们用睡眠模拟清理等待时间。

示例 11-5 中的例子提供了一些进展,但不幸的是,它们仍然在技术上泄漏。在某些方面,泄漏只是暂时的,但它仍然可能由于以下原因导致问题:

未记账的资源使用。

如果我们为请求 A 使用 Handle_AlsoWrong 函数,然后 A 取消。结果,ComplexComputationHandle_AlsoWrong 完成后意外分配了大量内存—这将导致混淆情况。此外,所有可观察性工具将指示请求 A 完成后发生了内存峰值,因此会错误地认为请求 A 与内存问题无关。

会计问题可能对我们程序未来的可扩展性有重大影响。例如,想象一下,取消的请求通常需要 200 毫秒才能完成。这并不正确—如果我们考虑了所有计算,我们会看到它与,例如,1 秒钟的 ComplexComputation 清理延迟有关。在给定某些机器资源情况下,这种计算非常重要,用于预测特定流量的资源使用情况。

我们可能更快地耗尽资源。

这样的“遗留”goroutine 仍然可能导致 OOM,因为使用是非确定性的。连续运行和取消仍然会给人一种服务器准备调度另一个请求的印象,并继续添加泄漏的异步作业,这最终可能使程序饿死。这种情况符合泄漏的定义。

我们确定他们完成了吗?

此外,留下 goroutine 还会使我们无法了解它们运行多长时间以及在所有边缘情况下是否完成。也许有一个 bug 会使它们在某个时候被卡住。

因此,我强烈建议在您的代码中永远不要留下 goroutine。幸运的是,示例 11-3 将所有三个函数(Handle_VeryWrongHandle_WrongHandle_AlsoWrong)标记为泄漏,这通常是我们想要的。要完全修复泄漏,我们可以在我们的情况下始终等待结果通道,就像示例 11-6 中所示。

示例 11-6. 不会泄漏的 Example 11-2 版本
func Handle_Better(w http.ResponseWriter, r *http.Request) {
    respCh := make(chan int)

    go func() {
        defer close(respCh)
        respCh <- ComplexComputationWithCtx(r.Context())
    }()

    resp := <-respCh ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    if r.Context().Err() != nil {
        return
    }

    _, _ = w.Write([]byte(strconv.Itoa(resp)))
}

1

始终从通道读取使我们能够等待 goroutine 停止。我们还通过向 ComplexComputationWithCtx 传播适当的上下文尽快响应取消。

最后但并非最不重要的,当您进行并发代码基准测试时要小心。始终在每个 b.N 迭代中等待您定义为“一个操作”的内容。在 Example 11-7 中提供了解决方案的常见基准测试中的泄漏。

示例 11-7. 展示并发代码基准测试中常见的泄漏。
func BenchmarkComplexComputation_Wrong(b *testing.B) { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    for i := 0; i < b.N; i++ {
        go func() { ComplexComputation() }()
        go func() { ComplexComputation() }()
    }
}

func BenchmarkComplexComputation_Better(b *testing.B) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    defer goleak.VerifyNone(
        b,
        goleak.IgnoreTopFunction("testing.(*B).run1"),
        goleak.IgnoreTopFunction("testing.(*B).doBench"),
    ) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

    for i := 0; i < b.N; i++ {
        wg := sync.WaitGroup{}
        wg.Add(2)

        go func() {
            defer wg.Done()
            ComplexComputation()
        }()
        go func() {
            defer wg.Done()
            ComplexComputation()
        }()
        wg.Wait()
   }
}

1

假设我们想要对并发的 ComplexComputation 进行基准测试。调度两个 goroutine 可能会发现一些有趣的减速,如果这些函数之间共享任何资源。然而,这些基准测试结果完全错误。我的机器显示 1860 ns/op,但如果仔细观察,我们会发现我们没有等待任何这些 goroutine 完成。因此,我们只测量了每个操作调度两个 goroutine 所需的延迟。

2

要测量两个并发计算的延迟,我们必须等待它们的完成,也许使用 sync.WaitGroup。这个基准测试显示了一个更加现实的 2000339135 ns/op(每个操作两秒)的结果。

3

我们还可以在我们的基准测试中使用 goleak 来验证泄漏!然而,由于这个 问题,我们需要一个特定于基准测试的过滤器。

总之,控制你的 goroutine 生命周期,以确保当前和未来的可靠效率!确保 goroutine 生命周期作为合理的优化。

可靠地关闭事物

这可能是显而易见的,但是如果我们创建了某个对象,该对象在使用后应该关闭,我们应该确保不要忘记或忽视这一点。如果我们创建了某个 struct 的实例或使用了某个函数,并且我们看到某种“closer”,例如:

如果我们遇到这种情况,请假设最坏的情况:如果在使用对象后没有调用该函数,会发生糟糕的事情。某些 goroutine 将无法完成,某些内存将被保持引用,或者更糟的是,我们的数据将无法保存(例如os.File.Close()的情况)。我们应该尽量保持警惕。当我们使用新的抽象时,应检查它是否有任何关闭器。不幸的是,没有 linter 会指出我们是否忘记调用它们。¹⁰

不幸的是,这还不是全部。我们不能只是推迟对Close的调用。通常,它还会返回错误,这可能意味着关闭操作无法完成,必须处理这种情况。例如,os.Remove由于权限问题失败,文件没有被删除。如果我们不能退出应用程序、重试或处理错误,至少应意识到可能存在的泄漏。

这是否意味着defer语句不那么有用,我们必须为所有关闭器添加if err != nil样板?并不完全是这样。这就是我建议使用errcapturelogerrcapture包的情况。参见 Example 11-8。

Example 11-8. 使用defer关闭文件的示例
// import "github.com/efficientgo/core/logerrcapture"
// import "github.com/efficientgo/core/errcapture"

func doWithFile_Wrong(fileName string) error {
    f, err := os.Open(fileName)
    if err != nil {
        return err
    }
    defer f.Close() // Wrong! ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    // Use file...

    return nil
}

func doWithFile_CaptureCloseErr(fileName string) (err error) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    f, err := os.Open(fileName)
    if err != nil {
        return err
    }
    defer errcapture.Do(&err, f.Close, "close file") ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

    // Use file...

    return nil
}

func doWithFile_LogCloseErr(logger log.Logger, fileName string) {
    f, err := os.Open(fileName)
    if err != nil {
        level.Error(logger).Log("err", err)
        return
    }
    defer logerrcapture.Do(logger, f.Close, "close file") ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

    // Use file...
}

1

绝不要忽视错误。特别是在文件关闭时,这通常会导致我们的写入仅在Close时刷新到磁盘上,如果出现错误,我们将丢失数据。

2

幸运的是,我们不需要放弃令人惊叹的 Go defer逻辑。使用errcapture,我们可以在f.Close返回错误时返回错误。如果doWithFile_CaptureCloseErr返回错误并且我们关闭文件,则潜在的关闭错误将附加到返回的错误中。这要归功于此函数的返回参数(err error)。如果没有它,这种模式将无法工作!

3

如果我们无法处理,还可以记录关闭错误。

如果我们看到我参与的任何项目(并影响了类似这样的模式),我会在所有返回错误的函数中使用errcapture,并且可以推迟它们——这是一种干净且可靠的方式来避免某些泄漏。

另一个常见的例子是错误情况下忘记关闭的情况。假设我们需要打开一组文件以备后用。确保关闭它们并不总是简单的,正如在 Example 11-9 中所示。

Example 11-9. 在错误情况下关闭文件
// import "github.com/efficientgo/core/merrors"

func openMultiple_Wrong(fileNames ...string) ([]io.ReadCloser, error) {
    files := make([]io.ReadCloser, 0, len(fileNames))
    for _, fn := range fileNames {
        f, err := os.Open(fn)
        if err != nil {
            return nil, err // Leaked files! ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
        }
        files = append(files, f)
    }
    return files, nil
}

func openMultiple_Correct(fileNames ...string) ([]io.ReadCloser, error) {
    files := make([]io.ReadCloser, 0, len(fileNames))
    for _, fn := range fileNames {
        f, err := os.Open(fn)
        if err != nil {
            return nil, merrors.New(err, closeAll(files)).Err() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        }
        files = append(files, f)
    }
    return files, nil
}

func closeAll(closers []io.ReadCloser) error {
    errs := merrors.New()
    for _, c := range closers {
        errs.Add(c.Close())
    }
    return errs.Err()
}

1

这通常很难注意到,但如果我们创建了更多需要关闭的资源,或者我们希望在不同的函数中关闭它们,defer 就不能用了。通常这没问题,但如果我们想创建三个文件并且在打开第二个文件时出现错误,我们就会泄漏第一个未关闭的文件的资源!我们不能只是从 openMultiple_Wrong 返回已打开的文件和一个错误,因为一致的流程是忽略返回的任何内容,如果有错误的话。我们通常必须关闭已打开的文件以避免泄漏和混乱。

2

解决方案通常是创建一个短小的辅助函数,遍历附加的关闭器并关闭它们。例如,我们使用 merrors 包进行方便的错误追加,因为我们希望知道在任何 Close 调用中是否发生了新的错误。

总之,关闭资源非常重要,被认为是一种良好的优化策略。当然,没有单一的模式或者检查工具能够防止所有的错误,但我们可以采取很多措施来减少风险。

用尽资源

更复杂的是,某些实现要求我们做更多的工作来完全释放所有资源。例如,一个 io.Reader 的实现可能没有提供 Close 方法,但它可能假定所有的字节都将被完全读取。另一方面,某些实现可能有 Close 方法,但仍然期望我们“用尽”读取器以实现高效利用。

具有这种行为的最流行的实现之一是标准库中的 http.Requesthttp.Response 的 body io.ReadCloser。问题显示在 Example 11-10 中。

Example 11-10. http/net 客户端由于错误处理的 HTTP 响应而导致效率低下的示例
func handleResp_Wrong(resp *http.Response) error { ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    if resp.StatusCode != http.StatusOK {
        return errors.Newf("got non-200 response; code: %v", resp.StatusCode)
    }
    return nil
}

func handleResp_StillWrong(resp *http.Response) error {
    defer func() {
        _ = resp.Body.Close() ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    }()

    if resp.StatusCode != http.StatusOK {
        return errors.Newf("got non-200 response; code: %v", resp.StatusCode)
    }
    return nil
}

func handleResp_Better(resp *http.Response) (err error) {
    defer errcapture.ExhaustClose(&err, resp.Body, "close") ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)

    if resp.StatusCode != http.StatusOK {
        return errors.Newf("got non-200 response; code: %v", resp.StatusCode)
    }
    return nil
}

func BenchmarkClient(b *testing.B) {
    defer goleak.VerifyNone(
        b,
        goleak.IgnoreTopFunction("testing.(*B).run1"),
        goleak.IgnoreTopFunction("testing.(*B).doBench"),
    )

    c := &http.Client{}
    defer c.CloseIdleConnections() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        resp, err := c.Get("http://google.com")
        testutil.Ok(b, err)
        testutil.Ok(b, handleResp_Wrong(resp))
    }
}

1

想象我们正在设计一个函数,处理来自 http.Client.Get 请求的 HTTP 响应。Get 明确提到,“调用方在读取完后应关闭 resp.Body。” 这个 handle​R⁠esp_Wrong 是错误的,因为它会泄漏两个 goroutine:

  • One doing net/http.(*persistConn).writeLoop

  • The second doing net/http.(*persistConn).readLoop,这在我们用 goleak 运行 BenchmarkClient 时可见。

2

handleResp_StillWrong更好,因为我们停止了主要的泄漏。然而,我们仍然没有从 body 中读取字节。我们可能不需要它们,但是如果我们不完全释放 body,net/http的实现可能会阻塞 TCP 连接。不幸的是,这并不是广为人知的信息。在http.Client.Do方法描述中简要提到:“如果没有读取到 EOF 并关闭 Body,Client 的底层 RoundTripper(通常是 Transport)可能无法重新使用与服务器的持久化 TCP 连接进行下一个‘keep-alive’请求。”

3

理想情况下,我们应该读取直到 EOF(文件结束),表示我们正在读取的内容结束。因此,我们创建了方便的辅助工具,如来自errcapturelogerrcaptureExhaustClose

4

客户端为每个想要保持活动并重用的 TCP 连接运行一些 goroutine。我们可以使用CloseIdleConnection关闭它们,以检测我们的代码可能引入的任何泄漏。

我希望像http.Response.Body这样的结构更容易使用。关闭和彻底释放 body 是重要的,应该作为合理优化的一部分使用。handleResp_Wrong导致BenchmarkClient出现泄漏错误。handleResp_StillWrong不会泄漏任何 goroutine,因此泄漏测试通过。这种“泄漏”发生在不同层次,即 TCP 层次,TCP 连接无法重用,这可能会导致额外的延迟和文件描述符不足。

我们可以通过 Example 11-10 中BenchmarkClient基准测试的结果看到其影响。在我的机器上,使用handleResp_StillWrong调用http://google.com花费了 265 毫秒。对于在handleResp_Better中清理所有资源的版本,只花费了 188 毫秒,快了 29%!¹¹

http.HandlerFunc代码中也可以看到需要彻底释放资源。我们应该始终确保我们的服务器实现会完全释放和关闭http.Request的 body。否则,我们将会遇到与 Example 11-10 中相同的问题。类似地,对于各种迭代器也是如此;例如,Prometheus 存储可以有一个ChunkSeriesSet迭代器。如果我们忘记遍历直到Next()为 false,一些实现可能会泄漏或过度使用资源。

总之,始终检查那些非常规的边界情况的实现。理想情况下,我们应该设计我们的实现以明显的效率保证。

现在让我们深入探讨我在前几章中提到的预分配技术。

如果可能的话,预分配资源。

我在 “优化代码不易阅读” 中提到了预分配作为一个合理的优化方法。我展示了如何在 示例 1-4 中使用 make 轻松地预分配一个片段,作为 append 的一个优化。通常,我们希望减少代码需要重新调整或分配新项的工作量,如果我们知道代码最终必须这样做。

append 的例子很重要,但还有更多例子。事实证明,几乎每个关心效率的容器实现都有一些更简单的预分配方法。查看 示例 11-11 中的解释。

示例 11-11. 一些常见类型的预分配示例
const size = 1e6 ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

slice := make([]string, 0, size) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
for i := 0; i < size; i++ {
    slice = append(slice, "something")
}

slice2 := make([]string, size) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
for i := 0; i < size; i++ {
    slice2[i] = "something"
}

m := make(map[int]string, size) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
for i := 0; i < size; i++ {
    m[i] = "something"
}

buf := bytes.Buffer{} ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
buf.Grow(size)
for i := 0; i < size; i++ {
    _ = buf.WriteByte('a')
}

builder := strings.Builder{}
builder.Grow(size)
for i := 0; i < size; i++ {
    builder.WriteByte('a')
}

1

假设我们知道要提前扩展容器的大小。

2

使用切片的 make 允许我们将基础数组的容量增长到给定的大小。由于使用 make 主动增长数组,所以使用 append 的循环在 CPU 时间和内存分配上更加便宜。这是因为 append 在数组太小时无需重新调整数组大小。

调整大小是相当朴素的。它只是创建一个新的更大的数组并复制所有元素。某种启发式算法还告诉我们要增长多少个新片段。这个启发式算法最近已经 改变,但它仍然会分配和复制几次,直到扩展到我们预期的一百万个元素。在我们的情况下,使用预分配的相同逻辑比传统方式快 8 倍,并且只分配了 16 MB 的内存,而不是 88 MB。

3

我们还可以预先分配片段的容量和长度。sliceslice2 都将具有相同的元素。两种方式几乎同样高效,因此我们选择更符合功能需求的方式。然而,在 slice2 中,我们使用了所有数组元素,而在 slice 中,我们可以将其扩展为更大,但如果需要的话最终使用较少的数字。¹²

4

可以使用 make 来创建 Map,并使用可选的数字表示其容量。如果我们提前知道大小,对于 Go 来说创建所需的内部数据结构更有效率。效率结果显示出差异——在我的机器上,使用预分配的地图初始化需要 87 毫秒,没有预分配则需要 179 毫秒!使用预分配的总分配空间为 57 MB,没有预分配则为 123 MB。然而,地图插入仍然可能分配一些内存,只是比预分配少得多。

5

各种缓冲区和构建器提供了 Grow 函数,也可以进行预分配。

上述示例实际上是我在几乎每次编码会话中经常使用的东西。预分配通常需要额外的一行代码,但它是一个很棒的、更可读的模式。如果你仍然不相信在切片大小上有很多情况时,你不会有很多情况,让我们来谈谈io.ReadAll。我们在 Go 社区中经常使用io.ReadAll(以前是ioutil.ReadAll)函数。你知道吗,如果你事先知道大小,你可以通过预分配内部字节切片来显著优化它?不幸的是,io.ReadAll没有sizecapacity参数,但有一种简单的方法可以优化它,就像在示例 11-12 中所示的那样。

示例 11-12. 使用基准测试的 ReadAll 优化示例
func ReadAll1(r io.Reader, size int) ([]byte, error) {
   buf := bytes.Buffer{}
   buf.Grow(size)
   n, err := io.Copy(&buf, r) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
   return buf.Bytes()[:n], err
}

func ReadAll2(r io.Reader, size int) ([]byte, error) {
   buf := make([]byte, size)
   n, err := io.ReadFull(r, buf) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
   if err == io.EOF {
      err = nil
   }
   return buf[:n], err
}

func BenchmarkReadAlls(b *testing.B) {
   const size = int(1e6)
   inner := make([]byte, size)

   b.Run("io.ReadAll", func(b *testing.B) {
      b.ReportAllocs()
      for i := 0; i < b.N; i++ {
         buf, err := io.ReadAll(bytes.NewReader(inner))
         testutil.Ok(b, err)
         testutil.Equals(b, size, len(buf))
      }
   })

   b.Run("ReadAll1", func(b *testing.B) {
      b.ReportAllocs()
      for i := 0; i < b.N; i++ {
         buf, err := ReadAll1(bytes.NewReader(inner), size)
         testutil.Ok(b, err)
         testutil.Equals(b, size, len(buf))
      }
   })

   b.Run("ReadAll2", func(b *testing.B) {
      b.ReportAllocs()
      for i := 0; i < b.N; i++ {
         buf, err := ReadAll2(bytes.NewReader(inner), size)
         testutil.Ok(b, err)
         testutil.Equals(b, size, len(buf))
      }
   })
}

1

模拟ReadAll的一种方式是创建一个预分配的缓冲区,并使用io.Copy复制所有字节。

2

更加高效的是预分配一个字节切片,并使用ReadFull,这与ReadAll类似。如果读取了所有内容,ReadAll不会使用io.EOF错误标志,因此我们需要特殊处理它。

结果如示例 11-13 所示。使用io.ReadFullReadAll2io.ReadAll快八倍,并为我们的一百万字节切片分配的内存少五倍。

示例 11-13. 在示例 11-12 中的基准测试结果
BenchmarkReadAlls
BenchmarkReadAlls/io.ReadAll
BenchmarkReadAlls/io.ReadAll-12  1210   872388 ns/op  5241169 B/op  29 allocs/op
BenchmarkReadAlls/ReadAll1
BenchmarkReadAlls/ReadAll1-12    8486   165519 ns/op  1007723 B/op  4 allocs/op
BenchmarkReadAlls/ReadAll2
BenchmarkReadAlls/ReadAll2-12    10000  102414 ns/op  1007676 B/op  3 allocs/op
PASS

在我们的 Go 代码中,io.ReadAll优化经常是可能的。特别是在处理 HTTP 代码时,请求或响应头通常提供了一个Content-Length头,允许预分配。¹³ 前面的例子只是允许预分配的类型和抽象的一个小子集。检查我们使用的类型的文档和代码,看看我们是否可以为了更好的效率而进行提前分配。

然而,还有一种令人惊奇的预分配模式我想让你知道。考虑一个简单的单链表。如果我们使用指针实现它,并且知道我们将在该列表上插入数百万个新元素,有没有办法为效率预分配东西?结果可能是有的,正如在示例 11-14 中所示。

示例 11-14. 链表元素的基本预分配
type Node struct {
    next *Node
    value int
}

type SinglyLinkedList struct {
    head *Node

    pool      []Node ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    poolIndex int
}

func (l *SinglyLinkedList) Grow(len int) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    l.pool = make([]Node, len)
    l.poolIndex = 0
}

func (l *SinglyLinkedList) Insert(value int) {
    var newNode *Node
    if len(l.pool) > l.poolIndex { ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        newNode = &l.pool[l.poolIndex]
        l.poolIndex++
    } else {
        newNode = &Node{}
    }

    newNode.next = l.head
    newNode.value = value
    l.head = newNode
}

1

这一行使得这个链表有些特殊。我们以一个切片的形式维护一个对象池。

2

多亏了池,我们可以实现自己的Grow方法,它将在一个分配内分配许多Node对象的池。通常,分配一个大的[]Node比分配数百万个*Node要快得多。

3

在插入过程中,我们可以检查我们的池中是否有空间,并从中取出一个元素,而不是单独分配一个Node。如果我们达到容量限制,此实现可以扩展为更加健壮的实现,例如为后续的增长。

如果我们使用前述的链表插入一百万个元素进行基准测试,我们将看到,通过一次急切的分配,插入操作时间减少了四倍,并且与仅进行一次分配相比,占用的空间相同。

如示例 11-11 中所示,简单的预分配与切片和映射几乎没有缺点,因此可以视为合理的优化。另一方面,示例 11-14 中介绍的预分配应该谨慎进行,并且需要进行基准测试,因为它并非没有权衡。

首先,问题在于删除逻辑的可能性或允许多次调用Grow不是易事。第二个问题是,单个Node元素现在连接到一个非常大的单一内存块。让我们在下一节深入探讨这个问题。

使用数组过度使用内存

正如您可能知道的那样,切片在 Go 语言中非常强大。它们为每天在 Go 社区中使用的数组提供了灵活的弹性。但是,随着强大和灵活性的增加,也伴随着责任。有许多情况下,我们可能会过度使用内存,有些人可能称之为“内存泄漏”。主要问题在于这些情况在“Go 基准测试”中永远不会出现,因为它涉及垃圾收集,并且不会释放我们认为可以释放的内存。让我们在示例 11-15 中探讨这个问题,该示例测试了在示例 11-14 中引入的SinglyLinkedList中的潜在删除操作。

示例 11-15. 重新制造过度使用内存的链表,该链表使用了预分配的方式在示例 11-14 中引入。
func (l *SinglyLinkedList) Delete(n *Node) { /* ... */ } ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

func TestSinglyLinkedList_Delete(t *testing.T) { ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    l := &SinglyLinkedList{}
    l.Grow(size)
    for k := 0; k < size; k++ {
        l.Insert(k)
    }
    l.pool = nil // Dispose pool. ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    _printHeapUsage() ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)

    // Remove all but last.
    for curr := l.head; curr.next != nil; curr = curr.next { ![5](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/5.png)
        l.Delete(curr)
    }
    _printHeapUsage() ![6](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/6.png)

    l.Delete(l.head)
    _printHeapUsage() ![7](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/7.png)
}

func _printHeapUsage() {
    m := runtime.MemStats{}

    runtime.GC()
    runtime.ReadMemStats(&m)
    fmt.Println(float64(m.HeapAlloc)/1024.0, "KB")
}

1

让我们向链表添加删除逻辑,以删除给定的元素。

2

使用微基准来评估Delete的效率将告诉我们,当使用Grow时,删除操作只会稍微快一点。然而,为了展示内存过度使用的问题,我们需要进行宏基准测试(参见“宏基准”)。或者,我们可以像这里一样编写脆弱的交互式测试。¹⁴

3

注意,我们正在尽力让垃圾收集器删除已删除的节点。但是,我们将pool变量设为nil,因此我们用于创建列表中所有节点的切片不再被引用。

4

我们手动触发 GC,并打印堆的状态,通常这并不十分可靠,因为它包含后台运行时工作的分配。然而,这足以展示问题。在运行的一个阶段,预分配的列表显示为 15,818.5 KB,而在没有Grow的情况下运行则为 15,813.0 KB。不要看这些之间的差异,而是关注预分配的这个值如何变化。

5

让我们仅保留一个元素。

6

在完美的世界中,我们期望只为一个Node保留内存,对吧?对于非预分配列表来说,堆上是 189.85 KB。另一方面,对于预分配的列表,我们可以观察到一个问题:堆仍然很大,有 15,831.2 KB!

7

只有在所有元素之后,我们才会看到两种情况下堆大小较小(每种情况大约为 190 KB)。

这个问题很重要,我们每次处理带有数组的结构体时都会遇到。在两种情况下删除所有但一个元素时发生的情况的表示见图 11-1。

efgo 1101

图 11-1. 带有列表中一个节点引用的堆状态。左侧是未使用池创建的状态,右侧是使用了池的状态。

当我们分配一个单独的对象时,我们看到它会获得自己的内存块,可以独立管理。如果我们使用池化或者从更大的切片中进行子切片(例如,buf[1:2]),GC 将会看到数组使用的连续大内存块被引用。它并不聪明地意识到只有其中的 1%被使用,可以被“裁剪”。

解决方案是避免使用池化,或者设计一个更高级的池,可以增长或缩小(甚至可以自动执行)。例如,如果删除了一半的对象,我们可以“裁剪”我们链表节点背后的数组。或者,我们可以添加按需的ClipMemory方法,如示例 11-16 所示。

示例 11-16. 裁剪过大内存块的示例实现
func (l *SinglyLinkedList) ClipMemory() {
    var objs int
    for curr := l.head; curr != nil; curr = curr.next {
        objs++
    }

    l.pool = make([]Node, objs) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    l.poolIndex = 0
    for curr := l.head; curr != nil; curr = curr.next {
        oldCurr := curr
        curr = &l.pool[l.poolIndex]
        l.poolIndex++

        curr.next = oldCurr.next ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        curr.value = oldCurr.value

        if oldCurr == l.head {
            l.head = curr ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        }
    }
}

1

此时,我们摆脱对旧的[]Node切片的引用,并创建一个较小的切片。

2

正如我们在图 11-1 中看到的,列表中每个元素仍然引用着更大的内存块。因此,我们需要使用新的对象池执行复制,以确保 GC 可以清除那些旧的更大的池。

3

让我们不要忘记最后一个指针l.head,否则它将仍然指向旧的内存块。

现在我们可以在删除一些项目时使用ClipMemory来调整底层内存块的大小。

正如示例 11-15 所展示的,过度使用内存比我们想象的更为常见。然而,并非需要专门的池化来避免这种情况。子切片和像示例 10-4 中的智能零拷贝函数(zeroCopyToString)很容易受到这个问题的影响。¹⁵

这一部分并不是要阻止你预先分配事物、进行子切片或者试验重用字节切片。相反,它提醒我们在尝试更高级的切片和底层数组操作时,始终要牢记 Go 是如何管理内存的(详见《Go 内存管理》)。

请记住,如同“微基准与内存管理”所述,Go 的基准测试不涵盖内存使用特性。如果怀疑受到这个问题的影响,移步“宏基准”级别以验证所有效率方面。

既然提到了池化,让我们深入探讨最后一节。在 Go 中重用和池化内存的其他方法是什么?事实证明,有时不池化任何东西可能更好!

内存重用与池化

内存重用允许在后续操作中使用相同的内存块。如果我们执行的操作需要更大的 structslice,并且快速连续执行许多此类操作,则每次分配新的内存块是一种浪费,因为:

  • 分配保证零化内存块会消耗 CPU 时间。

  • 我们为 GC 做了更多工作,因此使用了更多 CPU 周期。

  • GC 是最终性的,因此我们的最大堆大小可能无法受到控制地增长。

我已经在示例 10-8 中介绍了一些内存重用技术,使用一个小缓冲区逐块处理文件。然后在示例 11-14 中,我展示了我们可以一次性分配一个更大的内存块,并将其用作对象池。

物体的重用逻辑,特别是字节切片的重用,经常由许多流行的实现启用,比如 io.CopyBufferio.ReadFull。即使是我们的 示例 10-8 中的 Sum6Reader​(r ⁠io.Reader, buf []byte) 也允许进一步重用缓冲区。然而,内存重用并非总是那么简单。考虑以下字节切片重用示例 示例 11-17。

示例 11-17. 简单缓冲或字节切片
func processUsingBuffer(buf []byte) {
    buf = buf[:0] ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    for i := 0; i < 1e6; i++ {
        buf = append(buf, 'a')
    }

    // Use buffer...
}

func BenchmarkProcess(b *testing.B) {
    b.Run("alloc", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            processUsingBuffer(nil) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
        }
    })

    b.Run("buffer", func(b *testing.B) {
        buf := make([]byte, 1e6)
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            processUsingBuffer(buf) ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
        }
    })
}

1

因为我们的逻辑使用了 append,所以在重用相同的底层数组以提升效率时,我们需要将切片的长度清零。

2

我们可以通过简单传递 nil 来模拟没有缓冲区。幸运的是,Go 在诸如 buf[:0]append([]byte(nil), 'a') 的操作中处理空切片。

3

在这种情况下重用缓冲区更好。在我的机器上,基准测试显示每个重用缓冲区的操作几乎快两倍,并且不分配任何字节。

前面的例子看起来很不错,但是真正的代码包含了复杂性和边缘情况。有两个主要问题有时会阻止我们实现这种天真的内存重用,就像示例 11-17 中一样:

  • 我们知道大多数操作的缓冲区大小将类似,但并不知道确切的数量。通过传递一个空缓冲区并重用从第一个操作中增长的底层数组,这可以很容易地解决。

  • 我们可能会在某个时刻并发运行processUsingBuffer代码。有时使用四个工作线程,有时使用一千个,有时使用一个。在这种情况下,我们可以通过维护一个静态数量的缓冲区来实现。这个数量可以是我们想要并发运行的最大 goroutine 数量,或者少于某些锁定。显然,如果 goroutine 的数量动态变化且有时为零,这可能会浪费很多资源。

因此,Go 团队提出了sync.Pool结构,执行一种特定形式的内存池。重要的是要理解,内存池不同于典型的缓存。

Brad Fitzpatrick 请求的类型[sync.Pool]实际上是一个池:一组可以互换的值,你得到的具体值并不重要,因为它们都是相同的。与从池中获取值而不是获取新创建的值相比,你甚至不会注意到这一点。相反,缓存会将键映射到具体的值。

Dominik Honnef,《Go Tip 中正在发生的事情》(https://oreil.ly/z6AUf)

标准库中的sync.Pool是纯粹作为一个非常短暂的临时缓存实现的,用于相同类型的自由内存块,这些内存块会持续到下一次或更多的 GC 调用。它使用相当智能的逻辑,使其线程安全并尽可能避免锁定,以便进行高效访问。sync.Pool的主要思想是重用 GC 尚未释放的内存。因为我们保留这些内存块直到最终的 GC,为什么不让它们可访问且有用呢?在示例 11-17 中展示了使用sync.Pool的示例,也呈现在示例 11-18 中。

示例 11-18。使用sync.Pool进行简单缓冲
func processUsingPool(p *sync.Pool) {
    buf := p.Get().([]byte) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
    buf = buf[:0]

    for i := 0; i < 1e6; i++ {
        buf = append(buf, 'a')
    }
    defer p.Put(buf) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)

    // Use buffer...
}

func BenchmarkProcess(b *testing.B) {
    b.ReportAllocs()

    p := sync.Pool{
        New: func() any { return []byte{} }, ![3](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/3.png)
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        processUsingPool(&p) ![4](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/4.png)
    }
}

1

sync.Pool池化给定类型的对象,因此我们必须将其转换为我们放置或创建的类型。当涉及到Get时,我们要么分配一个新对象,要么使用其中一个池化的对象。

2

要有效地使用池,我们需要将对象放回以便重用。记住永远不要将你仍在使用的对象放回,以避免竞争!

3

New 闭包指定如何创建新对象。

4

对于我们的示例,使用 sync.Pool 的实现非常高效。它比不重用的代码快两倍,每次操作平均分配了 2 KB 的空间,而不重用缓冲区的代码则分配了 5 MB。

虽然结果看起来非常有前景,但使用 sync.Pool 进行池化是一种更高级的优化,如果使用不当,可能会带来更多效率瓶颈而不是优化。第一个问题是,与任何其他使用切片的复杂结构一样,使用它容易出错。考虑带有基准测试的代码在 示例 11-19 中。

示例 11-19. 使用 sync.Pooldefer 时常见但难以发现的 bug
func processUsingPool_Wrong(p *sync.Pool) {
    buf := p.Get().([]byte)
    buf = buf[:0]

    defer p.Put(buf) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)

    for i := 0; i < 1e6; i++ {
        buf = append(buf, 'a')
    }

    // Use buffer...
}

func BenchmarkProcess(b *testing.B) {
    p := sync.Pool{
        New: func() any { return []byte{} },
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        processUsingPool_Wrong(&p) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
    }
}

1

在这个函数中有一个 bug,这个 bug 违背了使用 sync.Pool 的初衷 — 在我们的情况下,Get 总是会分配一个对象。你能发现吗?

问题在于 Put 可能被推迟到正确的时间,但其参数在 defer 调度时会被评估。因此,我们正在放置的 buf 变量如果 append 需要扩展它,可能会指向不同的切片。

2

结果是,基准测试显示,这种 processUsingPool_Wrong 操作比总是分配的 alloc 情况(在 示例 11-17 中使用 make([]byte))慢两倍。使用 sync.Pool 只进行 Get 而不进行 Put 操作比直接分配(在我们的情况下为 make([]byte))更慢。

然而,真正的困难来自于 sync.Pool 的特性:它只能在短时间内池化对象,这在我们典型的微基准测试中并未体现,就像在 示例 11-18 中展示的一样。如果我们在基准测试中手动触发 GC,我们就能看到区别,这在 示例 11-20 中做了演示。

示例 11-20. 使用 sync.Pooldefer 时常见但难以发现的 bug,手动触发 GC
func BenchmarkProcess(b *testing.B) {
    b.Run("buffer-GC", func(b *testing.B) {
        buf := make([]byte, 1e6)
        b.ResetTimer()
      for i := 0; i < b.N; i++ {
            processUsingBuffer(buf) ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
            runtime.GC()
            runtime.GC()
        }
    })

    b.Run("pool-GC", func(b *testing.B) {
        p := sync.Pool{
            New: func() any { return []byte{} },
        }
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            processUsingPool(&p) ![2](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/2.png)
            runtime.GC()
            runtime.GC()
        }
    })
}

1

第二个惊喜来自于我们最初的基准测试中,process* 操作快速执行,一个接一个地进行。然而,从宏观的角度来看,情况可能并非如此。对于 processUsingBuffer 来说这是没问题的。如果 GC 在此期间运行了一次或两次,对于我们简单的缓冲解决方案来说,分配和延迟(通过 GC 延迟调整)保持不变,因为我们在 buf 变量中保留了内存引用。下一个 processUsingBuffer 将像往常一样快。

2

标准池子则不是这样。经过两次 GC 运行后,sync.Pool 是根据设计完全清除所有对象的¹⁶,这导致性能比 示例 11-17 中的 alloc 更差。

正如您所见,使用sync.Pool很容易犯错。它在垃圾回收后不保留池可能在我们不希望长时间保留池对象的情况下有益。然而,根据我的经验,由于非确定性行为的组合,由于非常复杂的sync.Pool实现与更复杂的 GC 调度,使得与其一起工作变得非常困难。

为了展示当sync.Pool应用于错误工作负载时的潜在损害,让我们尝试使用从“Go e2e Framework”优化的缓冲代码来优化来自 Example 10-8 的labeler服务,并使用四种不同的缓冲技术:

no-buffering

Sum6Reader在不使用缓冲的情况下—总是分配一个新的缓冲区。

sync-pool

使用sync.Pool

gobwas-pool

使用gobwas/pool,它维护多个sync.Pool的桶。理论上,它应该很好地处理可能需要不同缓冲区大小的字节片。

static-buffers

使用四个静态缓冲区,为最多四个 goroutine 提供缓冲区。

主要问题在于,Example 10-8 的工作负载可能不会立即看起来像一个错误的选择。每次操作只需进行一次make([]byte, 8*1024)的小内存分配,因此池化以节省总内存使用量可能感觉像是一个有效的选择。微基准测试也显示了惊人的结果。基准测试在两个不同文件(50%的时间使用 1000 万数字的文件,50%的时间使用 1 亿数字的文件)上顺序执行Sum6操作。结果显示在 Example 11-21 中。

示例 11-21. 百次迭代的微基准测试结果,比较了使用 Example 10-8 和四种不同缓冲版本的标签器labelObject逻辑。
name                  time/op
Labeler/no-buffering   430ms ± 0%
Labeler/sync-pool      435ms ± 0%
Labeler/gobwas-pool    438ms ± 0%
Labeler/static-buffers 434ms ± 0%

name                  alloc/op
Labeler/no-buffering   3.10MB ± 0%
Labeler/sync-pool      62.0kB ± 0%
Labeler/gobwas-pool    94.5kB ± 0% ![1](https://gitee.com/OpenDocCN/ibooker-go-zh/raw/master/docs/eff-go/img/1.png)
Labeler/static-buffers 62.0kB ± 0%

name                  allocs/op
Labeler/no-buffering    3.00 ± 0%
Labeler/sync-pool       3.00 ± 0%
Labeler/gobwas-pool     3.00 ± 0%
Labeler/static-buffers  2.00 ± 0%

1

桶化池略微更消耗内存,但这是预期的,因为维护了两个单独的池。然而,理想情况下,我们期望在更大规模上看到更大的好处。

我们看到,使用sync.Pool版本和静态缓冲区在内存分配方面更胜一筹。考虑到大部分时间在整数解析上,而不是在分配缓冲区上,延迟更多或更少相似。

不幸的是,在宏观层面,对于每个版本在k6s上进行 5 分钟测试,使用 2 个虚拟用户对 1000 万行和 1 亿行文件进行求和,我们发现现实与 Example 11-21 所示不同。好在labeler在没有缓冲的情况下在该负载期间分配了显著更多的内存(总计 3.3 GB),而其他版本则较少(平均 500 MB),如 Figure 11-2 中所示。

efgo 1102

图 11-2. 从堆分析的宏基准期间分配的总内存的 Parca 图表。四条线分别表示四个不同版本的运行顺序:no-bufferingsync-poolgobwas-poolstatic-buffers

然而,看起来这样的分配对 GC 来说并非是一个巨大的问题,因为最简单的无缓冲解决方案labelObject1与其他解决方案具有类似的平均延迟(CPU 使用率也相同),但在最大堆使用量方面最低,如图 11-3 所示。

efgo 1103

图 11-3. 宏基准期间堆大小的 Prometheus 图表。四条线分别表示四个不同版本的运行顺序:no-bufferingsync-poolgobwas-poolstatic-buffers

您可以通过示例仓库中的e2e框架代码重现整个实验。结果并不令人满意,但这个实验可以给我们很多启示:

  • 减少分配可能是提高延迟和内存效率的最简单方法,但并非总是如此!显然,在这种情况下,较高的分配比池化更好。一个原因是示例 10-8 中的Sum6已经经过了大幅优化。在示例 10-8 中,Sum6的 CPU 分析清楚地显示分配不是延迟的瓶颈。其次,较慢的分配速度导致 GC 启动的频率较低,通常允许更高的最大内存使用。这里可能会帮助额外的GOGC调整。

  • 微基准测试并不总是展示完整的图片。因此,务必在多个层面上评估效率以确保准确性。

  • sync.Pool在分配延迟方面提供了最大的帮助,但在最大内存使用方面则不然,这是我们这里的目标。

优化之旅可能就像坐过山车一样!

有时我们能够取得进展,有时我们花几天时间做的更改却无法合并。我们每天都在学习,尝试各种方法,有时也会失败。最重要的是早日失败,这样不高效的版本就不会意外地发布给我们的用户!

这个实验的主要问题在于sync.Pool并不适用于labeler所代表的工作负载类型。sync.Pool有非常特定的使用案例。在以下情况下使用它:

  • 您希望重复使用大量或极端数量的对象,以减少这些分配的延迟。

  • 您不关心对象内容,只关心其内存块。

  • 您希望从多个 goroutine 中重复使用这些对象,其数量可能不同。

  • 您希望在频繁发生的快速计算之间重复使用对象(最多间隔一个 GC 周期)。

例如,当我们希望为极快速伪随机生成器池化对象时,sync.Pool效果非常好。HTTP 服务器使用许多不同的字节池来重用从网络读取的字节。

不幸的是,根据我的经验,sync.Pool 被过度使用了。人们认为 sync.Pool 是标准库的一部分,所以一定很方便,但这并不总是正确的。sync.Pool 的使用情况非常有限,很可能并不是我们想要的。

总结一下,我更倾向于先进行简单的优化。优化越巧妙,我们就应该越警惕,并进行更多的基准测试。sync.Pool 结构是更复杂的解决方案之一。我建议首先看看更简单的解决方案,比如静态可重用的内存缓冲区,如示例 11-17。我的建议是,在确保你的工作负载与前述用例匹配之前,避免使用 sync.Pool。在大多数情况下,在减少工作和分配后,添加 sync.Pool 只会使你的代码更不高效、更脆弱,并且更难评估其效率。

摘要

就是这样了。你已经读到了本书的末尾,祝贺你!我希望这是一次美妙而有价值的旅程。对我来说,确实如此!

或许,如果你已经读到这里,那么高效、实用的软件世界对你来说比在打开这本书之前更加可接近了。或许你看到了我们如何编写代码和设计算法的所有细节如何影响软件的效率,这在长远来看会转化为真实的成本。

在某些方面,这非常令人兴奋。通过一个有意的变更和正确的可观察性工具来评估它,我们有时可以为雇主节省数百万美元,或者实现以前不可能的用例或客户需求。但另一方面,很容易因为愚蠢的错误而浪费资金,比如泄露几个 goroutine 或者在关键路径上没有预先分配一些切片。

如果你更倾向于“害怕”的一面,我的建议是……放松!记住,世界上没有什么是完美的,我们的代码也不可能完美无缺。了解向何处追求完美是好事,但正如俗语所说,“完美是善的敌人”,软件也有一个“足够好”的时刻。在我看来,这是我想在这里教给你的专业、实用、日常效率实践与唐纳德·库努斯“过早优化是万恶之源”的世界之间的关键区别。这也是为什么我的书叫 Efficient Go 而不是 超高性能、超快速的 Go

我认为实际的汽车技师职业可以作为实用的效率感知软件开发者的一个很好的比较(抱歉使用了汽车的类比!)。想象一位充满激情和经验丰富的机械工程师,在建造世界上最快的赛车之一 F1 赛车方面有着丰富的经验。想象他们在汽车修理厂工作,一位顾客带着一辆标准轿车来修理,而这辆车有漏油的问题。即使对制造汽车极速的知识有着最深的了解,实际的技师会修理漏油,全面检查整辆车是否有其他问题,就这样。然而,如果技师开始调整顾客的车以获得更快的加速、更好的空气效率和制动性能,您可以想象顾客可能不会满意。提升汽车性能可能会让顾客高兴,但这通常伴随着高昂的工时费、昂贵的零件和延误的修理时间。

遵循与您对汽车技师的期望相同的规则。做必要的事情以满足功能和效率目标。这并不是懒惰,而是实用和专业。如果我们在需求的前提下这样做,那么没有任何优化是过早的。

这就是为什么我给出的第二条建议是始终设定一些目标。看看在第十章中评估Sum优化是否可接受,从某种意义上来说是多么“容易”。在我大部分软件项目中犯的最大错误之一是忽视或拖延为项目的预期效率设定明确、最好是书面的、数据驱动的目标。即使显而易见,注意,“我希望这个功能在一分钟内完成。”稍后您可以在更好的需求上进行迭代!没有清晰的目标,每一次优化都可能是过早的。

最后,我给出的第三条建议是投资于良好的可观测性工具。我很幸运,在过去几年的日常工作中,我所在的团队提供了可观测性软件。此外,这些可观测性工具在开源中是免费的,本书的每一位读者都可以立即安装它们。我无法想象没有第六章中提到的工具会是怎样的。

另一方面,作为云原生计算基金会(CNCF)可观测性兴趣小组的技术领导者、技术会议的发言人和与会者,我也看到有多少开发人员和组织没有使用可观测性工具。他们要么不观察他们的软件,要么没有正确使用这些工具!这就是为什么对于那些个人或组织来说,要实际地提高程序效率是非常困难的。

不要被那些承诺高价值观察解决方案的过度炒作的供应商分心。¹⁸相反,我建议从像PrometheusLokiOpenSearchTempo,或Jaeger这样的开源监控和观察解决方案开始。!

下一步

在本书中,如果需要,我们已经全面介绍了成为高效开发 Go 的所有必要元素。特别是:

  • 我们讨论了高效程序的动机和简介在第一章。

  • 我们在第二章详细介绍了 Go 的基础知识。

  • 我们在第三章讨论了挑战、优化、RAER 和 TFBO。

  • 我解释了我们优化的两个最重要的资源:CPU 在第四章和内存在第五章。我还提到了延迟。

  • 我们在第六章讨论了可观察性和常见的仪器化。

  • 我们在第七章详细讨论了数据驱动的效率分析、复杂性和实验的可靠性。

  • 我们在第八章讨论了基准测试。

  • 我介绍了性能分析的主题,这有助于瓶颈分析在第九章。

  • 最后,我们在第十章优化了各种代码示例,并在第十一章总结了常见的模式。

然而,与一切一样,如果您感兴趣,总是有更多可以学习的!

首先,我跳过了一些与效率主题无关的 Go 语言方面。要了解更多信息,我建议阅读 Maximilien Andile 撰写的《实用 Go 课程》,并…为工作或作为有趣的副项目编写实际的 Go 程序。¹⁹

其次,希望我使您能够理解您正在优化的资源的基本机制。成为软件效率更高的下一步之一是学习更多关于我们通常优化的其他资源,例如:

磁盘

我们在我们的 Go 程序中每天使用磁盘存储。操作系统处理对其的读取或写入的方式可能同样复杂,正如您在《操作系统内存管理》中看到的那样。更好地理解磁盘存储(例如SSD的特性)将使您成为更好的开发人员。如果您对磁盘访问的替代优化感兴趣,我还建议阅读与新 Linux 内核一起提供的io_uring接口。它可能允许您使用大量磁盘访问为您的 Go 程序构建更好的并发性。

网络

阅读更多关于网络约束(如延迟、带宽和不同协议)的信息,将使您更加了解如何优化受网络限制的 Go 代码。

GPU 和 FPGA

若要了解更多有关将一些计算卸载到外部设备(如GPUsprogrammable hardware)的信息,我建议使用cu,它使用流行的NVIDIA GPUs 的 CUDA API,或者使用这个指南在 Apple M1 GPUs 上运行 Go。

第三,虽然我可能会在本书的下一版中添加更多优化示例,但列表永远不会完整。这是因为一些开发人员可能希望尝试许多更或更少极端的优化方法,用于其程序的特定部分。例如:

最后,本书中的所有示例都可以在https://github.com/efficientgo/examples开源库中找到。提供反馈,贡献代码,与他人共同学习。

每个人的学习方式不同,所以尝试对您最有帮助的内容。然而,我强烈建议使用本书学到的实践来练习您选择的软件。设定合理的效率目标,并尝试优化它们。²⁰

欢迎您还可以使用和贡献我在开源中维护的其他 Go 工具:https://github.com/efficientgo/corehttps://github.com/efficientgo/e2ehttps://github.com/prometheus/prometheus,等等!²¹

加入我们的 “高效 Go” Discord 社区,随时就书籍提供反馈,提出额外问题或结识新朋友!

非常感谢所有直接或间接帮助创作这本书的人(请参阅 “致谢”)。感谢那些指导我达到现在的人!

感谢您购买并阅读我的书。期待在开源社区中见到您! 😃

¹ 我在 GitHub 全球维护者峰会 上讨论了这个问题。

² 这个列表受到 Jon Louis Bentley 的 编写高效程序 第四章的启发。

³ 有些人称缓存为 “你还不知道的内存泄漏”

⁴ 在这里可以看到一个关于这些的不错的博客文章 here

⁵ 例如,在 Prometheus 项目中我们移除 当代码条件稍有变化时的手动 GC 触发器。这个决定基于在 第七章 中讨论的微观和宏观基准测试。

⁶ 原因是我们可能会在更长期的场景中重复使用相同的代码,这种泄漏可能会带来更大的后果。

⁷ 除非我们使用 GOGC=off 环境变量禁用它。

⁸ 为此,我们可以使用工具来 分析转储的核心,但目前它们并不是很易于获取,所以我不建议使用它们。

⁹ 是的!如果我们没有调用返回的 context.CancelContext 函数,它将一直保持 goroutine 运行(当使用 WithContext 时)或直到超时(WithTimeout)。

¹⁰ 我只见过检查一些基本事项的 linter,比如检查代码是否关闭了 request bodysql 语句。还有更多贡献的空间,例如 semgrep-go 项目中

¹¹ 而这相当有趣,考虑到我们在代码中做了更多工作。我们会读取 Google 返回的 HTML 的所有字节。然而,由于我们创建了更少的 TCP 连接,速度更快。

¹² 当我们只知道最坏情况的 size 时,通常会这样做。有时值得将其扩展到最坏情况,即使最终使用的量较少。参见 “使用数组时过度使用内存”。

¹³ 例如,这就是我们一段时间前在 Thanos 中所做的。

¹⁴ 这在快速展示方面非常棒,但作为可靠的效率评估并不起作用。

¹⁵ 在 Prometheus 项目生态系统中,我们多次遇到这样的问题。例如,分块池化导致我们保留了比所需更大得多的数组,因此我们引入了Compact方法。在 Thanos 中,我引入了一个(可能过于)聪明的ZLabel构造,避免了为度量标签进行昂贵的字符串复制。事实证明,在我们不长时间保留标签字符串时,这对于性能是有利的。例如,在进行延迟复制时表现更好。

¹⁶ 如果你对具体的实现细节感兴趣,请查阅这篇精彩的博客文章

¹⁷ 有趣的是,sync.Pool最初被提议命名为sync.Cache,并具有缓存语义。

¹⁸ 当有人以低廉的价格提供闪亮的可观察性时,请保持警惕。实际上,鉴于我们通常需要通过这些系统传递大量数据,这通常并不便宜。

¹⁹ 我的建议是避免仅仅跟随教程。如果你处于不舒适的境地,必须自己思考,你会学到更多。

²⁰ 如果你感兴趣,我想邀请你参加我们每年一次的效率编码圣诞活动,我们尝试用高效的方式解决圣诞时期的编码挑战

²¹ 你可以在我的网站找到我维护(或曾经维护过)的所有项目。

附录 A. Napkin Math 计算的延迟

为了设计和评估不同层面的优化,了解我们与计算机交互中看到的基本操作的延迟数字是很有用的。

记住其中一些数字是很好的,但如果你不记得,我已经在表 A-1 中准备了一个简单的表格,显示了近似、四舍五入的平均延迟,这个表格深受Simon Eskildsen 的 napkin-math 代码库启发,做了一些修改。

该代码库创建于 2021 年。对于基于 CPU 的操作,这些数字基于来自至强家族的服务器 x86 CPU。请注意,尽管每年都在改善,但由于“硬件变得更快更便宜”中解释的限制,大部分数字自 2005 年以来保持稳定。CPU 相关的延迟也可能因不同的 CPU 架构(如 ARM)而有所不同。

表 A-1. CPU 相关的延迟

操作 延迟 吞吐量
3 GHz CPU 时钟周期 0.3 ns N/A
CPU 寄存器访问 0.3 ns(1 个周期) N/A
CPU L1 缓存访问 0.9 ns(3 个周期) N/A
CPU L2 缓存访问 3 ns N/A
顺序内存读写(64 字节) 5 ns 10 GBps
CPU L3 缓存访问 20 ns N/A
哈希运算,不是加密安全(64 字节) 25 ns 2 GBps
随机内存读写(64 字节) 50 ns 1 GBps
互斥锁的加锁/解锁 17 ns N/A
系统调用 500 ns N/A
哈希运算,加密安全(64 字节) 500 ns 200 MBps
顺序 SSD 读取(8 KB) 1 μs 4 GBps
上下文切换 10 μs N/A
顺序 SSD 写入,-fsync(8KB) 10 μs 1 GBps
TCP 回显服务器(32 KiB) 10 μs 4 GBps
顺序 SSD 写入,+fsync(8KB) 1 ms 10 MBps
排序(64 位整数) N/A 200 MBps
随机 SSD 寻址(8 KiB) 100 μs 70 MBps
压缩 N/A 100 MBps
解压缩 N/A 200 MBps
代理:Envoy/ProxySQL/NGINX/HAProxy 50 μs ?
同一区域内的网络 250 μs 100 MBps
MySQL,memcached,Redis 查询 500 μs ?
随机 HDD 寻址(8 KB) 10 ms 0.7 MBps
美国东部↔西部的网络 60 ms 25 MBps
欧洲西部↔美国东部的网络 80 ms 25 MBps
美国西部↔新加坡的网络 180 ms 25 MBps
欧洲西部↔新加坡的网络 160 ms 25 MBps
posted @ 2024-06-18 18:05  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报