本篇内容是根据2020年2月份#117 Foundations of Go performance[1]音频录制内容的整理与翻译
在这个多部分系列的第一部分中,Ian[2] 和 Johnny[3] 以及 Miriah Peterson[4] 和 Bryan Boreham[5] 一起揭开了 Go 程序性能的第一层重要内容。
过程中为符合中文惯用表达有适当删改, 版权归原作者所有.
Johnny Boursiquot: 好的,大家好,欢迎来到这一期的 Go Time 播客节目。今天我们有非常特别的一期节目。在我开始介绍今天的主题之前---
这可是好东西哦---
我想先介绍一下我的联合主持人 Ian Lopshire。Ian,和大家打个招呼吧。
Ian Lopshire: 大家好。
Johnny Boursiquot: [笑] 够简洁了。
Ian Lopshire: 我只是按指示行事。
Johnny Boursiquot: 确实是按指示行事。哈哈,这节目真是越来越有意思了。好了,我今天请来了几位嘉宾,和我一起讨论性能问题。哦,对了,我是 Johnny。 [笑] 我总是忘记介绍自己。不过你们应该已经听出我的声音了,你们知道我是谁了。无论如何,今天我请来了几位嘉宾,一起讨论性能问题,尤其是 Go 性能的基础知识。在介绍他们之前,或者让他们自我介绍之前,我想说一下我们这期节目的构思。希望这是一个系列节目的开始,围绕 Go 和性能问题展开,帮助你从零开始,最终成为高手。我们会给你提供一些指导,特别是针对初学者、中级开发者,甚至是一些高级 Go 开发者,帮助他们了解有哪些工具可用,Go 编程的惯用方法有哪些,以及在编写高效 Go 程序时应该注意什么。
为了帮助我讨论这些问题,我首先请来了 Miriah Peterson。Miriah,给大家介绍一下自己吧?
Miriah Peterson: 大家好,我是 Miriah Peterson。如果要用两个词来形容我自己,那就是“不要相信我的数据运维工程师这个头衔,因为数据工程师不怎么用 Go,但我用了。” 所以……
Johnny Boursiquot: 我们稍后会深入讨论这个问题的,哈哈。另外还有一位嘉宾是 Bryan Boreham。希望我发音没错,Bryan,来给大家介绍一下自己吧。
Bryan Boreham: 大家好,我是 Bryan Boreham。我做了很多 Go 性能优化的工作,已经使用 Go 近十年了。目前我在 Grafana Labs 工作,同时也是 Prometheus 的维护者。
Johnny Boursiquot: 很棒,我就说今天的节目会很有趣吧。我请来了真正懂行的嘉宾。好了,那我们开始深入讨论吧。在开始之前,我想先设定一下讨论的背景。假设你是团队中的一名开发人员,负责维护多个组件或者服务,不管它们是以 CLI 形式运行,还是作为开发工具,或者运行在某个集群上。无论如何,你是某些服务的负责人。然后,你的团队负责人找到你,说“嘿,这个组件,当我们给它输入更多数据时,表现得比其他服务更慢,更不可预测。我们怀疑可能有性能问题,可能是 CPU 或内存的瓶颈……但我们不确定。所以我让你来找出问题所在,并解决它。”
所以我会扮演这个角色,我会问一些问题。我假设自己对 Go 和 Go 性能优化并不了解。我会问一些可能不是愚蠢的问题,但一定是天真的问题。我会扮演那种不懂但想学的人。大家觉得怎么样?
Ian Lopshire: 好的……
Johnny Boursiquot: 听起来不错。我看到大家都点头了。好,那从一开始告诉我,关于 Go 的设计原则我知道它追求简单和高效……我知道它是一种垃圾回收语言。首先,我可能需要了解垃圾回收到底是什么?能不能帮我设定一下基础,让我明白在性能方面该如何理解 Go 的设计原则?你能为我提供一些关于 Go 性能哲学的起点吗?Bryan,为什么不由你开始呢?
Bryan Boreham: 我想说,在深入了解 Go 细节之前,如果我们首先知道某个组件运行缓慢,那我们接下来要了解的是它在做什么。它是因为占用了大量 CPU 而慢,还是因为在等待其他东西而慢?通常来说,这个其他东西要么是网络,要么是磁盘之类的东西。所以这是第一步,在深入 Go 代码或代码细节之前,先弄清楚它到底在做什么。 我几乎每天都对着屏幕喊这个问题。不过,因为这是 Go Time 不是网络时间或磁盘时间,我们可以假设我们已经完成了这一步,并且确定问题出在 Go 代码中,它占用了很多 CPU。那接下来该做什么呢?这个时候,进行性能分析是个好步骤。
Johnny Boursiquot: 所以一旦我知道了,假设我排除了网络或磁盘问题---
假设我的是某种服务,它监听一个端口,并接收一些流量……那我该如何理解 Go 的设计和哲学呢?遇到性能问题时,我该如何处理?我应该从哪里开始思考?
Miriah Peterson: 当然可以。不过我想先强调一下 Bryan 刚才说的东西,这也是我经常遇到的情况。我从事软件开发只有六年,刚开始工作时,我只接触过云服务。所以有很多背景知识,比如理解性能分析,这些经验来自于“哦,我在 Linux 上做过一些事情”或者“哦,我有在不同内核、不同受限环境下工作的经验”。这些其实是非常基础的技能,帮助我们理解很多问题的根源。因为我们总是会说“云资源很便宜,所以我们可以随意使用。”
所以在深入 Go 之前,有很多背景知识需要掌握。然后,当我们确认问题出在代码时,接下来要问的是“有哪些工具可以使用?” 幸运的是,我们选择了 Go,很多工具都随标准库一起提供。所以这就是我们开始的地方。
Johnny Boursiquot: 我非常喜欢这个初步的思路。在我转向 Go 之前,我自己也做了很多 Ruby 编程,Ian,我不确定你是否也有类似的经历。但在我转换到 Go 的过程中,我发现即使是我那些天真的 Go 程序,性能也比我优化过的 Ruby 程序快得多。这并不是说 Ruby 不好,只是像 Go 这样的静态编译语言和 Ruby 或 Python 这样的动态语言有着不同的性能表现……在大多数情况下是如此。我并不想一概而论。但就我的情况而言,我在解决某些问题时,明显感到用 Go 事半功倍。所以,作为一个切换到 Go 的程序员,你可能会想,“好吧,我刚写了这个程序,结果它比我之前做的任何东西都要快得多。” 你可以长时间不必担心性能优化,除非你遇到某些情况---
尤其是当你处理的项目规模较大时---
你可能会发现需要进行优化。 比如在我们假设的情景下,你的老板找你说,“嘿,我们通过集群层面的分析发现,这个特定的服务是一个瓶颈。” 在这样的背景下,Ian,我很想知道在 Miriah 和 Bryan 提出的基础上,你接下来会怎么做?
Ian Lopshire: 是的,我想我会开始问一些问题,比如“为什么这是瓶颈?它是在丢请求吗?它的响应速度变慢了吗?还是它偶尔会停滞,完全停止运行?” 我觉得很多人认为性能问题就是速度问题,但其实关键是“我是否在我需要的约束条件内运行?” 所以我的第一步是弄清楚这些约束条件是什么,然后我们才能开始进行优化,以满足这些条件。
Bryan Boreham: [无法听清 00:10:05.17] 你可能还需要进一步分析,是每个请求都慢吗?还是某一类请求?或者是来自特定用户的请求?你或许可以归类这些问题,也可能无法归类……但如果能归类的话会非常有帮助,特别是如果问题是可以复现的。最糟糕的问题是那些偶尔发生、你不知道原因、也无法自己触发的问题。所以,找出问题的原因并能够复现它,这非常重要。
Johnny Boursiquot: 我确实想谈谈---
我会稍微概括一下你刚才说的,Ian,关于预期的问题。这个特定服务的性能预期是什么?因为我认为这些预期通常在生产环境中会反映在资源分配上,比如 CPU 或内存的分配。刚开始接触编排工具时,我遇到过一个非常棘手的问题,比如 Docker 或其他类似工具。我发现,“哦,我的程序……” 我运行程序时,它们工作得很好,但当达到某个阈值时,突然就像被卡住了一样,无法继续执行某些操作。它会突然停止运行。我当时就想,“为什么这个程序运行得好好的突然就停了?” 没有堆栈跟踪,也没有错误信息……它就这么被终止了。我后来才意识到,“哦,原来是编排工具---
”不管你用的是 ECS 还是 Kubernetes 或其他工具---
为这个服务分配了固定的 CPU 和内存资源,每当我超出这个分配的资源时,进程就会被杀掉。我当时并不知道我的服务是被那个环境终止的。当我意识到这一点时,我就想,“哦,天哪。” 正如 Ian 所说,你得提前知道这些信息,不管是和运维团队沟通,还是你本身就是运维团队,做一些 SRE(站点可靠性工程)工作,了解“我需要提前做些什么?” 这会帮助你了解你的应用或服务在处理数据时应该做什么……这也引出了另一个问题,如果你需要处理大量数据,并且你觉得在处理这些数据时遇到了问题,接下来该怎么做?你如何找出问题所在?
Bryan Boreham: 嗯,我已经提过分析了,我还会再提一次。
Johnny Boursiquot: 好……
Miriah Peterson: 除了分析还有其他答案吗?这是我一直想知道的。我觉得 Johnny 提到了一个有趣的点---
我从来没做过,但我知道有些人会这样做,就是坐下来计算,“我预计传输多少字节,占用多少空间。” 我一直是那种蛮力派,尽可能多地进行健全性测试,看看什么时候会崩溃。但这会导致另一个问题---
概念上的知识有时并不总是存在。我觉得你不想遇到 CPU 和内存问题。通常你不用担心这些问题,直到你被锁定在机器外面,不得不去解决它们。就像是这样的问题突然出现了,你会想,“好吧,现在怎么办?” 于是你才想到,“我应该设置一个分析器。” 但到那时你已经撞上了墙,已经达到了那个阈值,程序已经崩溃了。这个时候再去考虑,“哦,天哪,我之前从来不用担心的东西现在成了我唯一关心的。” 所以,Bryan,我同意你的看法……
Bryan Boreham: 是的…… 提前去预估资源使用是非常困难的。但我觉得这是随着经验的积累而来的。另外,有一个概念叫“机械同情心”(Mechanical Sympathy),你听说过吗?
译者注:
---注释开始--- “机械同情心”(Mechanical Sympathy)是一个术语,最初由计算机科学家杰夫·阿特伍德(Jeff Atwood)提出,用来描述程序员对计算机系统和软件的深刻理解和同情。它强调了程序员在编写代码时,应理解并考虑计算机的内部工作原理。
主要概念
理解底层机制:
程序员应了解计算机硬件、操作系统和编程语言的运行机制,这样可以编写出更高效、更可靠的代码。
性能优化:
通过理解计算机的工作原理,程序员能够识别和消除性能瓶颈,从而提高软件的整体性能。
错误调试:
对系统内部运作的理解可以帮助开发者更快地找到和修复错误,减少调试时间。
系统设计:
在设计系统时,考虑到硬件和软件的交互可以帮助创建更具可扩展性和可维护性的架构。
实际应用
在编写高性能程序时,程序员会考虑内存管理、CPU缓存、并发处理等因素。 在选择数据结构和算法时,程序员会根据它们的时间复杂度和空间复杂度做出明智的选择。
机械同情心强调了对计算机系统深刻理解的重要性,从而帮助程序员在开发过程中做出更好的决策。
---注释结束---
Ian Lopshire: 这是我打算在这个播客里提到的内容之一。
Johnny Boursiquot: 那我们来聊聊吧。
Bryan Boreham: 我觉得这个概念来自一位一级方程式赛车手,他谈到“如果你理解车子的内部运作方式,你就能更好地驾驶它。” 计算机也有点类似。比如,CPU 每秒可以处理十亿个操作,而这些操作可能就是加两个数这样的简单操作。即便不需要知道太多细节---
即使这个十亿的数字也有点偏差---
我只是做个大致的简化。如果你坐在那里等待计算机返回结果,意味着它可能花了半秒钟左右的时间。那么在这段时间里,CPU 可以执行约五亿个操作。那么你到底在代码里写了些什么,让 CPU 执行了五亿个操作?这是我通常会思考的起点;它到底在做什么?到底是什么让它花了这么长时间来执行我要求的操作?
Johnny Boursiquot: 如果你真的需要处理十亿个操作---
如果是这样,我为你感到遗憾……这确实是个难题…… [笑]
Miriah Peterson: 欢迎来到我的世界。
Johnny Boursiquot: [笑] 如果你不得不处理---
其实,Miriah,作为一个数据处理人员,我知道你处理的数据量和方式可能会与那些编写网络应用程序的人有所不同。当然,你也会涉及一些网络方面的工作,但我觉得如果你处理的是---
我应该这样说---
不可预测的工作负载,那么情况会有所不同。如果你处理的是不可预测的工作负载,这将与处理一组你明确知道的数据有所不同。比如,你知道自己要处理 5GB 的文本处理任务,可能在编写代码时的方法就会和处理一个需要流数据的网络服务不同。这两者是不同的;就像你在处理一个更大数据池中的某个子集一样。
Miriah Peterson: 我觉得这个话题很有趣,也正好可以引入 Go 的讨论。最近我在为一个课程做研究,这个课程的主题是 Go 和数据工程。我一直在使用 Go 的性能分析工具 pprof,我们稍后可以回到这个话题。我一直在用 pprof,试图理解 goroutines 的运行机制……主要是在编排层面上,究竟是使用一个程序中的 goroutine 更好,还是将程序扩展出去更好?应该在 Kubernetes 中水平自动扩展,还是应该在内部使用worker线程?这些都是需要考虑的问题。部分问题涉及到基本的 API 调用,可以这样说。不管你在构建什么程序,Go 使用 io.Reader
和 io.Writer
作为其大部分操作的接口---
无论你是连接数据库,连接流服务,还是连接 API,所有东西都回归到这个层面。所以,不论你是在处理成千上万的 API 调用,还是成千上万的数据库写入,或者是处理成千上万个数据点,Go 的延迟通常不会---
除非你的程序设计有问题---
来自于实际的数据处理或数据点的操作,而是来自于文件系统连接或 API 连接的延迟。
因此,当你在 Go 中设计服务并尝试优化时,那些连接点往往是内存问题的来源。内存问题通常来自于这些连接点或 API 点,即从一个函数向另一个函数,或从一个系统向另一个系统传递数据的连接点,而不是来自于服务的水平扩展。这些问题往往出现在 IO 读写层面,比如“哦糟糕,我忘了关闭我的 writer”或者“哦糟糕,我开了 15 个连接,但实际上只需要一个。” 这些都是问题所在……我觉得不管你在构建什么程序,这些问题都是一样的,因为最终我们都在处理字节……而我发现很多人会转向 Kubernetes 日志或其他东西,而并没有使用 Go 内置的一些工具来帮助我们跟踪这些问题。
Johnny Boursiquot: 我认为在代码中也存在一些简单的错误,同样涉及到读取和 IO。比如,当我教 Go 时,我首先告诉大家的是,如果你需要处理磁盘上的文件,即使文件大小是可预测的,要知道如果你使用 io.ReadAll
,你会把文件的每一个字节都加载到程序的内存中。这是一个容易犯的错误,很多人会想,“哦,我只需将所有内容读入内存,然后逐行进行某种转换,或者统计每一行。” 但实际上,你是在将整个文件加载到内存中。我解释说,“你应该采用流处理的方式,而不是将所有内容都读入内存。” 然后他们会反应过来,“哦,原来我可以这样做?我可以一次读取一行,并逐行处理?” 这就是“哦,原来可以这样做”的那种心态转变。如果我不知道这些库,或者不知道如何避免这些容易出错的方式,这些问题就会非常频繁地发生。
所以,当你遇到这些情况时,这就是我们开始引入更多工具的时候了。pprof 这个工具已经提到过几次了……我们来聊聊 pprof。它是什么?为什么它重要?
Bryan Boreham: pprof 这样的工具的基本原理是,你运行你的程序,让它执行它的任务,分析器每秒会中断 100 次左右。每次中断时,分析器会记录下当前正在执行的代码。经过几秒钟的运行,或者你让它运行的任何时间长度,它会统计这些数据,这就是为什么我们称之为“分析”。你可以统计出程序运行了 10 秒钟,分析器每秒中断 100 次,所以总共有 1000 次记录。在这 1000 次记录中,一半的时间花在了某个函数上,10% 的时间花在了另一个函数上,10% 的时间花在了另一个函数上。这就是分析结果。这个过程就是每秒中断 100 次,记录执行情况,然后加总这些计数,最后在屏幕上绘制出来。 我喜欢一种叫做火焰图的可视化方式,这种图表非常直观……不过在播客里描述并不太好,我现在手舞足蹈地比划着,但这并没有帮助。说真的,如果你从没见过,去找些展示这些图表的视频看看吧。基本上,火焰图上会有一些矩形条,条越长,表示在那个函数上花费的时间越多。所以你只需要看这些大条形图就可以了。
Johnny Boursiquot: 这就是你首先要看的地方……当然,这些是显而易见的标志,但这并不一定意味着你会在这些地方获得最大的优化效果。也许你正在处理的某个函数已经高度优化了,问题可能并不出在函数本身,可能是你传递给它的数据量过多,需要在其他地方优化。所以 pprof 给了你一个显而易见的起点,让你开始深入分析,对吧?
Bryan Boreham: 是的,你接下来可能会做的是---
基本的过程是想出一些方法,看看如何让程序运行更快。你会怎么做呢?你可能会发现你在执行一些不必要的操作,那就跳过这些操作。或者你会找到一个更聪明的方式来执行这些操作。或者你会发现你重复做了某些事情,那就缓存结果,后面再用。你会使用这些技术中的某一种。所以你需要规划一下你的优化路径……这就是你在找的东西。而你刚才提到的,如果某个函数已经高度优化了,那么……如果有人已经在那里应用了所有的优化技术,你可能仍然可以通过并行化进一步优化。Go 是一个非常适合并行化的语言。如果你有足够的 CPU 资源,你可以将任务分解,分别在不同的 goroutines 上并发运行……并行和并发的区别我总是记不住……
Johnny Boursiquot: 我一般玩得比较安全,我会说“并发”。这样比较保险,哈哈……
Miriah Peterson: 这是另一个话题了,我觉得…… [笑]
Johnny Boursiquot: 好的,好的。所以,pprof 工具提供了很多调整的选项,而且确实有很多。不过我通常觉得有趣的是 CPU 分析,它和内存分析是不同的……还有一个追踪功能,可以更清楚地显示 goroutine 和相关的执行情况。我想,Bryan,按照你的思路,你有了一个起点,比如某个函数;接下来你要弄清楚,“有哪些选项?我可以做些什么?” 比如,确定那些“尴尬并行”的问题,看看是否有并发机会你没有利用到。也许问题就是这么简单吧?如果一个函数的多次运行之间没有什么依赖关系,也就是说,数据之间没有依赖,那这可能就是一个很好的并发机会,对吧?你可以启动一些 goroutines……如果你事先知道需要多少个,也许可以用一个等待组;如果不知道,也许可以用一些通道进行通信……然后你就开始逐步剥开问题的层层表面,弄清楚“接下来该怎么做?” 但说到这,我想回到那个函数可能已经优化过的观点……我们怎么知道它已经优化了呢?还有什么工具可以帮助我们确定这个函数在给定的数据下能够稳定地表现?
Miriah Peterson: 你提得很明确,我觉得你是在指向基准测试工具……[笑] 不过在我们讨论基准测试工具之前,我想说……我以前在本地组织了一个 Meetup(现在不再组织了),我曾经为这个 Meetup 准备过关于 pprof 的演讲,还做了一些视频……但老实说,我个人从来记不住 pprof 工具到底是怎么用的。我记不住这些工具的所有含义,也记不住所有图表的意思……Go 的 pprof 里有火焰图,传统的火焰图,还有一个叫做新火焰图的东西。这个工具可以告诉你,“Go 的编译器是否为你做了优化?它是否为你内联了一些函数?” 你可以从火焰图中直接看出来,编译器是否为你进行了优化。所以,第一步,把 pprof 加入到你的程序中。第二步,查看火焰图,看看“是否有函数已经为我内联了?编译器是否已经为我做了一些优化?” 接着,你可能会发现某个函数……你看着那个网页图---
这是他们的另一个工具。顺便说一句,如果你想知道它的具体含义,我在 YouTube 上有一个视频,详细讲解了它。这是可视化的,所以更容易理解。
你可以看到,“哦,这是一个昂贵的函数,它占用了很大的 CPU 和内存……很好,我想看看能不能优化它。让我写一组基准测试,打开内存标志,看看能不能调整这个高成本函数。” 所以,我不觉得这是非此即彼的选择。我认为这些工具需要联合使用。
我个人觉得基准测试总是最后一步,最后的“很好,我的程序已经工作了……我现在试图优化它……也许它不是最优化的状态,但我尽量让它达到 80% 的优化。我已经识别出一些占用大量 CPU 的函数,或者占用大量内存的函数”,你可以通过 pprof 很容易地识别这些问题。“现在让我选择这些函数,打开正确的标志,开始编写基准测试,看看能否优化它们。” 基准测试的工作方式是,不是只运行一次,而是默认情况下在给定的时间窗口内尽可能多地运行它,从而给出一个平均性能。所以你可以看到,“很好,平均来说,我的这个函数分配了 3000 字节。” 这将是一个非常大的问题,你应该解决它。然后你可以问自己,“我能不能让它更低一点?能不能让它不那么占用内存?” 然后你发现,“哦,它每次运行需要 300 毫秒,但旁边的函数每次只需要 20 纳秒?” 也许我可以做一些权衡,这样这个函数就不会成为整个系统的瓶颈。
所以,当我讲解基准测试时,我总是说的两件事是:看看你的函数运行需要多长时间,然后看看你在系统中分配了多少字节。这是我认为可以开始调整的第一个开关。我自己在工作中不是特别常用。我很少需要通过基准测试来证明优化。我从没在那种公司工作过,速度慢到会影响公司盈利……我总是在那种需要把基础设施从一个地方迁移到另一个地方的公司,所以我总能有足够的预算去构建新东西。不管怎样,这仍然是我为大家指明的方向。
Johnny Boursiquot: 你真幸运。
Miriah Peterson: 我知道。当他们告诉我开始修复东西时,我就会说---
Johnny Boursiquot: “我走了。” [笑]
Miriah Peterson: “换新工作吧。去找一个新的‘绿地项目’。” 但这也是为什么我对这个话题如此感兴趣---
也许我没有在工作中用到,但现在我需要学的足够好才能教别人,因为我确实认为它非常重要。所以这是给你的基准测试推荐,Johnny。
Johnny Boursiquot: 很好,很好,很好。Ian,对基准测试的讨论有什么要补充的吗?
Ian Lopshire: 抱歉,我有点走神了……没什么特别的……
Johnny Boursiquot: 你需要基准测试一下你的思路。
Ian Lopshire: 确实……我喜欢它们结合使用的想法。你发现了问题,用 pprof 找到了占用大量资源的地方,比如分配了大量内存,或者使用了很多 CPU 周期……接下来的步骤就是编写基准测试,这样你就能知道你是否真正做出了改变。我喜欢它们结合使用的想法,必须要结合使用,对吧?
Bryan Boreham: 是的,这让它变得可重复。我们最开始的假设例子是,在生产环境中测量某些东西,测量真实发生的事情……但你可能没法那么轻易地重新创建它,而且你也不想在生产环境中做太多实验……所以将特定的任务提取出来,做成一个小的独立程序---
那就是基准测试。然后像 Miriah 说的那样,你可以反复运行它,这样我们就能得到一个平均的时长…… Go的测试框架会为你做这个。
我也做过一些这方面的教学,我觉得结果大概是对半开的。有些人看过基准测试,喜欢它们,经常用它们……而另一半的人基本上从来没接触过基准测试。也许他们在文件里偶尔刷到过几次,但从没真正看过它。
所以我当然会鼓励大家---
这是一个非常简单的模式。你只需要写一个循环,反复运行你感兴趣的东西……而更复杂的部分是设置测试条件。但这与任何单元测试是一样的。它只是一个可以反复运行同一事务的单元测试。现在你真的可以开始迭代了,尝试一些想法,运行基准测试……它变快了吗?还是没有?再尝试其他东西。
一次只改变一件事。这是另一个重要的建议。当你兴奋的时候,你会有很多想法。“我要把它们都写进去。它一定会更快。” 但要一次只改一件事。改一个,测一次。再改一个,再测一次。这样才能真正弄清楚问题出在哪里。
Johnny Boursiquot: 有时候,改变那一件事可能意味着要将它部署到生产环境,看看结果是否真的不同,对吧?
Bryan Boreham: 是的,确实有可能。我是说,这取决于你的基准测试有多好。有些情况下,确实很难模拟真实的生产环境。还有一些需要注意的事情。你知道我之前提到过处理器每秒可以执行数十亿个操作吗?但前提是你不能使用超过几十KB的内存。一旦你超过了 L1 缓存,整个系统的速度就会下降 10 倍。如果你再超过 L2 缓存,速度又会再下降 10 倍。所以,当你在基准测试中试图重现问题时,你需要小心,不要让测试规模太小。因为如果它太小,就会不自然地适应处理器的紧凑缓存环境……
这属于“机械同情”(mechanical sympathy)的一部分知识。要了解处理器架构和不同级别的缓存等方面的知识是非常庞大的任务。我不认为每个人都需要学会这些,但至少应该了解一些基本的东西---
比如,你不想让基准测试的规模太小。你不希望它大到需要一天时间来运行,但也不希望它小到不切实际地快。
Johnny Boursiquot: 说到缓存和内存,关于内存使用优化的整个话题有自己的一套术语。当我第一次学习堆(heap)、栈(stack)和内存分配这些概念时,我心想:“我在编写程序时需要关心这些吗?我需要担心变量的声明和保留吗?Go 是垃圾回收(garbage collected)的语言,难道它不会自动处理这些事情吗?” 我们能不能给内存优化的话题做一些定义?
Miriah Peterson: 不。[笑] 在 Go 的文档中,它明确指出:“你不需要知道什么是写入栈还是堆的区别。” 这是直接从 Go 的官网 go.dev 上摘下来的。我也在幻灯片中引用了这句话,我会告诉大家:“从技术上讲,这并不重要,但从概念上讲,我认为它确实有帮助。” 因为它可以帮助你做出选择,比如“哦,这里我是不是应该使用指针?或者这里我不应该使用指针?” 了解 Go 在幕后如何使用指针是有帮助的。比如,字符串在幕后总是使用指针……所以在 Go 中共享字符串要比共享字节切片(slice of bytes)或其他奇怪的东西更容易。但大多数情况下,这并不重要,因为垃圾回收器会处理它。但我认为它确实重要的时候,是当你做了一些蠢事来阻止垃圾回收器工作……人们经常会这样做。另一个它重要的时刻是,当你看到人们开始从其他语言中引入设计模式时。
我们总是开玩笑说那些从 Java 转来写 Go 的开发者,他们会带来一些可能在 JVM 上运行更好的代码设计,但这些设计并不适用于 Go 的编译器或类型系统。Bill Kennedy[6] 的书《The Ultimate Go Notebook》[7] 是我最喜欢的 Go 书之一,因为它包含了所有那些不常见但很实用的小技巧。其中有一条用加粗字体写着“不要使用 getter 和 setter。” 每次我说这句话时,所有曾经写过 Java 的人都会问:“为什么?!我们需要这些!” 我会回答:“有时候你确实需要它们。” 比如当你有一个私有的类型时,你可能需要通过方法来访问它。是的,这是 getter 和 setter 的一个好用例。但如果是公有类型,Go 编译器可以内联你所有的调用,并为你进行优化,而不是通过一个函数调用来执行这些操作,这样会增加堆栈上的字节数……每个函数都会占用更多的内存,并且需要通过接口进行额外的调用来完成所有这些事情……编译器的目标是快速;它只能在达到某个速度阈值之前进行有限量的内联操作。
所以我们应该按照 Go 的惯用方法来构建代码,这将帮助编译器更快地优化代码。这就是为什么我认为,虽然这些细节本身可能不重要,但如果我们了解这些背景知识,就可以更好地理解为什么 Go 的惯用方法是这样,为什么这段代码是好的,而那段代码是不好的,或者说是“Java 风格的代码”。不是说 Java 不好,只是说在 Go 中写类似 Java 的代码可能会,或者确实会,导致系统效率降低,因为这是一个不同的编译器、不同的系统、不同的类型签名。因此,虽然这些细节本身并不重要,但它们确实能帮助我们写出更好的代码。
Ian Lopshire: 我觉得我们应该把这叫做“编译器同情”(compiler sympathy),或者类似的东西……
Bryan Boreham: 是的……我认为值得尝试理解栈和堆这两者,它们的基本区别在于生命周期。如果你进入一段代码或函数,并且有一些数据,这些数据的生命周期只持续到函数结束。然后 Go 的整个系统---
编译器和运行时会协同工作来进行内存管理。所以,如果你的数据的生命周期仅限于一个函数,编译器可以非常快速地清理它。这就是栈的概念。每次我们调用一个函数,数据会像堆叠一样叠加在我们之前使用的数据之上。而当我们离开函数时,我们可以清理掉所有这些数据,这基本上就是减去一个数字。
而堆则是存放我们不确定生命周期的地方。所以发生的事情是,你还在使用的东西和你不再需要的东西都会放在堆上。而你不再需要的东西,也就是你不再引用的东西---
那就是垃圾。但系统的工作方式是,它会让所有东西堆积起来,直到某个时刻开始进行垃圾回收。而这才是真正影响性能的地方。
那么什么是垃圾回收呢?当 Go 运行时开始垃圾回收时,它会从程序中的那些可以访问数据的地方开始。所以包括所有的全局变量、所有局部变量中的指针等等。它会创建一个列表,接着会说:“好,这个指针指向了什么?哦,这个东西需要用到,我还能访问它。它有指针吗?好,我要访问它们的每一个。这些数据是需要的。当我到达那里时,它们有指针吗?” 这在大程序中,或者说在任何规模的程序中,都是非常繁琐的工作……它需要跟踪所有的指针,这就是会拖慢程序的原因。这就是为什么在 Go 中内存管理对性能非常重要。
影响垃圾回收成本的有两个因素。首先是你有多少指针。这基本上取决于你实际需要的内存有多大。如果你的整个程序只使用 16K 的内存,那就没多少指针。而我的程序通常会使用几 GB 的内存。所以会有成千上万,甚至几百万个指针,它们需要相当多的时间来追踪。
所以,指针的数量(基本上取决于堆的大小)是一个因素。然后是你留下垃圾的速度有多快?你生成新垃圾的速度有多快?这两个因素相乘,就会得出垃圾回收的成本。并且这两个因素都由你使用的内存量决定。第一个是你实际需要的内存量,第二个是你创建并丢弃的垃圾量。
Johnny Boursiquot: 而每次清理运行时,程序实际上都会暂停。
Miriah Peterson: 垃圾回收是一个“暂停世界”(stop the world)操作,没错。不过我从来没有感受到它的明显影响。它不会停止运行时,对吧?
Bryan Boreham: 自从 Go 1.5 以来就没有了。垃圾回收有两个阶段。标记操作---
这一过程叫做“标记-清除”(mark and sweep)。标记阶段是我刚才提到的那个过程,我们会跟踪所有的指针。这个阶段可以并发进行;让我们再用这个词。与……我不知道哪个是并行,哪个是并发。希望会有人在推特上纠正我们。
Miriah Peterson: 可能是并发,因为如果它碰到锁的话就会生气。所以我同意,并发。[笑]
Bryan Boreham: 标记阶段可以和你程序的其他部分同时进行。当标记完成时,当我们知道哪些内存是需要的,哪些是不需要的,我们就进入清除阶段。在这个阶段,我们基本上把所有的垃圾变成可用的内存。这是一个“暂停世界”(stop the world)的操作,不过它真的非常短,通常只需要几微秒。而对于一个 GB 级别的堆,标记操作可能会持续几秒钟。
Miriah Peterson: 我现在要偷用这个解释了。我以前总是把垃圾回收解释为清除阶段……我总是说,垃圾回收的标记是在并行进行的,接着就是垃圾回收。我现在要改用这些术语了,不管它是更混乱还是更清楚,我不知道,但它确实更准确。而准确才是最重要的。所以谢谢你,Bryan,今天教了我这些东西。
Bryan Boreham: 不用谢,乐意之至。在 Go 1.5 之前,整个过程都是 [无法听清 00:41:14.15],这让很多人感到相当不满……但现在它是并行运行的。其实你可以在你的性能分析(profile)中看到这一点。在 CPU 性能分析中,你会看到垃圾回收器在运行。垃圾回收器的名字有点奇怪,它不会直接以大大的“垃圾回收器”字样出现,但你可以看到一些像 mallocgc 这样的函数。通常它们的名字中会有 gc 的字样,你可以在 CPU 性能分析中找到它们。但如果不想让事情变得太复杂,需要注意的不仅仅是标记和清除的时间……因为整个遍历内存的过程会把很多数据踢出 CPU 缓存。
我之前提到过,CPU 中间的那一点是唯一能以最快速度运行的部分……标记过程中,我们扫描所有数据,确定哪些是需要的,哪些是不需要的---
这个过程会踢出缓存的数据,因为它会去访问所有的东西。因此,垃圾回收对程序整体的影响不仅仅是性能分析中显示的垃圾回收时间。
换句话说……如果你查看性能分析,发现垃圾回收占用了你整个 CPU 的 20%,然后你把垃圾回收减少一半,我会预期你的程序速度会提升 40%。因为它的影响是你所能看到的测量值的倍数,通常是两倍左右。 (译者注:个人经验是如果超过30%就要尝试干预,进行优化)
Johnny Boursiquot: 那么,作为程序员---
我可以采用 Miriah 的方法,基本上说“你知道吗?没必要担心我是不是在这里用了指针,或者在那里用了某个值……”嗯,我不确定是否正确理解了---
如果我理解错了,请纠正我,Miriah。如果我说错了,你可以指出来。但你是在说:“不要太担心你是否在用指针,或者是值传递……让垃圾回收器来处理这些问题。或者,先写出能运行的程序,然后再考虑是否有一些内存溢出,或者函数调用时是否有内存逃逸。” 那么,我们究竟应该多关注内存分配的生命周期呢?Bryan 提到的那些内存分配生命周期的影响到底有多大?我们应该如何处理这一问题呢?我还是经常遇到有人问:“什么时候应该用指针?什么时候应该传值?我该怎么做?从性能角度来看,这重要吗?还是说这只是语义问题?我应该返回 nil 还是返回一个零值?我该怎么处理这些问题?”
Miriah Peterson: 我通常会说,所谓的最佳实践---
我的建议是默认不使用指针。然后在一些特殊情况下,这个规则有例外。比如:“哦,你正在使用一个接口,所以你必须有一个实现该接口的类型。” 这些就是例外情况……我不知道,我开始对编写优秀软件和编写优秀的 Go 代码变得挑剔起来。如果你到了某个阶段,所使用的类型已经对垃圾回收速度产生了影响,也许 Go 这个语言并不适合你。你可以去用 Rust,它没有垃圾回收机制,并且让你不得不考虑这些问题。
我不想遇到那样的问题。我使用 Go,我会说“垃圾回收器帮帮我吧。” 我不会使用指针,除非在某些情况下指针确实是最佳选择。我会使用切片,直到需要使用数组的时候。我觉得 Go 的设计是有意地把很多底层的东西抽象掉了,当我们需要用到这些低层知识时,我并不确定 Go 是否是最佳选择。也许我有点唱反调,但我只是用 Go 来做它擅长的事情。而 Go 擅长的是作为一个非常简单的语言,帮你完成很多工作。你应该还是要了解它的工作机制,还是要使用 pprof,做性能基准测试,了解底层原理,并写出优秀的 Go 代码,高效的 Go 代码。但当你超过了某个点,也许你应该考虑一下 Rust。我不知道。再说一次,我还没有遇到那个点,我还在使用 Go。所以这只是我的想法。
Johnny Boursiquot: 但我们经历过那样的一个时代---
我相信你们都记得,在 Go 社区里我们经历过一个时期,那时大家都有点……我们都反对内存分配。我无法告诉你我见过多少关于 HTTP 路由器的性能测试,都是关于“哦,这个是零分配的”,或者“这个没有……” 我们经历过那个阶段……
Miriah Peterson: 那我们为什么还需要垃圾回收器呢?我不知道……只要写出好 Go 代码,而好 Go 代码是使用 Go 提供的工具写出来的。
Bryan Boreham: 我有一个例子……
Miriah Peterson: 你讲吧,Bryan。你比我经验丰富。
Bryan Boreham: …这个例子应该是比较中立的,这是很多人会用到的一个模式……
Miriah Peterson: 我并不是想划清界限,我只是说这是我的经验。你继续,Bryan。
Bryan Boreham:想象一下,在你的程序中你决定要创建一个切片,并向其中加入一些元素。在 Go 语言中,使用 append
是非常便利的。基本上有两种方式来实现这个操作:一种是从一个完全空的切片开始,然后不断地使用 append
操作。假设你要在切片中加入 100 个元素,因此你会不断地 append
,append
,append
,append
…… 在底层,刚开始时并没有分配任何内存,切片是空的,没有分配任何空间。当你放入第一个元素时,它会分配一个位置。然后你再放入另一个元素,它会说:“好吧,空间不够了,这次我要分配更多空间。” 我不想深入探讨细节,但假设下次它会为三个元素分配空间。然后你填满这些空间后,它可能会为八个元素分配空间。不要纠结具体的数值,重点在于那些我们不再需要的小切片会变成垃圾。如果我们一开始就知道要放入 100 个元素,我们可以在一开始就调用一个函数,创建一个有 100 个空间的切片。这样,整个 append
过程就不会产生垃圾。
我希望这个例子在音频中能够比较容易理解…… 有一些非常简单的模式,比如预先分配你的数据结构到一个合适的大小。如果你知道要放入 100 个元素,那就直接创建一个大小为 100 的切片。如果你大概知道是 100 个,可以分配成 120 个,类似这样。因为即使是一次不必要的内存分配,其代价也可能比多分配 10% 或 20% 的空间更大。
如果你不确定是 100 个还是 100 万个元素,那当然,你可能会有一些浪费…… 但尽量接近正确的大小,并倾向于稍微多分配一点,这会帮你提升性能。这类技巧有很多。
Miriah Peterson:我同意。这就是编写优秀 Go 代码的一个例子。我认为这正是你应该处理的方式……如果你能够预测到,比如你知道这个数据会如何表现,那么你应该明确地分配所需的空间。我也同意,切片在底层的实现很有趣。
Johnny Boursiquot:它们的代价一般是很小的,但确实存在一些成本……是的,这是一个很好的建议。随着讨论接近尾声,作为 Go 程序员,还有没有其他类似的显而易见的建议?不一定是为了进行过早优化,而是像你们所说的那样,在日常开发中,如果你知道一个数组或切片的大小,在一开始就进行预分配,这似乎是一个显而易见的好做法---
不一定是在优化的精神下,而是为了编写良好的 Go 代码。你们还有其他类似的最佳实践建议吗?
Bryan Boreham:嗯,映射(maps)也是一样的。切片和映射都可以在创建时指定大小。映射要复杂得多,但基本原理相同:如果你知道会有 1000 个元素,告诉运行时在创建时为 1000 个元素留出空间,这样一切都会运转得更好。
很多节省内存分配的技巧都比较晦涩。不幸的是,除了这些基本操作之外,比如在循环开始时调用 make
,设计接口时,当你调用某个函数并且它返回一个切片时,如果你可以传入目标切片会更好,因为你可能已经有一个大小合适的切片可以传入…… Go 标准库中有一些这样的 API。不过,这确实会让事情变得复杂……
我想说的是,尽量让代码优雅,然后再考虑性能优化。除非真的非常有必要,否则不要打破这个规则。通常优雅的代码本身就能运行得很快。我真的不希望大家把程序搞得乱七八糟,只是因为他们认为这样可以节省几个字节,或者几纳秒。遵循 80/20 法则。大部分时间消耗通常集中在一个地方或几个地方。在这些地方,你可能需要做一些小技巧。
Miriah Peterson:我认为我的大部分建议都很显而易见…… 遵循惯用法,小心使用指针…… 我建议不要在没有必要时使用它们。你需要指针的地方是显而易见的---
我觉得 Bryan 提到了一个很好的点…… 如果你要填充某个数据结构,并且需要修改其中的数据,传递指针并在对象内部修改数据要比传递一个副本并返回新对象好得多。这类操作我认为是编写良好 Go 代码的典范。但我还想说,尤其是对于新手,找一个优秀的代码检查工具(linter)非常重要。所有优秀的代码检查工具都会检查诸如“你是否关闭了 SQL 连接?你是否关闭了 HTTP 连接?你是否关闭了文件连接?” 这些小问题往往是我容易忘记的,导致内存问题…… 它们都很简单,在代码审查中也不一定能被发现。
因此,代码检查工具,尤其是当你对它们设置得非常严格时,能够帮助你编写出优雅的代码,也就是良好的 Go 代码,这可以避免很多愚蠢的内存问题或 CPU 问题…… 当问题真的出现时,它会是一个真正的问题,而不仅仅是因为有人忘记关闭文件。这些是我认为大家应该从一开始就注意的,然后再逐步深入。无论如何,它确实救了我几次。
Johnny Boursiquot:不错。在我们结束之前,Ian 还有什么要补充的吗?
Ian Lopshire:我对于早期优化的看法是:尽量减少工作量。因此,如果你要使用正则表达式,那就编译一次并重复使用。如果你要使用模板,那就编译一次并重复使用。我经常看到,有人在处理器中定义了正则表达式,并且每次都会重新编译。所以,即使在你进行性能分析或基准测试之前,如果你发现有一些不必要的工作量,那就是最低垂的果实---
做更少的工作。
Jingle:(插播音乐)
Johnny Boursiquot:好吧,来吧,我听着呢,谁有不受欢迎的意见?
Miriah Peterson:我先来。
Johnny Boursiquot:请讲。
Miriah Peterson:我有两个不受欢迎的意见。第一个是“巧克力很难吃,因为我不喜欢巧克力。”
Johnny Boursiquot:[笑] 好的。
Miriah Peterson:第二个---
我可能之前提到过。我认为 Python 是一个不适合数据工程的语言。我觉得它非常适合数据分析或数据科学……但对于数据工程本身来说,Python 是一个缓慢、臃肿的语言,它只是封装了其他语言。那么,为什么我们要使用 Python,而不是它底层的那些语言呢?已经有很多人反驳我了,所以我知道这并不受欢迎。
Johnny Boursiquot:不可能吧……[笑] 哇,好激烈的言论。
Ian Lopshire:我希望它是对的……
Miriah Peterson: 希望?这是事实。这是在“不受欢迎的事实”部分,抱歉。
Johnny Boursiquot:哇……
Ian Lopshire:我每天都在 Go 语言中工作……然后为了处理数据,我还得切换到 Python,重新记得怎么用它……所以我很希望不必这么做。但基础设施及其他东西,使用别的语言做起来实在太麻烦了。
Miriah Peterson:你还会经常看到的另一个 Python 场景是,Python 的 SDK 有时并不实际执行代码,只是配置另一个服务。而我想,我们不是已经解决了这个配置问题了吗……这就是为什么我们在 DevOps 世界中使用 YAML,因为它在配置方面比 Python 好得多?所以总之,我认为在数据工程中使用 Python 没有道理。
Johnny Boursiquot:哇。我们看看当我们进行调查时,你会因为这个观点招致多少批评……
Miriah Peterson:我们看看有多少数据工程师在听这个播客吧。这就是我们要看的。
Johnny Boursiquot:是的,这可能会给我们带来全新的听众群体……[笑] Bryan,你有不受欢迎的意见要分享吗?
Bryan Boreham:嗯……我的意见比巧克力要小众得多。我的不受欢迎意见是关于 Prometheus 查询语言 PromQL 的---
我不知道你们是否熟悉它……
Miriah Peterson:我很熟悉。
Bryan Boreham:它有两个类似的函数---``rate
和 irate
。我的观点是永远不要使用 irate
。
Johnny Boursiquot:告诉我们为什么。
Bryan Boreham:它们的区别在于---
你给出一个窗口,对吧?你想查看的时间范围。基本上,当你缩放时,你会查看更大的窗口。比如,当你放大时,你可能会查看一分钟内的 rate
,而当你缩小时,可能是五分钟,进一步缩小可能是一个小时……而 irate
只考虑窗口中的最后两个点。因此,你不应该使用它,因为当你缩小窗口时,它会丢弃越来越多的数据。这大大增加了你会得到错误数据的可能性。假设你查看一个五分钟的窗口,而你只看窗口中最后的两个点。如果每五分钟有一个大的峰值---
它看起来就像这个事情是持续发生的。
Johnny Boursiquot:对。
Bryan Boreham:它确实有用,但使用它的场景非常少,而且你必须对具体情况非常了解,所以我通常会说“永远不要使用 irate
”。
Johnny Boursiquot:这对我来说其实很合理,所以我不确定这会有多不受欢迎……但我相信会有一些人认为这不受欢迎。
Bryan Boreham:是的,irate
被很多人使用。我在各种人的仪表板中都见过它。
Ian Lopshire:i
代表什么?
Bryan Boreham:我认为是“瞬时(instantaneous)”。
Johnny Boursiquot:听起来它是一个针对非常特定用例的工具。
Bryan Boreham:是的,我想人们喜欢它是因为它让你的图表---
因为 rate
会随着缩放平滑数据……而 irate
会保留数据中的峰值。当你使用 irate
时,图表看起来更有活力,显示的内容更多。
Johnny Boursiquot:所以我认为这可能取决于你如何消费和可视化这些数据……
Bryan Boreham:是的。
Johnny Boursiquot:有意思。很酷。我有一个要带回家的观点……最近,苹果发布了一款有趣的开源产品---
这不是经常能看到的:苹果开源。这不太常见。不过他们最近发布了一款让我觉得非常有趣的开源软件。他们推出了一种叫做 pickle
或 pkl
的配置编程语言……我敢说,pkl
比 JSON 和 YAML 加起来还要好。
Bryan Boreham:哇……
Ian Lopshire:它和 CUE 怎么比?
Miriah Peterson:我本来也想问这个问题。
Johnny Boursiquot:这是我脑海中第一个比较的对象。“嗯……CUE 语言。” 所以我要比较一下这两者,然后再汇报。事实上,我正在计划做一期关于 CUE 核心贡献者的节目。也许我会问他们:“嘿,现在你们似乎有了竞争对手。” 我觉得他们可能在解决同类问题---
也许 CUE 有更多的细微差别,但它们可能在要解决的问题上有重叠。所以,是的,我需要更深入地研究……但我看了一个视频,读了一些文档,觉得:“你知道吗?这确实有道理。” 就像我当时看 CUE 时想的那样:“这确实有道理。” 所以我们拭目以待吧。但这就是我的不受欢迎意见。看看结果如何。
太棒了……那么,Ian,你有要补充的吗?
Ian Lopshire:没有……
Johnny Boursiquot:没有……[笑] 他今天只是沉默寡言。好吧,好吧,好吧。那我来结束吧。
#117 Foundations of Go performance: https://changelog.com/gotime/304
[2]Ian: https://github.com/ianlopshire
[3]Johnny: https://github.com/jboursiquot
[4]Miriah Peterson: https://github.com/Soypete
[5]Bryan Boreham: https://x.com/bboreham
[6]Bill Kennedy: https://github.com/ardanlabs
[7]《The Ultimate Go Notebook》: https://www.ardanlabs.com/ultimate-go-notebook/