1. Go语言性能导论
# 1. Go语言性能导论
本书面向具有中级到高级水平的Go语言开发者。这些开发者期望进一步提升Go应用程序的性能。为此,本书将围绕《站点可靠性工程手册》(https://landing.google.com/sre/sre-book/chapters/monitoring-distributed-systems/ )中定义的四个黄金指标展开。如果我们能够降低延迟、减少错误、增加流量并降低饱和度,那么我们的程序性能将会持续提升。对于任何致力于开发高性能Go应用程序的人来说,遵循这四个黄金指标的理念都大有裨益。
在本章中,你将初步接触计算机科学中一些与性能相关的核心概念。你将了解Go编程语言的发展历程,其创造者为何认为将性能置于语言设计的核心至关重要,以及编写高性能Go代码的重要性。Go是一门为性能而生的编程语言,本书将带你深入了解如何利用Go的设计和工具优势,帮助你编写出更高效的代码。
在本章中,我们将涵盖以下主题:
- 理解计算机科学中的性能概念
- Go语言简史
- Go语言性能背后的理念
这些主题将引导你初步了解编写高性能Go代码所需的方向。
# 技术要求
学习本书,你应当对Go语言有一定程度的理解。在探索相关主题之前,需要掌握的一些关键概念包括:
- Go语言参考规范:https://golang.org/ref/spec
- 如何编写Go代码:https://golang.org/doc/code.html
- 高效Go编程:https://golang.org/doc/effective_go.html
本书中包含许多代码示例和基准测试结果,你可以通过GitHub仓库(https://github.com/bobstrecansky/HighPerformanceWithGo/ )获取。
如果你有任何疑问,或者希望对仓库内容提出修改建议,欢迎在仓库(https://github.com/bobstrecansky/HighPerformanceWithGo/issues/new )中创建问题。
# 理解计算机科学中的性能
在计算机科学领域,性能是衡量计算机系统完成工作能力的指标。高性能代码对各类开发者都至关重要。无论你是大型软件公司的一员,需要快速向客户交付大量数据;还是嵌入式计算设备程序员,面临有限的计算资源;又或是热衷于用树莓派做项目的爱好者,期望在自己的项目中实现更多请求处理,性能都应当是你开发过程中首要考虑的因素。尤其是当规模不断扩大时,性能的重要性愈发凸显。
需要牢记的是,我们有时会受到物理条件的限制。CPU、内存、磁盘I/O和网络连接的性能上限都取决于你所购买或从云服务提供商租用的硬件。此外,还有一些系统可能与我们的Go程序同时运行,它们也会消耗资源,比如操作系统组件、日志工具、监控软件和其他二进制文件。要时刻谨记,我们的程序通常并非运行所在物理机器上的唯一“租户”。
优化后的代码通常在多个方面有所助益,包括:
- 缩短响应时间:即响应一个请求所需的总时长。
- 降低延迟:系统中原因与结果之间的时间延迟。
- 提高吞吐量:数据的处理速率。
- 增强可扩展性:在特定系统中能够处理更多工作。
在计算机系统中,有多种方式可以处理更多请求。增加计算机数量(通常称为横向扩展)或升级到性能更强的计算机(通常称为纵向扩展)是常见的应对系统需求的做法。而在无需额外硬件的情况下,提高代码性能是处理更多请求的最快途径之一。性能工程在横向扩展和纵向扩展中都发挥着重要作用。代码性能越高,在单台机器上能够处理的请求就越多。这种模式有可能减少运行工作负载所需的物理主机数量,或降低对物理主机性能的要求,从而节省成本。这对许多企业和个人开发者来说都极具价值,因为它有助于降低运营成本,提升终端用户体验。
# 关于大O符号的简要说明
大O符号(https://en.wikipedia.org/wiki/Big_O_notation )常用于描述函数在输入规模变化时的极限行为。在计算机科学中,大O符号用于阐释不同算法之间的效率差异,我们将在第2章“数据结构与算法”中详细讨论这一内容。大O符号在性能优化中非常重要,因为它可以作为一种比较工具,用于说明算法在规模扩展时的表现。理解大O符号有助于你编写更高性能的代码,因为在编写代码时,它能帮助你在面对不同算法时做出性能方面的决策。了解不同算法在何时具有相对优势和劣势,有助于你针对具体实现做出正确选择。毕竟,无法衡量就难以改进,大O符号为我们量化手头的问题提供了具体方法。
# 衡量长期性能的方法
在进行性能改进的过程中,我们需要持续监控所做的更改,以评估其影响。有许多方法可用于监测计算机系统的长期性能,以下是其中一些示例:
- 布伦丹·格雷格(Brendan Gregg)的USE方法:利用率(Utilization)、饱和度(saturation)和错误(errors)(www.brendangregg.com/usemethod.html)
- 汤姆·威尔基(Tom Wilkie)的RED指标:请求数(Requests)、错误数(errors)和持续时间(duration)(https://www.weave.works/blog/the-red-method-key-metrics-for-microservices-architecture/)
- 谷歌SRE的四个黄金指标:延迟(Latency)、流量(traffic)、错误(errors)和饱和度(saturation)(https://landing.google.com/sre/sre-book/chapters/monitoring-distributed-systems/)
我们将在第15章“跨版本比较代码质量”中进一步探讨这些概念。这些范式有助于我们在代码性能优化方面做出明智决策,同时避免过早优化。对于许多程序员而言,过早优化是一个关键问题。我们常常需要判断什么才算是“足够快”。有时,我们可能会在优化一小段代码上浪费大量时间,而实际上从性能角度来看,其他代码路径可能更有优化的空间。Go语言的简洁性使得在不增加认知负担和代码复杂度的情况下进行额外优化成为可能。我们将在第2章“数据结构与算法”中讨论的算法,将帮助我们避免过早优化。
# 优化策略概述
在本书中,我们还将尝试明确优化的目标。针对CPU或内存利用率的优化技术,与针对I/O或网络延迟的优化可能大相径庭。了解问题所在的领域,以及硬件和上游API的限制,有助于你确定如何针对具体问题进行优化。此外,优化往往存在边际收益递减的情况。通常,基于一些外部因素,对特定代码热点进行开发优化的投入产出比并不理想,而且添加优化可能会降低代码的可读性,增加整个系统的风险。如果你能在早期确定某项优化是否值得进行,就能更有针对性地开展工作,从而打造出性能更优的系统。
了解计算机系统中的基础操作也很有帮助。谷歌研究总监彼得·诺维格(Peter Norvig)设计了一张表格(如下所示),帮助开发者了解典型计算机上常见操作的耗时(https://norvig.com/21-days.html#answers ):
清楚了解计算机不同组件之间的交互方式,有助于我们判断性能优化的方向。从表格中可以看出,顺序从磁盘读取1MB数据所需的时间,远比通过1Gbps网络链路发送2KB数据要长得多。对于常见的计算机交互操作,能够进行大致的估算和比较,有助于判断接下来应该优化代码的哪一部分。当你从整体上审视系统时,确定程序中的瓶颈就会变得更加容易。
将性能问题分解为一个个可管理的小问题,并同时进行改进,这是优化过程中的一个有效思路。试图一次性解决所有性能问题,往往会让开发者感到困惑和沮丧,而且很多时候这样的优化努力会以失败告终。专注于解决当前系统中的瓶颈问题,通常会取得成效。解决一个瓶颈问题后,往往会很快发现下一个瓶颈。例如,在解决了CPU利用率问题后,你可能会发现系统磁盘写入计算结果的速度跟不上。有条理地解决瓶颈问题是创建高性能、可靠软件的最佳方法之一。
# 优化级别
从下图金字塔的底部开始,逐步向上推进。该图展示了进行性能优化时建议的优先级顺序。金字塔的前两个层级,即设计层和算法与数据结构层,通常能为实际的性能优化提供丰富的目标。以下图表展示了一种通常较为高效的优化策略。在改进程序算法和数据结构的同时,对程序设计进行调整,往往是提高代码速度和质量的最有效途径:
设计层面的决策通常对性能有着最为显著的影响。在设计阶段确定目标,有助于确定最佳的优化方法。例如,如果我们要优化一个磁盘I/O速度较慢的系统,就应该优先减少对磁盘的调用次数。反之,如果要优化一个计算资源有限的系统,我们就需要仅计算程序响应所必需的值。在新项目开始时创建一份详细的设计文档,有助于明确性能提升的关键所在,以及如何在项目中合理分配时间。从计算系统中数据传输的角度去思考,往往能够发现可优化的地方。我们将在第3章“理解并发”中进一步探讨设计模式。
算法和数据结构的选择通常会对计算机程序的性能产生显著影响。在编写高性能代码时,我们应着重使用常数时间复杂度O(1)、对数时间复杂度O(log n)、线性时间复杂度O(n)和对数线性时间复杂度O(nlog n)的函数。在大规模应用中,避免使用平方时间复杂度O(n²)的算法对于编写可扩展的程序也非常重要。我们将在第2章“数据结构与算法”中深入探讨大O符号及其与Go语言的关系。
# Go语言简史
罗伯特·格里塞默(Robert Griesemer)、罗布·派克(Rob Pike)和肯·汤普森(Ken Thompson)于2007年创造了Go编程语言。它最初被设计为一种通用语言,尤其侧重于系统编程领域。创造者在设计Go语言时秉持了以下几个核心原则:
- 静态类型
- 运行时效率
- 可读性
- 实用性
- 易于学习
- 高性能网络和多处理能力
Go语言于2009年正式对外发布,2012年3月3日发布了1.0.3版本。在撰写本书时,Go 1.14版本已经发布,Go 2版本也即将问世。如前所述,Go语言最初的核心架构考量之一就是具备高性能的网络和多处理能力。本书将深入探讨格里塞默、派克和汤普森为这门语言所实施和倡导的诸多设计理念。他们创造Go语言是因为对C++语言的一些选择和发展方向感到不满。大型分布式编译集群长期存在的复杂问题给他们带来了极大困扰。当时,他们了解到C++语言的下一个版本C++x11计划引入众多新特性,于是Go语言团队决定在他们用于工作的编程语言中采用“少即是多”的理念。
Go语言的创造者们首次会面时,讨论了以C语言为基础进行开发,添加所需特性并去除他们认为对语言不重要的功能。但最终,他们选择了从头开始构建,仅借鉴了C语言和其他他们熟悉的语言中一些最基本的部分。随着工作逐步成型,他们意识到去掉了其他语言的一些核心特性,特别是头文件、循环依赖和类。他们认为,即便去除了许多这样的内容,Go语言仍然比它的前辈们更具表现力。
# Go标准库
Go语言的标准库也遵循同样的模式。它在设计时兼顾了简洁性和功能性。将切片(slices)、映射(maps)和复合字面量(composite literals)添加到标准库中,使得Go语言在早期就形成了自己的风格。Go标准库位于$GOROOT
目录下,可直接导入。这些内置的默认数据结构使开发者能够高效地使用它们。标准库软件包随Go语言发行版一起提供,安装Go语言后即可立即使用。人们常说,标准库是编写地道Go语言代码的可靠参考。这是因为这些核心库代码编写得清晰、简洁,且上下文丰富。它们还很好地添加了一些虽小但很重要的实现细节,比如能够为连接设置超时时间,以及明确地从底层函数收集数据。这些语言细节推动了Go语言的蓬勃发展。
Go运行时的一些显著特性包括:
- 用于安全内存管理的垃圾回收机制(一种并发的三色标记 - 清除垃圾回收器)。
- 支持同时处理多个任务的并发功能(更多内容见第3章“理解并发”)。
- 用于内存优化的栈管理(最初的实现使用分段栈,当前Go语言的栈管理采用栈复制方式) 。
# Go工具集
Go语言的二进制发行版还包含了大量用于创建优化代码的工具。在Go二进制文件中,go
命令有许多功能,可帮助构建、部署和验证代码。下面我们讨论一些与性能相关的核心功能。
Godoc
是Go语言的文档工具,它将文档的关键内容置于程序开发的重要位置。清晰的实现、深入的文档以及模块化都是构建可扩展、高性能系统的核心要素。Godoc
通过自动生成文档来助力实现这些目标。它从在$GOROOT
和$GOPATH
中找到的软件包中提取并生成文档。生成文档后,Godoc
会运行一个Web服务器,并将生成的文档以网页形式展示出来。Go官方网站上可以查看标准库的文档。例如,标准库pprof
包的文档可在https://golang.org/pkg/net/http/pprof/ 找到。
gofmt
(Go语言的代码格式化工具)的加入为Go语言带来了不同类型的性能提升。gofmt
的出现使Go语言在代码格式化方面有了明确的规范。精确的强制格式化规则使得开发者在编写Go代码时可以专注于代码逻辑,同时让工具将代码格式化为符合Go项目统一模式的样式。许多开发者会在保存正在编写的文件时,让他们的集成开发环境(IDE)或文本编辑器执行gofmt
命令。
一致的代码格式减少了认知负担,使开发者能够专注于代码的其他方面,而无需纠结于使用制表符还是空格来缩进代码。减轻认知负担有助于提高开发者的工作效率和项目推进速度。
Go语言的构建系统也对性能有所帮助。go build
命令是一个强大的工具,用于编译软件包及其依赖项。Go的构建系统在依赖管理方面也很有用。构建系统的最终输出是一个经过编译的静态链接二进制文件,其中包含在目标平台上运行所需的所有必要元素。go module
(Go 1.11版本引入初步支持,1.13版本正式确定的新功能)是Go语言的依赖管理系统。为一门语言提供明确的依赖管理,有助于将版本化的软件包组合作为一个内聚单元,提供一致的体验,实现更具可重复性的构建。可重复性构建有助于开发者通过可验证的路径从源代码创建二进制文件。在项目中创建vendor
目录(可选步骤)也有助于在本地存储和满足项目的依赖项。
编译后的二进制文件也是Go生态系统的重要组成部分。Go语言还允许你为其他目标环境构建二进制文件,如果你需要为另一种计算机架构进行交叉编译,这会非常有用。能够构建可在任何平台上运行的二进制文件,有助于你快速迭代和测试代码,在替代架构上发现性能瓶颈,避免问题在后期变得更难修复。Go语言的另一个关键特性是,你可以在一台机器上使用操作系统和架构标志编译二进制文件,然后在另一台系统上直接运行该二进制文件。当构建系统拥有大量系统资源,而构建目标的计算资源有限时,这一点至关重要。为两种不同架构构建二进制文件非常简单,只需设置构建标志即可:
- 要为x86_64架构的macOS X构建二进制文件,使用以下执行模式:
GOOS=darwin GOARCH=amd64 go build -o myapp.osx
- 要为ARM架构的Linux构建二进制文件,使用以下执行模式:
GOOS=linux GOARCH=arm go build -o myapp.linuxarm
你可以使用以下命令查看所有有效的GOOS
和GOARCH
组合列表:
go tool dist list -json
这有助于你了解Go语言可以为哪些CPU架构和操作系统编译二进制文件。
# 基准测试概述
基准测试的概念也是本书的核心内容之一。Go语言的测试功能将性能测试作为一等公民。在开发和发布过程中能够触发测试基准,有助于持续交付高性能代码。随着新的副作用引入、功能增加以及代码复杂度上升,通过一种方法来验证代码库中的性能回归变得至关重要。许多开发者将基准测试结果纳入持续集成实践中,以确保在向代码仓库添加所有新的拉取请求后,代码仍能保持高性能。你还可以使用golang.org/x/perf/cmd/benchstat
包中提供的benchstat
工具来比较基准测试的统计信息。在https://github.com/bobstrecansky/HighPerformanceWithGo/tree/master/1-introduction 这个示例代码库中,有一个对标准库排序函数进行基准测试的示例。
标准库将测试和基准测试紧密结合,鼓励将性能测试作为代码发布过程的一部分。需要始终牢记的是,基准测试并不总是能反映真实世界的性能场景,所以对测试结果要持谨慎态度。对正在运行的系统进行日志记录、监控、性能分析(见第12章“分析Go代码性能”)和跟踪(见第13章“跟踪Go代码”;第15章“跨版本比较代码质量”),有助于在提交代码后验证你在基准测试中所做的假设。
# Go性能背后的理念
Go语言的性能优势很大程度上源于并发和并行处理能力。协程(Goroutines)和通道(Channels)常用于并行执行多个请求。Go语言提供的工具帮助开发者在保持代码可读性的同时,实现接近C语言的性能。这也是Go语言在大规模解决方案中被开发者广泛使用的众多原因之一。
# 协程——从一开始就具备的性能优势
在Go语言诞生之际,多核处理器在商用硬件中越来越普及。Go语言的创造者意识到新语言需要具备并发编程能力。Go语言通过协程和通道(我们将在第3章“理解并发”中讨论)让并发编程变得轻松。协程是一种轻量级的计算线程,与操作系统线程不同,它常被视为Go语言的最佳特性之一。协程并行执行代码,完成工作后即结束。协程的启动时间比线程更快,这使得程序中能够进行更多的并发操作。与依赖操作系统线程的Java等语言相比,Go语言的多处理模型效率更高。
Go语言在处理协程的阻塞操作方面也很智能。这有助于提高Go语言在内存利用、垃圾回收和延迟方面的性能。Go运行时使用GOMAXPROCS
变量将协程多路复用到真实的操作系统线程上。我们将在第2章“数据结构与算法”中进一步了解协程。
# 通道——一种类型化的管道
通道为协程之间发送和接收数据提供了一种模型,并且无需借助底层平台提供的同步原语。通过合理设计协程和通道,我们可以实现高性能。通道可以是有缓冲的,也可以是无缓冲的。因此,开发者可以通过打开的通道传递动态数量的数据,直到接收方接收到数据,此时发送方会解除通道的阻塞。如果通道是有缓冲的,发送方会在缓冲区填满之前阻塞。一旦缓冲区被填满,发送方将解除通道的阻塞。最后,可以调用close()
函数来表明该通道不会再接收任何值。我们将在第3章“理解并发”中进一步了解通道。
# 媲美C语言的性能
Go语言的另一个初始目标是在类似程序中达到与C语言相当的性能。Go语言还内置了丰富的性能分析和跟踪工具,我们将在第12章“分析Go代码性能”和第13章“跟踪Go代码”中学习这些内容。Go语言使开发者能够查看协程的使用情况、通道、内存和CPU利用率以及与各个调用相关的函数调用的详细信息。这非常有价值,因为Go语言通过数据和可视化让排查性能问题变得更加容易。
# 大规模分布式系统
由于操作简单且标准库中内置了网络原语,Go语言常被用于大规模分布式系统。在开发过程中能够快速迭代是构建强大、可扩展系统的关键部分。在分布式系统中,高网络延迟通常是一个问题,Go语言团队致力于在其平台上缓解这一问题。从标准库的网络实现到将gRPC作为在分布式平台上客户端和服务器之间传递缓冲消息的一流工具,Go语言开发者将分布式系统问题置于语言设计的重要位置,并为这些复杂问题提出了一些巧妙的解决方案。
# 总结
在本章中,我们学习了计算机科学中性能的核心概念。我们还了解了Go计算机编程语言的一些历史,以及它的诞生与性能工作的直接联系。最后,我们认识到由于Go语言的实用性、灵活性和可扩展性,它在众多不同场景中得到了应用。本章介绍的概念将在本书后续内容中不断拓展,帮助你重新思考编写Go代码的方式。
在第2章“数据结构与算法”中,我们将深入探讨数据结构和算法。我们将学习不同的算法、它们的大O符号表示,以及这些算法在Go语言中的构建方式。我们还将了解这些理论算法与实际问题的关联,并编写高性能的Go代码,以快速、高效地处理大量请求。深入学习这些算法将帮助你在本章前面提到的优化三角的第二层中提高效率。