Go 高性能实用指南(全)
原文:
zh.annas-archive.org/md5/CBDFC5686A090A4C898F957320E40302
译者:飞龙
前言
《Go 高性能实战》是一个完整的资源,具有经过验证的方法和技术,可帮助您诊断和解决 Go 应用程序中的性能问题。本书从性能概念入手,您将了解 Go 性能背后的思想。接下来,您将学习如何有效地实现 Go 数据结构和算法,并探索数据操作和组织,以便编写可扩展软件的程序。通道和 goroutines 用于并行和并发,以编写分布式系统的高性能代码也是本书的核心部分。接着,您将学习如何有效地管理内存。您将探索CUDA驱动API,使用容器构建 Go 代码,并利用 Go 构建缓存加快编译速度。您还将清楚地了解如何对 Go 代码进行性能分析和跟踪,以检测系统中的瓶颈。最后,您将评估集群和作业队列以进行性能优化,并监视应用程序以检测性能回归。
本书适合对象
这本 Go 书对于具有中级到高级 Go 编程理解的开发人员和专业人士来说是必不可少的,他们有兴趣提高代码执行速度。
本书涵盖内容
第一章《Go 性能简介》将讨论计算机科学中性能为何重要。您还将了解为什么 Go 语言中性能很重要。
第二章《数据结构和算法》涉及数据结构和算法,它们是构建软件的基本单元,尤其是复杂性能软件。理解它们将帮助您思考如何最有效地组织和操作数据,以编写有效的、高性能的软件。此外,迭代器和生成器对于 Go 是必不可少的。本章将包括不同数据结构和算法的解释,以及它们的大 O 符号是如何受影响的。
第三章《理解并发》将讨论利用通道和 goroutines 进行并行和并发,这在 Go 中是惯用的,也是在系统中编写高性能代码的最佳方式。能够理解何时何地使用这些设计模式对于编写高性能的 Go 是至关重要的。
第四章《Go 中的 STL 算法等价物》讨论了许多来自其他高性能语言(尤其是 C++)的程序员如何理解标准模板库的概念,该库提供了常见的编程数据结构和函数,以便快速迭代和编写大规模的高性能代码。
第五章《Go 中的矩阵和向量计算》涉及一般的矩阵和向量计算。矩阵在图形处理和人工智能中很重要,特别是图像识别。向量可以在动态数组中保存大量对象。它们使用连续存储,并可以被操作以适应增长。
第六章《编写可读的 Go 代码》着重于编写可读的 Go 代码的重要性。理解本章讨论的模式和习惯用法将帮助您编写更易读、更易操作的 Go 代码。此外,能够编写习惯用法的 Go 将有助于提高代码质量,并帮助项目保持速度。
第七章《Go 中的模板编程》专注于 Go 中的模板编程。元编程允许最终用户编写生成、操作和运行 Go 程序的 Go 程序。Go 具有清晰的静态依赖关系,这有助于元编程。它在元编程方面存在其他语言所没有的缺点,比如 Python 中的__getattr__
,但如果被认为明智,我们仍然可以生成 Go 代码并编译生成的代码。
第八章《Go 中的内存管理》讨论了内存管理对系统性能至关重要。能够充分利用计算机的内存占用量使您能够将高性能程序保留在内存中,这样您就不必经常承受切换到磁盘的巨大性能损失。有效地管理内存是编写高性能 Go 代码的核心原则。
第九章《Go 中的 GPU 并行化》专注于 GPU 加速编程,在当今高性能计算堆栈中变得越来越重要。我们可以使用 CUDA 驱动程序 API 进行 GPU 加速。这在诸如深度学习算法等主题中通常被使用。
第十章《Go 中的编译时评估》讨论了在编写 Go 程序时最小化依赖关系以及每个文件声明自己的依赖关系。常规语法和模块支持也有助于提高编译时间,以及接口满足。这些都有助于加快 Go 编译速度,同时利用容器构建 Go 代码并利用 Go 构建缓存。
第十一章《构建和部署 Go 代码》着重介绍了如何部署新的 Go 代码。更进一步地,本章解释了我们如何将其推送到一个或多个地方,以便针对不同环境进行测试。这样做将使我们能够推动系统的吞吐量极限。
第十二章《Go 代码性能分析》专注于对 Go 代码进行性能分析,这是确定 Go 函数中瓶颈所在的最佳方法之一。进行这种性能分析将帮助您推断在函数内部可以进行哪些改进,以及在整个系统中个别部分在函数调用中所占用的时间。
第十三章《跟踪 Go 代码》介绍了一种检查 Go 程序中函数和服务之间互操作性的绝妙方法,也称为跟踪。跟踪允许您通过系统传递上下文并评估您所卡住的位置。无论是第三方 API 调用、缓慢的消息队列还是 O(n²)函数,跟踪都将帮助您找到瓶颈所在。
第十四章《集群和作业队列》着重介绍了集群和作业队列在 Go 中的重要性,作为使分布式系统同步工作并传递一致消息的良好方式。分布式计算很困难,因此在集群和作业队列中寻找潜在的性能优化变得非常重要。
第十五章《跨版本比较代码质量》讨论了在编写、调试、分析和监控长期监控应用程序性能的 Go 代码之后,您应该做些什么。如果您无法继续提供基础架构中其他系统所依赖的性能水平,那么向您的代码添加新功能是毫无意义的。
为了充分利用本书
本书适用于 Go 专业人士和开发人员,他们希望加快代码执行速度,因此需要具有中级到高级的 Go 编程理解才能充分利用本书。Go 语言的系统要求相对较低。现代计算机和现代操作系统应该支持 Go 运行时及其依赖项。Go 在许多低功耗设备上使用,这些设备具有有限的 CPU、内存和 I/O 要求。
您可以在以下网址查看语言的要求:github.com/golang/go/wiki/MinimumRequirements
。
在本书中,我使用 Fedora Core Linux(在撰写本书时为第 29 版)作为操作系统。有关如何安装 Fedora Workstation Linux 发行版的说明,请访问以下网址:getfedora.org/en/workstation/download/
。
Docker 在本书的许多示例中使用。您可以在以下网址查看 Docker 的要求:docs.docker.com/install/
。
在第九章中,《Go 中的 GPU 并行化》,我们讨论了 GPU 编程。要执行本章的任务,您需要以下两种东西之一:
-
启用 NVIDIA 的 GPU。我在测试中使用了一款 NVIDIA GeForce GTX 670,计算能力为 3.0。
-
启用 GPU 的云实例。第九章讨论了几种不同的提供商和方法。Compute Engine 上的 GPU 适用于此。有关 Compute Engine 上 GPU 的最新信息,请访问以下网址:
cloud.google.com/compute/docs/gpus
。
阅读本书后,希望您能够编写更高效的 Go 代码。您将有望能够量化和验证自己的努力。
下载示例代码文件
您可以从您在www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册,直接将文件发送到您的邮箱。
按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择“支持”选项卡。
-
单击“代码下载”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保您使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip 适用于 Windows
-
Zipeg/iZip/UnRarX 适用于 Mac
-
7-Zip/PeaZip 适用于 Linux
本书的代码包也托管在 GitHub 上,网址为github.com/bobstrecansky/HighPerformanceWithGo/
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/
上找到。快去看看吧!
实际代码
本书的实际代码视频可在bit.ly/2QcfEJI
上观看。
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:static.packt-cdn.com/downloads/9781789805789_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"以下代码块将显示Next()
咒语"
代码块设置如下:
// Note the trailing () for this anonymous function invocation
func() {
fmt.Println("Hello Go")
}()
当我们希望引起你对代码块的特定部分的注意时,相关的行或项目会以粗体显示:
// Note the trailing () for this anonymous function invocation
func() {
fmt.Println("Hello Go")
}()
任何命令行输入或输出都是这样写的:
$ go test -bench=. -benchtime 2s -count 2 -benchmem -cpu 4
粗体:表示一个新术语,一个重要的词,或者你在屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“reverse algorithm接受一个数据集,并颠倒集合的值”
警告或重要提示会以这种方式出现。
技巧和窍门会以这种方式出现。
第一部分:学习 Go 语言中的性能
在这一部分,您将学习为什么计算机科学中的性能很重要。您还将了解为什么 Go 语言中的性能很重要。接下来,您将学习有关数据结构和算法、并发、STL 算法等价物以及在 Go 中的矩阵和向量计算。
本节的章节包括以下内容:
-
第一章,“Go 语言性能简介”
-
第二章,“数据结构和算法”
-
第三章,“理解并发”
-
第四章,“Go 中的 STL 算法等价物”
-
第五章,“在 Go 语言中的矩阵和向量计算”
第一章:Go 性能简介
本书是针对中级到高级 Go 开发人员编写的。这些开发人员将希望从其 Go 应用程序中挤出更多性能。为此,本书将帮助推动《Site Reliability Engineering Workbook》中定义的四个黄金信号(landing.google.com/sre/sre-book/chapters/monitoring-distributed-systems/
)。如果我们能减少延迟和错误,同时增加流量并减少饱和,我们的程序将继续更加高效。遵循四个黄金信号的理念对于任何以性能为目标开发 Go 应用程序的人都是有益的。
在本章中,您将介绍计算机科学性能的一些核心概念。您将了解 Go 计算机编程语言的一些历史,其创建者是如何决定将性能置于语言的前沿,并且为什么编写高性能的 Go 很重要。Go 是一种以性能为重点设计的编程语言,本书将带您了解如何利用 Go 的设计和工具来提高性能。这将帮助您编写更高效的代码。
在本章中,我们将涵盖以下主题:
-
了解计算机科学中的性能
-
Go 的简要历史
-
Go 性能背后的理念
这些主题旨在指导您开始了解在 Go 语言中编写高性能代码所需的方向。
技术要求
对于本书,您应该对 Go 语言有一定的了解。在探索这些主题之前,了解以下一些关键概念是很重要的:
-
Go 参考规范:
golang.org/ref/spec
-
如何编写 Go 代码:
golang.org/doc/code.html
-
Effective Go:
golang.org/doc/effective_go.html
在本书中,将提供许多代码示例和基准结果。所有这些都可以通过 GitHub 存储库访问github.com/bobstrecansky/HighPerformanceWithGo/
。
如果您有问题或想要请求更改存储库,请随时在存储库内创建问题github.com/bobstrecansky/HighPerformanceWithGo/issues/new
。
了解计算机科学中的性能
计算机科学中的性能是计算机系统可以完成的工作量的衡量标准。高性能的代码对许多不同的开发人员群体至关重要。无论您是大型软件公司的一部分,需要快速向客户交付大量数据,还是嵌入式计算设备程序员,可用的计算资源有限,或者是业余爱好者,希望从用于宠物项目的树莓派中挤出更多请求,性能都应该是您开发思维的前沿。性能很重要,特别是当您的规模不断增长时。
重要的是要记住,我们有时会受到物理限制。 CPU、内存、磁盘 I/O 和网络连接性都有性能上限,这取决于您从云提供商购买或租用的硬件。还有其他系统可能会与我们的 Go 程序同时运行,也会消耗资源,例如操作系统软件包、日志记录工具、监控工具和其他二进制文件——要记住,我们的程序很频繁地不是物理机器上唯一的租户。
优化的代码通常在许多方面有所帮助,包括以下内容:
-
响应时间减少:响应请求所需的总时间。
-
降低延迟:系统内因果关系之间的时间延迟。
-
增加吞吐量:数据处理速率。
-
更高的可扩展性:可以在一个封闭系统内处理更多的工作。
在计算机系统中有许多方法可以处理更多的请求。增加更多的个体计算机(通常称为横向扩展)或升级到更强大的计算机(通常称为纵向扩展)是处理计算机系统需求的常见做法。在不需要额外硬件的情况下,提高代码性能是服务更多请求的最快方法之一。性能工程既可以帮助横向扩展,也可以帮助纵向扩展。代码性能越高,单台机器就能处理更多的请求。这种模式可能导致运行工作负载的物理主机减少或更便宜。这对许多企业和爱好者来说是一个巨大的价值主张,因为它有助于降低运营成本,改善最终用户体验。
Big O 符号简要说明
Big O 符号(en.wikipedia.org/wiki/Big_O_notation
)通常用于描述基于输入大小的函数的极限行为。在计算机科学中,Big O 符号用于解释算法相对于彼此的效率——我们将在第二章中更详细地讨论这一点,数据结构和算法。Big O 符号在优化性能方面很重要,因为它被用作比较运算符,解释算法的扩展性如何。了解 Big O 符号将帮助您编写更高性能的代码,因为它将在代码编写时帮助您做出性能决策。了解不同算法在何时具有相对优势和劣势的点,将帮助您确定实现的正确选择。我们无法改进我们无法衡量的东西——Big O 符号帮助我们对手头的问题陈述给出一个具体的衡量。
衡量长期性能的方法
在进行性能改进时,我们需要不断监视我们的变化以查看影响。有许多方法可以用来监视计算机系统的长期性能。其中一些方法的例子如下:
-
Brendan Gregg 的 USE 方法:利用率、饱和度和错误(www.brendangregg.com/usemethod.html)
-
Tom Wilkie 的 RED 指标:请求、错误和持续时间(
www.weave.works/blog/the-red-method-key-metrics-for-microservices-architecture/
) -
Google SRE 的四个黄金信号:延迟、流量、错误和饱和度(
landing.google.com/sre/sre-book/chapters/monitoring-distributed-systems/
)
我们将在第十五章中进一步讨论这些概念,跨版本比较代码质量。这些范式帮助我们做出关于代码性能优化的明智决策,避免过早优化。过早优化对许多计算机程序员来说是非常关键的一个方面。我们经常不得不确定什么是足够快。当许多其他代码路径有机会从性能角度进行改进时,我们可能会浪费时间尝试优化一小部分代码。Go 的简单性允许进行额外的优化,而不会增加认知负担或增加代码复杂性。我们将在第二章中讨论的算法,将帮助我们避免过早优化。
优化策略概述
在这本书中,我们还将尝试理解我们到底在优化什么。优化 CPU 或内存利用率的技术可能看起来与优化 I/O 或网络延迟的技术大不相同。了解问题空间以及硬件和上游 API 中的限制将帮助您确定如何针对手头的问题陈述进行优化。优化通常也会显示出递减的回报。经常情况下,基于外部因素,特定代码热点的开发投资回报不值得,或者添加优化会降低可读性并增加整个系统的风险。如果您能够早期确定优化是否值得进行,您将能够更加狭窄地聚焦,并可能继续开发更高性能的系统。
了解计算机系统中的基线操作可能是有帮助的。Peter Norvig,谷歌研究总监,设计了一张表(随后的图片),帮助开发人员了解典型计算机上各种常见的时间操作(norvig.com/21-days.html#answers
)。
清楚地了解计算机的不同部分如何相互协作有助于我们推断出我们的性能优化应该放在哪里。从表中得出,从磁盘顺序读取 1 MB 的数据所需的时间要比通过 1 Gbps 网络链路发送 2 KB 的数据要长得多。当您能够对常见的计算机交互进行草稿计算比较运算符时,可以帮助您推断出下一个应该优化的代码部分。当您退后一步并全面审视系统的快照时,确定程序中的瓶颈变得更容易。
将性能问题分解为可以同时改进的小而可管理的子问题是一种有助于优化的转变。试图一次解决所有性能问题通常会让开发人员感到受挫和沮丧,并且经常导致许多性能努力失败。专注于当前系统中的瓶颈通常会产生结果。解决一个瓶颈通常会很快地发现另一个。例如,解决了 CPU 利用率问题后,您可能会发现系统的磁盘无法快速写入计算出的值。以结构化方式解决瓶颈是创建高性能和可靠软件的最佳方法之一。
优化级别
从下图的金字塔底部开始,我们可以逐步向上发展。这张图表显示了进行性能优化的建议优先级。这个金字塔的前两个级别——设计级别和算法和数据结构级别——通常会提供更多的现实世界性能优化目标。以下图表显示了一种通常有效的优化策略。改变程序的设计以及算法和数据结构往往是提高代码质量和速度的最有效的地方:
设计层面的决策通常对性能有最明显的影响。在设计层面确定目标可以帮助确定最佳的优化方法。例如,如果我们正在为一个具有缓慢磁盘 I/O 的系统进行优化,我们应该优先降低对磁盘的调用次数。相反,如果我们正在为一个具有有限计算资源的系统进行优化,我们需要计算程序响应所需的最基本值。在新项目开始时创建详细的设计文档将有助于理解性能提升的重要性以及如何在项目中优先考虑时间。从在计算系统内传输有效载荷的角度思考往往会导致注意到优化可能发生的地方。我们将在《理解并发》的第三章中更多地讨论设计模式。
算法和数据结构的决策通常会对计算机程序产生可衡量的性能影响。在编写高性能代码时,我们应该专注于尝试利用常数 O(1)、对数 O(log n)、线性 O(n)和对数线性 O(n log n)函数。在规模上避免二次复杂度 O(n²)对于编写可扩展的程序也很重要。我们将在《数据结构和算法》的第二章中更多地讨论 O 符号及其与 Go 语言的关系。
Go 的简要历史
Robert Griesemer、Rob Pike 和 Ken Thompson 于 2007 年创建了 Go 编程语言。最初,它被设计为一种以系统编程为重点的通用语言。语言的创造者们在设计 Go 语言时考虑了一些核心原则:
-
静态类型
-
运行效率
-
可读性
-
可用性
-
易学习
-
高性能网络和多处理
Go 于 2009 年公开宣布,v1.0.3 于 2012 年 3 月 3 日发布。在撰写本书时,Go 版本 1.14 已发布,Go 版本 2 也即将推出。正如前面提到的,Go 最初的核心架构考虑之一是具有高性能的网络和多处理。本书将涵盖 Griesemer、Pike 和 Thompson 实施和宣传的许多设计考虑。设计者们创建 Go 是因为他们对 C++语言中做出的一些选择和方向感到不满。长时间运行的大型分布式编译集群是创作者们的主要痛点。在此期间,作者们开始了解下一个 C++编程语言版本的发布,被称为 C++x11。这个 C++版本计划中有很多新功能,Go 团队决定他们想要在他们的工作中采用“少即是多”的计算语言习惯。
语言的作者们在第一次会议上讨论了从 C 编程语言开始,构建功能并删除他们认为对语言不重要的多余功能。最终,团队从零开始,只借用了一些最基本的 C 和其他编程语言的部分。在他们的工作开始形成后,他们意识到他们正在剥夺其他语言的一些核心特性,尤其是没有头文件、循环依赖和类。作者们相信,即使剥夺了许多这些片段,Go 仍然可以比其前身更具表现力。
Go 标准库
Go 标准库遵循相同的模式。它旨在同时考虑简单性和功能性。将切片,映射和复合文字添加到标准库有助于语言早期变得有见地。Go 的标准库位于$GOROOT
中,并且可以直接导入。将这些默认数据结构内置到语言中使开发人员能够有效地使用这些数据结构。标准库包与语言分发捆绑在一起,并在安装 Go 后立即可用。经常提到标准库是如何编写符合惯用法的 Go 的可靠参考。标准库符合惯用法的原因是这些核心库部分清晰,简洁,并且具有相当多的上下文。它们还很好地添加了一些小但重要的实现细节,例如能够为连接设置超时,并明确地能够从底层函数中收集数据。这些语言细节有助于语言的繁荣。
一些显着的 Go 运行时特性包括以下内容:
-
垃圾收集以进行安全内存管理(并发的,三色的,标记-清除收集器)
-
并发性以支持同时执行多个任务(关于这一点,可以在第三章中了解更多,理解并发性)
-
堆栈管理以进行内存优化(原始实现中使用了分段堆栈;当前的 Go 堆栈管理采用了堆栈复制)
Go 工具集
Go 的二进制发布还包括用于创建优化代码的庞大工具集。在 Go 二进制文件中,go
命令具有许多功能,可帮助构建,部署和验证代码。让我们讨论一些与性能相关的核心功能。
Godoc 是 Go 的文档工具,将文档的要点放在程序开发的前沿。清晰的实现,深入的文档和模块化都是构建可扩展,高性能系统的核心要素。Godoc 通过自动生成文档来帮助实现这些目标。Godoc 从在$GOROOT
和$GOPATH
中找到的包中提取和生成文档。生成文档后,Godoc 运行一个 Web 服务器,并将生成的文档显示为 Web 页面。可以在 Go 网站上查看标准库的文档。例如,标准库pprof
包的文档可以在golang.org/pkg/net/http/pprof/
找到。
将gofmt
(Go 的代码格式化工具)添加到语言中为 Go 带来了不同类型的性能。gofmt
的诞生使得 Go 在代码格式化方面非常有见地。强制执行精确的格式化规则使得可以以对开发人员有意义的方式编写 Go,同时让工具按照一致的模式格式化代码,从而使得在 Go 项目中保持一致的模式成为可能。许多开发人员在保存他们正在编写的文件时,让他们的 IDE 或文本编辑器执行gofmt
命令。一致的代码格式化减少了认知负荷,并允许开发人员专注于代码的其他方面,而不是确定是否使用制表符或空格来缩进他们的代码。减少认知负荷有助于开发人员的动力和项目速度。
Go 的构建系统也有助于性能。go build
命令是一个强大的工具,用于编译包及其依赖项。Go 的构建系统还有助于依赖管理。构建系统的输出结果是一个编译的、静态链接的二进制文件,其中包含了在您为其编译的平台上运行所需的所有必要元素。go module
(Go 1.11 中引入的初步支持功能,Go 1.13 中最终确定)是 Go 的依赖管理系统。语言的显式依赖管理有助于以版本化包的组合作为一个统一单元提供一致的体验,从而实现更可重现的构建。可重现的构建有助于开发人员通过源代码的可验证路径创建二进制文件。在项目中创建一个 vendored 目录的可选步骤也有助于本地存储和满足项目的依赖关系。
编译后的二进制文件也是 Go 生态系统中的重要组成部分。Go 还允许您为其他目标环境构建二进制文件,这在需要为另一台计算机架构交叉编译二进制文件时非常有用。能够构建可以在任何平台上运行的二进制文件,有助于您快速迭代和测试代码,以便在它们变得更难以修复之前,在其他架构上找到瓶颈。语言的另一个关键特性是,您可以在一个带有 OS 和架构标志的机器上编译二进制文件,然后在另一个系统上执行该二进制文件。当构建系统具有大量系统资源而构建目标具有有限的计算资源时,这一点至关重要。为两种架构构建二进制文件就像设置构建标志一样简单:
在 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
实用程序来比较基准测试的统计信息。以下示例存储库演示了对标准库的排序函数进行基准测试的示例,网址为github.com/bobstrecansky/HighPerformanceWithGo/tree/master/1-introduction
。
在标准库中密切结合测试和基准测试鼓励将性能测试作为代码发布过程的一部分。重要的是要记住,基准测试并不总是表明真实世界的性能场景,因此要对从中获得的结果持保留态度。记录、监控、分析和跟踪运行中的系统(将在第十二章《Go 代码性能分析》、第十三章《Go 代码跟踪》和第十五章《跨版本比较代码质量》中讨论)可以帮助验证您在进行基准测试后对代码所做的假设。
Go 性能背后的思想
Go 的许多性能立场都来自并发和并行。Goroutines 和 channels 经常用于并行执行许多请求。Go 提供的工具有助于实现接近 C 语言的性能,同时语义清晰易读。这是 Go 常被开发人员在大规模解决方案中使用的许多原因之一。
Goroutines - 从一开始就有性能
Go 语言的诞生是在多核处理器开始在商用硬件中变得越来越普遍的时候。Go 语言的作者意识到他们的新语言需要并发性。Go 通过 goroutines 和 channels(我们将在第三章《理解并发性》中讨论)使并发编程变得简单。Goroutines 是轻量级的计算线程,与操作系统线程不同,通常被描述为该语言的最佳特性之一。Goroutines 并行执行它们的代码,并在工作完成时完成。与依赖于操作系统线程的 Java 等语言相比,Goroutines 的启动时间比线程的启动时间更快,这允许程序中发生更多的并发工作。Go 还对于与 goroutines 相关的阻塞操作非常智能。这有助于 Go 在内存利用、垃圾回收和延迟方面更加高效。Go 的运行时使用GOMAXPROCS
变量将 goroutines 复用到真实的操作系统线程上。我们将在第二章《数据结构和算法》中学习更多关于 goroutines 的知识。
Channels - 一种类型的导管
Channels 提供了在 goroutines 之间发送和接收数据的模型,同时跳过底层平台提供的同步原语。通过深思熟虑的 goroutines 和 channels,我们可以实现高性能。Channels 可以是有缓冲的,也可以是无缓冲的,因此开发人员可以通过开放的通道传递动态数量的数据,直到接收者接收到值时,发送者解除通道的阻塞。如果通道是有缓冲的,发送者将会阻塞直到缓冲区填满。一旦缓冲区填满,发送者将解除通道的阻塞。最后,close()
函数可以被调用来指示通道将不再接收任何值。我们将在第三章《理解并发性》中学习更多关于 channels 的知识。
C-可比性能
另一个最初的目标是接近 C 语言对于类似程序的性能。Go 语言还内置了广泛的性能分析和跟踪工具,我们将在第十二章“Go 代码性能分析”和第十三章“Go 代码跟踪”中了解到。Go 语言让开发人员能够查看 goroutine 使用情况、通道、内存和 CPU 利用率,以及与个别调用相关的函数调用的细分。这是非常有价值的,因为 Go 语言使得通过数据和可视化轻松解决性能问题。
大规模分布式系统
由于其操作简单性和标准库中内置的网络原语,Go 经常用于大规模分布式系统。在开发过程中能够快速迭代是构建强大、可扩展系统的重要部分。在分布式系统中,高网络延迟经常是一个问题,Go 团队一直致力于解决这个平台上的问题。从标准库网络实现到使 gRPC 成为在分布式平台上在客户端和服务器之间传递缓冲消息的一等公民,Go 语言开发人员已经将分布式系统问题置于他们语言问题空间的前沿,并为这些复杂问题提出了一些优雅的解决方案。
摘要
在本章中,我们学习了计算机科学中性能的核心概念。我们还了解了 Go 编程语言的一些历史,以及它的起源与性能工作直接相关。最后,我们了解到由于语言的实用性、灵活性和可扩展性,Go 语言在许多不同的情况下被使用。本章介绍了将在本书中不断建立的概念,让您重新思考编写 Go 代码的方式。
在第二章“数据结构和算法”中,我们将深入研究数据结构和算法。我们将学习不同的算法、它们的大 O 表示法,以及这些算法在 Go 语言中的构建方式。我们还将了解这些理论算法如何与现实世界的问题相关,并编写高性能的 Go 代码,以快速高效地处理大量请求。了解更多关于这些算法的知识将帮助您在本章早期提出的优化三角形的第二层中变得更加高效。
第二章:数据结构和算法
数据结构和算法是构建软件的基本单元,尤其是复杂的性能软件。了解它们有助于我们思考如何有影响地组织和操作数据,以编写有效的、高性能的软件。本章将包括不同数据结构和算法的解释,以及它们的大 O 符号受到的影响。
正如我们在第一章中提到的,“Go 性能简介”,设计层面的决策往往对性能有着最明显的影响。最廉价的计算是您不必进行的计算——如果您在软件架构时早期努力优化设计,就可以避免很多性能惩罚。
在本章中,我们将讨论以下主题:
-
利用大 O 符号进行基准测试
-
搜索和排序算法
-
树
-
队列
创建不包含多余信息的简单数据结构将帮助您编写实用的、高性能的代码。算法也将有助于改善您拥有的数据结构的性能。
理解基准测试
度量和测量是优化的根本。谚语“不能衡量的东西无法改进”在性能方面是正确的。为了能够对性能优化做出明智的决策,我们必须不断地测量我们试图优化的函数的性能。
正如我们在第一章中提到的,“Go 性能简介”,Go 的创建者在语言设计中将性能作为首要考虑。Go 测试包(golang.org/pkg/testing/
)用于系统化地测试 Go 代码。测试包是 Go 语言的基本组成部分。该包还包括一个有用的内置基准测试功能。通过go test -bench
调用的这个功能运行您为函数定义的基准测试。测试结果也可以保存并在以后查看。拥有函数的基准测试以前的结果可以让您跟踪您在函数和它们结果中所做的长期变化。基准测试与性能分析和跟踪相结合,可以获取系统状态的准确报告。我们将在第十二章“Go 代码性能分析”和第十三章“Go 代码跟踪”中学习更多关于性能分析和跟踪的知识。在进行基准测试时,重要的是要注意禁用 CPU 频率调整(参见blog.golang.org/profiling-go-programs
)。这将确保在基准测试运行中更加一致。可以在github.com/bobstrecansky/HighPerformanceWithGo/blob/master/frequency_scaling_governor_diable.bash
找到一个包含的禁用频率调整的 bash 脚本。
基准测试执行
在 Go 中,基准测试使用在函数调用中以大写 B 开头的单词Benchmark
来表示它们是基准测试,并且应该使用基准测试功能。要执行您在测试包中为代码定义的基准测试,可以在go test
执行中使用-bench=.
标志。这个测试标志确保运行所有您定义的基准测试。以下是一个基准测试的示例代码块:
package hello_test
import (
"fmt"
"testing"
)
func BenchmarkHello(b *testing.B) { // Benchmark definition
for i := 0; i < b.N; i++ {
fmt.Sprintf("Hello High Performance Go")
}
}
在这个(诚然简单的)基准测试中,我们对我们的 fmt.Sprintf
语句进行了 b.N 次迭代。基准测试包执行并运行我们的 Sprintf
语句。在我们的测试运行中,基准测试会调整 b.N
,直到可以可靠地计时该函数。默认情况下,go 基准测试会运行 1 秒,以获得具有统计学意义的结果集。
在调用基准测试实用程序时有许多可用的标志。以下表格列出了一些有用的基准测试标志:
标志 | 用例 |
---|---|
-benchtime t |
运行足够的测试迭代以达到定义的 t 时长。增加此值将运行更多的 b.N 迭代。 |
-count n |
每个测试运行 n 次。 |
-benchmem |
为你的测试打开内存分析。 |
-cpu x,y,z |
指定应执行基准测试的 GOMAXPROCS 值列表。 |
以下是基准测试执行的示例。在我们的示例执行中,我们两次对现有的 Hello 基准测试进行了分析。我们还使用了四个 GOMAXPROCS
,查看了我们测试的内存分析,并将这些请求执行了 2 秒,而不是默认的 1 秒测试调用。我们可以像这样调用我们的 go test -bench
功能:
$ go test -bench=. -benchtime 2s -count 2 -benchmem -cpu 4
基准测试将一直运行,直到函数返回、失败或跳过。一旦测试完成,基准测试的结果将作为标准错误返回。在测试完成并整理结果后,我们可以对基准测试的结果进行智能比较。我们的下一个结果显示了一个示例测试执行以及前面的 BenchmarkHello
函数的结果输出:
在我们的输出结果中,我们可以看到返回了一些不同的数据:
-
GOOS
和GOARCH
(在第一章的Go 工具集部分讨论过) -
运行的基准测试的名称,然后是以下内容:
-
-8:用于执行测试的
GOMAXPROCS
的数量。 -
10000000:我们的循环运行了这么多次以收集必要的数据。
-
112 ns/op:我们测试中每次循环的速度。
-
PASS:表示我们的基准测试运行的结束状态。
-
测试的最后一行,编译测试运行的结束状态(ok),我们运行测试的路径以及测试运行的总时间。
真实世界的基准测试
在本书中运行基准测试时,请记住基准测试并非性能结果的全部和终极标准。基准测试既有积极的一面,也有缺点:
基准测试的积极面如下:
-
在问题变得难以控制之前就能发现潜在问题
-
帮助开发人员更深入地了解他们的代码
-
可以识别设计和数据结构以及算法阶段的潜在瓶颈
基准测试的缺点如下:
-
需要按照给定的节奏进行,才能产生有意义的结果
-
数据整理可能会很困难
-
并非总是能为手头的问题产生有意义的结果
基准测试适用于比较。在同一系统上将两个事物进行基准测试可以得到相对一致的结果。如果你有能力运行更长时间的基准测试,可能会更准确地反映函数的性能。
Go benchstat
(godoc.org/golang.org/x/perf/cmd/benchstat
) 包是一个有用的实用程序,它帮助你比较两个基准测试。比较非常重要,以便推断你对函数所做的更改对系统是否产生了积极或消极的影响。你可以使用 go get
实用程序安装 benchstat
:
go get golang.org/x/perf/cmd/benchstat
考虑以下比较测试。我们将测试单个 JSON 结构的编组,其中包含三个元素,与两个包含五个元素的 JSON 数组的编组进行比较。您可以在github.com/bobstrecansky/HighPerformanceWithGo/tree/master/2-data-structures-and-algorithms/Benchstat-comparison
找到这些的源代码。
为了得到一个示例比较运算符,我们执行我们的基准测试,如下面的代码片段所示:
[bob@testhost single]$ go test -bench=. -count 5 -cpu 1,2,4 > ~/single.txt
[bob@testhost multi]$ go test -bench=. -count 5 -cpu 1,2,4 > ~/multi.txt
[bob@testhost ~]$ benchstat -html -sort -delta single.txt multi.txt > out.html
这将生成一个 HTML 表格,用于验证执行时间的最大增量。如下图所示,即使对我们的数据结构和我们处理的元素数量增加了一点复杂性,也会对函数的执行时间产生相当大的变化:
快速识别终端用户的性能痛点可以帮助您确定编写高性能软件的路径。
在下一节中,我们将看到大 O 符号是什么。
介绍大 O 符号
大 O 符号是一种近似算法速度的好方法,它会随着传递给算法的数据大小而改变。大 O 符号通常被描述为函数的增长行为,特别是它的上限。大 O 符号被分解为不同的类。最常见的类别是 O(1)、O(log n)、O(n)、O(n log n)、O(n²)和 O(2^n)。让我们快速看一下每个算法的定义和在 Go 中的实际示例。
这些常见类别的图表如下。生成此图的源代码可以在github.com/bobstrecansky/HighPerformanceWithGo/blob/master/2-data-structures-and-algorithms/plot/plot.go
找到:
这个大 O 符号图表给我们一个常用的计算机软件中不同算法的可视化表示。
实际的大 O 符号示例
如果我们拿一个包含 32 个输入值的样本数据集,我们可以快速计算每个算法完成所需的时间。您会注意到下表中的完成单位时间开始迅速增长。实际的大 O 符号值如下:
算法 | 完成的单位时间 |
---|---|
O(1) | 1 |
O(log n) | 5 |
O(n) | 32 |
O(n log n) | 160 |
O(n²) | 1,024 |
O(2^n) | 4,294,967,296 |
随着完成单位时间的增加,我们的代码变得不那么高效。我们应该努力使用尽可能简单的算法来解决手头的数据集。
数据结构操作和时间复杂度
以下图表包含一些常见的数据结构操作及其时间复杂度。正如我们之前提到的,数据结构是计算机科学性能的核心部分。在编写高性能代码时,了解不同数据结构之间的差异是很重要的。有这个表格可以帮助开发人员在考虑操作对性能的影响时选择正确的数据结构操作:
常见的数据结构操作(来自 bigocheatsheet.com)- 感谢 Eric Rowell
这个表格向我们展示了特定数据结构的时间和空间复杂度。这是一个有价值的性能参考工具。
O(1) - 常数时间
在常数时间内编写的算法具有不依赖于算法输入大小的上限。常数时间是一个常数值的上限,因此不会比数据集的上限时间长。这种类型的算法通常可以添加到实践中的函数中——它不会给函数增加太多的处理时间。请注意这里发生的常数。单个数组查找对函数的处理时间增加了可忽略的时间量。在数组中查找成千上万个单独的值可能会增加一些开销。性能始终是相对的,重要的是要注意您为函数增加的额外负载,即使它们只执行微不足道的处理。
常数时间的例子如下:
-
访问地图或数组中的单个元素
-
确定一个数字的模
-
堆栈推送或堆栈弹出
-
推断一个整数是偶数还是奇数
在 Go 中,一个常数时间算法的例子是访问数组中的单个元素。
在 Go 中,这将被写成如下形式:
package main
import "fmt"
func main() {
words := [3]string{"foo", "bar", "baz"}
fmt.Println(words[1]) // This references the string in position 1 in the array, "bar"
}
这个函数的大 O 符号是 O(1),因为我们只需要查看words[1]
的单个定义值,就可以找到我们要找的值,也就是bar
。在这个例子中,随着数组大小的增长,引用数组中的对象的时间将保持恒定。该算法的标准化时间应该都是相同的,如下表所示:
数据集中的项目数 | 结果计算时间 |
---|---|
10 | 1 秒 |
100 | 1 秒 |
1,000 | 1 秒 |
O(1)符号的一些示例代码如下:
package oone
func ThreeWords() string {
threewords := [3]string{"foo", "bar", "baz"}
return threewords[1]
}
func TenWords() string {
tenwords := [10]string{"foo", "bar", "baz", "qux", "grault", "waldo", "plugh", "xyzzy", "thud", "spam"}
return tenwords[6]
}
无论数组中有多少项,查找一个元素的时间都是相同的。在下面的示例输出中,我们分别有三个元素和十个元素的数组。它们都花费了相同的时间来执行,并在规定的时间范围内完成了相同数量的测试迭代。这可以在下面的截图中看到:
这个基准测试的表现与我们的预期一样。BenchmarkThree
和BenchmarkTen
基准测试都花费了 0.26 ns/op,这应该在数组查找中保持一致。
O(log n) - 对数时间
对数增长通常表示为调和级数的部分和。可以表示如下:
在对数时间内编写的算法具有随着输入大小减少而趋于零的操作数量。当必须访问数组中的所有元素时,不能在算法中使用 O(log n)算法。当 O(log n)单独使用时,通常被认为是一种高效的算法。关于对数时间性能的一个重要概念是,搜索算法通常与排序算法一起使用,这增加了找到解决方案的复杂性。根据数据集的大小和复杂性,通常在执行搜索算法之前对数据进行排序是有意义的。请注意此测试的输入和输出范围——额外的测试被添加以显示数据集的结果计算时间的对数增长。
一些对数时间算法的例子如下:
-
二分查找
-
字典搜索
下表显示了对数时间的标准化时间:
数据集中的项目数 | 结果计算时间 |
---|---|
10 | 1 秒 |
100 | 2 秒 |
1,000 | 3 秒 |
Go 的标准库有一个名为sort.Search()
的函数。以下代码片段中已包含了它以供参考:
func Search(n int, f func(int) bool) int {
// Define f(-1) == false and f(n) == true.
// Invariant: f(i-1) == false, f(j) == true.
i, j := 0, n
for i < j {
h := int(uint(i+j) >> 1) // avoid overflow when computing h
// i ≤ h < j
if !f(h) {
i = h + 1 // preserves f(i-1) == false
} else {
j = h // preserves f(j) == true
}
}
// i == j, f(i-1) == false, and f(j) (= f(i)) == true => answer is i.
return i
}
这个代码示例可以在标准库中找到golang.org/src/sort/search.go
。O(log n)函数的代码和基准可以在github.com/bobstrecansky/HighPerformanceWithGo/tree/master/2-data-structures-and-algorithms/BigO-notation-o-logn
找到。
以下截图显示了对数时间基准:
这个测试显示了基于我们设置的输入的对数增长的时间。具有对数时间响应的算法在编写高性能代码方面非常有帮助。
O(n) – 线性时间
以线性时间编写的算法与其数据集的大小成线性比例。线性时间是当整个数据集需要按顺序读取时的最佳时间复杂度。算法在线性时间内花费的时间量与数据集中包含的项目数量呈 1:1 的关系。
一些线性时间的例子如下:
-
简单循环
-
线性搜索
线性时间的标准化时间可以在以下表中找到:
数据集中的项目数量 | 结果计算时间 |
---|---|
10 | 10 秒 |
100 | 100 秒 |
1,000 | 1,000 秒 |
请注意,结果计算时间呈线性增长,并与我们的数据集中找到的项目数量相关(参见以下截图)。O(n)函数的代码和基准可以在github.com/bobstrecansky/HighPerformanceWithGo/tree/master/2-data-structures-and-algorithms/BigO-notation-o-n
找到:
一个重要的要点是,大 O 符号并不一定是响应时间增长的完美指标;它只表示一个上限。在审查这个基准时,要注意计算时间随数据集中项目数量的线性增长。O(n)算法通常不是计算机科学中性能的主要瓶颈。计算机科学家经常在迭代器上执行循环,这是一个常用的模式,用于完成计算工作。确保你始终注意你的数据集的大小!
O(n log n) – 准线性时间
在 Go 中,通常使用准线性(或对数线性)时间编写的算法来对数组中的值进行排序。
一些准线性时间的例子如下:
-
Quicksort 的平均情况时间复杂度
-
Mergesort 的平均情况时间复杂度
-
Heapsort 的平均情况时间复杂度
-
Timsort 的平均情况时间复杂度
准线性时间的标准化时间可以在以下表中找到:
数据集中的项目数量 | 结果计算时间 |
---|---|
10 | 10 秒 |
100 | 200 秒 |
1,000 | 3,000 秒 |
你会在这里看到一个熟悉的模式。这个算法遵循了与 O(log n)算法类似的模式。这里唯一改变的是 n 的乘数,所以我们可以看到类似的结果与一个缩放因子(参见以下截图)。O(n log n)函数的代码和基准可以在github.com/bobstrecansky/HighPerformanceWithGo/tree/master/2-data-structures-and-algorithms/BigO-notation-o-nlogn
找到:
排序算法仍然相当快,并不是性能不佳代码的关键。通常,语言中使用的排序算法使用基于大小的多种排序算法的混合。Go 的quickSort
算法,在sort.Sort()
中使用,如果切片包含少于 12 个元素,则使用ShellSort
和insertionSort
。quickSort
的标准库算法如下:
func quickSort(data Interface, a, b, maxDepth int) {
for b-a > 12 { // Use ShellSort for slices <= 12 elements
if maxDepth == 0 {
heapSort(data, a, b)
return
}
maxDepth--
mlo, mhi := doPivot(data, a, b)
// Avoiding recursion on the larger subproblem guarantees
// a stack depth of at most lg(b-a).
if mlo-a < b-mhi {
quickSort(data, a, mlo, maxDepth)
a = mhi // i.e., quickSort(data, mhi, b)
} else {
quickSort(data, mhi, b, maxDepth)
b = mlo // i.e., quickSort(data, a, mlo)
}
}
if b-a > 1 {
// Do ShellSort pass with gap 6
// It could be written in this simplified form cause b-a <= 12
for i := a + 6; i < b; i++ {
if data.Less(i, i-6) {
data.Swap(i, i-6)
}
}
insertionSort(data, a, b)
}
}
前面的代码可以在标准库中找到golang.org/src/sort/sort.go#L183
。这个quickSort
算法性能良好,并且在 Go 生态系统中经常使用。
O(n2) – 二次时间
用二次时间编写的算法的执行时间与输入大小的平方成正比。嵌套循环是常见的二次时间算法,这带来了排序算法。
二次时间的一些例子如下:
-
冒泡排序
-
插入排序
-
选择排序
二次时间的标准化时间可以在下表中找到:
数据集中的项目数量 | 计算时间 |
---|---|
10 | 100 秒 |
100 | 10,000 秒 |
1,000 | 1,000,000 秒 |
您会注意到从这个表中,随着输入增加了 10 倍,计算时间呈二次增长。
如果可能的话,应该避免二次时间算法。如果需要嵌套循环或二次计算,请确保验证您的输入并尝试限制输入大小。
可以在github.com/bobstrecansky/HighPerformanceWithGo/tree/master/2-data-structures-and-algorithms/BigO-notation-o-n2
找到 O(n²)函数的代码和基准测试。以下是运行此基准测试的输出:
二次时间算法的计时非常迅速增加。我们可以通过自己的基准测试看到这一点。
O(2n) – 指数时间
当数据添加到输入集时,指数算法呈指数增长。通常在没有输入数据集的倾向时使用,必须尝试输入集的每种可能的组合。
指数时间的一些例子如下:
-
斐波那契数列的递归实现不佳
-
汉诺塔
-
旅行推销员问题
指数时间的标准化时间可以在下表中找到:
数据集中的项目数量 | 计算时间 |
---|---|
10 | 1,024 秒 |
100 | 1.267 * 10³⁰秒 |
1,000 | 1.07 * 10³⁰¹秒 |
随着数据集中项目数量的增加,计算时间呈指数增长。
指数时间算法应该只在非常狭窄的数据集范围内的紧急情况下使用。通常,澄清您的潜在问题或数据集进一步可以帮助您避免使用指数时间算法。
可以在github.com/bobstrecansky/HighPerformanceWithGo/tree/master/2-data-structures-and-algorithms/BigO-notation-o-n2
找到 O(n²)算法的代码。可以在以下截图中看到此基准测试的一些示例输出:
指数时间算法问题通常可以分解为更小、更易消化的部分。这也可以进行优化。
在下一节中,我们将看看排序算法。
了解排序算法
排序算法用于获取数据集中的各个元素并按特定顺序排列它们。通常,排序算法会获取数据集并将其按字典顺序或数字顺序排列。能够高效地进行排序对于编写高性能代码很重要,因为许多搜索算法需要排序的数据集。常见的数据结构操作可以在以下图表中看到:
常见数据结构操作(来自 bigocheatsheet.com)- 感谢 Eric Rowell
正如你所看到的,数组排序算法的大 O 符号表示可以有很大的不同。在为无序列表选择正确的排序算法时,这对于提供优化的解决方案非常重要。
插入排序
插入排序是一种排序算法,它一次构建一个数组项,直到结果为排序数组。它并不是非常高效,但它有一个简单的实现,并且对于非常小的数据集来说很快。数组是原地排序的,这也有助于减少函数调用的内存占用。
这个标准库中的insertionSort
算法可以在下面的代码片段中找到。我们可以使用下面的代码片段来推断插入排序是一个 O(n²)算法的平均情况。这是因为我们要遍历一个二维数组并操作数据:
func insertionSort(data Interface, a, b int) {
for i := a + 1; i < b; i++ {
for j := i; j > a && data.Less(j, j-1); j-- {
data.Swap(j, j-1)
}
}
}
这段代码可以在标准库中找到golang.org/src/sort/sort.go#L183
。简单的插入排序通常对小数据集很有价值,因为它非常容易阅读和理解。当编写高性能代码时,简单性往往比其他一切都更重要。
堆排序
Go 语言在标准库中内置了heapSort
,如下面的代码片段所示。这段代码片段帮助我们理解heapSort
是一个 O(n log n)的排序算法。这比我们之前的插入排序示例要好,因此对于更大的数据集,使用我们的堆排序算法时,我们将拥有更高性能的代码:
func heapSort(data Interface, a, b int) {
first := a
lo := 0
hi := b - a
// Build heap with greatest element at top.
for i := (hi - 1) / 2; i >= 0; i-- {
siftDown(data, i, hi, first)
}
// Pop elements, largest first, into end of data.
for i := hi - 1; i >= 0; i-- {
data.Swap(first, first+i)
siftDown(data, lo, i, first)
}
}
这段代码可以在标准库中找到golang.org/src/sort/sort.go#L53
。当我们的数据集变得更大时,开始使用高效的排序算法如heapSort
是很重要的。
归并排序
归并排序是一种平均时间复杂度为 O(n log n)的排序算法。如果算法的目标是产生稳定的排序,通常会使用MergeSort
。稳定的排序确保输入数组中具有相同键的两个对象在结果数组中以相同的顺序出现。如果我们想要确保键-值对在数组中有序,稳定性就很重要。Go 标准库中可以找到稳定排序的实现。下面的代码片段中可以看到:
func stable(data Interface, n int) {
blockSize := 20 // must be > 0
a, b := 0, blockSize
for b <= n {
insertionSort(data, a, b)
a = b
b += blockSize
}
insertionSort(data, a, n)
for blockSize < n {
a, b = 0, 2*blockSize
for b <= n {
symMerge(data, a, a+blockSize, b)
a = b
b += 2 * blockSize
}
if m := a + blockSize; m < n {
symMerge(data, a, m, n)
}
blockSize *= 2
}
}
这段代码可以在标准库中找到golang.org/src/sort/sort.go#L356
。当需要保持顺序时,稳定的排序算法非常重要。
快速排序
Go 标准库中有一个快速排序算法,正如我们在O(n log n) – quasilinear time部分中看到的。快速排序最初在 Unix 中作为标准库中的默认排序例程实现。从那时起,它被构建并用作 C 编程语言中的 qsort。由于它的熟悉度和悠久的历史,它通常被用作今天许多计算机科学问题中的排序算法。使用我们的算法表,我们可以推断quickSort
算法的标准实现具有 O(n log n)的平均时间复杂度。它还具有使用最坏情况下 O(log n)的空间复杂度的额外好处,使其非常适合原地移动。
现在我们已经完成了排序算法,我们将转向搜索算法。
理解搜索算法
搜索算法通常用于从数据集中检索元素或检查该元素是否存在。搜索算法通常分为两个独立的类别:线性搜索和区间搜索。
线性搜索
在线性搜索算法中,当顺序遍历切片或数组时,会检查切片或数组中的每个元素。这个算法并不是最高效的算法,因为它的复杂度为 O(n),因为它可以遍历列表中的每个元素。
线性搜索算法可以简单地写成对切片的迭代,如下面的代码片段所示:
func LinearSearch(data []int, searchVal int) bool {
for _, key := range data {
if key == searchVal {
return true
}
}
return false
}
这个函数告诉我们,随着数据集的增大,它会很快变得昂贵。对于包含 10 个元素的数据集,这个算法不会花费太长时间,因为它最多只会迭代 10 个值。如果我们的数据集包含 100 万个元素,这个函数将需要更长的时间才能返回一个值。
二分搜索
一个更常用的模式(也是您最有可能想要用于高性能搜索算法的模式)称为二分搜索。二分搜索算法的实现可以在 Go 标准库中找到golang.org/src/sort/search.go
,并且在本章前面的排序搜索函数中显示过。与我们之前编写的线性搜索函数的 O(n)复杂度相比,二分搜索树具有 O(log n)的搜索复杂度。二分搜索往往经常被使用,特别是当需要搜索的数据集达到任何合理大小时。二分搜索也很聪明地早早实现 - 如果您的数据集增长而您不知情,至少所使用的算法不会增加复杂性。在下面的代码中,我们使用了SearchInts
便利包装器来进行 Go 搜索函数。这允许我们使用二分搜索迭代整数数组:
package main
import (
"fmt"
"sort"
)
func main() {
intArray := []int{0, 2, 3, 5, 11, 16, 34}
searchNumber := 34
sorted := sort.SearchInts(intArray, searchNumber)
if sorted < len(intArray) {
fmt.Printf("Found element %d at array position %d\n", searchNumber, sorted)
} else {
fmt.Printf("Element %d not found in array %v\n", searchNumber, intArray)
}
}
这个函数的输出如下:
这告诉我们,二分搜索库能够在我们搜索的数组(intArray
)中找到我们正在搜索的数字(34
)。它在数组中的第 6 个位置找到了整数 34(这是正确的;数组是从 0 开始索引的)。
接下来的部分涉及另一个数据结构:树。
探索树
树是一种非线性数据结构,用于存储信息。它通常用于存储维护关系的数据,特别是如果这些关系形成层次结构。树也很容易搜索(理解排序算法部分的数组排序算法图表向我们展示了许多树的操作具有 O(log n)的时间复杂度)。对于许多问题,树是最佳解决方案,因为它们引用分层数据。树是由不形成循环的节点组合而成。
每棵树都由称为节点的元素组成。我们从根节点开始(下面的二叉树图中标有根的黄色框)。在每个节点中有一个左引用指针和一个右引用指针(在我们的例子中是数字 2 和 7),以及一个数据元素(在本例中是数字 1)。随着树的增长,节点的深度(从根到给定节点的边的数量)增加。在这个图中,节点 4、5、6 和 7 的深度都是 3。节点的高度是从节点到树中最深的叶子的边的数量(如下面二叉树图中的高度 4 框所示)。整个树的高度等于根节点的高度。
二叉树
二叉树是计算机科学中重要的数据结构。它们经常用于搜索、优先队列和数据库。它们是高效的,因为它们易于以并发方式遍历。Go 语言具有出色的并发原语(我们将在第三章中讨论,理解并发),可以让我们以简单的方式做到这一点。能够使用 goroutines 和通道来遍历二叉树可以帮助加快我们遍历分层数据的速度。平衡的二叉树可以在下图中看到:
以下是一些特殊的二叉树:
-
满二叉树:除了叶子节点外,每个节点都有 2 个子节点。
-
完全二叉树:一棵完全填充的树,除了底层之外。底层必须从左到右填充。
-
完美二叉树:一个完全二叉树,其中所有节点都有两个子节点,树的所有叶子都在同一层。
双向链表
双向链表也是 Go 标准库的一部分。这是一个相对较大的包,因此为了方便起见,可以在以下代码片段中找到此包的函数签名:
func (e *Element) Next() *Element {
func (e *Element) Prev() *Element {
func (l *List) Init() *List {
func New() *List { return new(List).Init() }
func (l *List) Len() int { return l.len }
func (l *List) Front() *Element {
func (l *List) Back() *Element {
func (l *List) lazyInit() {
func (l *List) insert(e, at *Element) *Element {
func (l *List) insertValue(v interface{}, at *Element) *Element {
func (l *List) remove(e *Element) *Element {
func (l *List) move(e, at *Element) *Element {
func (l *List) Remove(e *Element) interface{} {
func (l *List) PushFront(v interface{}) *Element {
func (l *List) PushBack(v interface{}) *Element {
func (l *List) InsertBefore(v interface{}, mark *Element) *Element {
func (l *List) InsertAfter(v interface{}, mark *Element) *Element {
func (l *List) MoveToFront(e *Element) {
func (l *List) MoveToBack(e *Element) {
func (l *List) MoveBefore(e, mark *Element) {
func (l *List) MoveAfter(e, mark *Element) {
func (l *List) PushBackList(other *List) {
func (l *List) PushFrontList(other *List) {
这些函数签名(以及它们对应的方法)可以在 Go 标准库中找到,网址为golang.org/src/container/list/list.go
。
最后,我们将看一下队列。
探索队列
队列是计算机科学中经常用来实现先进先出(FIFO)数据缓冲区的模式。进入队列的第一件事也是离开的第一件事。这是以有序的方式进行的,以处理排序数据。将事物添加到队列中称为将数据入队列,从队列末尾移除称为出队列。队列通常用作存储数据并在另一个时间进行处理的固定装置。
队列的好处在于它们没有固定的容量。新元素可以随时添加到队列中,这使得队列成为异步实现的理想解决方案,例如键盘缓冲区或打印机队列。队列用于必须按接收顺序完成任务的情况,但在实时发生时,可能基于外部因素而不可能完成。
常见的排队函数
非常频繁地,其他小的排队操作被添加,以使队列更有用:
-
isfull()
通常用于检查队列是否已满。 -
isempty()
通常用于检查队列是否为空。 -
peek()
检索准备出队的元素,但不出队。
这些函数很有用,因为正常的入队操作如下:
-
检查队列是否已满,如果队列已满则返回错误
-
递增后指针;返回下一个空位
-
将数据元素添加到后指针指向的位置
完成这些步骤后,我们可以将下一个项目入队到我们的队列中。
出队也和以下操作一样简单:
-
检查队列是否为空,如果队列为空则返回错误
-
访问队列前端的数据
-
将前指针递增到下一个可用元素
完成这些步骤后,我们已经从队列中出队了这个项目。
常见的排队模式
拥有优化的排队机制对于编写高性能的 Go 代码非常有帮助。能够将非关键任务推送到队列中,可以让您更快地完成关键任务。另一个要考虑的问题是,您使用的排队机制不一定非得是 Go 队列。您可以将数据推送到外部机制,如 Kafka (kafka.apache.org/
)或 RabbitMQ (www.rabbitmq.com/
)在分布式系统中。管理自己的消息队列可能会变得非常昂贵,因此在今天,拥有单独的消息排队系统是司空见惯的。当我们研究集群和作业排队时,我们将在第十四章 集群和作业队列中更详细地介绍这一点。
总结
在本章中,我们学习了如何对 Go 程序进行基准测试。我们了解了如何根据 Big O 符号的考虑来设计对问题集具有影响力的数据结构和算法。我们还学习了搜索和排序算法、树和队列,以使我们的数据结构和算法对手头的问题具有最大的影响力。
在第三章中,理解并发,我们将学习一些最重要的 Go 构造,并了解它们如何影响性能。闭包、通道和 goroutines 可以帮助我们在并行性和并发性方面做出一些强大的设计决策。
第三章:理解并发
迭代器和生成器对于 Go 至关重要。在 Go 中使用通道和 goroutine 进行并行和并发是 Go 中的惯用法,也是编写高性能、可读性强的代码的最佳方式之一。我们首先将讨论一些基本的 Go 构造,以便能够理解如何在 Go 的上下文中使用迭代器和生成器,然后深入探讨语言中可用的迭代器和生成器的构造。
在本章中,我们将涵盖以下主题:
-
闭包
-
Goroutines
-
通道
-
信号量
-
WaitGroups
-
迭代器
-
生成器
能够理解 Go 语言的基本构造以及何时何地使用适当的迭代器和生成器对于编写高性能的 Go 语言至关重要。
理解闭包
Go 语言最重要的部分之一是它是一种支持头等函数的语言。头等函数是具有作为变量传递给其他函数的能力的函数。它们也可以从其他函数返回。这一点很重要,因为我们可以将它们用作闭包。
闭包很有帮助,因为它们是保持代码 DRY 的好方法,同时有助于隔离数据。到目前为止,保持数据集小是本书的核心原则,这在本章(以及任何后续章节)中都没有改变。能够隔离希望操作的数据可以帮助您继续编写高性能的代码。
闭包保持局部作用域,并访问外部函数的作用域和参数,以及全局变量。闭包是引用其主体外部的变量的函数。这些函数有能力为引用的变量分配值并访问这些值,因此我们可以在函数之间传递闭包。
匿名函数
理解 Go 中的闭包的第一步是理解匿名函数。使用变量创建匿名函数。它们也是没有名称或标识符的函数,因此称为匿名函数。
将Hello Go
打印到屏幕的普通函数调用将是以下代码块中显示的内容:
func HelloGo(){
fmt.Println("Hello Go")
}
接下来,我们可以调用HelloGo()
,函数将打印Hello Go
字符串。
如果我们想将HelloGo()
函数实例化为匿名函数,我们将在以下代码块中引用它:
// Note the trailing () for this anonymous function invocation
func() {
fmt.Println("Hello Go")
}()
我们之前的匿名函数和HelloGo()
函数在词法上是相似的。
我们还可以将函数存储为变量以供以后使用,如下面的代码块所示:
fmt.Println("Hello Go from an Anonymous Function Assigned to a Variable")
}
这三个东西——HelloGo()
函数、我们之前定义的匿名函数和分配给hello
变量的函数——在词法上是相似的。
在我们分配了这个hello
变量之后,我们可以通过简单调用hello()
来调用这个函数,我们之前定义的匿名函数将被调用,并且Hello Go
将以与之前调用的匿名函数相同的方式打印到屏幕上。
我们可以在以下代码块中看到这些每个是如何工作的:
package main
import "fmt"
func helloGo() {
fmt.Println("Hello Go from a Function")
}
func main() {
helloGo()
func() { fmt.Println("Hello Go from an Anonymous Function") }()
var hello func() = func() { fmt.Println("Hello Go from an Anonymous Function Variable") }
hello()
}
此程序的输出显示了三个相似的打印语句,略有不同的打印以显示它们如何在以下截图中返回:
匿名函数是 Go 语言的一个强大方面。随着我们继续本章,我们将看到如何在它们的基础上构建一些非常有用的东西。
关于闭包的匿名函数
此时,您可能想知道为什么具有匿名函数以及它们与闭包有关是明智的。一旦我们有了匿名函数,我们就可以利用闭包来引用在其自身定义之外声明的变量。我们可以在接下来的代码块中看到这一点:
package main
import "fmt"
func incrementCounter() func() int {
var initializedNumber = 0
return func() int {
initializedNumber++
return initializedNumber
}
}
func main() {
n1 := incrementCounter()
fmt.Println("n1 increment counter #1: ", n1()) // First invocation of n1
fmt.Println("n1 increment counter #2: ", n1()) // Notice the second invocation; n1 is called twice, so n1 == 2
n2 := incrementCounter() // New instance of initializedNumber
fmt.Println("n2 increment counter #1: ", n2()) // n2 is only called once, so n2 == 1
fmt.Println("n1 increment counter #3: ", n1()) // state of n1 is not changed with the n2 calls
}
当我们执行此代码时,我们将收到以下结果输出:
在这个代码示例中,我们可以看到闭包如何帮助数据隔离。n1
变量使用incrementCounter()
函数进行初始化。这个匿名函数将initializedNumber
设置为0
,并返回一个增加的initializedNumber
变量的计数。
当我们创建n2
变量时,同样的过程再次发生。调用一个新的incrementCounter
匿名函数,并返回一个新的initializedNumber
变量。在我们的主函数中,我们可以注意到n1
和n2
有单独的维护状态。我们可以看到,即使在第三次调用n1()
函数之后。能够在函数调用之间保持这些数据,同时还将数据与另一个调用隔离开来,这是匿名函数的一个强大部分。
用于嵌套和延迟工作的闭包
闭包也经常用于嵌套和延迟工作。在下面的例子中,我们可以看到一个函数闭包,它允许我们嵌套工作:
package main
import (
"fmt"
"sort"
)
func main() {
input := []string{"foo", "bar", "baz"}
var result []string
// closure callback
func() {
result = append(input, "abc") // Append to the array
result = append(result, "def") // Append to the array again
sort.Sort(sort.StringSlice(result)) // Sort the larger array
}()
fmt.Print(result)
}
在这个例子中,我们可以看到我们两次向字符串切片添加内容并对结果进行排序。我们稍后将看到如何将匿名函数嵌套在 goroutine 中以帮助提高性能。
使用闭包的 HTTP 处理程序
闭包在 Go 的 HTTP 调用中也经常用作中间件。您可以将普通的 HTTP 函数调用包装在闭包中,以便在需要时为调用添加额外的信息,并为不同的函数重用中间件。
在我们的示例中,我们将设置一个具有四个独立路由的 HTTP 服务器:
-
/
:这提供以下内容: -
一个带有 HTTP 418 状态码的 HTTP 响应(来自
newStatusCode
中间件)。 -
一个
Foo:Bar
头部(来自addHeader
中间件)。 -
一个
Hello PerfGo!
的响应(来自writeResponse
中间件)。 -
/onlyHeader
:提供只添加Foo:Bar
头部的 HTTP 响应。 -
/onlyStatus
:只提供状态码更改的 HTTP 响应。 -
/admin
:检查用户admin
头部是否存在。如果存在,它会打印管理员门户信息以及所有相关的普通值。如果不存在,它会返回未经授权的响应。
这些示例已经被使用,因为它们易于理解。在 Go 中使用闭包处理 HTTP 处理程序也很方便,因为它们可以做到以下几点:
-
将数据库信息与数据库调用隔离开来
-
执行授权请求
-
用隔离的数据(例如时间信息)包装其他函数
-
与其他第三方服务透明地通信,并具有可接受的超时时间
位于[golang.org/doc/articles/wiki/
]的 Go 编写 Web 应用程序文档提供了一堆其他设置模板的主要示例,能够实时编辑页面,验证用户输入等。让我们来看看我们的示例代码,展示了在以下代码块中 HTTP 处理程序中的闭包。首先,我们初始化我们的包并创建一个adminCheck
函数,它帮助我们确定用户是否被授权使用系统:
package main
import (
"fmt"
"net/http"
)
// Checks for a "user:admin" header, proper credentials for the admin path
func adminCheck(h http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("user") != "admin" {
http.Error(w, "Not Authorized", 401)
return
}
fmt.Fprintln(w, "Admin Portal")
h.ServeHTTP(w, r)
})
}
接下来,我们设置了一些其他示例,比如提供一个 HTTP 418(I'm a teapot
状态码)并添加一个foo:bar
的 HTTP 头部,并设置特定的 HTTP 响应:
// Sets a HTTP 418 (I'm a Teapot) status code for the response
func newStatusCode(h http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
h.ServeHTTP(w, r)
})
}
// Adds a header, Foo:Bar
func addHeader(h http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Foo", "Bar")
h.ServeHTTP(w, r)
})
}
// Writes a HTTP Response
func writeResponse(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello PerfGo!")
}
最后,我们用一个 HTTP 处理程序将所有内容包装在一起:
// Wrap the middleware together
func main() {
handler := http.HandlerFunc(writeResponse)
http.Handle("/", addHeader(newStatusCode(handler)))
http.Handle("/onlyHeader", addHeader(handler))
http.Handle("/onlyStatus", newStatusCode(handler))
http.Handle("/admin", adminCheck(handler))
http.ListenAndServe(":1234", nil)
}
我们的路由器测试示例如下。这是修改头部和 HTTP 状态码的输出:
这是仅修改头部的输出:
这是仅修改状态的输出:
这是未经授权的管理员输出:
这是授权的管理员输出:
能够使用匿名函数添加中间件可以帮助快速迭代,同时保持代码复杂性低。在下一节中,我们将探讨 goroutines。
探索 goroutines
Go 是一种以并发为设计目标的语言。并发是执行独立进程的能力。Goroutines 是 Go 中的一种构造,可以帮助处理并发。它们通常被称为“轻量级线程”,原因是充分的。在其他语言中,线程由操作系统处理。这反过来使用了更大尺寸的调用堆栈,并且通常使用给定内存堆栈大小的并发较少。Goroutines 是在 Go 运行时内并发运行的函数或方法,不连接到底层操作系统。Go 语言内的调度器管理 goroutines 的生命周期。系统的调度器也有很多开销,因此限制正在使用的线程数量可以帮助提高性能。
Go 调度器
Go 运行时调度器通过几个不同的部分来管理 goroutine 的生命周期。Go 调度器在其第二次迭代中进行了更改,这是根据 Dmitry Vyukov 撰写的设计文档而得出的,该文档于 Go 1.1 中发布。在这份设计文档中,Vyukov 讨论了最初的 Go 调度器以及如何实现工作共享和工作窃取调度器,这是由 MIT 的 Robert D. Blumofe 博士和 Charles E. Leiserson 博士在一篇名为《通过工作窃取进行多线程计算的调度》的论文中最初提出的。这篇论文背后的基本概念是确保动态的、多线程的计算,以确保处理器被有效利用同时保持内存需求。
Goroutines 在初始时只有 2KB 的堆栈大小。这是为什么 goroutines 被用于大量并发编程的原因之一——因为在一个程序中拥有数万甚至数十万个 goroutines 要容易得多。其他语言中的线程可能占用数兆字节的空间,使它们不太灵活。如果需要更多内存,Go 的函数可以在可用内存空间的其他位置分配更多内存,以帮助 goroutine 的空间增长。默认情况下,运行时会给新的堆栈分配两倍的内存。
Goroutines 只有在系统调用时才会阻塞运行的线程。当这种情况发生时,运行时会从调度器结构中取出另一个线程。这些线程用于等待执行的其他 goroutines。
工作共享是一个过程,其中调度器将新线程迁移到其他处理器以进行工作分配。工作窃取执行类似的操作,但是未被充分利用的处理器从其他处理器窃取线程。在 Go 中遵循工作窃取模式有助于使 Go 调度器更加高效,并且反过来为在内核调度器上运行的 goroutines 提供更高的吞吐量。最后,Go 的调度器实现了自旋线程。自旋线程将利用额外的 CPU 周期而不是抢占线程。线程以三种不同的方式自旋:
-
当一个线程没有附加到处理器时。
-
当使一个 goroutine 准备好时,会将一个 OS 线程解除阻塞到一个空闲的处理器上。
-
当一个线程正在运行但没有 goroutines 附加到它时。这个空闲线程将继续搜索可运行的 goroutines 来执行。
Go 调度器 goroutine 内部
Go 调度器有三个关键结构来处理 goroutines 的工作负载:M 结构、P 结构和 G 结构。这三个结构共同工作,以高效的方式处理 goroutines。让我们更深入地看看每一个。如果你想查看这些的源代码,可以在github.com/golang/go/blob/master/src/runtime/runtime2.go/
找到。
M 结构
M 结构标记为M代表机器。M 结构是 OS 线程的表示。它包含一个指针,指向可运行的 goroutine 全局队列(由 P 结构定义)。M 从 P 结构中检索其工作。M 包含准备执行的空闲和等待的 goroutine。一些值得注意的 M 结构参数如下:
-
包含调度堆栈的 goroutine(go)
-
线程本地存储(tls)
-
用于执行 Go 代码的 P 结构(p)
P 结构
这个结构标记为P代表处理器。P 结构表示一个逻辑处理器。这是由GOMAXPROCS
设置的(在 Go 版本 1.5 之后应该等于可用核心数)。P 维护所有 goroutine 的队列(由 G 结构定义)。当您使用 Go 执行器调用新的 goroutine 时,这个新的 goroutine 会被插入到 P 的队列中。如果 P 没有关联的 M 结构,它将分配一个新的 M。一些值得注意的 P 结构参数如下:
-
P 结构 ID(id)
-
如果适用,与关联的 M 结构的后向链接(m)
-
可用延迟结构的池(deferpool)
-
可运行 goroutine 的队列(runq)
-
可用 G 的结构(gFree)
G 结构
这个结构标记为G代表goroutine。G 结构表示单个 goroutine 的堆栈参数。它包括一些对于 goroutine 很重要的不同参数的信息。对于每个新的 goroutine 以及运行时的 goroutine,都会创建 G 结构。一些值得注意的 G 结构参数如下:
-
堆栈指针的当前值(
stack.lo
和stack.hi
) -
Go 和 C 堆栈增长序言的当前值(
stackguard0
和stackguard1
) -
M 结构的当前值(m)
正在执行的 goroutine
现在我们对 goroutine 的基本原理有了基本的了解,我们可以看到它们的实际应用。在下面的代码块中,我们将看到如何使用go
调用来调用 goroutine:
package main
import (
"fmt"
"time"
)
func printSleep(s string) {
for index, stringVal := range s {
fmt.Printf("%#U at index %d\n", stringVal, index)
time.Sleep(1 * time.Millisecond) // printSleep sleep timer
}
}
func main() {
const t time.Duration = 9
go printSleep("HELLO GOPHERS")
time.Sleep(t * time.Millisecond) // Main sleep timer
fmt.Println("sleep complete")
}
在执行此函数期间,我们只得到了printSleep()
函数的部分返回(打印HELLO GOPHERS
),然后主睡眠计时器完成。为什么会发生这种情况?如果main()
goroutine 完成,它会关闭,程序终止,并且剩余的 goroutine 将不会运行。我们能够得到前九个字符的返回,是因为这些 goroutine 在主函数执行完成之前就已经完成了。如果我们将const t
的持续时间更改为14
,我们将收到整个HELLO GOPHERS
字符串。原因是在main
函数完成之前,go printSleep()
周围产生的所有 goroutine 都没有执行。只有在正确使用时,goroutine 才是强大的。
另一个帮助管理并发 goroutine 的 Go 内置功能是 Go 通道,这是我们将在下一节中讨论的主题。
引入通道
通道是允许发送和接收值的机制。通道通常与 goroutine 一起使用,以便在 goroutine 之间并发地传递对象。Go 中有两种主要类型的通道:无缓冲通道和缓冲通道。
通道内部
通道是使用make()
Golang 内置函数调用的,其中创建了一个hchan
结构。hchan
结构包含队列中的数据计数,队列的大小,用于缓冲区的数组指针,发送和接收索引和等待者,以及互斥锁。以下代码块说明了这一点:
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
这个代码块引用自golang.org/src/runtime/chan.go#L32
。
缓冲通道
缓冲通道是具有有限大小的通道。它们通常比无限大小的通道更高效。它们对于从你启动的一组显式数量的 goroutine 中检索值非常有用。因为它们是FIFO(先进先出)的排队机制,它们可以有效地用作固定大小的排队机制,我们可以按照它们进入的顺序处理请求。通道在使用之前通过调用make()
函数创建。一旦创建了缓冲通道,它就已经准备好可以使用了。如果通道中仍有空间,缓冲通道不会在接收写入时阻塞。重要的是要记住数据在通道内的箭头方向流动。在我们的示例中(以下代码块),我们执行以下操作:
-
将
foo
和bar
写入我们的buffered_channel
-
检查通道的长度-长度为
2
,因为我们添加了两个字符串 -
从通道中弹出
foo
和bar
-
检查通道的长度-长度为
0
,因为我们移除了两个字符串 -
向我们的通道中添加
baz
-
从通道中弹出
baz
到一个变量out
-
打印结果的
out
变量,它是baz
(我们添加到通道中的最后一个元素) -
关闭我们的缓冲通道,表示不再有数据通过这个通道传递
让我们看一下以下代码块:
package main
import "fmt"
func main() {
buffered_channel := make(chan string, 2)
buffered_channel <- "foo"
buffered_channel <- "bar"
// Length of channel is 2 because both elements added to channel
fmt.Println("Channel Length After Add: ", len(buffered_channel))
// Pop foo and bar off the stack
fmt.Println(<-buffered_channel)
fmt.Println(<-buffered_channel)
// Length of channel is 0 because both elements removed from channel
fmt.Println("Channel Length After Pop: ", len(buffered_channel))
// Push baz to the stack
buffered_channel <- "baz"
// Store baz as a variable, out
out := <-buffered_channel
fmt.Println(out)
close(buffered_channel)
}
正如我们在代码块示例中看到的,我们能够将数据推送到栈中并从栈中弹出数据。还需要注意的是len()
内置函数返回通道缓冲区中未读(或排队)的元素数量。除了len()
内置函数,我们还可以使用cap()
内置函数来推断缓冲区的总容量。这两个内置函数结合使用通常可以用来了解通道的当前状态,特别是如果它的行为不符合预期。关闭通道也是一个好习惯。当你关闭一个通道时,你告诉 Go 调度程序不会再有值被发送到该通道。还需要注意的是,如果你尝试向一个关闭的通道或者队列中没有空间的通道写入数据,你的程序会引发 panic。
以下程序会引发 panic:
package main
func main() {
ch := make(chan string, 1)
close(ch)
ch <- "foo"
}
我们将会看到以下的错误消息截图:
这是因为我们试图向一个已经关闭的通道(ch
)传递数据(foo
字符串)。
以下程序也会引发 panic:
package main
func main() {
ch := make(chan string, 1)
ch <- "foo"
ch <- "bar"
}
我们会看到以下错误消息:
程序会因为 goroutine 会被阻塞而引发 panic。这个错误会被运行时检测到,程序退出。
遍历通道
你可能想知道你的缓冲通道中所有的值。我们可以通过在我们想要检查的通道上调用range
内置函数来实现这一点。我们在以下代码块的示例中向通道添加了三个元素,关闭了通道,然后使用fmt
写入了通道中的所有元素:
package main
import "fmt"
func main() {
bufferedChannel := make(chan int, 3)
bufferedChannel <- 1
bufferedChannel <- 3
bufferedChannel <- 5
close(bufferedChannel)
for i := range bufferedChannel {
fmt.Println(i)
}
}
结果输出显示了我们缓冲通道中的所有值:
提醒一下-确保关闭通道。如果我们删除前面的close(bufferedChannel)
函数,我们将会遇到死锁。
无缓冲通道
在 Go 中,无缓冲通道是默认的通道配置。无缓冲通道是灵活的,因为它们不需要有一个有限的通道大小定义。当通道的接收者比通道的发送者慢时,它们通常是最佳选择。它们在读取和写入时都会阻塞,因为它们是同步的。发送者将阻塞通道,直到接收者接收到值。它们通常与 goroutines 一起使用,以确保项目按预期的顺序进行处理。
在我们接下来的示例代码块中,我们执行以下操作:
-
创建一个布尔通道来维护状态
-
创建一个未排序的切片
-
使用
sortInts()
函数对我们的切片进行排序 -
响应我们的通道,以便我们可以继续函数的下一部分
-
搜索我们的切片以查找给定的整数
-
响应我们的通道,以便我们的通道上的事务完成
-
返回通道值,以便我们的 Go 函数完成
首先,我们导入我们的包并创建一个函数,用于在通道中对整数进行排序:
package main
import (
"fmt"
"sort"
)
func sortInts(intArray[] int, done chan bool) {
sort.Ints(intArray)
fmt.Printf("Sorted Array: %v\n", intArray)
done < -true
}
接下来,我们创建一个 searchInts
函数,用于在通道中搜索整数:
func searchInts(intArray []int, searchNumber int, done chan bool) {
sorted := sort.SearchInts(intArray, searchNumber)
if sorted < len(intArray) {
fmt.Printf("Found element %d at array position %d\n", searchNumber, sorted)
} else {
fmt.Printf("Element %d not found in array %v\n", searchNumber, intArray)
}
done <- true
}
最后,我们在我们的 main
函数中将它们全部绑定在一起:
func main() {
ch := make(chan bool)
go func() {
s := []int{2, 11, 3, 34, 5, 0, 16} // unsorted
fmt.Println("Unsorted Array: ", s)
searchNumber := 16
sortInts(s, ch)
searchInts(s, searchNumber, ch)
}()
<-ch
}
我们可以在以下截图中看到该程序的输出:
这是使用通道并行执行操作的好方法。
选择
选择是一种允许您以有意义的方式结合 goroutines 和通道的构造。我们可以复用 Go 函数,以便能够执行 goroutine 运行时发生的情况。在我们的示例中,我们创建了三个单独的通道:一个 string
通道,一个 bool
通道和一个 rune
通道。接下来,我们在以下代码块中运行一些匿名函数,以便向这些通道中填充数据,并使用内置的 select 返回通道中的值。
- 首先,我们初始化我们的包并设置三个单独的通道:
package main
import (
"fmt"
"time"
)
func main() {
// Make 3 channels
ch1 := make(chan string)
ch2 := make(chan bool)
ch3 := make(chan rune)
- 接下来,通过匿名函数向每个通道传递适当的变量:
// string anonymous function to ch1
go func() {
ch1 <- "channels are fun"
}()
// bool anonymous function to ch2
go func() {
ch2 <- true
}()
// rune anonymous function to ch3 with 1 second sleep
go func() {
time.Sleep(1 * time.Second)
ch3 <- 'r'
}()
- 最后,我们通过我们的
select
语句将它们传递:
// select builtin to return values from channels
for i := 0; i < 3; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Channel 1 message: ", msg1)
case msg2 := <-ch2:
fmt.Println("Channel 2 message: ", msg2)
case msg3 := <-ch3:
fmt.Println("Channel 3 message: ", msg3)
}
}
}
该程序的结果输出可以在以下截图中看到:
您会注意到这里 rune
匿名函数最后返回。这是由于在该匿名函数中插入了休眠。如果多个值准备就绪,select
语句将随机返回传递到通道中的值,并在 goroutine 结果准备就绪时按顺序返回。
在下一节中,我们将学习什么是信号量。
引入信号量
信号量是另一种控制 goroutines 执行并行任务的方法。信号量很方便,因为它们使我们能够使用工作池模式,但我们不需要在工作完成并且工作线程处于空闲状态时关闭工作线程。在 Go 语言中使用加权信号量的概念相对较新;信号量的 sync 包实现是在 2017 年初实现的,因此它是最新的并行任务构造之一。
如果我们以以下代码块中的简单循环为例,向请求添加 100 毫秒的延迟,并向数组添加一个项目,我们很快就会看到随着这些任务按顺序操作,所需的时间增加:
package main
import (
"fmt"
"time"
)
func main() {
var out = make([]string, 5)
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
out[i] = "This loop is slow\n"
}
fmt.Println(out)
}
我们可以使用相同的构造创建一个加权信号量实现。我们可以在以下代码块中看到:
- 首先,我们初始化程序并设置信号量变量:
package main
import (
"context"
"fmt"
"runtime"
"time"
"golang.org/x/sync/semaphore"
)
func main() {
ctx := context.Background()
var (
sem = semaphore.NewWeighted(int64(runtime.GOMAXPROCS(0)))
result = make([]string, 5)
)
- 然后,我们运行我们的信号量代码:
for i := range result {
if err := sem.Acquire(ctx, 1); err != nil {
break
}
go func(i int) {
defer sem.Release(1)
time.Sleep(100 * time.Millisecond)
result[i] = "Semaphores are Cool \n"
}(i)
}
if err := sem.Acquire(ctx, int64(runtime.GOMAXPROCS(0))); err != nil {
fmt.Println("Error acquiring semaphore")
}
fmt.Println(result)
}
这两个函数之间的执行时间差异非常明显,可以在以下输出中看到:
信号量实现的运行速度比两倍还要快,如下截图所示:
信号量实现的速度超过两倍。 这是只有五个 100 毫秒的阻塞睡眠。 随着规模的不断增长,能够并行处理事务变得越来越重要。
在下一节中,我们将讨论 WaitGroups。
理解 WaitGroups
WaitGroups 通常用于验证多个 goroutine 是否已完成。 我们这样做是为了确保我们已完成了所有我们期望完成的并发工作。
在以下代码块的示例中,我们使用WaitGroup
对四个网站进行请求。 这个WaitGroup
将等到所有的请求都完成后才会完成main
函数,并且只有在所有的WaitGroup
值都返回后才会完成:
- 首先,我们初始化我们的包并设置我们的检索函数:
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func retrieve(url string, wg *sync.WaitGroup) {
// WaitGroup Counter-- when goroutine is finished
defer wg.Done()
start := time.Now()
res, err := http.Get(url)
end := time.Since(start)
if err != nil {
panic(err)
}
// print the status code from the response
fmt.Println(url, res.StatusCode, end)
}
- 在我们的
main
函数中,我们接下来使用我们的检索函数在一个 goroutine 中使用 WaitGroups:
func main() {
var wg sync.WaitGroup
var urls = []string{"https://godoc.org", "https://www.packtpub.com", "https://kubernetes.io/"}
for i := range urls {
// WaitGroup Counter++ when new goroutine is called
wg.Add(1)
go retrieve(urls[i], &wg)
}
// Wait for the collection of goroutines to finish
wg.Wait()
}
从以下输出中可以看出,我们收到了所有网页请求的测量数据,它们的响应代码和它们各自的时间:
我们经常希望所有的 goroutine 都能完成。 WaitGroups 可以帮助我们做到这一点。
在下一节中,我们将讨论迭代的过程。
迭代器和迭代的过程
迭代是查看一组数据的方法,通常是列表,以便从该列表中检索信息。 Go 有许多不同的迭代器模式,都有利有弊:
迭代器 | 优点 | 缺点 |
---|---|---|
for 循环 |
最简单的实现 | 没有默认并发。 |
具有回调的迭代器函数 | 简单的实现 | Go 的非常规样式; 难以阅读。 |
通道 | 简单的实现 | 在计算上比其他一些迭代器更昂贵(成本差异较小)。 唯一自然并发的迭代器。 |
有状态的迭代器 | 难以实现 | 良好的调用者接口。 适用于复杂的迭代器(通常在标准库中使用)。 |
重要的是要相互对比所有这些以验证关于每个迭代器需要多长时间的假设。 在以下测试中,我们对它们的和进行了0
到n
的求和,并对它们进行了基准测试。
以下代码块具有简单的for
循环迭代器:
package iterators
var sumLoops int
func simpleLoop(n int) int {
for i: = 0; i < n; i++ {
sumLoops += i
}
return sumLoops
}
以下代码块具有回调迭代器:
package iterators
var sumCallback int
func CallbackLoop(top int) {
err: = callbackLoopIterator(top, func(n int) error {
sumCallback += n
return nil
})
if err != nil {
panic(err)
}
}
func callbackLoopIterator(top int, callback func(n int) error) error {
for i: = 0; i < top; i++{
err: = callback(i)
if err != nil {
return err
}
}
return nil
}
以下代码块将展示Next()
的使用。 让我们再一次一步一步地看一下:
- 首先,我们初始化我们的包变量和结构。 接下来,我们创建一个
CounterIterator
:
package iterators
var sumNext int
type CounterStruct struct {
err error
max int
cur int
}
func NewCounterIterator(top int) * CounterStruct {
var err error
return &CounterStruct {
err: err,
max: top,
cur: 0,
}
}
- 接下来是
Next()
函数,Value()
函数和NextLoop()
函数:
func(i * CounterStruct) Next() bool {
if i.err != nil {
return false
}
i.cur++
return i.cur <= i.max
}
func(i * CounterStruct) Value() int {
if i.err != nil || i.cur > i.max {
panic("Value is not valid after iterator finished")
}
return i.cur
}
func NextLoop(top int) {
nextIterator: = NewCounterIterator(top)
for nextIterator.Next() {
fmt.Print(nextIterator.Value())
}
}
- 下一个代码块具有缓冲通道实现:
package iterators
var sumBufferedChan int
func BufferedChanLoop(n int) int {
ch: = make(chan int, n)
go func() {
defer close(ch)
for i: = 0;
i < n;
i++{
ch < -i
}
}()
for j: = range ch {
sumBufferedChan += j
}
return sumBufferedChan
}
- 下一个代码块具有无缓冲通道实现:
package iterators
var sumUnbufferedChan int
func UnbufferedChanLoop(n int) int {
ch: = make(chan int)
go func() {
defer close(ch)
for i: = 0;
i < n;
i++{
ch < -i
}
}()
for j: = range ch {
sumUnbufferedChan += j
}
return sumUnbufferedChan
}
-
将所有这些编译在一起后,我们可以进行测试基准。 这些基准测试可以在以下代码块中找到。 让我们再一次一步一步地看一下。
-
首先,我们初始化我们的包并设置一个简单的回调循环基准:
package iterators
import "testing"
func benchmarkLoop(i int, b *testing.B) {
for n := 0; n < b.N; n++ {
simpleLoop(i)
}
}
func benchmarkCallback(i int, b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
CallbackLoop(i)
}
}
- 接下来是一个
Next
和缓冲通道基准:
func benchmarkNext(i int, b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
NextLoop(i)
}
}
func benchmarkBufferedChan(i int, b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
BufferedChanLoop(i)
}
}
- 最后,我们设置了无缓冲通道基准,并为每个基准创建了循环函数:
func benchmarkUnbufferedChan(i int, b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
UnbufferedChanLoop(i)
}
}
func BenchmarkLoop10000000(b *testing.B) { benchmarkLoop(1000000, b) }
func BenchmarkCallback10000000(b *testing.B) { benchmarkCallback(1000000, b) }
func BenchmarkNext10000000(b *testing.B) { benchmarkNext(1000000, b) }
func BenchmarkBufferedChan10000000(b *testing.B) { benchmarkBufferedChan(1000000, b) }
func BenchmarkUnbufferedChan10000000(b *testing.B) { benchmarkUnbufferedChan(1000000, b) }
基准测试的结果可以在以下截图中找到:
这些迭代器测试的上下文非常重要。 因为在这些测试中我们只是做简单的加法,所以迭代的简单构造是关键。 如果我们在每次调用中添加延迟,那么并发通道迭代器的性能将更好。 并发在合适的上下文中是一件强大的事情。
在下一节中,我们将讨论生成器。
生成器简介
生成器是在循环结构中返回下一个顺序值的例程。生成器通常用于实现迭代器并引入并行性。在 Go 中,Goroutines 被用来实现生成器。为了在 Go 中实现并行性,我们可以使用生成器与消费者并行运行以产生值。它们通常在循环结构中被使用。生成器本身也可以并行化。这通常是在生成输出的成本很高且输出可以以任何顺序生成时才会这样做。
总结
在本章中,我们学习了 Go 中用于迭代器和生成器的许多基本构造。理解匿名函数和闭包帮助我们建立了关于这些函数如何工作的基础知识。然后我们学习了 goroutines 和 channels 的工作原理,以及如何有效地实现它们。我们还学习了关于信号量和 WaitGroups,以及它们在语言中的作用。理解这些技能将帮助我们以更有效的方式解析计算机程序中的信息,从而实现更多的并发数据操作。在第四章中,在 Go 中的 STL 算法等效实现,我们将学习如何在 Go 中实现标准模板库(STL)的实际应用。
第四章:Go 中的 STL 算法等价物
许多来自其他高性能编程语言,特别是 C++的程序员,了解标准模板库(STL)的概念。该库提供了常见的编程数据结构和函数访问通用库,以便快速迭代和编写大规模的高性能代码。Go 没有内置的 STL。本章将重点介绍如何在 Go 中利用一些最常见的 STL 实践。STL 有四个常见的组件:
-
算法
-
容器
-
函数对象
-
迭代器
熟悉这些主题将帮助您更快、更有效地编写 Go 代码,利用常见的实现和优化模式。在本章中,我们将学习以下内容:
-
如何在 Go 中使用 STL 实践
-
如何在 Go 中利用标准编程算法
-
容器如何存储数据
-
Go 中函数的工作原理
-
如何正确使用迭代器
记住,所有这些部分仍然是我们性能拼图的一部分。知道何时使用正确的算法、容器或函数对象将帮助您编写性能更好的代码。
了解 STL 中的算法
STL 中的算法执行排序、搜索、操作和计数等功能。这些功能由 C++中的<algorithm>
头文件调用,并用于元素范围。被修改的对象组不会影响它们所关联的容器的结构。这里每个小标题中概述的模式使用 Go 的语言结构来实现这些算法。本章的这一部分将解释以下类型的算法:
-
排序
-
逆转
-
最小和最大元素
-
二分搜索
能够理解所有这些算法的工作原理将帮助您在需要使用这些技术来操作数据结构时编写性能良好的代码。
排序
sort算法将数组按升序排序。排序不需要创建、销毁或复制新的容器——排序算法对容器中的所有元素进行排序。我们可以使用 Go 的标准库 sort 来实现这一点。Go 的标准库 sort 对不同的数据类型(IntsAreSorted
、Float64sAreSorted
和StringsAreSorted
)有辅助函数来对它们进行排序。我们可以按照以下代码中所示的方式实现排序算法:
package main
import (
"fmt"
"sort"
)
func main() {
intData := []int{3, 1, 2, 5, 6, 4}
stringData := []string{"foo", "bar", "baz"}
floatData := []float64{1.5, 3.6, 2.5, 10.6}
这段代码使用值实例化简单的数据结构。之后,我们使用内置的sort
函数对每个数据结构进行排序,如下所示:
sort.Ints(intData)
sort.Strings(stringData)
sort.Float64s(floatData)
fmt.Println("Sorted Integers: ", intData, "\nSorted Strings:
", stringData, "\nSorted Floats: ", floatData)
}
当我们执行这个代码时,我们可以看到所有的切片都按顺序排序,如下面的截图所示:
整数按从低到高排序,字符串按字母顺序排序,浮点数按从低到高排序。这些是sort
包中的默认排序方法。
反转
reverse算法接受一个数据集并反转集合的值。Go 标准的sort
包没有内置的反转切片的方法。我们可以编写一个简单的reverse
函数来反转我们数据集的顺序,如下所示:
package main
import (
"fmt"
)
func reverse(s []string) []string {
for x, y := 0, len(s)-1; x < y; x, y = x+1, y-1 {
s[x], s[y] = s[y], s[x]
}
return s
}
func main() {
s := []string{"foo", "bar", "baz", "go", "stop"}
reversedS := reverse(s)
fmt.Println(reversedS)
}
这个函数通过切片进行迭代,增加和减少x
和y
直到它们收敛,并交换切片中的元素,如下面的截图所示:
我们可以看到,我们的切片使用reverse()
函数被反转。使用标准库可以使一个难以手动编写的函数变得简单、简洁和可重用。
最小元素和最大元素
我们可以使用min_element
和max_element
算法在数据集中找到最小和最大值。我们可以使用简单的迭代器在 Go 中实现min_element
和max_element
:
- 首先,我们将编写一个函数来找到切片中最小的整数:
package main
import "fmt"
func findMinInt(a []int) int {
var minInt int = a[0]
for _, i := range a {
if minInt > i {
minInt = i
}
}
return minInt
}
- 接下来,我们将按照相同的过程,尝试在切片中找到最大的整数:
func findMaxInt(b []int) int {
var max int = b[0]
for _, i := range b {
if max < i {
max = i
}
}
return max
}
- 最后,我们将使用这些函数打印出最终的最小值和最大值:
func main() {
intData := []int{3, 1, 2, 5, 6, 4}
minResult := findMinInt(intData)
maxResult := findMaxInt(intData)
fmt.Println("Minimum value in array: ", minResult)
fmt.Println("Maximum value in array: ", maxResult)
}
这些函数遍历整数切片,并在切片中找到最小值和最大值,如下面的屏幕截图所示:
从我们的执行结果可以看出,找到了最小值和最大值。
在 Go 的math
包中,我们还有math.Min
和math.Max
。这些仅用于比较float64
数据类型。浮点数比较并不是一件容易的事情,因此 Go 的设计者决定将默认的Min
和Max
签名;在math
库中,应该使用浮点数。如果 Go 有泛型,我们上面编写的主要函数可能适用于不同类型。这是 Go 语言设计的一部分——保持事情简单和集中。
二分查找
二分查找是一种用于在排序数组中查找特定元素位置的算法。它从数组中间元素开始。如果没有匹配,算法接下来取可能包含该项的数组的一半,并使用中间值来找到目标。正如我们在第二章中学到的,数据结构和算法,二分查找是一个高效的O(log n)算法。Go 标准库的sort
包有一个内置的二分查找函数。我们可以这样使用它:
package main
import (
"fmt"
"sort"
)
func main() {
data := []int{1, 2, 3, 4, 5, 6}
findInt := 2
out := sort.Search(len(data), func(i int) bool { return data[i]
>= findInt })
fmt.Printf("Integer %d was found in %d at position %d\n",
findInt, data, out)
}
二分查找算法正确地找到了我们正在搜索的整数值2
,并且在预期位置(在零索引切片中的位置1
)上。我们可以在以下屏幕截图中看到二分查找的执行:
总之,STL 中的算法都很好地转换到了 Go 中。Go 的默认函数和迭代器使得组合简单、可重用的算法变得容易。在下一节中,我们将学习关于容器的知识。
理解容器
STL 中的容器分为三个独立的类别:
-
序列容器
-
序列容器适配器
-
关联容器
接下来,我们将在以下小节中介绍这三种类型的容器。
序列容器
序列容器存储特定类型的数据元素。目前有五种序列容器的实现:array
、vector
、deque
、list
和forward_list
。这些序列容器使得以顺序方式引用数据变得容易。能够利用这些序列容器是编写有效代码和重用标准库中模块化部分的一个很好的捷径。我们将在以下小节中探讨这些内容。
数组
在 Go 中,数组类似于 C++中的数组。Go 的数组结构在编译时静态定义,不可调整大小。数组在 Go 中的实现方式如下:
arrayExample := [5]string{"foo", "bar", "baz", "go", "rules"}
这个数组保存了在arrayExample
变量中定义的字符串的值,该变量被定义为一个数组。
向量
Go 最初有一个向量的实现,但这在语言开发的早期就被移除了(2011 年 10 月 11 日)。人们认为切片更好(正如拉取请求的标题所说),切片成为了 Go 中的事实上的向量实现。我们可以这样实现一个切片:
sliceExample := []string{"slices", "are", "cool", "in", "go"}
切片很有益,因为它们像 STL 中的向量一样,可以根据添加或删除而增长或缩小。在我们的示例中,我们创建一个切片,向切片附加一个值,并从切片中移除一个值,如下面的代码所示:
package main
import "fmt"
// Remove i indexed item in slice
func remove(s []string, i int) []string {
copy(s[i:], s[i+1:])
return s[:len(s)-1]
}
func main() {
slice := []string{"foo", "bar", "baz"} // create a slice
slice = append(slice, "tri") // append a slice
fmt.Println("Appended Slice: ", slice) // print slice [foo, bar baz, tri]
slice = remove(slice, 2) // remove slice item #2 (baz)
fmt.Println("After Removed Item: ", slice) // print slice [foo, bar, tri]
}
当我们执行我们的向量示例时,我们可以看到我们的附加和移除操作,如下面的屏幕截图所示:
我们可以看到tri
元素被附加到了我们的切片末尾,并且我们还可以看到基于我们的remove()
函数调用,baz
元素(切片中的第 3 个元素)被移除了。
双端队列
双端队列是一个可以扩展的容器。这些扩展可以发生在容器的前端或后端。当需要频繁引用队列的顶部或后部时,通常会使用双端队列。以下代码块是双端队列的简单实现:
package main
import (
"fmt"
"gopkg.in/karalabe/cookiejar.v1/collections/deque"
)
func main() {
d := deque.New()
elements := []string{"foo", "bar", "baz"}
for i := range elements {
d.PushLeft(elements[i])
}
fmt.Println(d.PopLeft()) // queue => ["foo", "bar"]
fmt.Println(d.PopRight()) // queue => ["bar"]
fmt.Println(d.PopLeft()) // queue => empty
}
deque
包接受一个元素的切片,并使用PushLeft
函数将它们推送到队列上。接下来,我们可以从双端队列的左侧和右侧弹出元素,直到我们的队列为空。我们可以在以下截图中看到我们双端队列逻辑的执行:
我们的结果显示了对双端队列的操作输出以及我们如何可以从队列的任一端取出东西。能够从队列的任一端取出东西在数据操作中是有优势的,这就是为什么双端队列是一种流行的数据结构选择。
列表
列表是 Go 语言中双向链表的实现。这是内置在标准库的 container/list 包中的。我们可以使用通用双向链表的实现执行许多操作,如下面的代码所示:
package main
import (
"container/list"
"fmt"
)
func main() {
ll := list.New()
three := ll.PushBack(3) // stack representation -> [3]
four := ll.InsertBefore(4, three) // stack representation -> [4 3]
ll.InsertBefore(2, three) // stack representation ->
// [4 2 3]
ll.MoveToBack(four) // stack representation ->
// [2 3 4]
ll.PushFront(1) // stack representation ->
// [1 2 3 4]
listLength := ll.Len()
fmt.Printf("ll type: %T\n", ll)
fmt.Println("ll length: :", listLength)
for e := ll.Front(); e != nil; e = e.Next() {
fmt.Println(e.Value)
}
}
双向链表类似于双端队列容器,但如果需要,它允许在堆栈的中间进行插入和移除。双向链表在实践中使用得更多。我们可以在以下截图中看到我们双向链表代码的执行。
我们可以看到所有元素在程序输出中按照它们在堆栈上协调的顺序。链表是编程的基本要素,因为它们是当今计算机科学建立在其上的基本算法。
前向列表
前向列表是单向链表的实现。单向链表通常比双向链表具有更小的内存占用;然而,通过单向链表进行迭代不太好,特别是在反向方向上。让我们看看如何实现前向列表:
- 首先,我们初始化我们的程序并定义我们的结构:
package main
import "fmt"
type SinglyLinkedList struct {
head *LinkedListNode
}
type LinkedListNode struct {
data string
next *LinkedListNode
}
- 然后我们创建我们的
Append
函数并在我们的main
函数中应用它:
func (ll *SinglyLinkedList) Append(node *LinkedListNode) {
if ll.head == nil {
ll.head = node
return
}
currentNode := ll.head
for currentNode.next != nil {
currentNode = currentNode.next
}
currentNode.next = node
}
func main() {
ll := &SinglyLinkedList{}
ll.Append(&LinkedListNode{data: "hello"})
ll.Append(&LinkedListNode{data: "high"})
ll.Append(&LinkedListNode{data: "performance"})
ll.Append(&LinkedListNode{data: "go"})
for e := ll.head; e != nil; e = e.next {
fmt.Println(e.data)
}
}
从以下截图的输出结果中可以看到,我们附加到我们的单链表的所有数据都是可访问的:
这个数据结构的初始元素按照它们在代码块中添加的顺序放入列表中。这是预期的,因为单向链表通常用于保持数据结构中的数据顺序。
容器适配器
容器适配器接受一个顺序容器并调整它的使用方式,以便原始顺序容器能够按照预期的方式运行。在研究这些容器适配器时,我们将学习它们是如何创建的,以及它们如何从实际的角度使用。
队列
队列是遵循FIFO队列方法或先进先出的容器。这意味着我们可以将东西添加到容器中,并从容器的另一端取出它们。我们可以通过向切片附加和出列来制作最简单形式的队列,如下面的代码所示:
package main
import "fmt"
func main() {
var simpleQueue []string
simpleQueue = append(simpleQueue, "Performance ")
simpleQueue = append(simpleQueue, "Go")
for len(simpleQueue) > 0 {
fmt.Println(simpleQueue[0]) // First element
simpleQueue = simpleQueue[1:] // Dequeue
}
fmt.Println(simpleQueue) //All items are dequeued so result should be []
}
在我们的示例中,我们将字符串附加到我们的simpleQueue
,然后通过移除切片的第一个元素来出列它们:
在我们的输出中,我们可以看到我们正确地向队列添加了元素并将它们移除。
优先队列
优先队列是使用堆来保持容器中元素的优先列表的容器。优先队列很有帮助,因为可以按优先级对结果集进行排序。优先队列通常用于许多实际应用,从负载平衡 Web 请求到数据压缩,再到 Dijkstra 算法。
在我们的优先级队列示例中,我们创建了一个新的优先级队列,并插入了几种具有给定优先级的不同编程语言。我们从 Java 开始,它是第一个优先级,然后 Go 成为第一个优先级。添加了 PHP,Java 的优先级被推到 3。以下代码是优先级队列的一个示例。在这里,我们实例化了必要的要求,创建了一个新的优先级队列,向其中插入元素,改变了这些项的优先级,并从堆栈中弹出项:
package main
import (
"fmt"
pq "github.com/jupp0r/go-priority-queue"
)
func main() {
priorityQueue := pq.New()
priorityQueue.Insert("java", 1)
priorityQueue.Insert("golang", 1)
priorityQueue.Insert("php", 2)
priorityQueue.UpdatePriority("java", 3)
for priorityQueue.Len() > 0 {
val, err := priorityQueue.Pop()
if err != nil {
panic(err)
}
fmt.Println(val)
}
}
在我们执行这个示例代码之后,我们可以看到基于我们设置的优先级队列值的语言的正确排序,如下所示:
优先级队列是一种常用的重要数据结构。它们用于首先处理数据结构中最重要的元素,并且能够使用 STL 等效实现这一点有助于我们节省时间和精力,同时能够优先处理传入的请求。
堆栈
堆栈使用push
和pop
来添加和删除容器中的元素,用于对数据进行分组。堆栈通常具有LIFO(后进先出)的操作顺序,Peek
操作通常允许您查看堆栈顶部的内容而不将其从堆栈中移除。堆栈非常适用于具有有限内存集的事物,因为它们可以有效地利用分配的内存。以下代码是堆栈的简单实现:
package main
import (
"fmt"
stack "github.com/golang-collections/collections/stack"
)
func main() {
// Create a new stack
fmt.Println("Creating New Stack")
exstack := stack.New()
fmt.Println("Pushing 1 to stack")
exstack.Push(1) // push 1 to stack
fmt.Println("Top of Stack is : ", exstack.Peek())
fmt.Println("Popping 1 from stack")
exstack.Pop() // remove 1 from stack
fmt.Println("Stack length is : ", exstack.Len())
}
我们可以从我们的程序输出中看到以下内容:
我们可以看到我们的堆栈操作按预期执行。能够使用堆栈操作在计算机科学中非常重要,因为这是许多低级编程技术执行的方式。
关联容器
关联容器是实现关联数组的容器。这些数组是有序的,只是在算法对它们的每个元素施加的约束上有所不同。STL 引用关联容器,即 set、map、multiset 和 multimap。我们将在以下部分探讨这些内容。
集合
集合用于仅存储键。Go 没有集合类型,因此经常使用map
类型到布尔值的映射来构建集合。以下代码块是 STL 等效集合的实现:
package main
import "fmt"
func main() {
s := make(map[int]bool)
for i := 0; i < 5; i++ {
s[i] = true
}
delete(s, 4)
if s[2] {
fmt.Println("s[2] is set")
}
if !s[4] {
fmt.Println("s[4] was deleted")
}
}
结果输出显示我们能够设置和删除相应的值:
从我们的输出中可以看出,我们的代码可以正确地操作集合,这对于常见的键-值对非常重要。
多重集
多重集是带有与每个元素关联的计数的无序集合。多重集可以进行许多方便的操作,例如取差集、缩放集合或检查集合的基数。
在我们的示例中,我们构建了一个多重集x
,将其缩放为多重集y
,验证x
是否是y
的子集,并检查x
的基数。我们可以在以下代码中看到多重集的一个示例实现:
package main
import (
"fmt"
"github.com/soniakeys/multiset"
)
func main() {
x := multiset.Multiset{"foo": 1, "bar": 2, "baz": 3}
fmt.Println("x: ", x)
// Create a scaled version of x
y := multiset.Scale(x, 2)
fmt.Println("y: ", y)
fmt.Print("x is a subset of y: ")
fmt.Println(multiset.Subset(x, y))
fmt.Print("Cardinality of x: ")
fmt.Println(x.Cardinality())
}
当我们执行此代码时,我们可以看到x
,x
的缩放版本y
的验证,以及x
的基数计算。以下是我们多重集代码片段执行的输出:
多重集对于集合操作非常有用,并且非常方便,因为每个元素可以有多个实例。多重集的一个很好的实际例子是购物车——您可以向购物车中添加许多物品,并且您可以在购物车中拥有同一物品的多个计数。
映射
映射是一种用于存储键-值对的容器。Go 的内置map
类型使用哈希表来存储键和它们关联的值。
在 Go 中,实例化映射很简单,如下所示:
package main
import "fmt"
func main() {
m := make(map[int]string)
m[1] = "car"
m[2] = "train"
m[3] = "plane"
fmt.Println("Full Map:\t ", m)
fmt.Println("m[3] value:\t ", m[3])
fmt.Println("Length of map:\t ", len(m))
}
现在让我们来看一下输出:
在前面的执行结果中,我们可以看到我们可以创建一个映射,通过使用它的键引用映射中的值,并使用Len()
内置类型找到我们映射中的元素数量。
多重映射
多重映射是一个可以返回一个或多个值的映射。多重映射的一个实际应用是 Web 查询字符串。查询字符串可以将多个值分配给相同的键,就像我们在下面的示例 URL 中看到的那样:https://www.example.com/?foo=bar&foo=baz&a=b
。
在我们的例子中,我们将创建一个汽车的多重映射。我们的car
结构体每辆车都有一个年份和一个风格。我们将能够聚合这些不同类型。以下代码片段是一个多重映射的实现:
package main
import (
"fmt"
"github.com/jwangsadinata/go-multimap/slicemultimap"
)
type cars []struct {
year int
style string
}
func main() {
newCars := cars{{2019, "convertible"}, {1966, "fastback"}, {2019, "SUV"}, {1920, "truck"}}
multimap := slicemultimap.New()
for _, car := range newCars {
multimap.Put(car.year, car.style)
}
for _, style := range multimap.KeySet() {
color, _ := multimap.Get(style)
fmt.Printf("%v: %v\n", style, color)
}
}
我们有多个版本的汽车,有一个2019
年的车型(敞篷车和 SUV)。在我们的输出结果中,我们可以看到这些值被聚合在一起:
当你想要在映射中捕获一对多的关联时,多重映射是非常有用的。在下一节中,我们将看看函数对象。
理解函数对象
函数对象,也称为函子,用于生成、测试和操作数据。如果将一个对象声明为函子,你可以像使用函数调用一样使用该对象。通常情况下,STL 中的算法需要一个参数来执行它们指定的任务。函子往往是一种有用的方式来帮助执行这些任务。在本节中,我们将学习以下内容:
-
函子
-
内部和外部迭代器
-
生成器
-
隐式迭代器
函子
函子是一种函数式编程范式,它在保持结构的同时对结构执行转换。
在我们的例子中,我们取一个整数切片intSlice
,并将该切片提升为一个函子。IntSliceFunctor
是一个包括以下内容的接口:
-
fmt.Stringer
,它定义了值的字符串格式及其表示。 -
Map(fn func(int int) IntSliceFunctor
– 这个映射将fn
应用到我们切片中的每个元素。 -
一个方便的函数,
Ints() []int
,它允许你获取函子持有的int
切片。
在我们有了我们的提升切片之后,我们可以对我们新创建的函子执行操作。在我们的例子中,我们执行了一个平方操作和一个模三操作。以下是一个函子的示例实现:
package main
import (
"fmt"
"github.com/go-functional/core/functor"
)
func main() {
intSlice := []int{1, 3, 5, 7}
fmt.Println("Int Slice:\t", intSlice)
intFunctor := functor.LiftIntSlice(intSlice)
fmt.Println("Lifted Slice:\t", intFunctor)
// Apply a square to our given functor
squareFunc := func(i int) int {
return i * i
}
// Apply a mod 3 to our given functor
modThreeFunc := func(i int) int {
return i % 3
}
squared := intFunctor.Map(squareFunc)
fmt.Println("Squared: \t", squared)
modded := squared.Map(modThreeFunc)
fmt.Println("Modded: \t", modded)
}
在执行这段代码时,我们可以看到我们的函子对函数操作的处理符合预期。我们取出了我们的初始intSlice
,将它提升为一个函子,用squareFunc
对每个值应用了平方,并用modThreeFunc
对每个值应用了%3
:
函子是一种非常强大的语言构造。函子以一种易于修改的方式抽象了一个容器。它还允许关注点的分离——例如,你可以将迭代逻辑与计算逻辑分开,函子可以更简单地进行参数化,函子也可以是有状态的。
迭代器
我们在第三章中讨论了迭代器,理解并发。迭代器是允许遍历列表和其他容器的对象。迭代器通常作为容器接口的一部分实现,这对程序员来说是一个重要的方法。它们通常被分为以下类别:
-
内部迭代器
-
外部迭代器
-
生成器
-
隐式迭代器
我们将在接下来的章节中更详细地讨论这些类别是什么。
内部迭代器
内部迭代器表示为高阶函数(通常使用匿名函数,正如我们在第三章中所见,理解并发)。高阶函数将函数作为参数并返回函数作为输出。匿名函数是不绑定标识符的函数。
内部迭代器通常映射到将函数应用于容器中的每个元素。这可以由变量标识符表示,也可以匿名表示。语言的作者曾提到在 Go 语言中可以使用 apply/reduce,但不应该使用(这是因为在 Go 语言中通常更喜欢使用for
循环)。这种模式符合 Go 语言的座右铭简单胜于巧妙。
外部迭代器
外部迭代器用于访问对象中的元素并指向对象中的下一个元素(分别称为元素访问和遍历)。Go 语言大量使用for
循环迭代器。for
循环是 Go 语言唯一的自然循环结构,并极大简化了程序构建。for
循环就像下面这样简单:
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
fmt.Println("Hi Gophers!")
}
}
我们可以看到我们的输出如下:
我们的for
循环迭代器很简单,但证明了一个重要观点——有时,简单对于复杂的问题集也能起到预期的作用。
生成器
生成器在调用函数时返回序列中的下一个值。如下面的代码块所示,匿名函数可以用于在 Go 语言中实现生成器迭代器模式:
package main
import "fmt"
func incrementCounter() func() int {
initializedNumber := 0
return func() int {
initializedNumber++
return initializedNumber
}
}
func main() {
n1 := incrementCounter()
fmt.Println("n1 increment counter #1: ", n1())
fmt.Println("n1 increment counter #2: ", n1())
n2 := incrementCounter()
fmt.Println("n2 increment counter #1: ", n2())
fmt.Println("n1 increment counter #3: ", n1())
}
当调用incrementCounter()
时,函数中表示的整数会递增。能够以这种方式并发使用匿名函数对许多从其他语言转到 Go 语言的程序员来说是一个很大的吸引点。它为利用语言的并发提供了简洁的方法。
隐式迭代器
隐式迭代器为程序员提供了一种简单的方法来迭代容器中存储的元素。这通常是使用 Go 语言中的内置 range 创建的。内置的 range 允许您遍历容器。以下是实现隐式迭代器的代码片段:
package main
import "fmt"
func main() {
stringExample := []string{"foo", "bar", "baz"}
for i, out := range stringExample {
fmt.Println(i, out)
}
}
我们可以看到以下结果输出:
此输出显示了我们对stringExample
变量范围的迭代。range
函数是一种非常强大的构造,简洁易读。
总结
在本章中,我们学习了如何在 Go 语言中使用 STL 实践。我们还学习了如何利用标准编程算法来处理 Go 语言,学习了容器如何存储数据,学习了函数在 Go 语言中的工作原理,并了解了如何正确使用迭代器。在我们继续 Go 性能之旅时,我们应始终将这些算法、容器、函数和迭代器放在编写代码选择的首要位置。这样做将帮助我们快速而简洁地编写符合惯例的 Go 代码。选择这些 STL 习语的正确组合将帮助我们更快、更有效地操作手头的数据。在下一章中,我们将学习如何在 Go 语言中计算向量和矩阵。
第五章:在 Go 中的矩阵和向量计算
矩阵和向量计算在计算机科学中很重要。向量可以在动态数组中保存一组对象。它们使用连续的存储,并且可以被操作以适应增长。矩阵建立在向量的基础上,创建了一个二维向量集。在本章中,我们将讨论矩阵和向量以及这两种数据结构如何实际使用,以执行今天计算机科学中发生的大部分数据操作。向量和矩阵是线性代数的基本组成部分,在今天的计算机科学中非常重要。诸如图像处理、计算机视觉和网络搜索等过程都利用线性代数来执行它们各自的操作。
在本章中,你将学习以下主题:
-
基本线性代数子程序(BLAS)
-
向量
-
矩阵
-
向量和矩阵操作
一旦我们能够将所有这些东西联系在一起,你将学会如何利用矩阵和向量计算的不同方面来推动大量数据的有效操作。
介绍 Gonum 和 Sparse 库
Go 中最受欢迎的科学算法库之一是 Gonum 包。Gonum 包(github.com/gonum
)提供了一些工具,帮助我们使用 Go 编写有效的数值算法。这个包专注于创建高性能算法,可以在许多不同的应用程序中使用,向量和矩阵是这个包的核心要点。这个库是以性能为目标创建的 - 创建者们在 C 中看到了向量化的问题,所以他们建立了这个库,以便更容易地在 Go 中操作向量和矩阵。Sparse 库(github.com/james-bowman/sparse
)是建立在 Gonum 库之上的,用于处理在机器学习和科学计算的其他部分中发生的一些正常的稀疏矩阵操作。在 Go 中使用这些库是一种高性能的方式来管理向量和矩阵。
在下一节中,我们将看看 BLAS 是什么。
介绍 BLAS
BLAS 是一个常用的规范,用于执行线性代数运算。这个库最初是在 1979 年作为 FORTRAN 库创建的,并且自那时以来一直得到维护。BLAS 对矩阵的高性能操作进行了许多优化。由于这个规范的深度和广度,许多语言选择在其领域内的线性代数库中使用这个规范的一部分。Go Sparse 库使用了 BLAS 实现进行线性代数操作。BLAS 规范由三个单独的例程组成:
-
级别 1:向量操作
-
级别 2:矩阵-向量操作
-
级别 3:矩阵-矩阵操作
有了这些分级的例程,有助于实现和测试这个规范。BLAS 已经在许多实现中使用过,从 Accelerate(macOS 和 iOS 框架)到英特尔数学核心库(MKL),并且已经成为应用计算机科学中线性代数的一个重要部分。
现在,是时候学习关于向量的知识了。
介绍向量
向量是一种常用于存储数据的一维数组。Go 最初有一个容器/向量实现,但在 2011 年 10 月 18 日被移除,因为切片被认为更适合在 Go 中使用向量。内置切片提供的功能可以提供大量的向量操作帮助。切片将是一个行向量,或者 1×m 矩阵的实现。一个简单的行向量如下所示:
正如你所看到的,我们有一个 1×m 矩阵。要在 Go 中实现一个简单的行向量,我们可以使用切片表示,如下所示:
v := []int{0, 1, 2, 3}
这是一种使用 Go 内置功能来描绘简单行向量的简单方法。
向量计算
列向量是一个 m x 1 矩阵,也被称为行向量的转置。矩阵转置是指矩阵沿对角线翻转,通常用上标 T 表示。我们可以在下面的图片中看到一个列向量的例子:
如果我们想在 Go 中实现一个列向量,我们可以使用 Gonum 向量包来初始化这个向量,就像下面的代码块中所示的那样:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
v := mat.NewVecDense(4, []float64{0, 1, 2, 3})
matPrint(v)
}
func matrixPrint(m mat.Matrix) {
formattedMatrix := mat.Formatted(m, mat.Prefix(""), mat.Squeeze())
fmt.Printf("%v\n", formattedMatrix)
}
这将打印出一个列向量,就像前面图片中所示的那样。
我们还可以使用 Gonum 包对向量进行一些整洁的操作。例如,在下面的代码块中,我们可以看到如何简单地将向量中的值加倍。我们可以使用AddVec
函数将两个向量相加,从而创建一个加倍的向量。我们还有prettyPrintMatrix
便利函数,使我们的矩阵更容易阅读:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
v := mat.NewVecDense(5, []float64{1, 2, 3, 4, 5})
d := mat.NewVecDense(5, nil)
d.AddVec(v, v)
fmt.Println(d)
}
func prettyPrintMatrix(m mat.Matrix) {
formattedM := mat.Formatted(m, mat.Prefix(""), mat.Squeeze())
fmt.Printf("%v\n", formattedM)
}
这个函数的结果,也就是加倍的向量,如下所示:
gonum/mat
包还为向量提供了许多其他整洁的辅助函数,包括以下内容:
-
Cap()
给出了向量的容量 -
Len()
给出了向量中的列数 -
IsZero()
验证向量是否为零大小 -
MulVec()
将向量a和b相乘并返回结果 -
AtVec()
返回向量中给定位置的值
gonum/mat
包中的向量操作函数帮助我们轻松地将向量操作成我们需要的数据集。
现在我们已经完成了向量,让我们来看看矩阵。
介绍矩阵
矩阵是二维数组,按行和列分类。它们在图形处理和人工智能中很重要;即图像识别。矩阵通常用于图形处理,因为矩阵中的行和列可以对应于屏幕上像素的行和列排列,以及因为我们可以让矩阵的值对应于特定的颜色。矩阵也经常用于数字音频处理,因为数字音频信号使用傅里叶变换进行滤波和压缩,矩阵有助于执行这些操作。
矩阵通常用M × N的命名方案表示,其中M是矩阵中的行数,N是矩阵中的列数,如下图所示:
例如,前面的图片是一个 3 x 3 的矩阵。M x N矩阵是线性代数的核心要素之一,因此在这里看到它的关系是很重要的。
现在,让我们看看矩阵是如何操作的。
矩阵操作
矩阵是以高效的方式存储大量信息的好方法,但是矩阵的操作是矩阵真正价值的所在。最常用的矩阵操作技术如下:
-
矩阵加法
-
矩阵标量乘法
-
矩阵转置
-
矩阵乘法
能够在矩阵上执行这些操作是很重要的,因为它们可以帮助处理规模化的真实世界数据操作。我们将在接下来的部分中看一些这些操作,以及它们的实际应用。
矩阵加法
矩阵加法是将两个矩阵相加的方法。也许我们想要找到两个 2D 集合的求和结果值。如果我们有两个相同大小的矩阵,我们可以将它们相加,就像这样:
我们也可以用 Go 代码表示这一点,就像下面的代码块中所示的那样:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
a := mat.NewDense(3, 3, []float64{1, 2, 3, 4, 5, 6, 7, 8, 9})
a.Add(a, a) // add a and a together
matrixPrint(a)
}
func matrixPrint(m mat.Matrix) {
formattedMatrix := mat.Formatted(m, mat.Prefix(""), mat.Squeeze())
fmt.Printf("%v\n", formattedMatrix)
}
执行这个函数的结果如下:
结果是我们代码块中矩阵求和的描述。
在下一节中,我们将讨论矩阵操作的一个实际例子。为了演示这个例子,我们将使用矩阵减法。
一个实际的例子(矩阵减法)
假设您拥有两家餐厅,一家位于纽约,纽约,另一家位于亚特兰大,乔治亚。您想要弄清楚每个月在您的餐厅中哪些物品销售最好,以确保您在接下来的几个月中备货正确的原料。我们可以利用矩阵减法找到每家餐厅的单位销售净总数。我们需要每家餐厅的单位销售原始数据,如下表所示:
五月销量:
纽约,纽约 | 亚特兰大,乔治亚 | |
---|---|---|
龙虾浓汤 | 1,345 | 823 |
鲜蔬沙拉 | 346 | 234 |
肋眼牛排 | 843 | 945 |
冰淇淋圣代 | 442 | 692 |
六月销量:
纽约,纽约 | 亚特兰大,乔治亚 | |
---|---|---|
龙虾浓汤 | 920 | 776 |
鲜蔬沙拉 | 498 | 439 |
肋眼牛排 | 902 | 1,023 |
冰淇淋圣代 | 663 | 843 |
现在,我们可以使用以下矩阵减法找到这两个月之间的单位销售差异:
我们可以在 Go 中执行相同的操作,如下所示的代码块:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
a := mat.NewDense(4, 2, []float64{1345, 823, 346, 234, 843, 945, 442, 692})
b := mat.NewDense(4, 2, []float64{920, 776, 498, 439, 902, 1023, 663, 843})
var c mat.Dense
c.Sub(b, a)
result := mat.Formatted(&c, mat.Prefix(""), mat.Squeeze())
fmt.Println(result)
}
我们的结果输出给出了五月和六月之间两家餐厅的销售差异,如下所示:
在上述屏幕截图中的结果显示为N × M矩阵,描述了销售差异。
随着我们拥有更多的餐厅并在餐厅菜单中添加更多项目,利用矩阵减法将有助于我们记下我们需要保持库存的物品。
标量乘法
在操作矩阵时,我们可能希望将矩阵中的所有值乘以一个标量值。
我们可以用以下代码在 Go 中表示这一点:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
a := mat.NewDense(3, 3, []float64{1, 2, 3, 4, 5, 6, 7, 8, 9})
a.Scale(4, a) // Scale matrix by 4
matrixPrint(a)
}
func matrixPrint(m mat.Matrix) {
formattedMatrix := mat.Formatted(m, mat.Prefix(""), mat.Squeeze())
fmt.Printf("%v\n", formattedMatrix)
}
这段代码产生了以下结果:
在这里,我们可以看到矩阵中的每个元素都被缩放了 4 倍,从而提供了矩阵缩放的执行示例。
标量乘法实际示例
假设我们拥有一个五金店,我们有一个产品目录,其中的产品与美元(USD)值相关联。我们公司决定开始在加拿大和美国销售我们的产品。在撰写本书时,1 美元等于 1.34 加拿大元(CAD)。我们可以查看我们的螺丝、螺母和螺栓价格矩阵,根据数量计数,如下表所示:
单个 USD | 100 个 USD | 1000 个 USD | |
---|---|---|---|
螺丝 | $0.10 | $0.05 | $0.03 | |
螺母 | $0.06 | $0.04 | $0.02 | |
螺栓 | $0.03 | $0.02 | $0.01 |
如果我们使用矩阵标量乘法来找到 CAD 中的结果成本,我们将得到以下矩阵计算:
我们可以使用 Go 标量乘法功能验证这一点,如下所示的代码片段:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
usd := mat.NewDense(3, 3, []float64{0.1, 0.05, 0.03, 0.06, 0.04, 0.02, 0.03, 0.02, 0.01})
var cad mat.Dense
cad.Scale(1.34, usd)
result := mat.Formatted(&cad, mat.Prefix(""), mat.Squeeze())
fmt.Println(result)
}
我们收到一个包含我们每个物品的 CAD 值的结果矩阵:
输出显示了我们缩放后的结果矩阵。
随着我们获得越来越多的产品,并有更多不同的货币需要考虑,我们的标量矩阵操作将非常方便,因为它将减少我们需要操作这些大量数据集的工作量。
矩阵乘法
我们可能还想将两个矩阵相乘。将两个矩阵相乘会得到两个矩阵的乘积。当我们想要同时以并发方式将许多数字相乘时,这将非常有帮助。我们可以取矩阵A,一个N × M矩阵,以及B,一个M × P矩阵。结果集称为AB,是一个N × P矩阵,如下所示:
我们可以用以下代码在 Go 中表示这一点:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
a := mat.NewDense(2, 2, []float64{1, 2, 3, 4})
b := mat.NewDense(2, 3, []float64{1, 2, 3, 4, 5, 6})
var c mat.Dense
c.Mul(a, b)
result := mat.Formatted(&c, mat.Prefix(""), mat.Squeeze())
fmt.Println(result)
}
执行后,我们得到以下结果:
这是我们可以使用gonum/mat
包将矩阵相乘的方式。矩阵乘法是一个常见的矩阵函数,了解如何执行这个操作将帮助您有效地操作矩阵。
矩阵乘法实际示例
让我们来谈谈矩阵乘法的一个实际例子,这样我们就可以将我们的理论工作与一个可行的例子联系起来。两家不同的电子供应商正在竞相为您的公司制造小部件。供应商 A 和供应商 B 都为该小部件设计并为您提供了所需的零件清单。供应商 A 和供应商 B 都使用相同的组件供应商。在这个例子中,我们可以使用矩阵乘法来找出哪个供应商创建了一个更便宜的小部件。每个供应商给您的零件清单如下:
- 供应商 A:电阻:5
晶体管:10
电容器:2
- 供应商 B:
电阻:8
晶体管:6
电容器:3
您从组件供应商目录中得知,每个组件的定价如下:
-
电阻成本:$0.10
-
晶体管成本:$0.42
-
电容器成本:$0.37
我们可以用之前学到的方法,用矩阵来表示每个输入。这样做如下:
- 我们创建了一个由组件成本组成的矩阵,如下所示:
我们创建了一个由每个供应商的组件数量组成的矩阵:
- 然后,我们使用矩阵乘法来找到一些有趣的结果:
这个结果告诉我们,供应商 A 的解决方案零件成本为 5.44 美元,而供应商 B 的解决方案零件成本为 4.43 美元。从原材料的角度来看,供应商 B 的解决方案更便宜。
这可以在 Go 中用以下代码计算:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
a := mat.NewDense(1, 3, []float64{0.10, 0.42, 0.37})
b := mat.NewDense(3, 2, []float64{5, 8, 10, 6, 2, 3})
var c mat.Dense
c.Mul(a, b)
result := mat.Formatted(&c, mat.Prefix(" "), mat.Squeeze())
fmt.Println(result)
}
得到的输出确认了我们在前面程序中所做的计算:
正如我们从结果中看到的,我们格式化的矩阵与我们之前执行的数学相吻合。在巩固我们对理论概念的理解方面,具有一个实际的例子可能会非常有帮助。
矩阵转置
矩阵转置是指将矩阵对角线翻转,交换行和列索引。以下图片显示了矩阵的一个转置示例:
我们可以用以下代码在 Go 中表示矩阵转置:
package main
import (
"fmt"
"gonum.org/v1/gonum/mat"
)
func main() {
a := mat.NewDense(3, 3, []float64{5, 3, 10, 1, 6, 4, 8, 7, 2})
matrixPrint(a)
matrixPrint(a.T())
}
func matrixPrint(m mat.Matrix) {
formattedMatrix := mat.Formatted(m, mat.Prefix(""), mat.Squeeze())
fmt.Printf("%v\n", formattedMatrix)
}
这个矩阵转置的结果可以在下图中看到:
在前面的输出中,我们可以看到常规矩阵和转置版本。矩阵转置经常用于计算机科学中,比如通过在内存中转置矩阵来改善内存局部性。
矩阵转置实际示例
转置矩阵很有趣,但对您来说,可能有一个矩阵转置可能会被使用的实际示例会很有帮助。假设我们有三个工程师:鲍勃,汤姆和爱丽丝。这三个工程师每天都推送 Git 提交。我们希望以一种有意义的方式跟踪这些 Git 提交,以便我们可以确保工程师们有他们需要继续编写代码的所有资源。让我们统计一下我们工程师连续 3 天的代码提交:
用户 | 天 | 提交 |
---|---|---|
鲍勃 | 1 | 5 |
鲍勃 | 2 | 3 |
鲍勃 | 3 | 10 |
汤姆 | 1 | 1 |
汤姆 | 2 | 6 |
汤姆 | 3 | 4 |
爱丽丝 | 1 | 8 |
爱丽丝 | 2 | 7 |
爱丽丝 | 3 | 2 |
当我们有了我们的数据点后,我们可以用一个二维数组来表示它们:
现在我们有了这个数组,我们可以对数组进行转置:
现在我们已经进行了这个转置,我们可以看到转置数组的行对应于提交的天数,而不是个体最终用户的提交。让我们看看第一行:
现在代表BD1、TD1和AD1——每个开发者的第 1 天提交。
现在我们完成了操作部分,是时候看看矩阵结构了。
理解矩阵结构
矩阵通常被分类为两种不同的结构:密集矩阵和稀疏矩阵。密集矩阵由大部分非零元素组成。稀疏矩阵是一个大部分由值为 0 的元素组成的矩阵。矩阵的稀疏度被计算为具有零值的元素数除以总元素数。
如果这个方程的结果大于 0.5,那么矩阵是稀疏的。这种区别很重要,因为它帮助我们确定矩阵操作的最佳方法。如果矩阵是稀疏的,我们可能能够使用一些优化来使矩阵操作更有效。相反,如果我们有一个密集矩阵,我们知道我们很可能会对整个矩阵执行操作。
重要的是要记住,矩阵的操作很可能会受到当今计算机硬件的内存限制。矩阵的大小是一个重要的记住的事情。当你在计算何时使用稀疏矩阵或密集矩阵时,密集矩阵将具有一个 int64 的值,根据 Go 中数字类型的大小和对齐,这是 8 个字节。稀疏矩阵将具有该值,加上一个条目的列索引的 int。在选择要用于数据的数据结构时,请记住这些大小。
密集矩阵
当你创建一个密集矩阵时,矩阵的所有值都被存储。有时这是不可避免的——当我们关心与表相关的所有值并且表大部分是满的时。对于密集矩阵存储,使用 2D 切片或数组通常是最好的选择,但如果你想操作矩阵,使用 Gonum 包可以以有效的方式进行数据操作。实际上,大多数矩阵不属于密集矩阵类别。
稀疏矩阵
稀疏矩阵在现实世界的数据集中经常出现。无论某人是否观看了电影目录中的视频,听了播放列表上的歌曲数量,或者完成了待办事项列表中的项目,都是可以使用稀疏矩阵的好例子。这些表中的许多值都是零,因此将这些矩阵存储为密集矩阵是没有意义的。这将占用大量内存空间,并且操作起来会很昂贵。
我们可以使用 Go 稀疏库来创建和操作稀疏矩阵。稀疏库使用来自 BLAS 例程的习语来执行许多常见的矩阵操作。Go 稀疏库与 Gonum 矩阵包完全兼容,因此可以与该包互换使用。在这个例子中,我们将创建一个新的稀疏键字典(DOK)。创建后,我们将为数组中的集合设置特定的M x N值。最后,我们将使用gonum/mat
包来打印我们创建的稀疏矩阵。
在以下代码中,我们使用 Sparse 包创建了一个稀疏矩阵。ToCSR()
和ToCSC()
矩阵函数分别创建 CSR 和 CSC 矩阵:
package main
import (
"fmt"
"github.com/james-bowman/sparse"
"gonum.org/v1/gonum/mat"
)
func main() {
sparseMatrix := sparse.NewDOK(3, 3)
sparseMatrix.Set(0, 0, 5)
sparseMatrix.Set(1, 1, 1)
sparseMatrix.Set(2, 1, -3)
fmt.Println(mat.Formatted(sparseMatrix))
csrMatrix := sparseMatrix.ToCSR()
fmt.Println(mat.Formatted(csrMatrix))
cscMatrix := sparseMatrix.ToCSC()
fmt.Println(mat.Formatted(cscMatrix))
}
执行完这段代码后,我们可以看到稀疏矩阵已经返回:
这个输出向我们展示了生成的稀疏矩阵。
稀疏矩阵可以分为三种不同的格式:
-
用于有效创建和修改矩阵的格式
-
用于有效访问和矩阵操作的格式
-
专用格式
用于有效创建和修改矩阵的格式如下:
-
键字典(DOK)
-
列表的列表(LIL)
-
坐标列表(COO)
这些格式将在以下部分中定义。
DOK 矩阵
DOK 矩阵是 Go 中的一个映射。这个映射将行和列对链接到它们的相关值。如果没有为矩阵中的特定坐标定义值,则假定为零。通常,哈希映射被用作底层数据结构,这为随机访问提供了 O(1),但遍历元素的速度会变得稍慢一些。DOK 对于矩阵的构建或更新是有用的,但不适合进行算术运算。一旦创建了 DOK 矩阵,它也可以简单地转换为 COO 矩阵。
LIL 矩阵
LIL 矩阵存储了每行的列表,其中包含列索引和值,通常按列排序,因为这样可以减少查找时间。LIL 矩阵对于逐步组合稀疏矩阵是有用的。当我们不知道传入数据集的稀疏模式时,它们也是有用的。
COO 矩阵
A COO 矩阵(也经常被称为三元组格式矩阵)存储了按行和列索引排序的元组列表,其中包含行、列和值。COO 矩阵可以简单地通过 O(1) 的时间进行追加。从 COO 矩阵中进行随机读取相对较慢(O(n))。COO 矩阵是矩阵初始化和转换为 CSR 的良好选择。COO 矩阵不适合进行算术运算。通过对矩阵内的向量进行排序,可以提高对 COO 矩阵的顺序迭代的性能。
用于高效访问和矩阵操作的格式如下:
-
压缩稀疏行(CSR)
-
压缩稀疏列(CSC)
这些格式将在以下部分中定义。
CSR 矩阵
CSR 矩阵使用三个一维数组来表示矩阵。CSR 格式使用这三个数组:
-
A:数组中存在的值。
-
IA:这些值的索引。这些值定义如下:
-
IA 在索引 0 处的值,IA[0] = 0
-
IA 在索引 i 处的值,IA[i] = IA[i − 1] +(原始矩阵中第 i-1 行上的非零元素数)
-
JA:存储元素的列索引。
下图显示了一个 4 x 4 矩阵的示例。这是我们将在下面的代码示例中使用的矩阵:
我们可以按以下方式计算这些值:
-
A = [ 1 2 3 4]
-
IA = [0 1 2 3 4]
-
JA = [2 0 3 1]
我们可以使用 sparse
包进行验证,如下面的代码片段所示:
package main
import (
"fmt"
"github.com/james-bowman/sparse"
"gonum.org/v1/gonum/mat"
)
func main() {
sparseMatrix := sparse.NewDOK(4, 4)
sparseMatrix.Set(0, 2, 1)
sparseMatrix.Set(1, 0, 2)
sparseMatrix.Set(2, 3, 3)
sparseMatrix.Set(3, 1, 4)
fmt.Print("DOK Matrix:\n", mat.Formatted(sparseMatrix), "\n\n") // Dictionary of Keys
fmt.Print("CSR Matrix:\n", sparseMatrix.ToCSR(), "\n\n") // Print CSR version of the matrix
}
结果显示了我们创建的矩阵的 DOK 表示的重新转换值,以及其对应的 CSR 矩阵:
这段代码的输出显示了一个打印 IA、JA 和 A 值的 CSR 矩阵。随着矩阵的增长,能够计算 CSR 矩阵使得矩阵操作变得更加高效。计算机科学通常会处理数百万行和列的矩阵,因此能够以高效的方式进行操作会使您的代码更加高效。
CSC 矩阵
CSC 矩阵与 CSR 矩阵具有相同的格式,但有一个小的不同之处。列索引切片是被压缩的元素,而不是行索引切片,就像我们在 CSR 矩阵中看到的那样。这意味着 CSC 矩阵以列为主序存储其值,而不是以行为主序。这也可以看作是对 CSR 矩阵的自然转置。我们可以通过对前一节中使用的示例进行操作,来看一下如何创建 CSC 矩阵,如下面的代码块所示:
package main
import (
"fmt"
"github.com/james-bowman/sparse"
"gonum.org/v1/gonum/mat"
)
func main() {
sparseMatrix := sparse.NewDOK(4, 4)
sparseMatrix.Set(0, 2, 1)
sparseMatrix.Set(1, 0, 2)
sparseMatrix.Set(2, 3, 3)
sparseMatrix.Set(3, 1, 4)
fmt.Print("DOK Matrix:\n", mat.Formatted(sparseMatrix), "\n\n") // Dictionary of Keys
fmt.Print("CSC Matrix:\n", sparseMatrix.ToCSC(), "\n\n") // Print CSC version
}
结果显示了我们创建的矩阵的 DOK 表示的重新转换值,以及其对应的 CSC 矩阵:
前面代码块的输出向我们展示了 DOK 矩阵和 CSC 矩阵。了解如何表示 CSR 和 CSC 矩阵对于矩阵操作过程至关重要。这两种不同类型的矩阵具有不同的特征。例如,DOK 矩阵具有 O(1)的访问模式,而 CSC 矩阵使用面向列的操作以提高效率。
摘要
在本章中,我们讨论了矩阵和向量,以及这两种数据结构如何在计算机科学中实际使用来执行大部分数据操作。此外,我们还了解了 BLAS、向量、矩阵和向量/矩阵操作。向量和矩阵是线性代数中常用的基本组件,我们看到了它们在哪些情况下会发挥作用。
本章讨论的示例将在涉及真实世界数据处理的情况下对我们有很大帮助。在第六章中,《编写可读的 Go 代码》,我们将讨论如何编写可读的 Go 代码。能够编写可读的 Go 代码将有助于保持主题和想法清晰简洁,便于代码贡献者之间的轻松协作。
第二部分:在 Go 中应用性能概念
在本节中,您将了解为什么性能概念在 Go 中很重要。它们使您能够有效地处理并发请求。Go 是以性能为重点编写的,了解与编写 Go 代码相关的性能习语将帮助您编写在许多情况下都有帮助的代码。
本节包括以下章节:
-
第六章,编写可读的 Go 代码
-
第七章,Go 中的模板编程
-
第八章,Go 中的内存管理
-
第九章,Go 中的 GPU 并行化
-
第十章,Go 中的编译时评估
第六章:编写可读的 Go 代码
学习如何编写可读的 Go 代码是语言的一个重要部分。语言开发人员在编写其他语言时使用了他们的先前经验来创建一种他们认为清晰简洁的语言。在描述使用这种语言编写的正确方式时,经常使用的短语是惯用 Go。这个短语用来描述在 Go 中编程的正确方式。风格往往是主观的,但 Go 团队为了以一种有见地的方式编写语言并促进开发者的速度、可读性和协作而努力工作。在本章中,我们将讨论如何保持语言的一些核心原则:
-
简单
-
可读性
-
打包
-
命名
-
格式化
-
接口
-
方法
-
继承
-
反射
了解这些模式和惯用法将帮助您编写更易读和可操作的 Go 代码。能够编写惯用的 Go 将有助于提高代码质量水平,并帮助项目保持速度。
保持 Go 中的简单性
Go 默认不遵循其他编程语言使用的特定模式。作者选择了不同的惯用法来保持语言简单和清晰。保持语言的简单性对语言开发人员来说是一项艰巨的任务。拥有工具、库、快速执行和快速编译,同时保持简单性,一直是语言开发的重中之重。Go 的语言开发人员一直坚持这些决定,采用共识设计模式——对向语言添加新功能的共识确保了这些功能的重要性。
语言维护者在 GitHub 的问题页面上活跃,并且非常乐意审查拉取请求。从其他使用该语言的人那里获得反馈,使语言维护者能够就向语言添加新功能和功能做出明智的决定,同时保持可读性和简单性。
接下来的部分将向我们展示 Go 语言的下一个基本方面:可读性。
保持 Go 语言中的可读性
可读性是 Go 的另一个核心原则。能够快速理解新代码库并理解其中一些微妙之处是任何编程语言的重要部分。随着分布式系统的不断增长,供应商库和 API 变得更加普遍,能够轻松阅读包含的代码并理解其中的意义对于推动前进是有帮助的。这也使得破损的代码更容易修复。
拥有具体的数据类型、接口、包、并发、函数和方法有助于 Go 继续前进。可读性是能够在较长时间内维护大型代码库的最重要参数之一,这是 Go 与竞争对手之间最重要的区别之一。该语言是以可读性作为一等公民构建的。
Go 语言有许多复杂的底层内部部分,但这些实际上并不复杂。诸如简单定义的常量、接口、包、垃圾回收和易于实现的并发等都是复杂的内部部分,但对最终用户来说是透明的。拥有这些构造有助于使 Go 语言蓬勃发展。
让我们在下一节看看 Go 语言中的打包意味着什么。
探索 Go 中的打包
打包是 Go 语言的一个基本部分。每个 Go 程序都需要在程序的第一行定义一个包。这有助于可读性、可维护性、引用和组织。
Go 程序中的main
包使用主声明。这个主声明调用程序的main
函数。这之后,我们在main
函数中有其他导入,可以用来导入程序中的其他包。我们应该尽量保持主包的小型化,以便将我们程序中的所有依赖项模块化。接下来我们将讨论包命名。
包命名
在命名包时,开发人员应遵循以下规则:
-
包不应该有下划线、连字符或混合大小写
-
包不应该以通用的命名方案命名,比如 common、util、base 或 helper
-
包命名应该与包执行的功能相关
-
包应该保持一个相当大的范围;包中的所有元素应该具有相似的目标和目标
-
在新包与公共 API 对齐之前,利用内部包可以帮助您审查新包
包装布局
当我们讨论 Go 程序的布局时,我们应该遵循一些不同的流程。一个常见的约定是将主程序放在名为cmd
的文件夹中。您构建的其他要从main
函数执行的包应该放在pkg
目录中。这种分离有助于鼓励包的重用。在下面的例子中,如果我们想要在 CLI 和 Web 主程序中都重用通知包,我们可以轻松地通过一个导入来实现。以下是一个屏幕截图显示了这种分离:
Go 的一个反模式是为包映射创建一对一的文件。我们应该以在特定目录结构内驱动常见用例的方式来编写 Go。例如,我们可以创建一个文件的单个目录并进行如下测试:
然而,我们应该按照以下方式创建我们的包:
所有这些不同的通知策略都共享一个共同的做法。我们应该尝试将类似的功能耦合在同一个包中。这将帮助其他人理解通知包具有类似功能的任何上下文。
内部包装
许多 Go 程序使用内部包的概念来表示尚未准备好供外部使用的 API。内部包的概念首次在 Go 1.4 中引入,以在程序内部添加组件边界。这些内部包不能从存储它们的子树之外导入。如果您想要维护内部包并不将它们暴露给程序的其余部分,这是很有用的。一旦您以您认为合适的方式审查了内部包,您可以更改文件夹名称并公开先前的内部包。
让我们看一个例子:
在前面的例子中,我们可以看到我们有一个内部目录。这只能从这个项目内部访问。然而,pkg
和cmd
目录将可以从其他项目访问。这对于我们继续开发新产品和功能是很重要的,这些产品和功能在其他项目中还不应该可以导入。
供应商目录
供应商目录的概念起源于 Go 1.5 的发布。 vendor
文件夹是一个存储外部和内部源代码的编译组合的地方,存放在项目的一个目录中。这意味着代码组合器不再需要将依赖包复制到源代码树中。当GOPATH
寻找依赖项时,将在vendor
文件夹中进行搜索。这有很多好处:
-
我们可以在我们的项目中保留外部依赖项的本地副本。如果我们想要在具有有限或没有外部网络连接的网络上执行我们的程序,这可能会有所帮助。
-
这样可以加快我们 Go 程序的编译速度。将所有这些依赖项存储在本地意味着我们不需要在构建时拉取依赖项。
-
如果您想使用第三方代码,但已经为您的特定用例进行了调整,您可以将该代码存储并更改为内部发布。
Go 模块
Go 模块是在 Go 1.11 中引入的。它们可以跟踪 Go 代码库中的版本化依赖项。它们是一组作为一个统一单元存储在项目目录中的go.mod
文件的 Go 包。
我们将执行以下步骤来初始化一个新模块:
- 首先执行
go mod init repository
:
go mod init github.com/bobstrecansky/HighPerformanceWithGo
go: creating new go.mod: module github.com/bobstrecansky/HighPerformanceWithGo
- 初始化新模块后,您可以构建 Go 包并像往常一样执行它。您将在项目目录中的
go.mod
文件中保存来自项目内导入的模块。
例如,如果我们想要使用 Gin 框架[github.com/gin-gonic/gin
]创建一个简单的 Web 服务器,我们可以在项目结构中创建一个目录,如下所示:/home/bob/git/HighPerformanceWithGo/6-composing-readable-go-code/goModulesExample
。
- 接下来创建一个简单的 Web 服务器,以对
/foo
请求返回bar
:
package main
import "github.com/gin-gonic/gin"
func main() {
server := gin.Default()
server.GET("/foo", func(c *gin.Context) {
c.JSON(200, gin.H{
"response": "bar",
})
})
server.Run()
}
- 之后,我们可以在新创建的目录中创建一个新的 Go 模块:
- 接下来,我们可以执行我们的 Go 程序;必要时将引入适当的依赖项:
现在我们可以看到我们的简单 Web 服务器的依赖项存储在我们目录中的go.sum
文件中(我使用了head
命令将列表截断为前 10 个条目):
Go 模块有助于保持 Go 存储库中的依赖项清洁和一致。如果需要,我们还可以使用存储库来保持所有依赖项与项目本地相关。
关于在存储库中存储依赖项的意见往往差异很大。一些人喜欢使用存储库,因为它可以减少构建时间并限制无法从外部存储库中拉取包的风险。其他人认为存储可能会妨碍包更新和安全补丁。您是否选择在程序中使用存储目录取决于您,但 Go 模块包含这种功能是很方便的。以下输出说明了这一点:
能够使用内置编译工具来存储目录使得设置和配置变得容易。
在下一节中,我们将讨论在 Go 中命名事物。
了解 Go 中的命名
有很多一致的行为,Go 程序员喜欢保留以保持可读性和可维护性的代码。Go 命名方案往往是一致的、准确的和简短的。我们希望在创建名称时记住以下习语:
-
迭代器的局部变量应该简短而简单:
-
i
代表迭代器;如果有二维迭代器,则使用i
和j
-
r
代表读取器 -
w
代表写入器 -
ch
代表通道 -
全局变量名称应该简短且描述性强:
-
RateLimit
-
Log
-
Pool
-
首字母缩略语应遵循使用全大写的约定:
-
FooJSON
-
FooHTTP
-
避免使用模块名称时的口吃:
-
log.Error()
而不是log.LogError()
-
具有一个方法的接口应遵循方法名称加上
-er
后缀: -
Stringer
-
Reader
-
Writer
-
Logger
-
Go 中的名称应遵循 Pascal 或 mixedCaps 命名法:
-
var ThingOne
-
var thingTwo
重要的是要记住,如果名称的首字母大写,它是公开的,并且可以在其他函数中使用。在为事物想出自己的命名方案时,请记住这一点。
遵循这些命名约定可以使您拥有可读性强、易消化、可重用的代码。另一个良好的实践是使用一致的命名风格。如果您正在实例化相同类型的参数,请确保它遵循一致的命名约定。这样可以使新的使用者更容易跟随您编写的代码。
在下一节中,我们将讨论 Go 代码的格式化。
了解 Go 中的格式化
正如在第一章中所述,Go 性能简介,gofmt
是 Go 代码的一种主观格式化工具。它会缩进和对齐您的代码,以便按照语言维护者的意图进行阅读。今天许多最受欢迎的代码编辑器在保存文件时都可以执行gofmt
。这样做,以及拥有您的持续集成软件验证,可以使您无需关注您正在编写的代码的格式,因为语言将会在输出中规定格式。使用这个工具将使 Go 代码更容易阅读、编写和维护,同时有多个贡献者。它还消除了语言内的许多争议,因为空格、制表符和大括号会自动定位。
我们还可以向我们的 Git 存储库(在.git/hooks/pre-commit
中)添加一个预提交挂钩,以确保提交到存储库的所有代码都按预期格式化。以下代码块说明了这一点:
#!/bin/bash
FILES=$(/usr/bin/git diff --cached --name-only --diff-filter=dr | grep '\.go$')
[ -z "$FILES" ] && exit 0
FORMAT=$(gofmt -l $FILES)
[ -z "$FORMAT" ] && exit 0
echo >&2 "gofmt should be used on your source code. Please execute:"
for gofile in $FORMAT; do
echo >&2 " gofmt -w $PWD/$gofile"
done
exit 1
在添加了这个预提交挂钩之后,我们可以通过向存储库中的文件添加一些错误的空格来确认一切是否按预期工作。这样做后,当我们git commit
我们的代码时,我们将看到以下警告消息:
git commit -m "test"
//gofmt should be used on your source code. Please execute:
gofmt -w /home/bob/go/example/badformat.go
gofmt
还有一个鲜为人知但非常有用的简化方法,它将在可能的情况下执行源代码转换。这将对一些复合、切片和范围复合文字进行缩短。简化格式化命令将采用以下代码:
package main
import "fmt"
func main() {
var tmp = []int{1, 2, 3}
b := tmp[1:len(tmp)]
fmt.Println(b)
for i, _ := range tmp {
fmt.Println(tmp[i])
}
}
这将简化为以下代码:gofmt -s gofmtSimplify.go
。
这个gofmt
代码片段的输出如下:
package main
import "fmt"
func main() {
var tmp = []int{1, 2, 3}
b := tmp[1:]
fmt.Println(b)
for i := range tmp {
fmt.Println(tmp[i])
}
}
请注意,前面代码片段中的变量b
有一个简单的定义,并且范围定义中的空变量已被gofmt
工具移除。这个工具可以帮助您在存储库中定义更清晰的代码。它还可以用作一种编写代码的机制,使编写者可以思考问题,但gofmt
生成的结果代码可以以紧密的方式存储在共享存储库中。
在下一节中,我们将讨论 Go 中的接口。
Go 接口简介
Go 的接口系统与其他语言的接口系统不同。它们是方法的命名集合。接口在组合可读的 Go 代码方面非常重要,因为它们使代码具有可伸缩性和灵活性。接口还赋予我们在 Go 中具有多态性(为具有不同类型的项目提供单一接口)的能力。接口的另一个积极方面是它们是隐式实现的——编译器检查特定类型是否实现了特定接口。
我们可以定义一个接口如下:
type example interface {
foo() int
bar() float64
}
如果我们想要实现一个接口,我们只需要实现接口中引用的方法。编译器会验证您的接口方法,因此您无需执行此操作。
我们还可以定义一个空接口,即一个没有方法的接口,表示为interface{}
。在 Go 中,空接口是有价值和实用的,因为我们可以向它们传递任意值,如下面的代码块所示:
package main
import "fmt"
func main() {
var x interface{}
x = "hello Go"
fmt.Printf("(%v, %T)\n", x, x)
x = 123
fmt.Printf("(%v, %T)\n", x, x)
x = true
fmt.Printf("(%v, %T)\n", x, x)
}
当我们执行我们的空接口示例时,我们可以看到 x 接口的类型和值随着我们改变(最初)空接口的定义而改变:
空的、可变的接口很方便,因为它们给了我们灵活性,以一种对代码编写者有意义的方式来操作我们的数据。
在下一节中,我们将讨论 Go 中的方法理解。
理解 Go 中的方法
Go 中的方法是具有特殊类型的函数,称为接收器
,它位于function
关键字和与关键字相关联的方法名称之间。Go 没有类与其他编程语言相同的方式。结构体通常与方法一起使用,以便以与其他语言中构造类似的方式捆绑数据及其相应的方法。当我们实例化一个新方法时,我们可以添加结构值以丰富函数调用。
我们可以实例化一个结构和一个方法如下:
package main
import "fmt"
type User struct {
uid int
name string
email string
phone string
}
func (u User) displayEmail() {
fmt.Printf("User %d Email: %s\n", u.uid, u.email)
}
完成后,我们可以使用此结构和方法来显示有关用户的信息,如下所示:
func main() {
userExample := User{
uid: 1,
name: "bob",
email: "[email protected]",
phone: "123-456-7890",
}
userExample.displayEmail()
}
这将返回userExample.displayEmail()
的结果,它会在方法调用中打印结构的相关部分,如下所示:
随着我们拥有更大的数据结构,我们有能力轻松有效地引用存储在这些结构中的数据。如果我们决定要编写一个方法来查找最终用户的电话号码,那么使用我们现有的数据类型并编写类似于displayEmail
方法的方法来返回最终用户的电话号码将是很简单的。
到目前为止,我们所看到的方法只有值接收器。方法也可以有指针接收器。指针接收器在您希望在原地更新数据并使结果可用于调用函数时很有帮助。
考虑我们之前的例子,做一些修改。我们将有两种方法,允许我们更新用户的电子邮件地址和电话号码。电子邮件地址更新将使用值接收器,而电话更新将使用指针接收器。
我们在以下代码块中创建这些函数,以便能够轻松更新最终用户的信息:
package main
import "fmt"
type User struct {
uid int
name string
email string
phone string
}
func (u User) updateEmail(newEmail string) {
u.email = newEmail
}
func (u *User) updatePhone(newPhone string) {
u.phone = newPhone
}
接下来在main
中创建我们的示例最终用户,如下代码块所示:
func main() {
userExample := User{
uid: 1,
name: "bob",
email: "[email protected]",
phone: "123-456-7890",
}
然后我们在以下代码块中更新我们最终用户的电子邮件和电话号码:
userExample.updateEmail("[email protected]")
(userExample).updatePhone("000-000-0000")
fmt.Println("Updated User Email: ", userExample.email)
fmt.Println("Updated User Phone: ", userExample.phone)
}
在我们的输出结果中,我们可以看到从接收器的角度来看,用户的电子邮件地址没有被更新,但用户的电话号码已经被更新了:
在尝试从方法调用中改变状态时,记住这一点是很重要的。方法在操作 Go 程序中的数据方面非常有帮助。
现在是时候看看 Go 中的继承是怎么回事了。
理解 Go 中的继承
Go 没有继承。组合用于将项目(主要是结构)嵌入在一起。当您有一个用于许多不同功能的基线结构时,这是方便的,其他结构在初始结构的基础上构建。
我们可以描述一些我厨房里的物品,以展示继承是如何工作的。
我们可以初始化我们的程序,如下代码块所示。在这个代码块中,我们创建了两个结构:
器具
:我厨房抽屉里的器具
电器
:我厨房里的电器
package main
import "fmt"
func main() {
type Utensils struct {
fork string
spoon string
knife string
}
type Appliances struct {
stove string
dishwasher string
oven string
}
接下来,我可以使用 Go 的嵌套结构来创建一个包含所有器具和电器的厨房
结构,如下所示:
type Kitchen struct {
Utensils
Appliances
}
然后我可以用我拥有的器具和电器填满我的厨房:
bobKitchen := new(Kitchen)
bobKitchen.Utensils.fork = "3 prong"
bobKitchen.Utensils.knife = "dull"
bobKitchen.Utensils.spoon = "deep"
bobKitchen.Appliances.stove = "6 burner"
bobKitchen.Appliances.dishwasher = "3 rack"
bobKitchen.Appliances.oven = "self cleaning"
fmt.Printf("%+v\n", bobKitchen)
}
所有这些东西都在之后,我们可以看到结果输出,我的厨房物品(器具
和电器
)被组织在我的厨房
结构中。我的厨房
结构稍后可以轻松地在其他方法中引用。
拥有嵌套结构对于未来的扩展非常实用。如果我决定想要向这个结构中添加其他元素,我可以创建一个House
结构,并将我的Kitchen
结构嵌套在House
结构中。我还可以为房子中的其他房间组合结构,并将它们添加到房子结构中。
在下一节中,我们将探讨 Go 中的反射。
探索 Go 中的反射
Go 中的反射是一种元编程形式。在 Go 中使用反射让程序理解自己的结构。有时候,当程序被组合时,我们想要在运行时使用一个变量,而这个变量在程序被组合时并不存在。我们使用反射来检查存储在接口变量中的键值对。反射通常不太清晰,因此在使用时要谨慎——它应该在必要时才使用。它只有运行时检查(而不是编译时检查),因此我们需要理性地使用反射。
重要的是要记住,Go 的变量是静态类型的。我们可以在 Go 中使用许多不同的变量类型——rune
、int
、string
等。我们可以声明特定类型如下:
Type foo int
var x int
var y foo
变量x
和y
都将是 int 类型的变量。
有三个重要的反射部分用于获取信息:
-
类型
-
种类
-
值
这三个不同的部分共同工作,以推断与接口相关的信息。让我们分别看看每个部分,看看它们如何配合。
类型
能够确定变量的类型在 Go 中是很重要的。在我们的例子中,我们可以验证字符串类型是否确实是字符串,如下面的代码块所示:
package main
import (
"fmt"
"reflect"
)
func main() {
var foo string = "Hi Go!"
fooType := reflect.TypeOf(foo)
fmt.Println("Foo type: ", fooType)
}
我们程序的输出将向我们展示反射类型将准确推导出foo string
类型:
尽管这个例子很简单,但重要的是要理解其中的基本原则:如果我们不是验证字符串,而是查看传入的网络调用或外部库调用的返回,或者尝试构建一个可以处理不同类型的程序,反射库的TypeOf
定义可以帮助我们正确地识别这些类型。
种类
种类被用作占位符,用于定义特定类型表示的类型。它用于表示类型由什么组成。这在确定定义了什么样的结构时非常有用。让我们看一个例子:
package main
import (
"fmt"
"reflect"
)
func main() {
i := []string{"foo", "bar", "baz"}
ti := reflect.TypeOf(i)
fmt.Println(ti.Kind())
}
在我们的例子中,我们可以看到我们创建了一个字符串切片——foo
、bar
和baz
。然后,我们可以使用反射来找到i
的类型,并且我们可以使用Kind()
函数来确定类型是由什么组成的——在我们的例子中,是一个切片,如下所示:
如果我们想要推断特定接口的类型,这可能会很有用。
值
反射中的值有助于读取、设置和存储特定变量的结果。在下面的例子中,我们可以看到我们设置了一个示例变量foo
,并且使用反射包,我们可以推断出我们示例变量的值如下所示:
package main
import (
"fmt"
"reflect"
)
func main() {
example := "foo"
exampleVal := reflect.ValueOf(example)
fmt.Println(exampleVal)
}
在我们的输出中,我们可以看到示例变量foo
的值被返回:
反射系统中的这三个不同的函数帮助我们推断我们可以在代码库中使用的类型。
总结
在本章中,我们学习了如何使用语言的一些核心原则来编写可读的 Go 代码。我们学习了简单性和可读性的重要性,以及打包、命名和格式化对于编写可读的 Go 代码是至关重要的。此外,我们还学习了接口、方法、继承和反射如何都可以用来编写其他人能够理解的代码。能够有效地使用这些核心 Go 概念将帮助您产生更高效的代码。
在下一章中,我们将学习 Go 语言中的内存管理,以及如何针对手头的内存资源进行优化。
第七章:Go 中的模板编程
Go 中的模板编程允许最终用户编写生成、操作和运行 Go 程序的 Go 模板。Go 具有清晰的静态依赖关系,这有助于元编程。Go 中的模板编程,包括生成的二进制文件、CLI 工具和模板化库,都是语言的核心原则,帮助我们编写可维护、可扩展、高性能的 Go 代码。
在本章中,我们将涵盖以下主题:
-
Go generate
-
协议缓冲区代码生成
-
链接工具链
-
使用 Cobra 和 Viper 进行配置元编程
-
文本和 HTML 模板
-
Go 模板的 Sprig
所有这些主题都将帮助您更快、更有效地编写 Go 代码。在下一节中,我们将讨论 Go generate 以及它在 Go 编程语言中的用途。
理解 Go generate
截至 Go 版本 1.4,该语言包含一个名为 Go generate 的代码生成工具。Go generate 扫描源代码以运行通用命令。这独立于go build
运行,因此必须在构建代码之前运行。Go generate 由代码作者运行,而不是由编译后的二进制文件的用户运行。这个工具的运行方式类似于通常使用 Makefile 和 shell 脚本的方式,但它是与 Go 工具一起打包的,我们不需要包含任何其他依赖项。
Go generate 将搜索代码库以查找以下模式的行://go:generate command argument
。
生成的源文件应该有以下一行,以传达代码是生成的:
^// Code generated .* DO NOT EDIT\.$
当生成器运行时,Go generate 利用一组变量:
-
$GOARCH
:执行平台的架构 -
$GOOS
:执行平台的操作系统 -
$GOFILE
:文件名 -
$GOLINE
:包含指令的源文件的行号 -
$GOPACKAGE
:包含指令的文件的包名称 -
$DOLLAR
:一个字面的$
我们可以在 Go 中使用这个 Go generate 命令来处理各种不同的用例。它们可以被视为 Go 的内置构建机制。使用 Go generate 执行的操作可以使用其他构建工具,比如 Makefile,但有了 Go generate,您就不需要在构建环境中包含任何其他依赖项。这意味着所有的构建产物都存储在 Go 文件中,以保持项目的一致性。
生成 protobufs 的代码
在 Go 中生成代码的一个实际用例是使用 gRPC 生成协议缓冲区。协议缓冲区是一种用于序列化结构化数据的新方法。它通常用于在分布式系统中的服务之间传递数据,因为它往往比其 JSON 或 XML 对应物更有效。协议缓冲区还可以跨多种语言和多个平台进行扩展。它们带有结构化数据定义;一旦您的数据被结构化,就会生成可以从数据源读取和写入的源代码。
首先,我们需要获取最新版本的协议缓冲区:github.com/protocolbuffers/protobuf/releases
。
在撰写本文时,该软件的稳定版本为 3.8.0。安装此软件包后,我们需要确保使用go get github.com/golang/protobuf/protoc-gen-go
命令拉取所需的 Go 依赖项。接下来,我们可以生成一个非常通用的协议定义:
syntax = "proto3";
package userinfo;
service UserInfo {
rpc PrintUserInfo (UserInfoRequest) returns (UserInfoResponse) {}
}
message UserInfoRequest {
string user = 1;
string email = 2;
}
message UserInfoResponse {
string response = 1;
}
之后,我们可以使用 Go generate 生成我们的 protofile。在与您的.proto
文件相同的目录中创建一个包含以下内容的文件:
package userinfo
//go:generate protoc -I ../userinfo --go_out=plugins=grpc:../userinfo ../userinfo/userinfo.proto
这使我们可以通过使用 Go generate 来生成协议缓冲区定义。在这个目录中执行 Go generate 后,我们会得到一个文件userinfo.pb.go
,其中包含了所有我们的协议缓冲区定义的 Go 格式。当我们使用 gRPC 生成客户端和服务器架构时,我们可以使用这些信息。
接下来,我们可以创建一个服务器来使用我们之前添加的 gRPC 定义:
package main
import (
"context"
"log"
"net"
pb "github.com/HighPerformanceWithGo/7-metaprogramming-in-go/grpcExample/userinfo/userinfo"
"google.golang.org/grpc"
)
type userInfoServer struct{}
func (s *userInfoServer) PrintUserInfo(ctx context.Context, in *pb.UserInfoRequest) (*pb.UserInfoResponse, error) {
log.Printf("%s %s", in.User, in.Email)
return &pb.UserInfoResponse{Response: "User Info: User Name: " + in.User + " User Email: " + in.Email}, nil
}
一旦我们初始化了服务器结构并有一个返回用户信息的函数,我们就可以设置我们的 gRPC 服务器监听我们的标准端口并注册我们的服务器:
func main() {
l, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen %v", err)
}
s := grpc.NewServer()
pb.RegisterUserInfoServer(s, &userInfoServer{})
if err := s.Serve(l); err != nil {
log.Fatalf("Couldn't create Server: %v", err)
}
}
一旦我们设置好服务器定义,我们就可以专注于客户端。我们的客户端具有所有常规的导入,以及一些默认的常量声明,如下所示:
package main
import (
"context"
"log"
"time"
pb "github.com/HighPerformanceWithGo/7-metaprogramming-in-go/grpcExample/userinfo/userinfo"
"google.golang.org/grpc"
)
const (
defaultGrpcAddress = "localhost:50051"
defaultUser = "Gopher"
defaultEmail = "[email protected]"
)
在我们设置好导入和常量之后,我们可以在主函数中使用它们将这些值发送到我们的服务器。我们设置了一个默认超时为 1 秒的上下文,我们发出了一个PrintUserInfo
的 protobuf 请求,然后得到了一个响应并记录下来。以下是我们的 protobuf 示例:
func main() {
conn, err := grpc.Dial(defaultGrpcAddress, grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewUserInfoClient(conn)
user := defaultUser
email := defaultEmail
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := c.PrintUserInfo(ctx, &pb.UserInfoRequest{User: user, Email: email})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("%s", r.Response)
}
我们可以在这里看到我们的 protobuf 示例在运行中的情况。Protobuf 是在分布式系统中发送消息的强大方式。Google 经常提到 protobuf 对于他们在规模上的稳定性有多么重要。我们将在下一节讨论我们的 protobuf 代码的结果。
Protobuf 代码结果
一旦我们有了我们的协议定义、我们的服务器和我们的客户端,我们可以一起执行它们,看到我们的工作在实际中的效果。首先,我们启动服务器:
接下来,我们执行客户端代码。我们可以在我们的客户端代码中看到我们创建的默认用户名和电子邮件地址:
在服务器端,我们可以看到我们发出的请求的日志:
gRPC 是一个非常高效的协议:它使用 HTTP/2 和协议缓冲区来快速序列化数据。客户端到服务器的单个连接可以进行多次调用,从而减少延迟并增加吞吐量。
在下一节中,我们将讨论链接工具链。
链接工具链
Go 语言在其链接工具中有一堆方便的工具,允许我们将相关数据传递给可执行函数。使用这个工具,程序员可以为具有特定名称和值对的字符串设置一个值。在 Go 语言的cmd
/link
包中允许您在链接时向 Go 程序传递信息。将此信息从工具链传递到可执行文件的方法是利用构建参数:
go build -ldflags '-X importpath.name=value'
例如,如果我们试图从命令行中获取程序的序列号,我们可以做如下操作:
package main
import (
"fmt"
)
var SerialNumber = "unlicensed"
func main() {
if SerialNumber == "ABC123" {
fmt.Println("Valid Serial Number!")
} else {
fmt.Println("Invalid Serial Number")
}
}
如前面的输出所示,如果我们尝试在不传入序列号的情况下执行此程序,程序将告诉我们我们的序列号无效:
如果我们传入一个不正确的序列号,我们将得到相同的结果:
如果我们传入正确的序列号,我们的程序将告诉我们我们有一个有效的序列号:
在链接时将数据传递到程序中的能力在排查大型代码库时非常有用。当您需要部署一个已编译的二进制文件,但稍后可能需要以非确定性方式更新一个常见值时,这也是非常有用的。
在下一节中,我们将讨论两个常用于配置编程的工具——Cobra 和 Viper。
介绍 Cobra 和 Viper 用于配置编程
两个常用的 Go 库spf13/cobra
和spf13/viper
用于配置编程。这两个库可以一起用于创建具有许多可配置选项的 CLI 二进制文件。Cobra 允许您生成应用程序和命令文件,而 Viper 有助于读取和维护 12 因素 Go 应用程序的完整配置解决方案。Cobra 和 Viper 在一些最常用的 Go 项目中使用,包括 Kubernetes 和 Docker。
要一起使用这两个库制作一个cmd
库,我们需要确保我们嵌套我们的项目目录,如下所示:
一旦我们创建了嵌套的目录结构,我们就可以开始设置我们的主程序。在我们的 main.go
文件中,我们已经定义了我们的日期命令 - Cobra 和 Viper 的 main.go
函数故意简单,以便我们可以调用在 cmd
目录中编写的函数(这是一个常见的 Go 习惯)。我们的 main
包如下所示:
package main
import (
"fmt"
"os"
"github.com/HighPerformanceWithGo/7-metaprogramming-in-go/clitooling/cmd"
)
func main() {
if err := cmd.DateCommand.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
一旦我们定义了我们的 main
函数,我们就可以开始设置我们的其余命令工具。我们首先导入我们的要求:
package cmd
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var verbose bool
接下来,我们可以设置我们的根 date
命令:
var DateCommand = &cobra.Command{
Use: "date",
Aliases: []string{"time"},
Short: "Return the current date",
Long: "Returns the current date in a YYYY-MM-DD HH:MM:SS format",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Current Date :\t", time.Now().Format("2006.01.02 15:04:05"))
if viper.GetBool("verbose") {
fmt.Println("Author :\t", viper.GetString("author"))
fmt.Println("Version :\t", viper.GetString("version"))
}
},
}
一旦我们设置了这个,我们还可以设置一个子命令来显示我们的许可信息,如下面的代码示例所示。子命令是 CLI 工具的第二个参数,以便为 cli
提供更多信息:
var LicenseCommand = &cobra.Command{
Use: "license",
Short: "Print the License",
Long: "Print the License of this Command",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("License: Apache-2.0")
},
}
最后,我们可以设置我们的 init()
函数。Go 中的 init()
函数用于一些事情:
-
向用户显示初始信息
-
初始变量声明
-
初始化与外部方的连接(例如 DB 连接池或消息代理初始化)
我们可以在代码的最后部分利用我们新的 init()
函数知识来初始化我们之前定义的 viper
和 cobra
命令:
func init() {
DateCommand.AddCommand(LicenseCommand)
viper.SetDefault("Author", "bob")
viper.SetDefault("Version", "0.0.1")
viper.SetDefault("license", "Apache-2.0")
DateCommand.PersistentFlags().BoolP("verbose", "v", false, "Date
Command Verbose")
DateCommand.PersistentFlags().StringP("author", "a", "bob", "Date
Command Author")
viper.BindPFlag("author",
DateCommand.PersistentFlags().Lookup("author"))
viper.BindPFlag("verbose",
DateCommand.PersistentFlags().Lookup("verbose"))
}
前面的代码片段向我们展示了 Viper 中常用的一些默认、持久和绑定标志。
Cobra/Viper 结果集
现在我们已经实例化了所有的功能,我们可以看到我们的新代码在运行中的情况。
如果我们调用我们的新的 main.go
而没有任何可选参数,我们将只看到我们在初始 DateCommand
运行块中定义的日期返回,如下面的代码输出所示:
如果我们向我们的输入添加额外的标志,我们可以收集详细信息并使用命令行标志更改包的作者,如下所示:
我们还可以通过将其作为参数添加来查看我们为许可创建的子命令,如下所示:
我们已经看到了 spf13
Cobra 和 Viper 包的一小部分功能,但重要的是要理解它们的根本原则 - 它们用于在 Go 中促进可扩展的 CLI 工具。在下一节中,我们将讨论文本模板。
文本模板
Go 有一个内置的模板语言 text/template
,它使用数据实现模板并生成基于文本的输出。我们使用结构来定义我们想要在模板中使用的数据。与所有事物一样,Go 输入文本被定义为 UTF-8,并且可以以任何格式传递。我们使用双大括号 {{}}
来表示我们想要在我们的数据上执行的操作。由 .
表示的光标允许我们向我们的模板添加数据。这些组合在一起创建了一个强大的模板语言,它将允许我们为许多代码片段重用模板。
首先,我们将初始化我们的包,导入我们需要的依赖项,并为我们想要传递到模板中的数据定义我们的结构:
package main
import (
"fmt"
"os"
"text/template"
)
func main() {
type ToField struct {
Date string
Name string
Email string
InOffice bool
}
现在,我们可以使用我们之前提到的 text/template 定义来设置我们的模板和输入结构:
const note = `
{{/* we can trim whitespace with a {- or a -} respectively */}}
Date: {{- .Date}}
To: {{- .Email | printf "%s"}}
{{.Name}},
{{if .InOffice }}
Thank you for your input yesterday at our meeting. We are going to go ahead with what you've suggested.
{{- else }}
We were able to get results in our meeting yesterday. I've emailed them to you. Enjoy the rest of your time Out of Office!
{{- end}}
Thanks,
Bob
`
var tofield = []ToField{
{"07-19-2019", "Mx. Boss", "[email protected]", true},
{"07-19-2019", "Mx. Coworker", "[email protected]", false},
}
最后,我们可以执行我们的模板并打印它。我们的示例打印到 Stdout
,但我们也可以打印到文件,写入缓冲区,或自动发送电子邮件:
t := template.Must(template.New("Email Body").Parse(note))
for _, k := range tofield {
err := t.Execute(os.Stdout, k)
if err != nil {
fmt.Print(err)
}
}
}
利用 Go 文本模板系统,我们可以重复使用这些模板来生成一致的高质量内容。由于我们有新的输入,我们可以调整我们的模板并相应地得出结果。在下一节中,我们将讨论 HTML 模板。
HTML 模板
我们还可以使用 HTML 模板,类似于我们执行文本模板,以便在 Go 中为 HTML 页面生成动态结果。为了做到这一点,我们需要初始化我们的包,导入适当的依赖项,并设置一个数据结构来保存我们计划在 HTML 模板中使用的值,如下所示:
package main
import (
"html/template"
"net/http"
)
type UserFields struct {
Name string
URL string
Email string
}
接下来,我们创建userResponse
HTML 模板:
var userResponse = `
<html>
<head></head>
<body>
<h1>Hello {{.Name}}</h1>
<p>You visited {{.URL}}</p>
<p>Hope you're enjoying this book!</p>
<p>We have your email recorded as {{.Email}}</p>
</body>
</html>
`
然后,我们创建一个 HTTP 请求处理程序:
func rootHandler(w http.ResponseWriter, r *http.Request) {
requestedURL := string(r.URL.Path)
userfields := UserFields{"Bob", requestedURL, "[email protected]"}
t := template.Must(template.New("HTML Body").Parse(userResponse))
t.Execute(w, userfields)
log.Printf("User " + userfields.Name + " Visited : " + requestedURL)
}
之后,我们初始化 HTTP 服务器:
func main() {
s := http.Server{
Addr: "127.0.0.1:8080",
}
http.HandleFunc("/", rootHandler)
s.ListenAndServe()
}
然后,我们使用go run htmlTemplate.go
调用我们的 Web 服务器。当我们在该域上请求页面时,我们将看到以下结果:
前面的输出来自于我们的 HTML 模板中的模板化代码。这个例子可以扩展到包括解析通过 X-Forwarded-For 头部的传入 IP 地址请求,基于用户代理字符串的最终用户浏览器信息,或者可以用于向客户端返回丰富响应的任何其他特定请求参数。在下一节中,我们将讨论 Sprig,一个用于 Go 模板函数的库。
探索 Sprig
Sprig 是一个用于定义 Go 模板函数的库。该库包括许多函数,扩展了 Go 的模板语言的功能。Sprig 库有一些原则,有助于确定哪些函数可用于驱动增强的模板:
-
只允许简单的数学运算
-
只处理传递给模板的数据;从不从外部来源检索数据
-
利用模板库中的函数构建结果布局
-
永远不会覆盖 Go 核心模板功能
在以下小节中,我们将更详细地了解 Sprig 的功能。
字符串函数
Sprig 具有一组字符串函数,可以在模板中操作字符串。
在我们的示例中,我们将采用" - bob smith"
字符串(注意空格和破折号)。然后,我们将执行以下操作:
-
使用
trim()
实用程序修剪空格 -
用单词
smith
替换单词strecansky
的实例 -
修剪
-
前缀 -
将字符串更改为标题大小写,即从
bob strecansky
更改为Bob Strecansky
-
重复字符串 10 次
-
创建一个 14 个字符的单词换行(我的名字的宽度),并用新行分隔每个字符。
Sprig 库可以在一行中执行此操作,类似于 bash shell 可以将函数串联在一起。
我们首先初始化我们的包并导入必要的依赖项:
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
接下来,我们将我们的字符串映射设置为interface
,执行我们的转换,并将我们的模板呈现到标准输出:
func main() {
inStr := map[string]interface{}{"Name": " - bob smith"}
transform := `{{.Name | trim | replace "smith" "strecansky" | trimPrefix "-" | title | repeat 10 | wrapWith 14 "\n"}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("Name Transformation").Funcs(functionMap).Parse(transform))
err := t.Execute(os.Stdout, inStr)
if err != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
}
执行程序后,我们将看到字符串操作发生的方式与我们预期的方式相同:
能够像我们的示例中那样在模板中操作字符串,有助于我们快速纠正可能存在的任何模板问题,并即时操纵它们。
字符串切片函数
能够在模板中操作字符串切片是有帮助的,正如我们在之前的章节中所看到的。Sprig 库帮助我们执行一些字符串切片操作。在我们的示例中,我们将根据.
字符拆分字符串。
首先,我们导入必要的库:
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
func main() {
接下来,我们使用.
分隔符拆分我们的模板字符串:
tpl := `{{$v := "Hands.On.High.Performance.In.Go" | splitn "." 5}}{{$v._3}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("String
Split").Funcs(functionMap).Parse(tpl))
fmt.Print("String Split into Dict (word 3): ")
err := t.Execute(os.Stdout, tpl)
if err != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
我们还可以使用sortAlpha
函数将模板化列表按字母顺序排序:
alphaSort := `{{ list "Foo" "Bar" "Baz" | sortAlpha}}`
s := template.Must(template.New("sortAlpha").
Funcs(functionMap).Parse(alphaSort))
fmt.Print("\nAlpha Tuple: ")
alphaErr := s.Execute(os.Stdout, tpl)
if alphaErr != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
fmt.Print("\nString Slice Functions Completed\n")
}
这些字符串操作可以帮助我们组织包含在模板化函数中的字符串列表。
默认函数
Sprig 的默认函数为模板化函数返回默认值。我们可以检查特定数据结构的默认值以及它们是否为空。对于每种数据类型,都定义了空。
数字 | 0 |
---|---|
字符串 | "" (空字符串) |
列表 | [] (空列表) |
字典 | {} (空字典) |
布尔值 | false |
并且总是 | 空(也称为空) |
结构 | 空的定义;永远不会返回默认值 |
我们从导入开始:
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
接下来,我们设置我们的空和非空模板变量:
func main() {
emptyTemplate := map[string]interface{}{"Name": ""}
fullTemplate := map[string]interface{}{"Name": "Bob"}
tpl := `{{empty .Name}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("Empty
String").Funcs(functionMap).Parse(tpl))
然后,我们验证我们的空模板和非空模板:
fmt.Print("empty template: ")
emptyErr := t.Execute(os.Stdout, emptyTemplate)
if emptyErr != nil {
fmt.Printf("Couldn't create template: %s", emptyErr)
return
}
fmt.Print("\nfull template: ")
fullErr := t.Execute(os.Stdout, fullTemplate)
if emptyErr != nil {
fmt.Printf("Couldn't create template: %s", fullErr)
return
}
fmt.Print("\nEmpty Check Completed\n")
}
当我们有模板输入需要验证输入不为空时,这是非常有用的。我们的输出结果显示了我们的预期:空模板标记为 true,而完整模板标记为 false:
我们还可以将 JSON 文字编码为 JSON 字符串并进行漂亮打印。如果您正在处理需要向最终用户返回 JSON 数组的 HTML 创建的模板,这将特别有帮助。
package main
import (
"fmt"
"os"
"text/template"
"github.com/Masterminds/sprig"
)
func main() {
jsonDict := map[string]interface{}{"JSONExamples": map[string]interface{}{"foo": "bar", "bool": false, "integer": 7}}
tpl := `{{.JSONExamples | toPrettyJson}}`
functionMap := sprig.TxtFuncMap()
t := template.Must(template.New("String Split").Funcs(functionMap).Parse(tpl))
err := t.Execute(os.Stdout, jsonDict)
if err != nil {
fmt.Printf("Couldn't create template: %s", err)
return
}
}
在我们的输出结果中,我们可以看到基于我们的jsonDict
输入的漂亮打印的 JSON 块:
当与 HTML/template 内置和添加的content-encoding:json
HTTP 头一起使用时,这非常有用。
Sprig 库有相当多的功能,其中一些我们将在本书的本节中讨论。
可以在masterminds.github.io/sprig/
找到通过 Sprig 可用的功能的完整列表。
总结
在本章中,我们讨论了生成 Go 代码。我们讨论了如何为 Go 代码中最常见的生成部分之一,gRPC protobufs,进行生成。然后,我们讨论了使用链接工具链添加命令行参数和spf13/cobra
和spf13/viper
来创建元编程 CLI 工具。最后,我们讨论了使用 text/template、HTML/template 和 Sprig 库进行模板化编程。使用所有这些包将帮助我们编写可读、可重用、高性能的 Go 代码。这些模板也将在长远来看为我们节省大量工作,因为它们往往是可重用和可扩展的。
在下一章中,我们将讨论如何优化内存资源管理。
第八章:Go 中的内存管理
内存管理对系统性能至关重要。能够充分利用计算机的内存占用空间,使您能够将高度运行的程序保持在内存中,以便您不经常不得不承受交换到磁盘的巨大性能损失。能够有效地管理内存是编写高性能 Go 代码的核心原则。在本章中,我们将学习以下主题:
-
计算机内存
-
内存如何分配
-
Go 如何有效利用内存
-
内存中如何分配对象
-
有限内存计算设备的策略
了解内存如何被利用可以帮助您学会在程序中有效地利用内存。内存是计算机中存储和操作数据的最快速的地方之一,因此能够高效地管理它将对您的代码质量产生持久的影响。
理解现代计算机内存 - 入门
现代计算机具有随机存取存储器(RAM),用于机器代码和数据存储。 RAM 与 CPU 和硬盘一起用于存储和检索信息。利用 CPU、RAM 和硬盘会有性能折衷。在撰写本文时的现代计算机中,我们对计算机中一些常见操作的一些通用、粗略的时间有以下表述:
数据存储类型 | 时间 |
---|---|
L1(处理器缓存)引用 | 1 ns |
L2(处理器缓存)引用 | 4 ns |
主内存引用 | 100 ns |
SSD 随机读取 | 16 μs |
7200 RPM HDD 磁盘搜索 | 2 ms |
正如您从表中所注意到的,不同的存储类型在现代计算机架构的不同部分具有截然不同的时间。新计算机具有 KB 的 L1 缓存,MB 的 L2 缓存,GB 的主内存和 TB 的 SSD/HDD。由于我们认识到这些不同类型的数据存储在成本和性能方面存在显着差异,我们需要学会如何有效地使用它们,以便编写高性能的代码。
分配内存
计算机的主内存用于许多事情。内存管理单元(MMU)是一种计算机硬件,用于在物理内存地址和虚拟内存地址之间进行转换。当 CPU 执行使用内存地址的指令时,MMU 会获取逻辑内存地址并将其转换为物理内存地址。这些以物理内存地址的分组称为页面。页面通常以 4 kB 段处理,使用称为页表的表。MMU 还具有其他功能,包括使用缓冲区,如转换旁路缓冲器(TLB),用于保存最近访问的转换。
虚拟内存有助于做到以下几点:
-
允许将硬件设备内存映射到地址空间
-
允许特定内存区域的访问权限(rwx)
-
允许进程具有单独的内存映射
-
允许内存更容易移动
-
允许内存更容易地交换到磁盘
-
允许共享内存,其中物理内存映射到多个进程
当在现代 Linux 操作系统中分配虚拟内存时,内核和用户空间进程都使用虚拟地址。这些虚拟地址通常分为两部分 - 虚拟地址空间中的内存上部分用于内核和内核进程,内存下部分用于用户空间程序。
操作系统利用这些内存。它将进程在内存和磁盘之间移动,以优化我们计算机中可用资源的使用。计算机语言在其运行的底层操作系统中使用虚拟内存空间(VMS)。 Go 也不例外。如果您在 C 中编程,您会知道 malloc 和 free 的习语。在 Go 中,我们没有malloc
函数。 Go 也是一种垃圾收集语言,因此我们不必考虑释放内存分配。
我们在用户空间内有两种不同的主要内存度量:VSZ 和 RSS。
介绍 VSZ 和 RSS
VSZ,虚拟内存大小,指的是一个单独进程可以访问的所有内存,包括交换内存。这是在程序初始执行时分配的内存大小。VSZ 以 KiB 为单位报告。
RSS,驻留集大小,指的是特定进程在 RAM 中分配了多少内存,不包括交换内存。RSS 包括共享库内存,只要该内存目前可用。RSS 还包括堆栈和堆内存。根据这些内存引用通常是共享的事实,RSS 内存可能大于系统中可用的总内存。RSS 以千字节为单位报告。
当我们启动一个简单的 HTTP 服务器时,我们可以看到分配给我们各个进程的 VSZ 和 RSS 如下:
package main
import (
"io"
"net/http"
)
func main() {
Handler := func(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Memory Management Test")
}
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
}
然后我们可以看一下在调用服务器时生成的进程 ID,如下所示:
在这里,我们可以看到我们调用的server.go
进程的 VSZ 和 RSS 值。
如果我们想要减小 Go 二进制文件的构建大小,我们可以使用build
标志构建我们的二进制文件,而不包括 libc 库,如下所示:
go build -ldflags '-libgcc=none' simpleServer.go
如果我们构建二进制文件时不包括 libc 库,我们的示例服务器的内存占用将会小得多,如下所示:
正如我们所看到的,我们的 VSZ 和 RSS 内存利用率都大大减少了。在实践中,内存是廉价的,我们可以将 libc 库留在我们的 Golang 二进制文件中。Libc 用于许多标准库部分,包括用户和组解析以及主机解析的部分,这就是为什么它在构建时动态链接的原因。
在构建 Go 二进制文件后,它们以容器格式存储。Linux 机器将这个特定的二进制文件存储在一种称为ELF(可执行和可链接格式)的格式中。Go 的标准库有一种方法来读取 ELF 文件。我们可以检查之前生成的simpleServer
二进制文件:
package main
import (
"debug/elf"
"fmt"
"log"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ./elfReader elf_file")
os.Exit(1)
}
elfFile, err := elf.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
for _, section := range elfFile.Sections {
fmt.Println(section)
}
}
我们的simpleServer
示例的输出结果如下:
还有其他 Linux 工具可以用来调查这些 ELF 二进制文件。readelf
也会以更易读的格式打印 ELF 文件。例如,我们可以这样查看一个 ELF 文件:
ELF 文件有特定的格式。该格式如下:
文件布局部分 | 描述 |
---|---|
文件头 | 类字段:定义 32 位和 64 位地址分别为 52 或 64 字节长。数据:定义小端或大端。版本:存储 ELF 版本(目前只有一个版本,01)。OS/ABI:定义操作系统和应用程序二进制接口。机器:告诉你机器类型。类型:指示这是什么类型的文件;常见类型有 CORE,DYN(用于共享对象),EXEC(用于可执行文件)和 REL(用于可重定位文件)。 |
程序头或段 | 包含有关如何在运行时创建进程或内存映像以执行的指令。然后内核使用这些指令通过 mmap 映射到虚拟地址空间。 |
部分头或部分 | .text :可执行代码(指令,静态常量,文字).data :受控访问的初始化数据.rodata :只读数据.bss :读/写未初始化数据 |
我们还可以编译这个程序的 32 位版本以查看差异。如第一章中所述,Go 性能简介,我们可以为不同的架构构建 Go 二进制文件。我们可以使用以下构建参数为 i386 Linux 系统构建二进制文件:
env GOOS=linux GOARCH=386 go build -o 386simpleServer simpleServer.go
完成此构建后,我们可以检查生成的 ELF 文件,并证实生成的 ELF 与之前为我的 x86_64 计算机处理的 ELF 不同。我们将使用-h
标志仅查看每个文件的头部以简洁起见:
如您在输出结果中所见,这个特定的二进制文件是为 i386 处理器生成的,而不是最初生成的 x86_64 二进制文件:
了解系统的限制、架构和内存限制可以帮助您构建在主机上有效运行的 Go 程序。在本节中,我们将处理内存利用。
理解内存利用
一旦我们有了初始的二进制文件,我们就开始建立对 ELF 格式的了解,以继续理解内存利用。文本、数据和 bss 字段是堆和栈的基础。堆从.bss
和.data
位的末尾开始,并持续增长以形成更大的内存地址。
堆栈是连续内存块的分配。这种分配在函数调用堆栈内自动发生。当调用函数时,其变量在堆栈上分配内存。函数调用完成后,变量的内存被释放。堆栈具有固定大小,只能在编译时确定。从分配的角度来看,堆栈分配是廉价的,因为它只需要推送到堆栈和从堆栈中拉取以进行分配。
堆是可用于分配和释放的内存组合。内存是以随机顺序分配的,由程序员手动执行。由于其非连续的块,它在时间上更昂贵,访问速度较慢。然而,堆中的元素可以调整大小。堆分配是昂贵的,因为 malloc 搜索足够的内存来容纳新数据。随着垃圾收集器的工作,它扫描堆中不再被引用的对象,并将它们释放。这两个过程比堆栈分配/释放位要昂贵得多。因此,Go 更喜欢在堆栈上分配而不是在堆上分配。
我们可以使用-m
的 gcflag 编译程序,以查看 Go 编译器如何使用逃逸分析(编译器确定在运行时初始化变量时是否使用堆栈或堆的过程)。
我们可以创建一个非常简单的程序如下:
package main
import "fmt"
func main() {
greetingString := "Hello Gophers!"
fmt.Println(greetingString)
}
然后,我们可以使用逃逸分析标志编译我们的程序如下:
在我们的输出结果中,我们可以看到我们简单的greetingString
被分配到了堆上。如果我们想要使用此标志进行更多详细信息,我们可以传递多个m
值。在撰写本文时,传递多达 5 个-m
标志会给我们不同级别的详细信息。以下屏幕截图是使用 3 个-m
标志进行构建的(为简洁起见):
静态分配的 Go 变量倾向于存在堆栈上。指向内存或接口类型方法的项目倾向于是动态的,因此通常存在堆上。
如果我们想在执行构建时看到更多可用的优化,我们可以使用以下命令查看它们:go tool compile -help
。
Go 运行时内存分配
正如我们在第三章中所学的,理解并发性,Go 运行时使用G
结构来表示单个 goroutine 的堆栈参数。P
结构管理执行的逻辑处理器。作为 Go 运行时的一部分使用的 malloc,在golang.org/src/runtime/malloc.g
中定义,做了很多工作。Go 使用 mmap 直接向底层操作系统请求内存。小的分配大小(内存分配最多达到 32KB)与大内存分配分开处理。
内存分配入门
让我们快速讨论与 Go 的小对象内存分配相关的一些对象。
我们可以在golang.org/src/runtime/mheap.go
中看到mheap
和mspan
结构。
mheap
是主要的 malloc 堆。它跟踪全局数据,以及许多其他堆细节。一些重要的细节如下:
名称 | 描述 |
---|---|
lock | 互斥锁机制 |
free | 一个非清除的 mspan 的 mTreap(一种树和堆的混合数据结构) |
scav | 一个包含空闲和清除的 mspan 的 mTreap |
sweepgen | 用于跟踪跨度清除状态的整数 |
sweepdone | 跟踪所有跨度是否都被清除 |
sweepers | 活动的sweepone 调用数量 |
mspan
是主要的跨度 malloc。它跟踪所有可用的跨度。跨度是内存的 8K 或更大的连续区域。它还保留许多其他跨度细节。一些重要的细节如下:
名称 | 描述 |
---|---|
next |
列表中的下一个跨度;如果没有则为(nil) |
previous |
列表中的前一个跨度;(nil)如果没有 |
list |
用于调试的跨度列表 |
startAddr |
跨度的第一个字节 |
npages |
跨度中的页面数 |
内存对象分配
内存对象有三种分类:
-
微小:小于 16 字节的对象
-
小:大于 16 字节且小于或等于 32KB 的对象
-
大:大于 32KB 的对象
在 Go 中,内存中的微小对象执行以下内存分配过程:
-
如果
P
的 mcache 有空间,就使用那个空间。 -
取现有的 mcache 中的子对象,并将其四舍五入为 8、4 或 2 字节。
-
如果适合分配空间,则将对象放入内存中。
在 Go 中,内存中的小对象遵循特定的内存分配模式:
- 对象的大小被四舍五入并分类为在
golang.org/src/runtime/mksizeclasses.go
中生成的小尺寸类之一。在以下输出中,我们可以看到在我的 x86_64 机器上定义的_NumSizeClasses
和class_to_size
变量分配。然后使用此值在 P 的 mcache 中找到一个空闲位图,并根据需要进行分配,如果有可用的内存空间。以下截图说明了这一点:
-
如果 P 的 mspan 没有空闲位置,则从 mcentral 的 mspan 列表中获取一个新的 mspan,该列表有足够的空间来存放新的内存对象。
-
如果该列表为空,则从 mheap 中执行页面运行,以便为 mspan 找到空间。
-
如果失败,为空,或者没有足够大的页面来分配,就会从操作系统中分配一组新的页面。这很昂贵,但至少以 1MB 的块来完成,这有助于减少与操作系统通信的成本。
从 mspan 中释放对象遵循类似的过程:
-
如果 mspan 正在响应分配而被清除,则将其返回到 mcache。
-
如果 mspan 仍然有分配给它的对象,mcentral 的空闲列表将接收该 mspan 以进行释放。
-
如果 mspan 处于空闲状态(没有分配的对象),它将被返回到 mheap。
-
一旦 mspan 在给定的间隔内处于空闲状态,这些页面就会被返回到底层操作系统。
大对象不使用 mcache 或 mcentral;它们直接使用 mheap。
我们可以使用先前创建的 HTTP 服务器来查看一些内存统计信息。使用 runtime 包,我们可以推导出程序从操作系统检索的内存量,以及 Go 程序的堆分配。让我们一步一步地看看这是如何发生的:
- 首先,我们初始化我们的包,执行我们的导入,并设置我们的第一个处理程序:
package main
import (
"fmt"
"io"
"net/http"
"runtime"
)
func main() {
Handler := func(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Memory Management Test")
}
- 然后我们编写一个匿名函数来捕获我们的运行统计:
go func() {
for {
var r runtime.MemStats
runtime.ReadMemStats(&r)
fmt.Println("\nTime: ", time.Now())
fmt.Println("Runtime MemStats Sys: ", r.Sys)
fmt.Println("Runtime Heap Allocation: ", r.HeapAlloc)
fmt.Println("Runtime Heap Idle: ", r.HeapIdle)
fmt.Println("Runtime Head In Use: ", r.HeapInuse)
fmt.Println("Runtime Heap HeapObjects: ", r.HeapObjects)
fmt.Println("Runtime Heap Released: ", r.HeapReleased)
time.Sleep(5 * time.Second)
}
}()
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
}
- 执行此程序后,我们可以看到我们服务的内存分配。以下结果中的第一个打印输出显示了内存的初始分配:
第二个打印输出是在对http://localhost:1234/
发出请求后。您可以看到系统和堆分配大致保持不变,并且空闲堆和正在使用的堆会随着 Web 请求的利用而发生变化。
Go 的内存分配器最初源自 TCMalloc,一个线程缓存的 malloc。有关 TCMalloc 的更多信息可以在goog-perftools.sourceforge.net/doc/tcmalloc.html
找到。
Go 分配器,Go 内存分配器,使用线程本地缓存和 8K 或更大的连续内存区域。这些 8K 区域,也称为 span,通常用于以下三种能力之一:
-
空闲:可以重用于堆/栈或返回给操作系统的 span
-
使用中:当前在 Go 运行时中使用的 span
-
堆栈:用于 goroutine 堆栈的 span
如果我们创建一个没有共享库的程序,我们应该看到我们的程序的内存占用要小得多:
- 首先,我们初始化我们的包并导入所需的库:
package main
import (
"fmt"
"runtime"
"time"
)
- 然后,我们执行与之前的简单 http 服务器相同的操作,但我们只使用
fmt
包来打印一个字符串。然后我们休眠,以便能够看到内存利用输出:
func main() {
go func() {
for {
var r runtime.MemStats
runtime.ReadMemStats(&r)
fmt.Println("\nTime: ", time.Now())
fmt.Println("Runtime MemStats Sys: ", r.Sys)
fmt.Println("Runtime Heap Allocation: ", r.HeapAlloc)
fmt.Println("Runtime Heap Idle: ", r.HeapIdle)
fmt.Println("Runtime Heap In Use: ", r.HeapInuse)
fmt.Println("Runtime Heap HeapObjects: ", r.HeapObjects)
fmt.Println("Runtime Heap Released: ", r.HeapReleased)
time.Sleep(5 * time.Second)
}
}()
fmt.Println("Hello Gophers")
time.Sleep(11 * time.Second)
}
- 从执行此程序的输出中,我们可以看到此可执行文件的堆分配要比我们的简单 HTTP 服务器小得多:
但为什么会这样呢?我们可以使用 goweight 库[github.com/jondot/goweight
]来查看程序中依赖项的大小。我们只需要下载这个二进制文件:go get github.com/jondot/goweight
。
- 然后我们可以确定我们 Go 程序中的大依赖项是什么:
我们可以看到net/http
库占用了很多空间,runtime 和 net 库也是如此。
相比之下,让我们看一下带有内存统计的简单程序:
我们可以看到,没有运行时的下一个最大段要比net/http
和net
库小得多。了解资源的确切利用情况总是很重要,以便制作更高效的二进制文件。
如果我们使用 strace 查看操作系统级别的调用,我们接下来可以看到与我们的简单 Web 服务器和简单程序的交互之间的差异。我们简单 Web 服务器的示例如下:
我们简单程序的示例可以在这里看到:
从输出中,我们可以注意到几件事情:
-
我们的
simpleWebServer
的输出比我们的simpleProgram
要长得多(在截图中已经被截断,但如果生成了,我们可以看到响应长度更长)。 -
simpleWebServer
加载了更多的 C 库(我们可以在截图中的 strace 捕获中看到ld.so.preload
、libpthread.so.0
和libc.so.6
)。 -
我们的
simpleWebServer
中的内存分配比我们的simpleProgram
输出要多得多。
我们可以看看这些是从哪里拉取的。net/http
库没有任何 C 引用,但其父库 net 有。在 net 库中的所有 cgo 包中,我们有文档告诉我们如何跳过使用底层 CGO 解析器的包:golang.org/pkg/net/#pkg-overview
。
这份文档向我们展示了如何使用 Go 和 cgo 解析器:
export GODEBUG=netdns=go # force pure Go resolver
export GODEBUG=netdns=cgo # force cgo resolver
让我们使用以下命令仅启用 Go 解析器在我们的示例 Web 服务器中:
export CGO_ENABLED=0
go build -tags netgo
在下面的屏幕截图中,我们可以看到没有 C 解析器的simpleServer
正在执行的过程:
我们可以看到我们的 VSZ 和 RSS 都很低。将其与使用 C 解析器进行比较,方法是输入以下命令:
export CGO_ENABLED=1
go build -tags cgo
我们可以看到使用以下 C 解析器的simpleServer
的输出:
我们的 VSZ 在没有使用 cgo 解析器编译的服务器中显着较低。接下来,我们将讨论有限的内存情况以及如何考虑和构建它们。
有限内存情况简介
如果您在嵌入式设备或内存非常受限的设备上运行 Go,有时了解运行时内部的一些基本过程以便就您的进程做出明智的决策是明智的。Go 垃圾收集器优先考虑低延迟和简单性。它使用非生成并发三色标记和扫描垃圾收集器。默认情况下,它会自动管理内存分配。
Go 在调试标准库中有一个函数,它将强制进行垃圾收集并将内存返回给操作系统。Go 垃圾收集器在 5 分钟后将未使用的内存返回给操作系统。如果您在内存较低的设备上运行,可以在这里找到此函数FreeOSMemory()
: golang.org/pkg/runtime/debug/#FreeOSMemory
。
我们还可以使用GC()
函数,可以在这里找到:golang.org/pkg/runtime/#GC
。
GC()
函数也可能会阻塞整个程序。使用这两个函数要自担风险,因为它们可能导致意想不到的后果。
总结
在本章中,我们了解了 Go 如何分配堆和栈。我们还学习了如何有效地监视 VSZ 和 RSS 内存,以及如何优化我们的代码以更好地利用可用内存。能够做到这一点使我们能够有效地利用我们拥有的资源,使用相同数量的硬件为更多的并发请求提供服务。
在下一章中,我们将讨论 Go 中的 GPU 处理。
第九章:Go 中的 GPU 并行化
GPU 加速编程在当今的高性能计算堆栈中变得越来越重要。它通常用于人工智能(AI)和机器学习(ML)等领域。GPU 通常用于这些任务,因为它们往往非常适合并行计算。
在本章中,我们将学习 Cgo、GPU 加速编程、CUDA(Compute Unified Device Architecture的缩写)、make 命令、Go 程序的 C 样式链接,以及在 Docker 容器中执行启用 GPU 的进程。学习所有这些单独的东西将帮助我们使用 GPU 来支持 Go 支持的 CUDA 程序。这将帮助我们确定如何有效地使用 GPU 来帮助使用 Go 解决计算问题:
-
Cgo - 在 Go 中编写 C
-
GPU 加速计算-利用硬件
-
GCP 上的 CUDA
-
CUDA-为程序提供动力
Cgo - 在 Go 中编写 C
Cgo 是 Go 标准库中内置的一个库,允许用户在其 Go 代码中调用底层 C 程序。Cgo 通常用作当前用 C 编写但没有等效 Go 代码的事物的代理。
应该谨慎使用 Cgo,只有在系统中没有等效的 Go 库可用时才使用。Cgo 对您的 Go 程序添加了一些限制:
-
不必要的复杂性
-
困难的故障排除
-
构建和编译 C 代码的复杂性增加
-
Go 的许多工具在 Cgo 程序中不可用
-
交叉编译不像预期的那样有效,或者根本不起作用
-
C 代码的复杂性
-
本机 Go 调用比 Cgo 调用快得多
-
构建时间较慢
如果您可以(或必须)接受所有这些规定,Cgo 可能是您正在开发的项目的必要资源。
有一些情况是适合使用 Cgo 的。主要的两个例子如下:
-
当您必须使用专有的软件开发工具包(SDK)或专有库时。
-
当您有一个遗留的 C 软件,由于业务逻辑验证的原因,将其移植到 Go 可能会很困难。
-
您已经将 Go 运行时耗尽,并且需要进一步优化。我们很少有机会遇到这种特殊情况。
更多优秀的 cgo 文档可以在以下网址找到:
在下一节中,我们将看一个简单的 cgo 示例,以便熟悉 Cgo 的工作原理,以及它的一些亮点和缺点。
一个简单的 Cgo 示例
让我们来看一个相对简单的 Cgo 示例。在这个例子中,我们将编写一个简单的函数来从 C 绑定打印“Hello Gophers”,然后我们将从我们的 Go 程序中调用该 C 代码。在这个函数中,我们返回一个常量字符字符串。然后我们在 Go 程序中调用hello_gophers
C 函数。我们还使用C.GoString
函数将 C 字符串类型转换为 Go 字符串类型:
package main
/*
#include <stdio.h>
const char* hello_gophers() {
return "Hello Gophers!";
}
*/
import "C"
import "fmt"
func main() {
fmt.Println(C.GoString(C.hello_gophers()))
}
一旦执行了这个程序,我们就可以看到一个简单的“Hello Gophers!”输出:
这个例子虽然简单,但向我们展示了如何在我们的 Go 程序中绑定 C 函数。为了进一步强调执行时间的差异,我们可以看一下我们的 Cgo 函数和我们的 Go 函数的基准测试:
package benchmark
/*
#include <stdio.h>
const char* hello_gophers() {
return "Hello Gophers!";
}
*/
import "C"
import "fmt"
func CgoPrint(n int) {
for i := 0; i < n; i++ {
fmt.Sprintf(C.GoString(C.hello_gophers()))
}
}
func GoPrint(n int) {
for i := 0; i < n; i++ {
fmt.Sprintf("Hello Gophers!")
}
}
然后,我们可以使用这些函数来对我们的绑定 C 函数进行基准测试,以比较普通的GoPrint
函数:
package benchmark
import "testing"
func BenchmarkCPrint(b *testing.B) {
CgoPrint(b.N)
}
func BenchmarkGoPrint(b *testing.B) {
GoPrint(b.N)
}
执行完这个之后,我们可以看到以下输出:
请注意,绑定的 Cgo 函数所需的时间大约比本机 Go 功能长一个数量级。在某些情况下这是可以接受的。这个基准测试只是进一步验证了我们只有在有意义的时候才应该使用 Cgo 绑定。重要的是要记住,有特定的时机我们可以证明使用 Cgo 是合理的,比如当我们必须执行本地 Go 功能中不可用的操作时。
在下一节中,我们将学习 GPU 加速编程和 NVIDIA 的 CUDA 平台。
GPU 加速计算-利用硬件
在今天的现代计算机中,我们有一些硬件部件来完成系统的大部分工作。CPU 执行大部分来自计算机其他部分的指令操作,并传递这些操作的结果。内存是数据存储和处理的快速短期位置。硬盘用于长期数据存储和处理,网络设备用于在网络中的计算设备之间发送这些数据位。现代计算系统中经常使用的设备是独立 GPU。无论是显示具有高保真图形的最新电脑游戏,解码 4K 视频,还是执行金融数字计算,GPU 都成为高速计算的更受欢迎的选择。
GPU 旨在以高效的方式执行特定任务。随着高吞吐量计算的广泛采用,将 GPU 用作通用图形处理单元(GPGPUs)变得更加普遍。
有许多不同的 GPU 编程 API 可供使用,以充分利用 GPU 的性能,包括以下内容:
-
OpenCL:
www.khronos.org/opencl/
-
OpenMP:
www.openmp.org/
-
NVIDIA 的 CUDA 平台:
developer.nvidia.com/cuda-zone
NVIDIA 的 CUDA 库是成熟、高性能且广泛接受的。我们将在本章的示例中使用 CUDA 库。让我们更多地了解 CUDA 平台。
NVIDIA 的 CUDA 平台是由 NVIDIA 团队编写的 API,用于增加并行性并提高具有 CUDA 启用的图形卡的速度。在数据结构上执行并行算法可以严重提高计算时间。许多当前的 ML 和 AI 工具集在内部使用 CUDA,包括但不限于以下内容:
-
TensorFlow:
www.tensorflow.org/install/gpu
-
Numba:
devblogs.nvidia.com/gpu-accelerated-graph-analytics-python-numba/
-
PyTorch:
pytorch.org/
CUDA 提供了一个用于在 C++中访问这些处理习语的 API。它使用内核的概念,内核是从 C++代码调用的函数,在 GPU 设备上执行。内核是代码的部分,可以并行执行。CUDA 使用 C++语法规则来处理指令。
有许多地方可以使用云中的 GPU 来执行计算任务,例如以下:
-
Google Cloud GPU:
cloud.google.com/gpu/
-
带有 GPU 的 AWS EC2 实例:
aws.amazon.com/nvidia/
-
Paperspace:
www.paperspace.com/
-
FloydHub:
www.floydhub.com/
您还可以在本地工作站上运行 CUDA 程序。这样做的要求如下:
-
支持 CUDA 的 GPU(我在示例中使用了 NVIDIA GTX670)
-
具有 GCC 编译器和工具链的操作系统(我在示例中使用了 Fedora 29)
在下一节中,我们将介绍如何设置我们的工作站进行 CUDA 处理:
- 首先,我们需要为我们的主机安装适当的内核开发工具和内核头文件。我们可以通过执行以下命令在我们的示例 Fedora 主机上执行此操作:
sudo dnf install kernel-devel-$(uname -r) kernel-headers-$(uname -r)
- 我们还需要安装
gcc
和适当的构建工具。我们可以通过以下方式来实现:
sudo dnf groupinstall "Development Tools"
- 安装了先决条件后,我们可以获取 NVIDIA 为 CUDA 提供的本地
.run
文件安装程序。在撰写本文时,cuda_10.2.89_440.33.01_linux.run
包是最新可用的。您可以从developer.nvidia.com/cuda-downloads
下载最新的 CUDA 工具包:
wget http://developer.download.nvidia.com/compute/cuda/10.2/Prod/local_installers/cuda_10.2.89_440.33.01_linux.run
- 然后我们可以使用以下代码安装此软件包:
sudo ./cuda_10.2.89_440.33.01_linux.run
这将为我们提供一个安装提示,如下截图所示:
- 接受最终用户许可协议后,我们可以选择安装所需的依赖项并选择
Install
:
接受安装提示后,CUDA 安装程序应成功完成安装。如果在安装过程中出现任何错误,请查看以下位置可能会帮助您解决安装问题:
-
/var/log/cuda-installer.log
-
/var/log/nvidia-installer.log
在下一节中,我们将讨论如何使用主机机器进行 CUDA 进程。
CUDA - 利用主机进程
安装了 CUDA 后,您需要设置一些环境变量,以便将安装的部分添加到执行路径中。如果您在主机上没有 Docker 访问权限,或者您更愿意使用裸机执行 GPU 密集型操作,此功能将按预期工作。如果您想使用更可重现的构建,可以使用以下Docker for GPU-enabled programming部分中定义的 Docker 配置。
我们需要更新我们的PATH
以包括我们刚刚安装的 CUDA 二进制路径。我们可以通过执行以下命令来实现:export PATH=$PATH:/usr/local/cuda-10.2/bin:/usr/local/cuda-10.2/NsightCompute-2019.1
。
我们还需要更新我们的LD_LIBRARY_PATH
变量,这是一个环境变量,您的操作系统在链接动态和共享库时会查找它。我们可以通过执行export LD_LIBRARY_PATH=:/usr/local/cuda-10.2/lib64
来添加 CUDA 库。
这将把 CUDA 库添加到您的库路径中。我们将在本章的结束部分的 GNU Makefile 中以编程方式将这些添加到我们的路径中。在下一节中,我们将讨论如何使用 Docker 利用 CUDA。
用于 GPU 启用编程的 Docker
如果您想在本章中使用 Docker 进行 GPU 启用的编程,可以执行以下步骤,但是为了使用此功能,您必须在计算机上拥有兼容的 NVIDIA CUDA GPU。您可以在developer.nvidia.com/cuda-gpus
找到已启用的卡的完整列表。
在生产环境中,我们可能不会以这种方式使用 Docker 进行 GPU 加速计算,因为您很可能希望尽可能接近硬件以进行 GPU 加速编程,但我选择在本章中使用这种方法,以便本书的使用者有一个可重现的构建。大多数情况下,可重现的构建是使用容器化方法略有性能损失的可接受折衷方案。
如果您不确定您的 NVIDIA 启用的 GPU 支持什么,您可以使用cuda-z
实用程序来查找有关您的显卡的更多信息。该程序的可执行文件可以在cuda-z.sourceforge.net/
找到。
下载适用于您特定操作系统的版本后,您应该能够执行以下文件:
./CUDA-Z-0.10.251-64bit.run
您将看到一个输出,其中包含有关您当前使用的卡的各种信息:
一旦您确定您的卡支持所需的 GPU 处理,我们可以使用 Docker 来连接到您的 GPU 进行处理。为此,我们将按照以下步骤进行:
- 为您的计算机启用 NVIDIA 容器工具包。对于我的 Fedora 测试系统,我不得不通过将我的发行版更改为
centos7
来进行一些小调整——安装的 RPM 仍然按预期工作:
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.repo | sudo tee /etc/yum.repos.d/nvidia-docker.repo
在其他操作系统上安装的完整说明可以在github.com/NVIDIA/nvidia-docker#quickstart
找到。
- 安装
nvidia-container-toolkit
:
sudo yum install -y nvidia-container-toolkit
- 重新启动 Docker 以应用这些新更改:
sudo systemctl restart docker
- 禁用 SELINUX,以便您的计算机能够使用 GPU 进行这些请求:
setenforce 0 #as root
- 执行一个测试
docker run
,以确保您能够在 Docker 中执行 GPU 操作,并检查有关您特定 NVIDIA 卡的信息:
docker run --gpus all tensorflow/tensorflow:latest-gpu nvidia-smi
在下一节中,我们将介绍如何在 Google Cloud Platform 上设置支持 CUDA GPU 的机器。
GCP 上的 CUDA
如果您没有必要的硬件,或者您想在云中运行支持 GPU 的代码,您可能决定您更愿意在共享托管环境中使用 CUDA。在下面的示例中,我们将向您展示如何在 GCP 上使用 GPU。
还有许多其他托管的 GPU 提供商(您可以在本章的GPU 加速计算-利用硬件部分中看到所有这些提供商的列表)——我们将在这里以 GCP 的 GPU 实例为例。
您可以在cloud.google.com/gpu
了解更多关于 GCP 的 GPU 提供。
创建一个带有 GPU 的虚拟机
我们需要创建一个 Google Compute Engine 实例,以便能够在 GCP 上利用 GPU。
您可能需要增加 GPU 配额。要这样做,您可以按照以下网址的步骤进行:
https://cloud.google.com/compute/quotas#requesting_additional_quota
在撰写本文时,NVIDIA P4 GPU 是平台上最便宜的,而且具有足够的性能来展示我们的工作。您可以通过在 IAM 管理员配额页面上检查 NVIDIA P4 GPU 指标来验证您的配额:
为此,我们可以访问 Google Cloud 控制台上的 VM 实例页面。以下是此页面的截图。点击屏幕中央的创建按钮:
接下来,我们创建一个附加了 GPU 的 Ubuntu 18.04 VM。我们的 VM 实例配置示例如下截图所示:
我们在这里使用 Ubuntu 18.04 作为示例,而不是 Fedora 29,以展示如何为多种架构设置 CUDA。
我们的操作系统和其他配置参数如下截图所示:
点击创建按钮后,我们将返回到 VM 实例页面。等待您的 VM 完全配置好(它的名称左侧会有一个绿色的勾号):
接下来,我们可以 SSH 到实例,如下截图所示:
在接下来的小节中,我们将安装运行支持 GPU 的 CGo 程序所需的所有依赖项。我还在解释的最后包括了一个执行所有这些操作的脚本,以方便您使用。
安装 CUDA 驱动程序
按照cloud.google.com/compute/docs/gpus/install-drivers-gpu
中的说明安装 NVIDIA CUDA 驱动程序:
- 检索 CUDA 存储库:
curl -O http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/cuda-repo-ubuntu1804_10.0.130-1_amd64.deb
- 安装
.deb
软件包:
sudo dpkg -i cuda-repo-ubuntu1804_10.0.130-1_amd64.deb
- 将 NVIDIA GPG 密钥添加到 apt 源密钥环:
sudo apt-key adv --fetch-keys http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/7fa2af80.pub
- 安装 NVIDIA CUDA 驱动程序:
sudo apt-get update && sudo apt-get install cuda
- 现在我们在 GCP VM 上有一个支持 CUDA 的 GPU。我们可以使用
nvidia-smi
命令验证这一点:
nvidia-smi
- 我们将在截图中看到以下输出:
在 GCP 上安装 Docker CE
接下来,我们需要在启用 CUDA 的 GCE VM 上安装 Docker CE。要在我们的 VM 上安装 Docker CE,我们可以按照此页面上的说明进行操作:
docs.docker.com/install/linux/docker-ce/ubuntu/
在撰写本书时,以下步骤是必要的:
- 验证主机上没有其他 docker 版本:
sudo apt-get remove docker docker-engine docker.io containerd runc
- 确保我们的存储库是最新的:
sudo apt-get update
- 安装安装 docker CE 所需的依赖项:
sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
- 添加 docker CE 存储库:
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
- 运行更新以确保 docker CE 存储库是最新的:
sudo apt-get update
- 安装必要的 docker 依赖项:
sudo apt-get install docker-ce docker-ce-cli containerd.io
我们现在在主机上有一个可用的 Docker CE 实例。
在 GCP 上安装 NVIDIA Docker
要在我们的 VM 上安装 NVIDIA docker 驱动程序,我们可以按照此页面上的说明进行操作:
github.com/NVIDIA/nvidia-docker#ubuntu-16041804-debian-jessiestretchbuster
- 设置一个分发变量:
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
- 添加
nvidia-docker
存储库 gpg 密钥和 apt 存储库:
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
- 安装 nvidia-container-toolkit:
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
- 重新启动您的 VM 以使此驱动程序生效。
将所有内容脚本化
以下 bash 脚本将所有先前的操作组合在一起。首先,我们安装 CUDA 驱动程序:
#!/bin/bash
# Install the CUDA driver
curl -O http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/cuda-repo-ubuntu1804_10.0.130-1_amd64.deb
dpkg -i cuda-repo-ubuntu1804_10.0.130-1_amd64.deb
apt-key adv --fetch-keys http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/7fa2af80.pub
apt-get -y update && sudo apt-get -y install cuda
然后我们安装 Docker CE:
# Install Docker CE
apt-get remove docker docker-engine docker.io containerd runc
apt-get update
apt-get -y install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
apt-get -y update
apt-get -y install docker-ce docker-ce-cli containerd.io
最后我们安装nvidia-docker
驱动程序:
# Install nvidia-docker
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
apt-get -y update && sudo apt-get -y install nvidia-container-toolkit
usermod -aG docker $USER
systemctl restart docker
这包含在git/HighPerformanceWithGo/9-gpu-parallelization-in-go/gcp_scripts
中的 repo 中,并且可以通过运行以下命令来执行:
sudo bash nvidia-cuda-gcp-setup.sh
在目录中。在下一节中,我们将通过一个使用 Cgo 执行的示例 CUDA 程序。
CUDA-推动程序。
在安装了所有 CUDA 依赖项并运行后,我们可以从一个简单的 CUDA C++程序开始:
- 首先,我们将包括所有必要的头文件,并定义我们想要处理的元素的数量。
1 << 20
是 1,048,576,这已经足够多的元素来展示一个合适的 GPU 测试。如果您想要查看处理时间的差异,可以进行移位:
#include <cstdlib>
#include <iostream>
const int ELEMENTS = 1 << 20;
我们的multiply
函数被包装在一个__global__
说明符中。这允许nvcc
,CUDA 特定的 C++编译器,在 GPU 上运行特定的函数。这个乘法函数相对简单:它使用一些 CUDA 魔法将a
和b
数组相乘,并将值返回到c
数组中:
__global__ void multiply(int j, float * a, float * b, float * c) {
int index = threadIdx.x * blockDim.x + threadIdx.x;
int stride = blockDim.x * gridDim.x;
for (int i = index; i < j; i += stride)
c[i] = a[i] * b[i];
}
这个 CUDA 魔法是指 GPU 的并行处理功能。变量定义如下:
-
gridDim.x
:处理器上可用的线程块数
-
blockDim.x
:每个块中的线程数 -
blockIdx.x
:网格内当前块的索引 -
threadId.x
:块内当前线程的索引
然后我们需要添加一个extern "C"
调用,以便为这个特定函数使用 C 风格的链接,这样我们就可以有效地从我们的 Go 代码中调用这个函数。这个cuda_multiply
函数创建了三个数组:
-
a
和b
,它们存储 1 到 10 之间的随机数
-
c
,它存储了a
和b
的乘积的结果
extern "C" {
int cuda_multiply(void) {
float * a, * b, * c;
cudaMallocManaged( & a, ELEMENTS * sizeof(float));
cudaMallocManaged( & b, ELEMENTS * sizeof(float));
cudaMallocManaged( & c, ELEMENTS * sizeof(float));
- 然后我们创建我们的随机浮点数数组:
for (int i = 0; i < ELEMENTS; i++) {
a[i] = rand() % 10;
b[i] = rand() % 10;
}
然后我们执行我们的乘法函数(我们在文件开头定义的),基于块大小。我们根据数字计算出我们想要使用的块数:
int blockSize = 256;
int numBlocks = (ELEMENTS + blockSize - 1) / blockSize;
multiply << < numBlocks, blockSize >>> (ELEMENTS, a, b, c);
完成我们的乘法后,我们将等待 GPU 完成,然后才能访问我们在主机上的信息:cudaDeviceSynchronize();
。
- 然后我们可以将我们执行的乘法的值打印到屏幕上,以便让最终用户看到我们正在执行的计算。这在代码中被注释掉了,因为打印到
stdout
对于这段特定的代码来说并不显示很好的性能。如果您想要查看正在发生的计算,可以取消注释:
//for (int k = 0; k < ELEMENTS; k++) {
//std::cout << k << ":" << a[k] << "*" << b[k] << "=" << c[k] << "\n";
//}
- 然后,我们释放为乘法函数分配的 GPU 内存,通过在每个数组指针上调用
cudaFree
,然后返回0
来完成我们的程序:
cudaFree(a);
cudaFree(b);
cudaFree(c);
return 0;
}
}
- 然后,我们将添加我们的头文件
cuda_multiply.h
:
int cuda_multiply(void);
本章中,我们的 Go 程序只是围绕我们使用一些语法糖创建的cuda_multiply.cu
函数的包装器。
- 我们实例化
main
并导入必要的包:
package main
import (
"fmt"
"time"
)
- 然后,我们添加了我们需要的
CFLAGS
和LDFLAGS
,以便引用我们使用 nvcc make 创建的库,以及系统库。这里需要注意的是,这些注释,在 cgo 代码中称为preambles,在编译包的 C 部分时用作头文件。我们可以在这里包含任何必要的 C 代码,以使我们的 Go 代码更易于理解。如果您计划使用以下任何一种风格的标志,它们必须以#cgo
指令为前缀,以调整底层编译器的行为:
-
CFLAGS
-
CPPFLAGS
-
CXXFLAGS
-
FFLAGS
-
LDFLAGS
- 然后,我们导入伪包
C
,这使我们能够执行我们编写的 C 代码(回想一下我们在cuda_multiply.cu
文件中的extern C
调用)。我们还在这个函数周围添加了一个计时包装器,以便查看执行这个函数需要多长时间:
//#cgo CFLAGS: -I.
//#cgo LDFLAGS: -L. -lmultiply
//#cgo LDFLAGS: -lcudart
//#include <cuda_multiply.h>
import "C"
func main() {
fmt.Printf("Invoking cuda library...\n")
start := time.Now()
C.cuda_multiply()
elapsed := time.Since(start)
fmt.Println("\nCuda Execution took", elapsed)
}
- 我们将为接下来要构建的 Docker 容器提供一个 Makefile。我们的 Makefile 定义了一个方法来构建我们的 nvcc 库,运行我们的 Go 代码,并清理我们的 nvcc 库:
//target:
nvcc -o libmultiply.so --shared -Xcompiler -fPIC cuda_multiply.cu
//go:
go run cuda_multiply.go
//clean:
rm *.so
我们的 Dockerfile 将所有内容整合在一起,以便我们的演示可以非常容易地再现:
FROM tensorflow/tensorflow:latest-gpu
ENV LD_LIBRARY_PATH=/usr/local/cuda-10.1/lib64
RUN ln -s /usr/local/cuda-10.1/lib64/libcudart.so /usr/lib/libcudart.so
RUN apt-get install -y golang
COPY . /tmp
WORKDIR /tmp
RUN make
RUN mv libmultiply.so /usr/lib/libmultiply.so
ENTRYPOINT ["/usr/bin/go", "run", "cuda_multiply.go"]
- 接下来,我们将构建和运行我们的 Docker 容器。以下是来自缓存构建的输出,以缩短构建步骤的长度:
$ sudo docker build -t cuda-go .
Sending build context to Docker daemon 8.704kB
Step 1/9 : FROM tensorflow/tensorflow:latest-gpu
---> 3c0df9ad26cc
Step 2/9 : ENV LD_LIBRARY_PATH=/usr/local/cuda-10.1/lib64
---> Using cache
---> 65aba605af5a
Step 3/9 : RUN ln -s /usr/local/cuda-10.1/lib64/libcudart.so /usr/lib/libcudart.so
---> Using cache
---> a0885eb3c1a8
Step 4/9 : RUN apt-get install -y golang
---> Using cache
---> bd85bd4a8c5e
Step 5/9 : COPY . /tmp
---> 402d800b4708
Step 6/9 : WORKDIR /tmp
---> Running in ee3664a4669f
Removing intermediate container ee3664a4669f
---> 96ba0678c758
Step 7/9 : RUN make
---> Running in 05df1a58cfd9
nvcc -o libmultiply.so --shared -Xcompiler -fPIC cuda_multiply.cu
Removing intermediate container 05df1a58cfd9
---> 0095c3bd2f58
Step 8/9 : RUN mv libmultiply.so /usr/lib/libmultiply.so
---> Running in 493ab6397c29
Removing intermediate container 493ab6397c29
---> 000fcf47898c
Step 9/9 : ENTRYPOINT ["/usr/bin/go", "run", "cuda_multiply.go"]
---> Running in 554b8bf32a1e
Removing intermediate container 554b8bf32a1e
---> d62266019675
Successfully built d62266019675
Successfully tagged cuda-go:latest
然后,我们可以使用以下命令执行我们的 Docker 容器(根据您的 docker 守护程序配置情况,可能需要使用 sudo):
sudo docker run --gpus all -it --rm cuda-go
接下来是前述命令的输出:
对于如此大的乘法计算来说,相当令人印象深刻!在高计算工作负载下,GPU 编程通常是非常快速计算的良好解决方案。在同一台机器上,仅使用 CPU 的等效 C++程序大约需要 340 毫秒才能运行。
摘要
在本章中,我们学习了 cgo、GPU 加速编程、CUDA、Make 命令、用于 Go 程序的 C 风格链接,以及在 Docker 容器中执行启用 GPU 的进程。学习所有这些单独的元素帮助我们开发了一个性能良好的 GPU 驱动应用程序,可以进行一些非常大的数学计算。这些步骤可以重复进行,以便以高性能的方式进行大规模计算。我们还学会了如何在 GCP 中设置启用 GPU 的 VM,以便我们可以使用云资源来执行 GPU 计算。
在下一章中,我们将讨论 Go 语言中的运行时评估。
第十章:Go 中的编译时评估
Go 的作者以一种最小化依赖的方式编写了语言,每个文件都声明了自己的依赖关系。常规的语法和模块支持也有助于开发人员提高编译时间,以及接口满意度。在本章中,我们将看到运行时评估如何帮助加快 Go 编译速度,以及如何使用容器构建 Go 代码和利用 Go 构建缓存。
在本章中,我们将涵盖以下主题:
-
Go 运行时
-
GCTrace
-
GOGC
-
GOMAXPROCS
-
GOTRACEBACK
-
Go 构建缓存
-
供应
-
缓存
-
调试
-
KeepAlive
-
NumCPU
-
ReadMemStats
这些都是了解 Go 运行时如何工作以及如何使用它编写高性能代码的宝贵主题。
探索 Go 运行时
在 Go 源代码中,我们可以通过查看golang.org/src/runtime/
来查看运行时源代码。运行时包含与 Go 运行时交互的操作。该包用于控制诸如 goroutines、垃圾回收、反射和调度等功能,这些功能对语言的运行至关重要。在运行时包中,我们有许多环境变量,可以帮助我们改变 Go 可执行文件的运行时行为。让我们回顾一些关于 Go 运行时的最重要的环境变量。
GODEBUG
GODEBUG
是变量的控制器,用于在 Go 运行时进行调试。该变量包含一系列以逗号分隔的name=val
键值对。这些命名变量用于调整二进制文件返回的调试信息的输出。关于这个变量的一个好处是,运行时允许您直接将其应用于预编译的二进制文件,而不是在构建时调用它。这很好,因为它允许您调试已经构建的二进制文件(并且可能已经在生产环境中造成了损害)。您可以传递给GODEBUG
的变量如下:
GODEBUG 变量 | 启用值 | 描述 |
---|---|---|
allocfreetrace |
1 | 用于对每个分配进行分析。为每个对象的分配和释放打印堆栈跟踪。每个堆栈跟踪包含内存块、大小、类型、goroutine ID 和单个元素的堆栈跟踪。 |
clobberfree |
1 | 当释放对象时,GC 会用不良内容破坏对象的内容。 |
cgocheck |
0 – 禁用 1(默认)– 廉价检查 2 – 昂贵检查 | 用于检查使用 cgo 的包是否将错误传递给非 Go 代码的 go 指针。设置为 0 表示禁用,1 表示廉价检查可能会错过一些错误(默认),或者 2 表示昂贵检查会减慢程序运行速度。 |
efence |
1 | 分配器将确保每个对象都分配在唯一的页面上,并且内存地址不会被重复使用。 |
gccheckmark |
1 | 通过进行第二次标记传递来验证 GC 的当前标记阶段。在这第二次标记传递期间,世界会停止。如果第二次传递发现了并发标记没有找到的对象,GC 将会发生 panic。 |
gcpacertrace |
1 | 打印有关垃圾收集器的并发 pacer 内部状态的信息。 |
gcshrinkstackoff |
1 | 移动的 goroutines 不能移动到更小的堆栈上。在这种模式下,goroutine 的堆栈只会增长。 |
gcstoptheworld |
1 – 禁用 GC 2 – 禁用 GC 和并发扫描 | 1 禁用并发垃圾回收。这将使每个 GC 事件变成一个全局停止的情况。2 禁用 GC 并在垃圾回收完成后禁用并发扫描。 |
gctrace |
1 | 请参阅下一页的GCTrace 标题。 |
madvdontneed |
1 | 在 Linux 上使用MADV_DONTNEED 而不是MADV_FREE 将内存返回给内核。使用此标志会导致内存利用效率降低,但也会使 RSS 内存值更快地下降。 |
memprofilerate |
0 – 关闭分析 1 – 包括每个分配的块 X – 更新MemProfileRate 的值 |
控制在内存分析中报告和记录的内存分配分数。更改 X 控制记录的内存分配的分数。 |
invalidptr |
0 – 禁用此检查 1 – 如果发现无效指针,则垃圾收集器和堆栈复制器将崩溃 | 如果在存储指针的地方发现无效指针的值,垃圾收集器和堆栈复制器将崩溃。 |
sbrk |
1 | 从操作系统中交换一个不回收内存的简单分配器,而不是使用默认的内存分配器和垃圾收集器。 |
scavenge |
1 | 启用堆清扫调试模式。 |
scheddetail |
1(与 schedtrace=X 一起使用) | 调度器每 X 毫秒返回与调度器、处理器、线程和 goroutine 进程相关的信息。 |
schedtrace |
X | 每 X 毫秒向 STDERR 发出一行调度器状态摘要。 |
tracebackancestors |
N | 哪些 goroutine 的回溯与它们关联的堆栈被扩展,报告 N 个祖先 goroutine。如果 N = 0,则不返回祖先信息。 |
其他包还有一些变量可以传递给GODEBUG
。这些通常是非常知名的包,可能需要运行时性能调整,比如crypto/tls
和net/http
。如果包含GODEBUG
标志在运行时是可用的,包应该包含文档。
GCTRACE
GCTRACE
在运行时被使用,以查看已经打印到 stderr 的单行,显示每次收集时总内存和暂停的长度。在撰写本文时,此行组织如下:
gc# @#s #%: #+#+# ms clock, #+#/#/#+# ms cpu, #->#-># MB, # MB goal, #P
我们可以为提供一个简单的 HTTP 服务器来提供这个工作原理的示例。首先,我们编写一个简单的 HTTP 服务器,对localhost:8080
的根目录返回一个简单的Hello Gophers
响应:
package main
import (
"fmt"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello Gophers")
}
func main() {
http.HandleFunc("/", hello)
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println(err)
}
}
接下来,我们可以构建并运行这个简单的 Web 服务器,然后我们可以使用 Apache bench (httpd.apache.org/docs/2.4/programs/ab.html
) 来模拟对主机的一些负载:
当我们从 Apache bench 看到这个输出,显示我们的测试已经完成,我们将在最初实例化我们的简单 HTTP 守护程序的终端上看到一些垃圾回收统计信息:
让我们分解一下这个示例的垃圾回收输出:|
输出 | 描述 |
---|---|
gc 1 | 垃圾回收编号。每次垃圾回收时,此编号会递增。 |
@6.131s | 此垃圾回收发生在程序启动后的 6.131 秒。 |
0% | 自程序启动以来在 GC 中花费的时间百分比。 |
| 0.016+2.1+0.023 ms clock | GC 阶段发生的挂钟/CPU 时间。这可以表示为Tgc = Tseq + Tmark + Tsweep. Tseq: 用户 Go 例程时间停止(停止世界清扫终止)。
Tmark: 堆标记时间(并发标记和扫描时间)。
Tsweep: 堆清扫时间(清扫世界标记终止)。|
4->4->3 MB | GC 开始、GC 结束和活动堆大小。 |
---|---|
5 MB goal | 目标堆大小。 |
4 P | 使用的处理器数。 |
如果我们等待几分钟,我们的终端应该会产生以下输出:
scvg1: 57 MB released
scvg1: inuse: 1, idle: 61, sys: 63, released: 57, consumed: 5 (MB)
这是使用gctrace > 0
发生的输出。每当 Go 运行时将内存释放回系统时,也称为清扫,它会产生一个摘要。在撰写本文时,此输出遵循以下格式:
输出 | 描述 |
---|---|
scvg1: 57 MB released | 垃圾回收周期编号。每次垃圾回收时,此编号会递增。此数据点还让我们知道释放回操作系统的内存块的大小。 |
inuse: 1 | 程序中使用的内存大小(这也可能表示部分使用的跨度)。 |
空闲:61 | 待清理的跨度大小(以 MB 为单位)。 |
sys: 3 | 从系统映射的内存大小(以 MB 为单位)。 |
released: 57 | 释放给系统的内存大小(以 MB 为单位)。 |
consumed: 5 | 从系统分配的内存大小(以 MB 为单位)。 |
垃圾收集和清理输出示例都很重要-它们可以以简单易读的方式告诉我们系统内存利用的当前状态。
GOGC
GOGC
变量允许我们调整 Go 垃圾收集系统的强度。垃圾收集器(在golang.org/src/runtime/mgc.go
实例化)读取GOGC
变量并确定垃圾收集器的值。值为off
会关闭垃圾收集器。这在调试时通常很有用,但在长期内不可持续,因为程序需要释放在可执行堆中收集的内存。将此值设置为小于默认值 100 将导致垃圾收集器更频繁地执行。将此值设置为大于默认值 100 将导致垃圾收集器执行更不频繁。对于多核大型机器,垃圾收集经常发生,如果我们减少垃圾收集的频率,可以提高性能。我们可以使用标准库的编译来查看更改垃圾收集如何影响编译时间。在以下代码示例中,我们可以看到标准库的构建及其相应的时间:
#!/bin/bash
export GOGC=off
printf "\nBuild with GOGC=off:"
time go build -a std
printf "\nBuild with GOGC=50:"
export GOGC=50
time go build -a std
for i in 0 500 1000 1500 2000
do
printf "\nBuild with GOGC = $i:"
export GOGC=$i
time go build -a std
done
我们的输出显示了 Go 标准库编译时间的相应时间:
通过调整垃圾收集,我们可以看到编译时间有很大的差异。这将大大变化,取决于您的架构、系统规格和 Go 版本。重要的是要认识到这是一个我们可以为我们的 Go 程序调整的旋钮。这个旋钮通常用于构建时间或高度监控、对延迟敏感的二进制文件,在执行时间内需要挤出更多的性能。
GOMAXPROCS
GOMAXPROCS
是一个可以调整的变量,允许我们控制操作系统为 Go 二进制文件中的 goroutine 分配的线程数。默认情况下,GOMAXPROCS
等于应用程序可用的核心数。这可以通过运行时包动态配置。重要的是要注意,从 Go 1.10 开始,GOMAXPROCS
将没有上限限制。
如果我们有一个 CPU 密集型且并行化的函数(例如 goroutine 排序字符串),如果调整我们拥有的GOMAXPROCS
数量,我们将看到一些严重的改进。在以下代码示例中,我们将测试使用不同数字设置GOMAXPROCS
来构建标准库:
#!/bin/bash
for i in 1 2 3 4
do
export GOMAXPROCS=$i
printf "\nBuild with GOMAXPROCS=$i:"
time go build -a std
done
在我们的结果中,我们可以看到当我们操纵GOMAXPROCS
的总数时会发生什么:
实际上,我们不应该手动设置GOMAXPROCS
。很少有情况下,您可能希望根据系统上可用的资源限制特定二进制文件的 CPU 利用率,或者您可能确实需要根据手头的资源进行优化。然而,在大多数情况下,默认的GOMAXPROCS
值是合理的。
GOTRACEBACK
GOTRACEBACK
允许您控制 Go 程序在出现意外运行时条件或未恢复的恐慌状态时生成的输出。设置GOTRACEBACK
变量将允许您查看有关为特定错误或恐慌实例化的 goroutine 的更多或更少粒度的信息。来自通道/ goroutine 中断的恐慌示例如下:
package main
import (
"time"
)
func main() {
c := make(chan bool, 1)
go panicRoutine(c)
for i := 0; i < 2; i++ {
<-c
}
}
func panicRoutine(c chan bool) {
time.Sleep(100 * time.Millisecond)
panic("Goroutine Panic")
c <- true
}
如果我们在输出中调整GOTRACEBACK
变量,我们将看到不同级别的堆栈跟踪。设置GOTRACEBACK=none
或GOTRACEBACK=0
会给我们关于此恐慌的最少信息:
设置GOTRACEBACK=single
(Go 运行时的默认选项)将为我们的特定请求发出当前 goroutine 的单个堆栈跟踪,如下所示:
设置GOTRACEBACK=all
或GOTRACEBACK=1
将为用户创建的所有 goroutine 发送回堆栈跟踪:
设置GOTRACEBACK=system
或GOTRACEBACK=2
将为由运行时创建的函数和 goroutine 添加所有运行时堆栈帧。
最后,我们可以设置GOTRACEBACK=crash
。这与系统类似,但允许操作系统触发核心转储。
大多数情况下,默认的GOTRACEBACK=single
为我们提供了关于当前上下文的足够信息,以便就为什么我们的程序以我们没有预期的方式结束做出明智的决定。
Go 构建缓存
在本章中,我们讨论了优化 Go 构建的几种方法。我们还可以通过一些简单的调整来提高 Go 构建时间的能力。Go 团队一直在优化运行时,而不是构建时间。Go 具有缓存构建时间依赖项的能力,这有助于重用先前构建的常见构件。这些构件保存在$GOPATH/pkg/
中。我们可以通过在调用 go build 时使用-i
标志来保留这些中间结果,以便重新利用这些构件。如果我们想调试构建过程中发生了什么,我们可以使用-x
标志运行我们的构建,以便从 Go 构建系统产生更详细的输出。
Vendoring 依赖项
Vendoring 也是改善构建一致性和质量的流行选择。在项目结构中,语言的作者们对保持对 vendoring 依赖的支持的反馈持开放态度。将依赖项保留在存储库中会使其非常庞大,但可以帮助在构建时保持本地可用的第三方依赖项。当我们使用 Go 版本 1.11 或更高版本时,我们可以使用 Go 模块标志来允许 vendored 构建。我们可以使用go mod vendor
来捕获vendor/
目录中的所有依赖项,然后在构建时使用go build -mod vendor
。
缓存和 vendoring 改进
为了看到我们可以通过构建和缓存资产进行的改进,让我们构建一个具有第三方依赖的项目。Prometheus[prometheus.io/
]是一个流行的时间序列数据库(也是用 Go 编写的),通常用于指标收集和收集。我们可能希望在我们的任何应用程序中启动一个 Prometheus 指标服务器,以便从系统角度了解我们当前运行的二进制文件。为此,我们可以按如下方式导入 Prometheus 库:
package main
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
http.Handle("/promMetrics", promhttp.Handler())
http.ListenAndServe(":1234", nil)
}
在我们在基本二进制文件中实例化prometheus
服务器之后,我们可以构建我们的二进制文件并执行它。要对已经是最新的包执行强制重建,我们可以使用go build
的-a
标志。如果你想知道在我们超长的构建时间中到底花了多长时间,你也可以添加-x
标志——它会给你一个非常详细的输出,说明构建过程中发生了什么。
默认情况下,较新版本的 Golang 将定义一个GOCACHE
。您可以使用go env GOCACHE
查看其位置。使用GOCACHE
和 mod vendor 的组合,我们可以看到我们的构建时间显著提高了。列表中的第一个构建是冷构建,强制重新构建包以使其保持最新。我们的第二个构建,其中一些项目存储在 mod vendor 段中,要快得多。我们的第三个构建,应该有大部分构建元素被缓存,与之相比非常快。以下截图说明了这一点:
调试
运行时内的调试包为我们提供了许多可用于调试的函数和类型。我们可以做到以下几点:
-
使用
FreeOSMemory()
强制进行垃圾收集。 -
使用
PrintStack()
打印在运行时生成的堆栈跟踪到 stderr。 -
使用
ReadGCStats()
读取我们的垃圾收集统计数据。 -
使用
SetGCPercent()
设置我们的垃圾收集百分比。 -
使用
SetMaxStack()
设置单个 goroutine 的最大堆栈大小。 -
使用
SetMaxThreads()
设置我们的最大 OS 线程数。 -
使用
SetPanicOndefault()
在意外地址故障时控制运行时行为。 -
使用
SetTraceback()
设置回溯的数量。 -
使用
Stack()
返回 goroutine 的堆栈跟踪。 -
使用
WriteHeapDump()
编写堆转储。
PProf/race/trace
我们将在第十二章 Go 代码性能分析和第十三章 Go 代码追踪中详细介绍性能分析和追踪 Go 程序的细节。值得注意的是运行时库是这些实用程序的关键驱动程序。能够使用 pprof/race/trace 可以帮助您以有意义的方式调试代码,并能够找到新生错误。在下一节中,我们将学习运行时函数以及它们对 Go 运行时库的重要性。
理解函数
Go 运行时库还有一些函数,可以注入到程序的运行时中以发出运行时数据。让我们通过一些主要示例来了解一下。所有可用运行时函数的完整列表可以在golang.org/pkg/runtime/#pkg-index
找到。这个包中提供的许多函数也包含在runtime/pprof
包中,我们将在第十二章 Go 代码性能分析中更详细地进行调查。
KeepAlive
runtime.KeepAlive()
函数期望interface{}
,并确保传递给它的对象不被释放,并且它的终结器(由runtime.SetFinalizer
定义)不被运行。这使得传递给KeepAlive
的参数可达。编译器设置了OpKeepAlive
,如静态单赋值(SSA)包中所定义的(golang.org/src/cmd/compile/internal/gc/ssa.go#L2947
)- 这使得编译器能够知道接口的状态作为一个变量,并允许保持保持活动的上下文。
作为一个经验法则,我们不应该在正常的实现中调用KeepAlive
。它用于确保垃圾收集器不会从函数内部不再被引用的值中回收内存。
NumCPU
NumCPU
函数返回当前进程可用的逻辑 CPU 数量。当二进制文件被调用时,运行时会验证启动时可用的 CPU 数量。这个的一个简单示例可以在以下代码片段中找到:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("Number of CPUs Available: ", runtime.NumCPU())
}
现在,我们可以看到当前进程可用的 CPU 数量。在我的情况下,这个值最终是4
:
通过这个,我们可以看到我的计算机有 4 个可用于使用的 CPU。
ReadMemStats
ReadMemStats()
函数读取内存分配器统计信息并将其填充到一个变量中,比如m
。MemStats
结构体包含了关于内存利用的很多有价值的信息。让我们深入了解一下它可以为我们产生哪些值。一个允许我们查看二进制文件内存利用的 HTTP 处理程序函数可能会有所帮助,因为我们在系统中发出更多请求并希望看到我们的内存分配是在哪里被利用:
- 首先,我们可以实例化程序和函数:
package main
import (
"fmt"
"net/http"
"runtime"
)
func memStats(w http.ResponseWriter, r *http.Request) {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
- 接下来,我们可以打印运行时提供给我们的各个内存统计值。让我们从
Alloc
、Mallocs
和Frees
开始:
fmt.Fprintln(w, "Alloc:", memStats.Alloc)
fmt.Fprintln(w, "Total Alloc:", memStats.TotalAlloc)
fmt.Fprintln(w, "Sys:", memStats.Sys)
fmt.Fprintln(w, "Lookups:", memStats.Lookups)
fmt.Fprintln(w, "Mallocs:", memStats.Mallocs)
fmt.Fprintln(w, "Frees:", memStats.Frees)
- 现在,让我们看一下堆信息:
fmt.Fprintln(w, "Heap Alloc:", memStats.HeapAlloc)
fmt.Fprintln(w, "Heap Sys:", memStats.HeapSys)
fmt.Fprintln(w, "Heap Idle:", memStats.HeapIdle)
fmt.Fprintln(w, "Heap In Use:", memStats.HeapInuse)
fmt.Fprintln(w, "Heap Released:", memStats.HeapReleased)
fmt.Fprintln(w, "Heap Objects:", memStats.HeapObjects)
- 接下来,我们将查看堆栈/跨度/缓存/桶分配:
fmt.Fprintln(w, "Stack In Use:", memStats.StackInuse)
fmt.Fprintln(w, "Stack Sys:", memStats.StackSys)
fmt.Fprintln(w, "MSpanInuse:", memStats.MSpanInuse)
fmt.Fprintln(w, "MSpan Sys:", memStats.MSpanSys)
fmt.Fprintln(w, "MCache In Use:", memStats.MCacheInuse)
fmt.Fprintln(w, "MCache Sys:", memStats.MCacheSys)
fmt.Fprintln(w, "Buck Hash Sys:", memStats.BuckHashSys)
- 然后,我们查看垃圾收集信息:
fmt.Fprintln(w, "EnableGC:", memStats.EnableGC)
fmt.Fprintln(w, "GCSys:", memStats.GCSys)
fmt.Fprintln(w, "Other Sys:", memStats.OtherSys)
fmt.Fprintln(w, "Next GC:", memStats.NextGC)
fmt.Fprintln(w, "Last GC:", memStats.LastGC)
fmt.Fprintln(w, "Num GC:", memStats.NumGC)
fmt.Fprintln(w, "Num Forced GC:", memStats.NumForcedGC)
- 现在,让我们看一下垃圾收集中断信息:
fmt.Fprintln(w, "Pause Total NS:", memStats.PauseTotalNs)
fmt.Fprintln(w, "Pause Ns:", memStats.PauseNs)
fmt.Fprintln(w, "Pause End:", memStats.PauseEnd)
fmt.Fprintln(w, "GCCPUFraction:", memStats.GCCPUFraction)
fmt.Fprintln(w, "BySize Size:", memStats.BySize)
- 接下来,我们实例化一个简单的 HTTP 服务器:
func main() {
http.HandleFunc("/", memStats)
http.ListenAndServe(":1234", nil)
}
在这里,我们可以使用我们的 Apache bench 工具在我们的内存分配器上生成一些负载:
ab -n 1000 -c 1000 http://localhost:1234/
最后,我们可以通过向localhost:1234
发出请求来查看一些活动的 HTTP 服务器信息和响应:
所有MemStats
值的定义可以在文档中找到:golang.org/pkg/runtime/#MemStats
。
总结
在本章中,我们学习了GODEBUG
、GCTRACE
、GOGC
、GOMAXPROCS
和GOTRACEBACK
运行时优化。我们还了解了GOBUILDCACHE
和 Go 依赖项的供应。最后,我们学习了调试和从代码中调用运行时函数。在排除 Go 代码问题时使用这些技术将帮助您更容易地发现问题和瓶颈。
在下一章中,我们将讨论有效部署 Go 代码的正确方法。
第三部分:部署、监控和迭代 Go 程序时考虑性能
在本节中,您将了解编写高性能 Go 代码的各种惯用方法。因此,在本节中,我们将努力在实际场景中编写高性能的 Go 代码。
本节包括以下章节:
-
第十一章,构建和部署 Go 代码
-
第十二章,Go 代码性能分析
-
第十三章,Go 代码追踪
-
第十四章,集群和作业队列
-
第十五章,跨版本比较代码质量
第十一章:构建和部署 Go 代码
一旦我们找到了编写高性能 Go 代码的方法,我们需要部署它,验证它,并继续迭代它。这个过程的第一步是部署新的 Go 代码。Go 的代码被编译成二进制文件,这允许我们在代码开发的迭代过程中以模块化的方式部署新的 Go 代码。我们可以将其推送到一个或多个位置,以便针对不同的环境进行测试。这样做将使我们能够优化我们的代码,充分利用系统中将可用的吞吐量。
在本章中,我们将学习有关 Go 构建过程的所有内容。我们将看看 Go 编译器如何构建二进制文件,并利用这些知识为当前平台构建合适大小、优化的二进制文件。我们将涵盖以下主题:
-
构建 Go 二进制文件
-
使用
go clean
来删除对象文件 -
使用
go get
来下载和安装依赖项 -
使用
go mod
进行依赖管理 -
使用
go list
来列出包和模块 -
使用
go run
来执行程序 -
使用
go install
来安装包
这些主题将帮助我们从我们的源代码构建高效的 Go 二进制文件。
构建 Go 二进制文件
在第十章中,Go 中的编译时评估,我们讨论了一些可能有助于优化我们构建策略的 Go 构建优化。Go 的构建系统有很多选项,可以帮助系统操作员向他们的构建策略添加额外的参数化。
Go 工具有许多不同的方法来构建我们的源代码。让我们先了解每个顶层理解,然后我们将更深入地讨论每个包。了解这些命令之间的关键区别可能会帮助您了解它们如何相互作用,并选择适合工作的正确工具。让我们来看看它们:
-
go build
:为您的项目构建二进制文件,编译包和依赖项 -
go clean
:从包源目录中删除对象和缓存文件 -
go get
:下载并安装包及其依赖项 -
go mod
:Go 的(相对较新的)内置依赖模块系统 -
go list
:列出命名的包和模块,并显示有关文件、导入和依赖项的重要构建信息 -
go run
:运行和编译命名的 Go 程序 -
go install
:为您的项目构建二进制文件,将二进制文件移动到$GOPATH/bin
,并缓存所有非主要包
在本章中,我们将调查 Go 构建系统的这些不同部分。随着我们对这些程序如何相互操作的了解越来越多,我们将能够看到如何利用它们来构建适合我们期望的支持架构和操作系统的精简、功能丰富的二进制文件。
在下一节中,我们将通过go build
来看一下。
Go build - 构建您的 Go 代码
go build 的调用标准如下:
go build [-o output] [build flags] [packages]
使用-o
定义输出,使用特定命名的文件编译二进制文件。当您有特定的命名约定要保留到您的文件中,或者如果您想根据不同的构建参数(平台/操作系统/git SHA 等)命名二进制文件时,这将非常有帮助。
包可以定义为一组 go 源文件,也可以省略。如果指定了一组 go 源文件的列表,构建程序将使用作为指定单个包的组传递的文件列表。如果未定义任何包,构建程序将验证目录中的包是否可以构建,但将丢弃构建的结果。
构建标志
Go 的构建标志被build
、clean
、install
、list
、run
和test
命令共享。以下是一个表格,列出了构建标志及其用法描述:
构建标志 | 描述 |
---|---|
-a |
强制重新构建包。如果您想确保所有依赖项都是最新的,这可能特别方便。 |
-n |
打印编译器使用的命令,但不运行命令(类似于其他语言中的干运行)。这对于查看包的编译方式很有用。 |
-p n |
并行化构建命令。默认情况下,此值设置为构建系统可用的 CPU 数量。 |
|-race
| 启用竞争检测。只有某些架构才能检测到竞争检测:
-
linux/amd64
-
freebsd/amd64
-
darwin/amd64
-
windows/amd64
|
-msan |
检测 C 中未初始化的内存读取。这仅在 Linux 上支持 amd64 或 arm64 架构,并且需要使用 clang/LLVM 编译器进行主机。可以使用CC=clang go build -msan example.go 进行调用。 |
---|---|
-v |
在编译程序时,构建的包的名称将列在 stdout 中。这有助于验证用于构建的包。 |
-work |
打印 Go 在构建二进制文件时使用的临时工作目录的值。这通常默认存储在/tmp/ 中。 |
-x |
显示构建过程中使用的所有命令。这有助于确定如何构建包。有关更多信息,请参见构建信息部分。 |
-asmflags '[pattern=]arg list' |
调用go tool asm 时要传递的参数列表。 |
|-buildmode=type
| 这告诉构建命令我们想要构建哪种类型的目标文件。目前,buildmode
有几种类型选项:
-
archive
: 将非主包构建为.a
文件。 -
c-archive
: 将主包和其所有导入项构建为 C 存档文件。 -
c-shared
: 将主包和其导入项构建为 C 共享库。 -
default
: 创建主包列表。 -
shared
: 将所有非主包合并为单个共享库。 -
exe
: 将主包和其导入项构建为可执行文件。 -
pie
: 将主包和其导入项构建为位置无关可执行文件(PIE)。 -
plugin
: 将主包和其导入项构建为 Go 插件。
|
-compiler name |
确定要使用的编译器。常见用途是gccgo 和gc 。 |
---|---|
-gccgoflags |
gccgo 编译器和链接器调用标志。 |
-gcflags |
gc 编译器和链接器调用标志。有关更多详细信息,请参见编译器和链接器部分。 |
-installsuffix suffix |
向包安装目录的名称添加后缀。这是为了使输出与默认构建分开而使用的。 |
-ldflags '[pattern=]arg list' |
Go 工具链接调用参数。有关更多详细信息,请参见编译器和链接器部分。 |
-linkshared |
在进行-buildmode=shared 调用后,此标志将链接到新创建的共享库。 |
-mod |
确定要使用的模块下载模式。在撰写本文时,有两个选项:- readonly 或vendor 。 |
-pkgdir dir |
利用定义的dir 来安装和加载所有包。 |
-tags tag,list |
要在构建过程中满足的构建标签列表。此列表以逗号分隔的形式传递。 |
|-trimpath
| 结果构建的可执行文件将在可执行文件构建期间使用不同的文件系统路径命名方案。这些如下:
-
Go(用于标准库)
-
路径@版本(用于 go 模块)
-
普通导入路径(使用
GOPATH
)
|
-toolexec 'cmd args' |
调用工具链程序,例如调试器或其他交互式程序。这用于诸如 vet 和 asm 的程序。 |
---|
有了所有这些信息,您将能够有效地构建正确的链接器标志。
构建信息
为了更好地了解构建过程,让我们看一些构建示例,以便更好地了解构建工具是如何协同工作的。
假设我们想要构建一个简单的 HTTP 服务器,其中有一个 Prometheus 导出器。我们可以这样创建一个导出器:
package main
import (
"fmt"
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
http.Handle("/", promhttp.Handler())
port := ":2112"
fmt.Println("Prometheus Handler listening on port ", port)
http.ListenAndServe(port, nil)
}
当我们的包准备好后,我们可以使用以下命令构建我们的包:
go build -p 4 -race -x prometheusExporterExample.go
当我们构建这个二进制文件时,我们会看到一些东西回到 stdout(因为我们传递了-x
标志来查看在过程中使用的命令)。让我们来看一下:
- 我们将截断输出,以便结果更易于阅读。如果你自己测试一下,你会看到更大的构建输出:
WORK=/tmp/go-build924967855
为构建设置了一个临时工作目录。正如我们之前提到的,这通常位于/tmp/
目录中,除非另有规定:
mkdir -p $WORK/b001/
- 编译器还创建了一个子工作目录:
cat >$WORK/b001/importcfg.link << 'EOF' # internal
- 创建并添加了一个链接配置。这会向链接配置添加各种不同的参数:
packagefile command-line-arguments=/home/bob/.cache/go-build/aa/aa63d73351c57a147871fde4964d74c9a39330b467c6d73640815775e6673084-d
- 命令行参数的包是从缓存中引用的:
packagefile fmt=/home/bob/.cache/go-build/74/749e110dc104578def1859fbd4ca5c5546f4032f02ffd5ea4d14c730fbd65b81-d
fmt
是我们用来显示fmt.Println("Prometheus Handler listening on port ", port)
的打印包。这样引用:
packagefile github.com/prometheus/client_golang/prometheus/promhttp=/home/bob/.cache/go-build/e9/e98940b17504e2f647dccc7832793448aa4e8a64047385341c94c1c4431d59cf-d
- 编译器还为 Prometheus HTTP 客户端库添加了包。之后,还有许多其他引用被添加到构建中。由于篇幅原因,这部分已被截断。
文件末尾用EOF
表示。
- 创建一个可执行目录:
mkdir -p $WORK/b001/exe/
- 然后编译器使用之前创建的
importcfg
构建二进制文件:
/usr/lib/golang/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -installsuffix race -buildmode=exe -buildid=bGYa4XecCYqWj3VjKraU/eHfXIjk2XJ_C2azyW4yU/8YHxpy5Xa69CGQ4FC9Kb/bGYa4XecCYqWj3VjKraU -race -extld=gcc /home/bob/.cache/go-build/aa/aa63d73351c57a147871fde4964d74c9a39330b467c6d73640815775e6673084-
- 然后添加了一个
buildid
:
/usr/lib/golang/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal
- 接下来,二进制文件被重命名为我们在导出示例中使用的文件名(因为我们没有使用
-o
指定不同的二进制文件名):
cp $WORK/b001/exe/a.out prometheusExporterExample
- 最后,工作目录被删除:
rm -r $WORK/b001/
这个程序的工作输出是一个 Go 二进制文件。在下一节中,我们将讨论编译器和链接器标志。
编译器和链接器标志
在构建 Go 二进制文件时,-gcflags
标志允许您传递可选的编译器参数,而-ldflags
标志允许您传递可选的链接器参数。可以通过调用以下命令找到编译器和链接器标志的完整列表:
go tool compile -help
go tool link -help
让我们看一个利用编译器和链接器标志的例子。我们可以构建一个简单的程序,返回一个未初始化的字符串变量的值。以下程序看起来似乎无害:
package main
import "fmt"
var linkerFlag string
func main() {
fmt.Println(linkerFlag)
}
如果我们使用一些常见的编译器和链接器标志构建这个,我们将看到一些有用的输出:
编译器标志我们在这里传递的实现了以下功能:
-
"-m -m"
:打印有关编译器优化决策的信息。这是我们在构建命令后看到的前面截图中的输出。 -
"-N"
:禁用 Go 二进制文件中的优化。 -
"-l"
:禁用内联。
我们传递的链接器标志做了以下事情:
-
"-X main.linkerFlag=Hi_Gophers"
:为main
中的linkerFlag
变量设置一个值。在构建时添加变量是很重要的,因为许多开发人员希望在编译时向他们的代码添加某种构建参数。我们可以使用date -u +.%Y%m%d%.H%M%S
传递构建日期,也可以使用git rev-list -1 HEAD
传递 git 提交版本。这些值以后可能对引用构建状态很有帮助。 -
"-s"
:禁用符号表,这是一种存储源代码中每个标识符的数据结构,以及声明信息。这通常不需要用于生产二进制文件。 -
"-w"
:禁用 DWARF 生成。由于 Go 二进制文件包括基本类型信息、PC 到行数据和符号表,通常不需要保存 dwarf 表。
如果我们使用标准方法构建二进制文件,然后使用一些可用的编译器和链接器标志,我们将能够看到二进制文件大小的差异:
- 非优化构建:
$ go build -ldflags "-X main.linkerFlag=Hi_Gophers" -o nonOptimized
- 优化构建:
$ go build -gcflags="-N -l" -ldflags "-X main.linkerFlag=Hi_Gophers -s -w" -o Optimized
正如我们所看到的,Optimized
二进制文件比nonOptimized
二进制文件小 28.78%:
这两个二进制文件对最终用户执行相同的功能,因此考虑使用编译器和链接器标志删除一些构建优化,以减少最终生成的二进制文件大小。这在存储和部署这些二进制文件时可能是有益的。
构建约束
如果您想要向您的 Go 构建添加构建约束,可以在文件开头添加一行注释,该注释只在空行和其他注释之前。此注释的形式是// +build darwin,amd64,!cgo, android,386,cgo
。
这对应于darwin AND amd64 AND (NOT cgo)) OR (android AND 386 AND cgo
的布尔输出。
这需要在包声明之前,构建约束和包初始化之间有一个换行。这采用以下形式:
// +build [OPTIONS]
package main
可以在golang.org/pkg/go/build/#hdr-Build_Constraints
找到完整的构建约束列表。此列表包括以下构建约束:
-
GOOS
-
GOARCH
-
编译器类型(
gc
或gccgo
) -
cgo
-
所有 1.x Go 版本(beta 或次要版本没有构建标签)
-
ctxt.BuildTags
中列出的其他单词
如果您的库中有一个文件,您希望在构建中排除它,您也可以以以下形式添加注释:
// +build ignore
相反,您可以使用以下形式的注释将文件构建限制为特定的GOOS
、GOARCH
和cgo
位:
// +build windows, 386, cgo
只有在使用cgo
并在 Windows 操作系统的 386 处理器上构建时才会构建文件。这是 Go 语言中的一个强大构造,因为您可以根据必要的构建参数构建包。
文件名约定
如果文件匹配GOOS
和GOARCH
模式,并去除任何扩展名和_test
后缀(用于测试用例),则该文件将为特定的GOOS
或GOARCH
模式构建。这样的模式通常被引用如下:
-
*_GOOS
-
*_GOARCH
-
*_GOOS_GOARCH
例如,如果您有一个名为example_linux_arm.go
的文件,它将只作为 Linux arm 构建的一部分构建。
在下一节中,我们将探讨go clean
命令。
Go clean - 清理您的构建目录
Go 命令会在临时目录中构建二进制文件。go clean 命令是为了删除其他工具创建的多余的对象文件或手动调用 go build 时创建的对象文件。Go clean 有一个用法部分go clean [clean flags] [build flags] [packages]
。
对于 clean 命令,以下标志是可用的:
-
-cache
标志会删除整个 go 构建缓存。如果您想要比较多个系统上的新构建,或者想要查看新构建所需的时间,这可能会有所帮助。 -
-i
标志会删除 go install 创建的存档或二进制文件。 -
-n
标志是一个空操作;打印结果会删除命令,但不执行它们。 -
-r
标志会递归地应用逻辑到导入路径包的所有依赖项。 -
-x
标志会打印并执行生成的删除命令。 -
-cache
标志会删除整个 go 构建缓存。 -
-testcache
标志会删除构建缓存中的测试结果。 -
-modcache
标志会删除模块下载缓存。
如果我们想尝试一个没有现有依赖关系的干净构建,我们可以使用一个命令从 go 构建系统的许多重要缓存中删除项目。让我们来看一下:
- 我们将构建我们的
prometheusExporterExample
以验证构建缓存的大小是否发生变化。我们可以使用 go 环境GOCACHE
变量找到我们的构建缓存位置:
-
对于我们的验证,我们将连续使用几个命令。首先,我们将使用
rm -rf ~/.cache/go-build/
删除整个缓存目录。 -
接下来,我们可以通过运行
go build prometheusExporterExample.go
命令来构建我们的 Go 二进制文件。 -
然后,我们可以通过使用
du -sh ~/.cache/go-build/
检查其大小来验证缓存的大小是否显著增加。 -
现在,我们可以使用 go clean 程序来清除缓存,即
go clean -cache -modcache -i -r 2&>/dev/null
。
需要注意的是,一些缓存信息存储在主要库中,因此普通用户无法删除。如果需要,我们可以通过以超级用户身份运行 clean 命令来绕过这个问题,但这通常不被推荐。
然后,我们可以验证缓存的大小是否减小。如果我们在清理后查看缓存目录,我们会发现缓存目录中只剩下三个项目:
-
一个解释目录的
README
文件。 -
有一个
log.txt
文件告诉我们有关缓存信息。 -
一个
trim.txt
文件,告诉我们上次完成缓存修剪的时间。在下面的截图中,我们可以看到一个清理后的构建缓存:
验证构建的正确缓存将加快构建过程并使开发体验更加轻松。
在下一节中,我们将看一下go get
和go mod
命令。
使用 go get 和 go mod 检索包依赖项
在构建 Go 程序时,您可能会遇到希望添加依赖项的地方。go get
下载并安装包及其依赖项。go get
的调用语法是go get [-d] [-f] [-t] [-u] [-v] [-fix] [-insecure] [build flags] [packages]
。
Go 1.11 增加了对 Go 模块的初步支持。我们在第六章中学习了如何在Go 模块部分中利用 Go 模块。
由于我们可以在我们的 Go 程序中使用打包的依赖项,因此 Go mod vendor 通常作为 Go 构建系统的一部分。在您的代码库中打包依赖项有积极和消极的方面。在构建时本地可用所有必需的依赖项可以加快构建速度。如果您用于构建依赖项的上游存储库发生更改或被删除,您将遇到构建失败。这是因为您的程序将无法满足其上游依赖项。
打包依赖项的消极方面包括,打包依赖项将使程序员负责保持包的最新状态 - 来自上游的更新,如安全更新、性能改进和稳定性增强可能会丢失,如果依赖项被打包而没有更新。
许多企业采用打包的方法,因为他们认为存储所有必需的依赖项的安全性胜过了需要从上游更新打包目录中的新包。
初始化 go 模块后,我们将我们的依赖项打包并使用我们的打包模块构建它们:
如前面的输出所示,我们有需要满足项目构建约束的依赖项(来自github.com/
和golang.org/
)。我们可以在我们的构建中使用go mod tidy
来验证go.mod
是否包含了仓库的所有必要元素。
go mod tidy
添加丢失的模块并删除未使用的模块,以验证我们的源代码与目录的go.mod
匹配。
在接下来的部分中,我们将学习go list
命令。
Go list
go list
执行列出命名的包和模块的操作,并显示有关文件、导入和依赖项的重要构建信息。go list
的调用语法是usage: go list [-f format] [-json] [-m] [list flags] [build flags] [packages]
。
拥有访问构建过程的主要数据结构的权限是强大的。我们可以使用go list
来了解我们正在构建的程序的很多信息。例如,考虑以下简单的程序,它打印一条消息并为最终用户计算平方根:
package main
import (
"fmt"
"math"
)
func main() {
fmt.Println("Hello Gophers")
fmt.Println(math.Sqrt(64))
}
如果我们想了解我们特定项目的所有依赖项,我们可以调用go list -f '{{.Deps}}'
命令。
结果将是我们的存储库包含的所有依赖项的切片:
go list
数据结构可以在这里找到:golang.org/cmd/go/#hdr-List_packages_or_modules
。它有许多不同的参数。从 go list 程序中得到的另一个流行输出是 JSON 格式的输出。在下面的截图中,您可以看到执行go list -json
对我们的listExample.go
的输出:
go list -m -u all
也会显示您的依赖项。如果它们有可用的升级,结果输出中还会列出第二个版本。如果我们想要密切监视我们的依赖项及其升级,使用go mod
包可能会有所帮助。
如果我们使用我们的 Prometheus 导出器示例,我们可以看到我们的包是否有需要升级的依赖关系:
在这个例子中,我们可以看到有几个包可以升级。如果我们为其中一个依赖项调用 go get,我们将能够有效地升级它们。我们可以使用go get github.com/pkg/[email protected]
将前面截图中列出的 errors 包从 v0.8.0 升级到 v0.8.1。
完成这次升级后,我们可以通过运行go list -m -u github.com/pkg/errors
来验证依赖项是否已经升级。
我们可以在下面的截图中看到这个输出:
在我们之前的输出中,我们可以看到被引用的 errors 包现在是 v0.8.1,而不是我们之前输出中显示的 v0.8.0。
接下来,让我们看看go run
是什么。
Go run – 执行您的包
go run
运行并编译一个命名的 Go 程序。go run
的调用标准是go run [build flags] [-exec xprog] package [arguments...]
。
Go run 允许开发人员快速编译和运行一个 go 二进制文件。在这个过程中,go run
构建可执行文件,运行它,然后删除可执行文件。这在开发环境中特别有帮助。当您快速迭代您的 Go 程序时,go run
可以用作一个快捷方式,以验证您正在更改的代码是否会产生您认为可以接受的构建产物。正如我们在本章前面学到的,许多这些工具的构建标志是一致的。
goRun.go
是可能的 go 程序中最简单的一个。它没有参数,只是一个空的main()
函数调用。我们使用这个作为一个例子,以展示这个过程没有额外的依赖或开销:
package main
func main() {}
我们可以通过执行go run -x goRun.go
命令来看到与go run
调用相关的工作输出。
当我们执行此操作时,我们将能够看到作为go run
程序的一部分调用的构建参数:
这应该看起来非常熟悉,因为输出与我们在 go build 示例中看到的输出非常相似。然后,我们可以看到我们的包被调用。
如果我们对我们的 Prometheus HTTP 服务器执行相同的操作,我们会看到我们的 Prometheus HTTP 服务器是通过执行go run
程序启动和运行的。在这个 go run 调用期间杀死进程后,我们会注意到我们的本地目录中没有存储任何二进制文件。go run
调用不会默认保存这些输出。
下一节中的 Go 命令(go install
)是本章的最后一个命令。让我们看看它是什么。
Go install – 安装您的二进制文件
go install
编译并安装一个命名的 Go 程序。go run
的调用标准是go install [-i] [build flags] [packages]
。
这些被导入到$GOPATH/pkg
。如果它们没有被修改,下次编译时将使用缓存的项目。go install 的结果输出是一个可执行文件,与使用 go build 命令编译的文件相同,安装在系统上的$GOBIN
路径上。例如,如果我们想要在我们的主机上安装我们的 Prometheus HTTP 服务器,我们可以调用 go install 命令,即GOBIN=~/prod-binaries/ go install -i prometheusExporterExample.go
。
设置我们的GOBIN
变量告诉编译器在编译完成后安装编译后的二进制文件的位置。go install 程序允许我们将二进制文件安装到我们的GOBIN
位置。-i
标志安装命名包的依赖项。我们可以在以下截图中看到这一点:
完成后,我们可以看到我们在示例中定义的GOBIN
位置有一个prometheusExporterExample
二进制文件可用。
在本章的即将到来的最后一节中,我们将看到如何使用 Docker 构建 Go 二进制文件。
使用 Docker 构建 Go 二进制文件
根据目标架构的不同,您可能希望使用 Docker 构建您的 Go 二进制文件,以保持可重现的构建,限制构建大小,并最小化服务的攻击向量。使用多阶段 Docker 构建可以帮助我们完成这项任务。
要执行这些操作,您必须安装最新版本的 Docker。我们将要使用的多阶段构建功能要求守护程序和客户端的 Docker 版本都为 17.05 或更高。您可以在docs.docker.com/install/
找到您的操作系统的最新版本的 Docker,以及安装说明。
考虑以下简单的包,它将一个调试消息记录到屏幕上:
package main
import "go.uber.org/zap"
func main() {
zapLogger: = zap.NewExample()
defer zapLogger.Sync()
zapLogger.Debug("Hi Gophers - from our Zap Logger")
}
如果我们想要在 Docker 容器中构建并执行它,同时最小化依赖关系,我们可以使用多阶段 Docker 构建。为此,我们可以执行以下步骤:
- 通过执行以下操作将当前目录初始化为模块的根:
go mod init github.com/bobstrecansky/HighPerformanceWithGo/11-deploying-go-code/multiStageDockerBuild
- 通过执行以下命令添加
vendor
存储库:
go mod vendor
现在我们的存储库中有所有必需的 vendor 包(在我们的情况下是 Zap 记录器)。可以在以下截图中看到:
- 构建我们的
zapLoggerExample
Docker 容器。我们可以使用以下 Dockerfile 构建我们的容器:
# Builder - stage 1 of 2
FROM golang:alpine as builder
COPY . /src
WORKDIR /src
RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -o zapLoggerExample
# Executor - stage 2 of 2
FROM alpine:latest
WORKDIR /src/
COPY --from=builder /src/zapLoggerExample .
CMD ["./zapLoggerExample"]
请注意,我们使用golang:alpine
镜像来构建 Go 二进制文件,因为它是包含成功构建我们的 Go 二进制文件所需的必要元素的最简单的 Docker 镜像之一。我们使用alpine:latest
镜像来执行 Go 二进制文件,因为它是包含成功运行我们的 Go 二进制文件所需的必要元素的最简单的 Docker 镜像之一。
在这个 Dockerfile 示例中,我们使用多阶段 Docker 构建来构建和执行我们的二进制文件。在第 1 阶段(构建阶段)中,我们使用 golang alpine 镜像作为基础。我们将当前目录中的所有文件复制到 Docker 容器的/src/
目录中,将/src/
设置为我们的工作目录,并构建我们的 Go 二进制文件。禁用 cgo,为我们的 Linux 架构构建,并添加我们在步骤 1中创建的 vendor 目录都可以帮助减小构建大小和时间。
在第 2 阶段(执行器阶段)中,我们使用基本的 alpine Docker 镜像,将/src/
设置为我们的工作目录,并将我们在第一阶段构建的二进制文件复制到这个 Docker 容器中。然后我们在这个 Docker 构建中执行我们的记录器作为最后的命令。
- 在我们收集了必要的依赖项之后,我们可以构建我们的 Docker 容器。我们可以通过执行以下命令来完成这个过程:
docker build -t zaploggerexample .
- 构建完成后,我们可以通过执行以下命令来执行 Docker 容器:
docker run -it --rm zaploggerexample
在以下截图中,您可以看到我们的构建和执行步骤已经完成:
在多阶段 Docker 容器中构建我们的 Go 程序可以帮助我们创建可重复的构建,限制二进制文件大小,并通过仅使用我们需要的部分来最小化我们服务的攻击向量。
总结
在本章中,我们学习了如何构建 Go 二进制文件。我们学会了如何有效和永久地做到这一点。我们还学会了如何理解和管理依赖关系,使用go run
测试 go 代码,并使用 go install 将 go 二进制文件安装到特定位置。了解这些二进制文件的工作原理将帮助您更有效地迭代您的代码。
在下一章中,我们将学习如何分析 Go 代码以找到功能瓶颈。
第十二章:Go 代码分析
分析是一种用于测量计算机系统中所使用资源的实践。通常进行分析以了解程序内的 CPU 或内存利用率,以便优化执行时间、大小或可靠性。在本章中,我们将学习以下内容:
-
如何使用
pprof
对 Go 中的请求进行分析 -
如何比较多个分析
-
如何阅读生成的分析和火焰图
进行分析将帮助您推断在函数内部可以进行哪些改进,以及在函数调用中个别部分所需的时间与整个系统相比有多少。
了解分析
对 Go 代码进行分析是确定代码基础中瓶颈所在的最佳方法之一。我们的计算机系统有物理限制(CPU 时钟速度、内存大小/速度、I/O 读/写速度和网络吞吐量等),但我们通常可以优化我们的程序,以更有效地利用我们的物理硬件。使用分析器对计算机程序进行分析后,将生成一份报告。这份报告通常称为分析报告,可以告诉您有关您运行的程序的信息。有许多原因可能会让您想了解程序的 CPU 和内存利用率。以下是一些例子:
CPU 性能分析的原因:
-
检查软件新版本的性能改进
-
验证每个任务使用了多少 CPU
-
限制 CPU 利用率以节省成本
-
了解延迟来自何处
内存分析的原因:
-
全局变量的不正确使用
-
未完成的 Goroutines
-
不正确的反射使用
-
大字符串分配
接下来我们将讨论探索仪器方法。
探索仪器方法
pprof
工具有许多不同的方法来将分析纳入您的代码。Go 语言的创建者希望确保它在实现编写高性能程序所需的分析方面简单而有效。我们可以在 Go 软件开发的许多阶段实现分析,包括工程、新功能的创建、测试和生产。
重要的是要记住,分析确实会增加一些性能开销,因为在运行的二进制文件中会持续收集更多的指标。许多公司(包括谷歌)认为这种权衡是可以接受的。为了始终编写高性能代码,增加额外的 5%的 CPU 和内存分析开销是值得的。
使用 go test 实施分析
您可以使用go test
命令创建 CPU 和内存分析。如果您想比较多次测试运行的输出,这可能很有用。这些输出通常会存储在长期存储中,以便在较长的日期范围内进行比较。要执行测试的 CPU 和内存分析,请执行go test -cpuprofile /tmp/cpu.prof -memprofile /tmp/mem.prof -bench
命令。
这将创建两个输出文件,cpu.prof
和mem.prof
,它们都将存储在/tmp/
文件夹中。稍后在本章的分析分析部分中可以使用这些生成的分析。
在代码中手动进行仪器分析
如果您想特别对代码中的特定位置进行分析,可以直接在该代码周围实施分析。如果您只想对代码的一小部分进行分析,如果您希望pprof
输出更小更简洁,或者如果您不想通过在已知的昂贵代码部分周围实施分析来增加额外开销,这可能特别有用。对代码基础的不同部分进行 CPU 和内存分析有不同的方法。
对特定代码块进行 CPU 利用率分析如下:
function foo() {
pprof.StartCPUProfile()
defer pprof.StopCPUProfile()
...
code
...
}
对特定代码块进行内存利用率分析如下:
function bar() {
runtime.GC()
defer pprof.WriteHeapProfile()
...
code
...
}
希望,如果我们设计有效,迭代有影响,并且使用下一节中的习语实现我们的分析,我们就不必实现代码的各个部分,但知道这始终是分析代码和检索有意义输出的潜在选择是很好的。
分析运行服务代码
在 Go 代码中实施分析的最常用方法是在 HTTP 处理程序函数中启用分析器。这对于调试实时生产系统非常有用。能够实时分析生产系统让您能够基于真实的生产数据做出决策,而不是基于您的本地开发环境。
有时,错误只会在特定规模的数据达到特定规模时发生。一个可以有效处理 1,000 个数据点的方法或函数,在其基础硬件上可能无法有效处理 1,000,000 个数据点。这在运行在不断变化的硬件上尤为重要。无论您是在具有嘈杂邻居的 Kubernetes 上运行,还是在具有未知规格的新物理硬件上运行,或者使用代码或第三方库的新版本,了解更改的性能影响对于创建可靠性和弹性至关重要。
能够从生产系统接收数据,其中您的最终用户及其数据的数量级可能大于您在本地使用的数量级,可以帮助您进行性能改进,影响最终用户,这可能是您在本地迭代时从未发现的。
如果我们想在我们的 HTTP 处理程序中实现pprof
库,我们可以使用net/http/pprof
库。这可以通过将_ "net/http/pprof"
导入到您的主包中来完成。
然后,您的 HTTP 处理程序将为您的分析注册 HTTP 处理程序。确保您不要在公开的 HTTP 服务器上执行此操作;您的程序概要会暴露一些严重的安全漏洞。pprof
包的索引显示了在使用此包时可用的路径。以下是pprof
工具索引的屏幕截图:
我们可以查看公开的 HTTP pprof
路径及其描述。路径和相关描述可以在以下表中找到:
名称 | HTTP 路径 | 描述 |
---|---|---|
allocs |
/debug/pprof/allocs |
内存分配信息。 |
block |
/debug/pprof/block |
Goroutines 阻塞等待的信息。这通常发生在同步原语上。 |
cmdline |
/debug/pprof/cmdline |
我们二进制命令行调用的值。 |
goroutine |
/debug/pprof/goroutine |
当前正在运行的 goroutines 的堆栈跟踪。 |
heap |
/debug/pprof/heap |
内存分配采样(用于监视内存使用和泄漏)。 |
mutex |
/debug/pprof/mutex |
有争议的互斥锁堆栈跟踪。 |
profile |
/debug/pprof/profile |
CPU 概要。 |
symbol |
/debug/pprof/symbol |
请求程序计数器。 |
threadcreate |
/debug/pprof/threadcreate |
操作系统线程创建堆栈跟踪。 |
trace |
/debug/pprof/trace |
当前程序跟踪。这将在第十三章中深入讨论,跟踪 Go 代码。 |
在下一节中,我们将讨论 CPU 分析。
CPU 分析简介
让我们对一个简单的 Go 程序执行一些示例分析,以了解分析器的工作原理。我们将创建一个带有一些休眠参数的示例程序,以便查看不同函数调用的时间:
- 首先,我们实例化我们的包并添加所有导入:
import (
"fmt"
"io"
"net/http"
_ "net/http/pprof"
"time"
)
- 接下来,在我们的
main
函数中,我们有一个 HTTP 处理程序,其中包含两个休眠函数,作为处理程序的一部分调用:
func main() {
Handler := func(w http.ResponseWriter, req *http.Request) {
sleep(5)
sleep(10)
io.WriteString(w, "Memory Management Test")
}
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
}
我们的sleep
函数只是睡眠了一段特定的毫秒数,并打印出结果输出:
func sleep(sleepTime int) {
time.Sleep(time.Duration(sleepTime) * time.Millisecond)
fmt.Println("Slept for ", sleepTime, " Milliseconds")
}
-
当我们运行我们的程序时,我们看到输出
go run httpProfiling.go
。要从这个特定的代码生成概要文件,我们需要调用curl -s "localhost:1234/debug/pprof/profile?seconds=10" > out.dump
。这将运行一个 10 秒钟的概要文件,并将结果返回到一个名为out.dump
的文件中。默认情况下,pprof
工具将运行 30 秒,并将二进制文件返回到STDOUT
。我们要确保我们限制这个测试的时间,以便测试持续时间合理,并且我们需要重定向输出,以便能够捕获一些有意义的内容在我们的分析工具中查看。 -
接下来,我们为我们的函数生成一个测试负载。我们可以使用 Apache Bench 来完成这个任务,生成 5,000 个并发为 10 的请求;我们使用
ab -n 5000 -c 10 http://localhost:1234/
来设置这个。 -
一旦我们得到了这个测试的输出,我们可以查看我们的
out.dump
文件,go tool pprof out.dump
。这将带您进入分析器。这是 C++分析器pprof
的一个轻微变体。这个工具有相当多的功能。 -
我们可以使用
topN
命令查看概要文件中包含的前N个样本,如下图所示:
在执行分析器时,Go 程序大约每秒停止 100 次。在此期间,它记录 goroutine 堆栈上的程序计数器。我们还可以使用累积标志(-cum)
,以便按照我们当前概要文件采样中的累积值进行排序:
- 我们还可以显示跟踪的可视化图形表示形式。确保安装了
graphviz
包(它应该包含在您的包管理器中,或者可以从www.graphviz.org/
下载,只需键入web
命令)
这将为我们提供一个从我们的程序内生成的概要文件的可视化表示:
概要文件中的红色框表示对请求流最有影响的代码路径。我们可以查看这些框,并且正如我们所期望的那样,我们可以看到我们的示例程序中有相当多的时间用于睡眠和向客户端写回响应。我们可以通过传递我们想要查看的函数的名称来以相同的 web 格式查看这些特定函数。例如,如果我们想要查看我们的sleep
函数的详细视图,我们只需键入(pprof) web sleep
命令。
- 然后我们将获得一个以睡眠调用为焦点的 SVG 图像:
- 在我们得到这个分解之后,我们可能想要查看睡眠函数实际执行了什么。我们可以使用
pprof
中的list
命令,以便获得对sleep
命令及其后续调用的调用进行分析的输出。以下屏幕截图显示了这一点;为了简洁起见,代码被缩短了:
通过对我们正在进行的工作进行分析并将其分解为可分段的块,可以告诉我们很多关于我们需要从利用角度采取的开发方向。
在下一节中,我们将看到内存分析是什么。
内存分析简介
我们可以对内存执行与我们在上一节中对 CPU 测试相似的操作。让我们看看另一种处理分析的方法,使用测试功能。让我们使用我们在第二章中创建的例子,数据结构和算法中的o-logn
函数。我们可以使用我们已经为这个特定函数创建的基准,并为这个特定的测试添加一些内存分析。我们可以执行go test -memprofile=heap.dump -bench
命令。
我们将看到与我们在第二章中看到的类似的输出,数据结构和算法:
唯一的区别是现在我们将从这个测试中得到堆剖析。如果我们用分析器查看它,我们将看到关于堆使用情况的数据,而不是 CPU 使用情况。我们还将能够看到该程序中每个函数的内存分配情况。以下图表说明了这一点:
这很有帮助,因为它使我们能够看到代码中每个部分生成的堆大小。我们还可以查看累积内存分配的前几名:
随着我们的程序变得更加复杂,理解内存利用情况变得越来越重要。在下一节中,我们将讨论如何通过上游pprof
扩展我们的分析能力。
上游 pprof 的扩展功能
如果我们想要默认使用额外的功能,我们可以使用上游的pprof
二进制文件来扩展我们的分析视图:
-
我们可以通过调用
go get github.com/google/pprof
来获取这个。pprof
工具有几种不同的调用方法。我们可以使用报告生成方法来生成所请求格式的文件(目前支持.dot
、.svg
、.web
、.png
、.jpg
、.gif
和.pdf
格式)。我们还可以像在前几节关于 CPU 和内存分析中所做的那样,使用交互式终端格式。最后,最常用的方法是使用 HTTP 服务器。这种方法涉及在一个易于消化的格式中托管包含大部分相关输出的 HTTP 服务器。 -
一旦我们通过
go get
获取了二进制文件,我们可以使用 web 界面调用它,查看我们之前生成的输出:pprof -http=:1234 profile.dump
。 -
然后我们可以访问新提供的 UI,看看默认的
pprof
工具中没有内置的功能和功能。这个工具提供的一些关键亮点如下:
-
一个正则表达式可搜索的表单字段,以帮助搜索必要的分析元素
-
一个下拉式视图菜单,方便查看不同的分析工具
-
一个样本下拉菜单,显示来自剖析的样本
-
一个细化的过滤器,用于隐藏/显示请求流的不同部分
拥有所有这些工具来进行分析有助于使分析过程更加流畅。如果我们想要查看运行任何带有fmt
名称的调用所花费的时间,我们可以使用带有正则表达式过滤器的示例视图,它将突出显示fmt
调用,正如我们在下面的截图中所看到的那样:
根据这些值进行过滤可以帮助缩小性能不佳函数的范围。
比较多个分析
分析的一个非常好的特性是可以将不同的分析进行比较。如果我们从同一个程序中有两个单独的测量,我们可以确定我们所做的更改是否对系统产生了积极的影响。让我们稍微改进一下我们的 HTTP 睡眠定时函数:
- 让我们添加一些额外的导入:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"strconv"
"time"
)
- 接下来,我们将增强我们的处理程序以接受
time
的查询字符串参数:
func main() {
Handler := func(w http.ResponseWriter, r *http.Request) {
sleepDuration := r.URL.Query().Get("time")
sleepDurationInt, err := strconv.Atoi(sleepDuration)
if err != nil {
fmt.Println("Incorrect value passed as a query string for time")
return
}
sleep(sleepDurationInt)
fmt.Fprintf(w, "Slept for %v Milliseconds", sleepDuration)
}
http.HandleFunc("/", Handler)
http.ListenAndServe(":1234", nil)
}
- 我们将保持我们的睡眠函数完全相同:
func sleep(sleepTime int) {
time.Sleep(time.Duration(sleepTime) * time.Millisecond)
fmt.Println("Slept for ", sleepTime, " Milliseconds")
}
- 现在我们有了这个额外的功能,我们可以通过向我们的 HTTP 处理程序传递查询参数来使用不同时间进行多个配置文件的采集:
- 我们可以运行我们的新定时配置工具:
go run timedHttpProfiling.go
-
- 在另一个终端中,我们可以启动我们的配置工具:
curl -s "localhost:1234/debug/pprof/profile?seconds=20" > 5-millisecond-profile.dump
-
- 然后我们可以对我们的新资源进行多次请求:
ab -n 10000 -c 10 http://localhost:1234/?time=5
-
- 然后我们可以收集第二个配置文件:
curl -s "localhost:1234/debug/pprof/profile?seconds=20" > 10-millisecond-profile.dump
-
- 然后我们对我们的新资源进行第二次请求,生成第二个配置文件:
ab -n 10000 -c 10 http://localhost:1234/?time=10
- 现在我们有两个单独的配置文件,分别存储在
5-millisecond-profile.dump
和10-millisecond-profile.dump
中。我们可以使用与之前相同的工具进行比较,设置一个基本配置文件和一个次要配置文件。以下截图说明了这一点:
比较配置文件可以帮助我们了解变化如何影响我们的系统。
让我们继续下一节的火焰图。
解释 pprof 中的火焰图
在上游pprof
包中最有帮助/有用的工具之一是火焰图。火焰图是一种固定速率采样可视化,可以帮助确定配置文件中的热代码路径。随着您的程序变得越来越复杂,配置文件变得越来越大。往往很难知道到底哪段代码路径占用了最多的 CPU,或者我经常称之为帐篷中的长杆。
火焰图最初是由 Netflix 的 Brendan Gregg 开发的,用于解决 MySQL 的 CPU 利用率问题。这种可视化的出现帮助许多程序员和系统管理员确定程序中延迟的来源。pprof
二进制文件生成一个 icicle-style(火焰向下指)火焰图。在火焰图中,我们有特定帧中的数据可视化。
-
x轴是我们请求的所有样本的集合
-
y 轴显示了堆栈上的帧数,通常称为堆栈深度
-
方框的宽度显示了特定函数调用使用的总 CPU 时间
这三个东西一起可视化有助于确定程序的哪一部分引入了最多的延迟。您可以访问pprof
配置文件的火焰图部分,网址为http://localhost:8080/ui/flamegraph
。以下图片显示了一个火焰图的示例:
如果我们看看第二章中的bubbleSort
示例,数据结构和算法,我们可以看到在我们的测试中占用 CPU 时间的不同部分。在交互式网络模式中,我们可以悬停在每个样本上,并验证它们的持续时间和百分比执行时间。
在接下来的部分中,我们将看到如何检测 Go 中的内存泄漏。
检测 Go 中的内存泄漏
正如第八章中Go 内存管理部分所讨论的,我们有很多工具可以查看当前正在执行的程序的内存统计信息。在本章中,我们还将学习使用 pprof 工具进行配置文件。Go 中更常见的内存泄漏之一是无限创建 goroutine。当您过载一个非缓冲通道或者有一个具有大量并发生成新 goroutine 的抽象时,这种情况经常发生。Goroutine 的占用空间非常小,系统通常可以生成大量的 goroutine,但最终会有一个上限,在生产环境中调试程序时很难找到。
在下面的示例中,我们将查看一个有泄漏抽象的非缓冲通道:
- 我们首先初始化我们的包并导入我们需要的依赖项:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"runtime"
"time"
)
- 在我们的主函数中,我们处理 HTTP 监听和为
leakyAbstraction
函数提供服务。我们通过 HTTP 提供这个服务,以便简单地看到 goroutines 的数量增长:
func main() {
http.HandleFunc("/leak", leakyAbstraction)
http.ListenAndServe("localhost:6060", nil)
}
- 在我们的
leakyAbstraction
函数中,我们首先初始化一个无缓冲的字符串通道。然后我们通过一个 for 循环无休止地迭代,将 goroutines 的数量写入 HTTP 响应写入器,并将我们的wait()
函数的结果写入通道:
func leakyAbstraction(w http.ResponseWriter, r *http.Request) {
ch := make(chan string)
for {
fmt.Fprintln(w, "Number of Goroutines: ", runtime.NumGoroutine())
go func() { ch <- wait() }()
}
}
- 我们的
wait()
函数休眠五微秒并返回一个字符串:
func wait() string {
time.Sleep(5 * time.Microsecond)
return "Hello Gophers!"
}
这些函数一起将生成 goroutines,直到运行时不再能够这样做并死亡。我们可以通过执行以下命令来测试这一点:
go run memoryLeak.go
服务器运行后,在一个单独的终端窗口中,我们可以使用以下命令向服务器发出请求:
curl localhost:6060/leak
curl
命令将打印生成的 goroutines 数量,直到服务器被关闭:
请注意,根据您系统的规格,此请求可能需要一段时间。这没关系——它说明了您的程序可用于使用的 goroutines 数量。
使用我们在本章学到的技术,我们将能够进一步调试类似这样的内存问题,但理解潜在的问题将帮助我们避免内存问题。
这个例子是为了明确展示内存泄漏,但如果我们想要使这个可执行文件不泄漏 goroutines,我们需要修复两件事:
-
我们的无限循环很可能应该有一个限制
-
我们可以添加一个带缓冲的通道,以确保我们有能力处理通过通道进入的所有生成的 goroutines
总结
在本章中,我们学习了关于 profiles 的知识——profiles 是什么,以及如何使用pprof
生成 profiles。您还学会了如何使用不同的方法分析 profiles,如何比较 profiles,以及如何阅读性能的火焰图。能够在生产环境中执行这个操作将帮助您保持稳定,提高性能,并为最终用户提供更好的用户体验。在下一章中,我们将讨论另一种分析代码的方法——跟踪。
第十三章:跟踪 Go 代码
跟踪 Go 程序是检查 Go 程序中函数和服务之间的互操作性的一种绝妙方式。跟踪允许您通过系统传递上下文,并评估您被阻止的位置,无论是由第三方 API 调用、缓慢的消息队列还是O(n²)函数。跟踪将帮助您找到这个瓶颈所在。在本章中,我们将学习以下内容:
-
实施跟踪的过程
-
使用跟踪进行采样的过程
-
解释跟踪的过程
-
比较跟踪的过程
能够实施跟踪并解释结果将帮助开发人员理解和排除故障他们的分布式系统。
实施跟踪仪器
Go 的并发模型使用 goroutines,非常强大。高并发的一个缺点是,当您尝试调试高并发模型时,您会遇到困难。为了避免这种困难,语言创建者创建了go tool trace
。然后他们在 Go 版本 1.5 中分发了这个工具,以便能够调查和解决并发问题。Go 跟踪工具钩入 goroutine 调度程序,以便能够提供有关 goroutines 的有意义信息。您可能希望使用 Go 跟踪调查的一些实现细节包括以下内容:
-
延迟
-
资源争用
-
并行性差
-
与 I/O 相关的事件
-
系统调用
-
通道
-
锁
-
垃圾收集 (GC)
-
Goroutines
解决所有这些问题将帮助您构建一个更具弹性的分布式系统。在下一节中,我们将讨论跟踪格式以及它如何适用于 Go 代码。
理解跟踪格式
Go 跟踪可以提供大量信息,并且可以捕获大量请求每秒。因此,跟踪以二进制格式捕获。跟踪输出的结构是静态的。在以下输出中,我们可以看到跟踪遵循特定的模式-它们被定义,并且事件被用十六进制前缀和有关特定跟踪事件的一些信息进行分类。查看这个跟踪格式将帮助我们理解我们的跟踪事件如何存储和如何使用 Go 团队为我们提供的工具检索:
Trace = "gotrace" Version {Event} .
Event = EventProcStart | EventProcStop | EventFreq | EventStack | EventGomaxprocs | EventGCStart | EventGCDone | EventGCScanStart | EventGCScanDone | EventGCSweepStart | EventGCSweepDone | EventGoCreate | EventGoStart | EventGoEnd | EventGoStop | EventGoYield | EventGoPreempt | EventGoSleep | EventGoBlock | EventGoBlockSend | EventGoBlockRecv | EventGoBlockSelect | EventGoBlockSync | EventGoBlockCond | EventGoBlockNet | EventGoUnblock | EventGoSysCall | EventGoSysExit | EventGoSysBlock | EventUser | EventUserStart | EventUserEnd .
EventProcStart = "\x00" ProcID MachineID Timestamp .
EventProcStop = "\x01" TimeDiff .
EventFreq = "\x02" Frequency .
EventStack = "\x03" StackID StackLen {PC} .
EventGomaxprocs = "\x04" TimeDiff Procs .
EventGCStart = "\x05" TimeDiff StackID .
EventGCDone = "\x06" TimeDiff .
EventGCScanStart= "\x07" TimeDiff .
EventGCScanDone = "\x08" TimeDiff .
EventGCSweepStart = "\x09" TimeDiff StackID .
EventGCSweepDone= "\x0a" TimeDiff .
EventGoCreate = "\x0b" TimeDiff GoID PC StackID .
EventGoStart = "\x0c" TimeDiff GoID .
EventGoEnd = "\x0d" TimeDiff .
EventGoStop = "\x0e" TimeDiff StackID .
EventGoYield = "\x0f" TimeDiff StackID .
EventGoPreempt = "\x10" TimeDiff StackID .
EventGoSleep = "\x11" TimeDiff StackID .
EventGoBlock = "\x12" TimeDiff StackID .
EventGoBlockSend= "\x13" TimeDiff StackID .
EventGoBlockRecv= "\x14" TimeDiff StackID .
EventGoBlockSelect = "\x15" TimeDiff StackID .
EventGoBlockSync= "\x16" TimeDiff StackID .
EventGoBlockCond= "\x17" TimeDiff StackID .
EventGoBlockNet = "\x18" TimeDiff StackID .
EventGoUnblock = "\x19" TimeDiff GoID StackID .
EventGoSysCall = "\x1a" TimeDiff StackID .
EventGoSysExit = "\x1b" TimeDiff GoID .
EventGoSysBlock = "\x1c" TimeDiff .
EventUser = "\x1d" TimeDiff StackID MsgLen Msg .
EventUserStart = "\x1e" TimeDiff StackID MsgLen Msg .
EventUserEnd = "\x1f" TimeDiff StackID MsgLen Msg .
有关 Go 执行跟踪器的更多信息可以在 Dmitry Vyukov 发布的原始规范文档中找到docs.google.com/document/u/1/d/1FP5apqzBgr7ahCCgFO-yoVhk4YZrNIDNf9RybngBc14/pub
。
能够看到跟踪的所有这些元素将帮助我们理解如何将跟踪分解为原子块。在下一节中,我们将讨论跟踪收集。
理解跟踪收集
能够收集跟踪是实施分布式系统中跟踪的重要部分。如果我们不在某个地方汇总这些跟踪,我们将无法在规模上理解它们。我们可以使用三种方法收集跟踪数据:
-
通过调用
trace.Start
和trace.Stop
手动调用数据的跟踪 -
使用测试标志
-trace=[OUTPUTFILE]
-
对
runtime/trace
包进行仪器化
为了了解如何在代码周围实施跟踪,让我们看一个简单的示例程序:
- 我们首先实例化我们的包并导入必要的包:
package main
import (
"os"
"runtime/trace"
)
- 然后我们调用我们的
main
函数。我们将跟踪输出写入一个名为trace.out
的文件,稍后我们将使用它:
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
- 接下来,我们实现我们想要在程序中使用的跟踪,并在函数返回时推迟跟踪的结束:
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
- 然后我们编写我们想要实现的代码。我们这里的示例只是在匿名函数中通过通道简单地传递字符串
"Hi Gophers"
:
ch := make(chan string)
go func() {
ch <- "Hi Gophers"
}()
<-ch
}
现在我们已经在我们的(诚然简单的)程序周围实施了跟踪,我们需要执行我们的程序以产生跟踪输出:
-
要查看跟踪,您可能需要安装额外的软件包。对于我正在测试的 Fedora 系统,我不得不安装额外的
golang-misc
软件包:sudo dnf install golang-misc
。 -
创建跟踪后,您可以使用
go tool trace trace.out
命令打开您创建的跟踪。
这使您可以启动将提供跟踪输出的 HTTP 服务器。我们可以在下面的截图中看到这个输出:
我们可以在 Chrome 浏览器中看到生成的跟踪输出。重要的是要提到,我们需要使用兼容的浏览器,即 Chrome。在撰写本书时,Firefox 会产生一个空白页面的跟踪输出。这是在 Chrome 浏览器中的跟踪输出:
这个 HTML 页面为您提供了许多不同的有用输出选择。让我们逐个在下表中查看它们:
链接 | 描述 |
---|---|
查看跟踪 | 查看 GUI 跟踪输出。 |
Goroutine 分析 | 显示不同的 goroutine 信息。 |
网络阻塞概要 | 显示网络阻塞;可以创建单独的概要。 |
同步阻塞概要 | 显示同步阻塞;可以创建单独的概要。 |
系统调用阻塞概要 | 显示系统调用阻塞;可以创建单独的概要。 |
调度器延迟概要 | 显示与调度器相关的所有延迟;可以创建单独的概要。 |
用户定义的任务 | 允许查看任务数据类型;用于跟踪用户定义的逻辑操作。这是使用格式 trace.NewTask() 调用的。 |
用户定义的区域 | 允许查看区域数据类型;用于跟踪代码区域。这是使用格式 trace.WithRegion() 调用的。 |
最小 mutator 利用率 | 创建一个可视化图表,显示垃圾收集器从程序中窃取工作的位置和时间。这有助于您了解您的生产服务是否受到 GC 的限制。 |
我们可以先在网页浏览器中查看跟踪:
当我们查看这些跟踪时,我们可以做的第一件事是查看帮助菜单,它位于屏幕右上角的问号框中。这个信息菜单为我们提供了有关跟踪工具能力的许多描述:
能够快速有效地在跟踪窗口中移动将帮助您快速查看跟踪。当您试图快速解决生产问题时,这可能非常有帮助。
跟踪窗口中的移动
使用经典的 WASD 移动键(受到许多第一人称角色扮演视频游戏的启发),我们可以在跟踪中移动。移动键的描述如下:
-
按下 W 键,可以放大跟踪的时间窗口。
-
按下 S 键缩小。
-
按下 A 键向后移动时间。
-
按下 D 键向前移动时间。我们也可以通过点击和拖动鼠标向前和向后移动时间。
使用鼠标指针选择器或点击数字键可以操作时间信息。键盘更改列在以下项目符号中:
-
按下 1 键让我们选择要检查的跟踪部分
-
按下 2 键可以平移
-
按下 3 键调用放大功能
-
按下 4 键可以选择特定的时间
现在我们可以使用 / 键搜索跟踪,使用 Enter 键浏览结果。
我们还有文件大小统计、指标、帧数据和右侧屏幕上可用的输入延迟窗口。单击这些按钮将打开一个弹出窗口,告诉您有关跟踪中每个特定统计信息的更多细节。
如果我们在跟踪中的 goroutines 行中点击蓝色区域,我们可以查看一些我们的 goroutines 可用统计信息:
-
GCWaiting
,即正在等待的垃圾收集运行数量(当前值为 0) -
当前可运行的 goroutines 数量为 1
-
当前正在运行的 goroutines 数量为 1
我们可以在以下截图中看到我们的 goroutines 的可用统计信息:
goroutine 信息对于最终用户调试程序可能有所帮助。在 Go 跟踪工具中观察 goroutines 可以帮助我们确定 goroutine 何时在争用。它可能正在等待通道清除,可能被系统调用阻塞,或者可能被调度程序阻塞。如果有许多 goroutines 处于等待状态,这意味着程序可能创建了太多的 goroutines。这可能导致调度程序被过度分配。拥有所有这些信息可以帮助我们做出明智的决定,以更有效地编写程序来利用 goroutines。
单击堆行中的橙色条将显示堆信息:
在所选时间(0.137232)时,我们可以看到我们的堆分配了 425984 字节,或大约 425 KB。了解当前分配给堆的内存量可以告诉我们我们的程序是否存在内存争用。剖析(正如我们在第十二章中学到的,Go 代码的剖析)通常是查看堆信息的更好方法,但在跟踪上下文中对分配有一个一般的了解通常是有帮助的。
接下来我们可以查看线程信息。单击跟踪中线程行中的活动线程(跟踪的 Threads 行中的洋红色块)将显示处于 InSyscall 和 Running 状态的线程数量:
了解正在运行的 OS 线程数量以及当前有多少个线程被系统调用阻塞可能会有所帮助。
接下来,我们可以查看正在运行的每个单独进程。单击进程将显示以下截图中显示的所有详细信息。如果将鼠标悬停在跟踪底部窗格中的事件之一上,您将能够看到进程如何相互关联,如以下截图中的红色箭头所示:
了解您的进程的端到端流程通常可以帮助您诊断问题进程。在下一节中,我们将学习如何探索类似 pprof 的跟踪。
探索类似 pprof 的跟踪
Go 工具跟踪也可以生成四种不同类型的跟踪,这可能与您的故障排除需求相关:
-
net
:一个网络阻塞配置文件 -
sync
:一个同步阻塞的配置文件 -
syscall
:一个系统调用阻塞配置文件 -
sched
:一个调度器延迟配置文件
让我们看看如何在 Web 服务器上使用这些跟踪配置文件的示例:
- 首先,我们初始化我们的
main
并导入必要的包。请注意,对于_ "net/http/pprof"
中的显式包名称,使用了空白标识符。这是为了确保我们可以进行跟踪调用:
package main
import (
"io"
"net/http"
_ "net/http/pprof"
"time"
)
- 接下来,我们设置一个简单的 Web 服务器,等待五秒钟并向最终用户返回一个字符串:
func main() {
handler := func(w http.ResponseWriter, req *http.Request) {
time.Sleep(5 * time.Second)
io.WriteString(w, "Network Trace Profile Test")
}
http.HandleFunc("/", handler)
http.ListenAndServe(":1234", nil)
}
- 在执行
go run netTracePprof.go
后运行服务器后,我们可以进行跟踪:curl localhost:1234/debug/pprof/trace?seconds=10 > trace.out
。我们可以在以下截图中看到我们的curl
的输出:
- 同时,在另一个终端中,我们可以对我们示例的 Web 服务器的
/
路径进行请求:curl localhost:1234/
。然后我们将在运行跟踪的目录中返回一个trace.out
文件。然后我们可以使用go tool trace trace.out
打开我们的跟踪。然后我们将看到我们的跟踪结果。在生成的 HTTP 页面中利用网络阻塞配置文件,我们可以看到网络阻塞配置文件的跟踪:
正如预期的那样,我们看到了五秒的等待,因为这是我们为这个特定的 web 请求在处理程序函数中添加的等待时间。如果我们愿意,我们可以下载这个配置文件,并在我们在第十二章中讨论的上游pprof
工具中查看它,Go 代码性能分析。在跟踪 HTML 窗口中,有一个下载按钮,旁边是 web 配置文件:
在我们下载了这个配置文件之后,我们可以使用我们在第十二章中安装的上游pprof
工具来查看它,Go 代码性能分析:
$ pprof -http=:1235 ~/Downloads/io.profile
然后我们可以看一下火焰图:
我们可以在以下截图中看到 peek UI:
火焰图和 peek UI 都有助于使这些复杂的调试视图变得更加简洁。在下一节中,我们将看到 Go 中的分布式跟踪是什么。
Go 分布式跟踪
为 Go 程序实现和调查单个跟踪可能是一项富有成效的工作,可以提供大量关于导致我们程序请求的数据的输出。随着企业拥有越来越多的分布式代码库,以及更多相互操作的复杂调用,追踪单个调用在长期内变得不可行。有两个项目试图帮助 Go 进行分布式跟踪,它们分别是 OpenCensus Go 库和 OpenTelemetry 库:
-
opencensus-go
:github.com/census-instrumentation/opencensus-go
-
opentracing-go
:github.com/opentracing/opentracing-go
这些项目的维护者已决定将这两个项目合并,并开始在一个名为 OpenTelemetry 的代码库上进行工作。这个新的代码库将允许在许多语言和基础设施中简化集成分布式跟踪。您可以在github.com/open-telemetry/opentelemetry-go
了解更多关于 OpenTelemetry 的 Go 实现。
在撰写本书时,OpenTelemetry 尚未准备好供生产使用。OpenTelemetry 将向后兼容 OpenCensus 和 OpenTracing,并提供安全补丁。在本书的下一节中,我们将看看如何使用 OpenCensus 实现 Go 程序。将来,使用我们将要讨论的实现 OpenCensus 跟踪的策略,使用 OpenTelemetry 实现您的程序应该是相对简单的。
在接下来的部分,我们将看到如何为我们的应用程序实现 OpenCensus。
为您的应用程序实现 OpenCensus
让我们用一个实际的例子来介绍在应用程序中使用 OpenCensus 跟踪。要开始,我们需要确保我们的机器上安装了 Docker。您可以使用docs.docker.com/
上的安装文档来确保 Docker 已安装并在您的机器上正确运行。完成后,我们可以开始创建、实现和查看一个示例应用程序。安装了 Docker 后,我们可以拉取我们的仪器的重要镜像。在我们的示例中,我们将使用 Redis(一个键值存储)来存储应用程序中的键值事件,并使用 Zipkin(一个分布式跟踪系统)来查看这些跟踪。
让我们拉取这个项目的依赖项:
- Redis 是我们将在示例应用程序中使用的键值存储:
docker pull redis:latest
- Zipkin 是一个分布式跟踪系统:
docker pull openzipkin/zipkin
- 我们将启动我们的 Redis 服务器,并让它在后台运行:
docker run -it -d -p 6379:6379 redis
- 我们将为我们的 Zipkin 服务器做同样的事情:
docker run -it -d -p 9411:9411 openzipkin/zipkin
一旦我们安装并准备好所有依赖项,我们就可以开始编写我们的应用程序:
- 首先,我们将实例化我们的
main
包并添加必要的导入:
package main
import (
"context"
"log"
"net/http"
"time"
"contrib.go.opencensus.io/exporter/zipkin"
"go.opencensus.io/trace"
"github.com/go-redis/redis"
openzipkin "github.com/openzipkin/zipkin-go"
zipkinHTTP "github.com/openzipkin/zipkin-go/reporter/http"
)
- 我们的
tracingServer
函数定义了一些内容:
-
我们设置了一个新的 Zipkin 端点。
-
我们初始化一个新的 HTTP 报告器,这是我们发送跨度的端点。
-
我们设置了一个新的导出器,它返回一个
trace.Exporter
(这是我们将跨度上传到 Zipkin 服务器的方式)。 -
我们将我们的导出器注册到跟踪处理程序。
-
我们应用了采样率的配置。在这个例子中,我们设置我们的示例始终跟踪,但我们可以将其设置为我们请求的较小百分比:
func tracingServer() {
l, err := openzipkin.NewEndpoint("oc-zipkin", "192.168.1.5:5454")
if err != nil {
log.Fatalf("Failed to create the local zipkinEndpoint: %v", err)
}
r := zipkinHTTP.NewReporter("http://localhost:9411/api/v2/spans")
z := zipkin.NewExporter(r, l)
trace.RegisterExporter(z)
trace.ApplyConfig(trace.Config{DefaultSampler: trace.AlwaysSample()})
}
- 在我们的
makeRequest
函数中,我们执行以下操作:
-
创建一个新的
span
-
向给定的 HTTP URL 发出请求
-
设置睡眠超时以模拟额外的延迟
-
注释我们的跨度
-
返回响应状态
func makeRequest(ctx context.Context, url string) string {
log.Printf("Retrieving URL")
_, span := trace.StartSpan(ctx, "httpRequest")
defer span.End()
res, _ := http.Get(url)
defer res.Body.Close()
time.Sleep(100 * time.Millisecond)
log.Printf("URL Response : %s", res.Status)
span.Annotate([]trace.Attribute{
trace.StringAttribute("URL Response Code", res.Status),
}, "HTTP Response Status Code:"+res.Status)
time.Sleep(50 * time.Millisecond)
return res.Status
}
- 在我们的
writeToRedis
函数中,我们执行以下操作:
-
开始一个新的跨度
-
连接到我们的本地 Redis 服务器
-
设置特定的键值对
func writeToRedis(ctx context.Context, key string, value string) {
log.Printf("Writing to Redis")
_, span := trace.StartSpan(ctx, "redisWrite")
defer span.End()
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
err := client.Set(key, value, 0).Err()
if err != nil {
panic(err)
}
}
- 然后我们使用我们的
main
函数将所有这些内容整合在一起:
func main() {
tracingServer()
ctx, span := trace.StartSpan(context.Background(), "main")
defer span.End()
for i := 0; i < 10; i++ {
url := "https://golang.org/"
respStatus := makeRequest(ctx, url)
writeToRedis(ctx, url, respStatus)
}
}
- 在我们通过执行
go run ocZipkin.go
调用我们的程序之后,我们可以查看我们的 Zipkin 服务器。如果我们选择我们跟踪列表中的一个跟踪,我们可以看到我们创建的跟踪:
如果我们点击一个跨度,我们可以进一步调查它:
我们可以看到我们代码中的httprequest
和rediswrite
函数的调用。随着我们在代码周围实现更多的跨度,我们将获得越来越大的跟踪,这将帮助我们诊断代码的延迟最严重的地方。
如果我们点击跟踪中的一个单独元素,我们可以看到我们在代码中编写的注释:
如果我们试图理解特定用户行为,注释可能会很有用。我们还可以看到traceId
、spanId
和parentId
的详细信息。
摘要
在本章中,我们学习了有关跟踪的所有内容。我们学会了如何在特定代码片段上实现单独的跟踪并分析它们以了解它们的行为。我们还学会了如何实现和分析分布式跟踪以了解分布式系统中的问题。能够使用这些技能将帮助您调试分布式系统,并进而帮助降低平均解决时间(MTTR)。
在第十四章中,集群和作业队列,我们将学习如何评估集群和作业队列以进行性能优化。
第十四章:簇和作业队列
在 Go 中的聚类和作业队列是使分布式系统同步工作并传递一致消息的好方法。分布式计算很困难,因此在聚类和作业队列中都非常重要地观察潜在的性能优化。
在本章中,我们将学习以下主题:
-
使用分层和质心算法进行聚类
-
Goroutines 作为队列
-
作业队列中的缓冲通道
-
实现第三方排队系统(Kafka 和 RabbitMQ)
了解不同的聚类系统可以帮助您识别数据中的大型群组,以及如何在数据集中准确对其进行分类。了解排队系统将帮助您将大量信息从数据结构传输到特定的排队机制,以便实时将大量数据传递给不同的系统。
Go 中的聚类
聚类是一种方法,您可以使用它来搜索给定数据集中一致的数据组。使用比较技术,我们可以寻找数据集中包含相似特征的项目组。然后将这些单个数据点划分为簇。聚类通常用于解决多目标问题。
聚类有两种一般分类,都有不同的子分类:
-
硬聚类:数据集中的数据点要么明确属于一个簇,要么明确不属于一个簇。硬聚类可以进一步分类如下:
-
严格分区:一个对象只能属于一个簇。
-
带异常值的严格分区:严格分区,还包括一个对象可以被分类为异常值的概念(意味着它们不属于任何簇)。
-
重叠聚类:个体对象可以与一个或多个簇相关联。
-
软聚类:根据明确的标准,数据点被分配与特定簇相关联的概率。它们可以进一步分类如下:
-
- 子空间:簇使用二维子空间,以便进一步分类为两个维度。
- 分层:使用分层模型进行聚类;与子簇相关联的对象也与父簇相关联。
还有许多不同类型的算法用于聚类。以下表格中显示了一些示例:
名称 | 定义 |
---|---|
分层 | 用于尝试构建簇的层次结构。通常基于自顶向下或自底向上的方法,试图将数据点分割为一对多个簇(自顶向下)或多对少个簇(自底向上)。 |
质心 | 用于找到作为簇中心的特定点位置。 |
密度 | 用于寻找数据集中具有数据点密集区域的位置。 |
分布 | 用于利用分布模型对簇内的数据点进行排序和分类。 |
在本书中,我们将专注于分层和质心算法,因为它们在计算机科学中(特别是在机器学习中)通常被使用。
K 最近邻
分层聚类是一种聚类方法,其中与子簇相关联的对象也与父簇相关联。该算法从数据结构中的所有单个数据点开始,分配到单个簇。最近的簇合并。这种模式持续进行,直到所有数据点都与另一个数据点相关联。分层聚类通常使用一种称为树状图的图表技术来显示。分层聚类的时间复杂度为O(n²),因此通常不用于大型数据集。
K 最近邻(KNN)算法是机器学习中经常使用的一种分层算法。在 Go 中查找 KNN 数据的最流行的方法之一是使用golearn
包。作为机器学习示例经常使用的经典 KNN 示例是鸢尾花的分类,可以在github.com/sjwhitworth/golearn/blob/master/examples/knnclassifier/knnclassifier_iris.go
中看到。
给定一个具有萼片和花瓣长度和宽度的数据集,我们可以看到关于该数据集的计算数据:
我们可以在此预测模型中看到计算出的准确度。在前面的输出中,我们有以下描述:
描述符 | 定义 |
---|---|
参考类 | 与输出相关联的标题。 |
真阳性 | 模型正确预测了正面响应。 |
假阳性 | 模型错误地预测了正面响应。 |
真阴性 | 模型正确预测了负面响应。 |
精确度 | 不将实际上是负面的实例标记为正面的能力。 |
召回率 | 真阳性/(真阳性总和+假阴性)的比率。 |
F1 分数 | 精确度和召回率的加权调和平均值。该值介于 0.0 和 1.0 之间,1.0 是该值的最佳可能结果。 |
最后但肯定不是最不重要的,我们有一个总体准确度,告诉我们算法如何准确地预测了我们的结果。
K-means 聚类
K-means 聚类是机器学习中最常用的聚类算法之一。K-means 试图识别数据集中数据点的潜在模式。在 K-means 中,我们将k定义为我们的聚类具有的质心数(具有均匀密度的对象的中心)。然后,我们根据这些质心对不同的数据点进行分类。
我们可以使用 K-means 库,在github.com/muesli/kmeans
中找到,对数据集执行 K-means 聚类。让我们来看一下:
- 首先,我们实例化
main
包并导入我们所需的包:
package main
import (
"fmt"
"log"
"math/rand"
"github.com/muesli/clusters"
"github.com/muesli/kmeans"
)
- 接下来,我们使用
createDataset
函数创建一个随机的二维数据集:
func createDataset(datasetSize int) clusters.Observations {
var dataset clusters.Observations
for i := 1; i < datasetSize; i++ {
dataset = append(dataset, clusters.Coordinates{
rand.Float64(),
rand.Float64(),
})
}
return dataset
}
- 接下来,我们创建一个允许我们打印数据以供使用的函数:
func printCluster(clusters clusters.Clusters) {
for i, c := range clusters {
fmt.Printf("\nCluster %d center points: x: %.2f y: %.2f\n", i, c.Center[0], c.Center[1])
fmt.Printf("\nDatapoints assigned to this cluster: : %+v\n\n", c.Observations)
}
}
在我们的main
函数中,我们定义了我们的聚类大小,数据集大小和阈值大小。
- 现在,我们可以创建一个新的随机 2D 数据集,并对该数据集执行 K-means 聚类。我们按如下方式绘制结果并打印我们的聚类:
func main() {
var clusterSize = 3
var datasetSize = 30
var thresholdSize = 0.01
rand.Seed(time.Now().UnixNano())
dataset := createDataset(datasetSize)
fmt.Println("Dataset: ", dataset)
km, err := kmeans.NewWithOptions(thresholdSize, kmeans.SimplePlotter{})
if err != nil {
log.Printf("Your K-Means configuration struct was not initialized properly")
}
clusters, err := km.Partition(dataset, clusterSize)
if err != nil {
log.Printf("There was an error in creating your K-Means relation")
}
printCluster(clusters)
}
执行此函数后,我们将能够看到我们的数据点分组在各自的聚类中:
在我们的结果中,我们可以看到以下内容:
-
我们的初始(随机生成的)2D 数据集
-
我们定义的三个聚类
-
分配给每个聚类的相关数据点
该程序还生成了每个聚类步骤的.png
图像。最后创建的图像是数据点聚类的可视化:
如果要将大型数据集分组为较小的组,K-means 聚类是一个非常好的算法。它的 O 符号是O(n),因此通常适用于大型数据集。K-means 聚类的实际应用可能包括以下的二维数据集:
-
使用 GPS 坐标在地图上识别犯罪多发区
-
为值班开发人员识别页面聚类
-
根据步数输出与休息天数的比较来识别运动员表现特征
在下一节中,让我们探索 Go 中的作业队列。
在 Go 中探索作业队列
作业队列经常用于在计算机系统中处理工作单元。它们通常用于调度同步和异步函数。在处理较大的数据集时,可能会有需要花费相当长时间来处理的数据结构和算法。系统正在处理非常大的数据段,应用于数据集的算法非常复杂,或者两者兼而有之。能够将这些作业添加到作业队列中,并以不同的顺序或不同的时间执行它们,对于维护系统的稳定性并为最终用户提供更好的体验非常有帮助。作业队列也经常用于异步作业,因为作业完成的时间对最终用户来说并不那么重要。如果实现了优先级队列,作业系统还可以对作业进行优先处理。这允许系统首先处理最重要的作业,然后处理没有明确截止日期的作业。
Goroutines 作为作业队列
也许您的特定任务并不需要作业队列。对于任务,使用 goroutine 通常就足够了。假设我们想在某个特定任务期间异步发送电子邮件。我们可以在我们的函数中使用 goroutine 发送这封电子邮件。
在这个例子中,我将通过 Gmail 发送电子邮件。为了做到这一点,您可能需要允许不太安全的应用程序访问电子邮件验证工作(myaccount.google.com/lesssecureapps?pli=1
)。这并不是长期推荐的做法;这只是一个展示真实世界电子邮件交互的简单方法。如果您有兴趣构建更健壮的电子邮件解决方案,您可以使用 Gmail API(developers.google.com/gmail/api/quickstart/go
)。让我们开始吧:
- 首先,我们将实例化我们的
main
包,并将必要的包导入到我们的示例程序中:
package main
import (
"log"
"time"
"gopkg.in/gomail.v2"
)
- 然后,我们将创建我们的
main
函数,它将执行以下操作:
-
记录一个
Doing Work
行(代表在我们的函数中做其他事情)。 -
记录一个
Sending Emails
行(代表电子邮件被添加到 goroutine 的时间)。 -
生成一个 goroutine 来发送电子邮件。
-
确保 goroutine 完成后再休眠(如果需要,我们也可以在这里使用
WaitGroup
):
func main() {
log.Printf("Doing Work")
log.Printf("Sending Emails!")
go sendMail()
time.Sleep(time.Second)
log.Printf("Done Sending Emails!")
}
在我们的sendMail
函数中,我们接收一个收件人,设置我们需要发送电子邮件的正确电子邮件头,并使用gomail
拨号器发送它。如果您希望看到此程序成功执行,您需要更改sender
、recipient
、username
和password
变量:
func sendMail() {
var sender = "[email protected]"
var recipient = "[email protected]"
var username = "[email protected]"
var password = "PASSWORD"
var host = "smtp.gmail.com"
var port = 587
email := gomail.NewMessage()
email.SetHeader("From", sender)
email.SetHeader("To", recipient)
email.SetHeader("Subject", "Test Email From Goroutine")
email.SetBody("text/plain", "This email is being sent from a Goroutine!")
dialer := gomail.NewDialer(host, port, username, password)
err := dialer.DialAndSend(email)
if err != nil {
log.Println("Could not send email")
panic(err)
}
}
从我们的输出结果中可以看出,我们能够有效地完成一些工作并发送电子邮件:
本书已经指出,执行任务的最有效方法通常是最简单的方法。如果不需要构建新的作业排队系统来执行简单的任务,就应该避免这样做。在大公司中,通常有专门的团队来维护大规模数据的作业队列系统。从性能和成本的角度来看,它们是昂贵的。它们通常是管理大规模数据系统的重要组成部分,但我觉得如果不提到在将分布式作业队列添加到技术栈之前应该仔细考虑,我会感到遗憾。
作业队列作为缓冲通道
Go 的缓冲通道是一个完美的工作队列示例。正如我们在第三章中学到的理解并发,缓冲通道是具有有界大小的通道。它们通常比无界通道更高效。它们用于从您启动的显式数量的 goroutine 中检索值。因为它们是先进先出(FIFO)的排队机制,它们可以有效地用作固定大小的排队机制,我们可以按照它们进来的顺序处理请求。我们可以使用缓冲通道编写一个简单的作业队列。让我们来看一下:
- 我们首先实例化我们的
main
包,导入所需的库,并设置我们的常量:
package main
import (
"log"
"net/http"
)
const queueSize = 50
const workers = 10
const port = "1234"
- 然后,我们创建一个
job
结构。这个结构跟踪作业名称和有效载荷,如下面的代码块所示:
type job struct {
name string
payload string
}
- 我们的
runJob
函数只是打印一个成功的消息。如果我们愿意,这里可以添加更多的工作:
func runJob(id int, individualJob job) {
log.Printf("Worker %d: Completed: %s with payload %s", id, individualJob.name, individualJob.payload)
}
我们的主函数创建了一个定义的queueSize
的jobQueue
通道。然后,它遍历工作人员并为每个工作人员生成 goroutine。最后,它遍历作业队列并运行必要的作业:
func main() {
jobQueue := make(chan job, queueSize)
for i := 1; i <= workers; i++ {
go func(i int) {
for j := range jobQueue {
runJob(i, j)
}
}(i)
}
我们还在这里有一个 HTTP 处理函数,用于接收来自外部来源的请求(在我们的情况下,它将是一个简单的 cURL 请求,但您可以从外部系统接收许多不同的请求):
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
submittedJob := job{r.FormValue("name"), r.FormValue("payload")}
jobQueue <- submittedJob
})
http.ListenAndServe(":"+port, nil)
}
- 在此之后,我们启动作业队列并执行请求以测试命令:
for i in {1..15}; do curl localhost:1234/ -d id=$i -d name=job$i -d payload=”Hi from Job $i”; done
以下截图显示了一个结果集,显示了不同的工作人员完成了不同的工作:
请注意,个别的工作人员会根据自己的能力接手工作。这对我们继续发展需要这些工作的系统是有帮助的。
集成作业队列
有时我们可能不想使用内置的 Go 队列系统。也许我们已经有一个包含其他消息队列系统的流水线,或者我们知道我们将不得不维护一个非常大的数据输入。用于这项任务的两个常用系统是 Apache Kafka 和 RabbitMQ。让我们快速看一下如何使用 Go 与这两个系统集成。
Kafka
Apache Kafka 被称为分布式流系统,这只是说分布式作业队列的另一种方式。Kafka 是用 Java 编写的,使用发布/订阅模型进行消息队列。它通常用于编写实时流数据管道。
我们假设您已经设置了 Kafka 实例。如果没有,您可以使用以下 bash 脚本快速获取 Kafka 实例:
#!/bin/bash
rm -rf kafka_2.12-2.3.0
wget -c http://apache.cs.utah.edu/kafka/2.3.0/kafka_2.12-2.3.0.tgz
tar xvf kafka_2.12-2.3.0.tgz
./kafka_2.12-2.3.0/bin/zookeeper-server-start.sh kafka_2.12-2.3.0/config/zookeeper.properties &
./kafka_2.12-2.3.0/bin/kafka-server-start.sh kafka_2.12-2.3.0/config/server.properties
wait
我们可以执行以下 bash 脚本:
./testKafka.sh
在这之后,我们可以运行kafka
读取和写入 Go 程序来读取和写入 Kafka。让我们分别调查一下。
我们可以使用writeToKafka.go
程序来写入 Kafka。让我们来看一下:
- 首先,我们初始化我们的
main
包并导入所需的包:
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/segmentio/kafka-go"
)
- 在我们的
main
函数中,我们创建了一个连接到 Kafka,设置了写入截止日期,然后写入了我们的 Kafka 主题/分区的消息。在这种情况下,它只是从 1 到 10 的简单消息计数:
func main() {
var topic = "go-example"
var partition = 0
var connectionType = "tcp"
var connectionHost = "0.0.0.0"
var connectionPort = ":9092"
connection, err := kafka.DialLeader(context.Background(), connectionType,
connectionHost+connectionPort, topic, partition)
if err != nil {
log.Fatal(err)
}
connection.SetWriteDeadline(time.Now().Add(10 * time.Second))
for i := 0; i < 10; i++ {
connection.WriteMessages(
kafka.Message{Value: []byte(fmt.Sprintf("Message : %v", i))},
)
}
connection.Close()
}
readFromKafka.go
程序实例化main
包并导入所有必要的包,如下所示:
package main
import (
"context"
"fmt"
“log”
"time"
"github.com/segmentio/kafka-go"
)
- 我们的
main
函数然后设置了一个 Kafka 主题和分区,然后创建了一个连接,设置了连接截止日期,并设置了批处理大小。
有关 Kafka 主题和分区的更多信息,请访问:kafka.apache.org/documentation/#intro_topics
。
- 我们可以看到我们的
topic
和partition
已经被设置为变量,并且我们的连接已经被实例化:
func main() {
var topic = "go-example"
var partition = 0
var connectionType = "tcp"
var connectionHost = "0.0.0.0"
var connectionPort = ":9092"
connection, err := kafka.DialLeader(context.Background(), connectionType,
connectionHost+connectionPort, topic, partition)
if err != nil {
log.Fatal("Could not create a Kafka Connection")
}
- 然后,我们在连接上设置了截止日期并读取我们的批处理。最后,我们关闭我们的连接:
connection.SetReadDeadline(time.Now().Add(1 * time.Second))
readBatch := connection.ReadBatch(500, 500000)
byteString := make([]byte, 500)
for {
_, err := readBatch.Read(byteString)
if err != nil {
break
}
fmt.Println(string(byteString))
}
readBatch.Close()
connection.Close()
}
- 在我们执行
readFromKafka.go
和writeFromKafka.go
文件之后,我们可以看到生成的输出:
我们的 Kafka 实例现在有了我们从writeToKafka.go
程序发送的消息,现在可以被我们的readFromKafka.go
程序消费。
在完成 Kafka 和 zookeeper 服务后,我们可以执行以下命令来停止它们:
./kafka_2.12-2.3.0/bin/kafka-server-stop.sh
./kafka_2.12-2.3.0/bin/zookeeper-server-stop.sh
许多企业使用 Kafka 作为消息代理系统,因此能够理解如何在 Go 中从这些系统中读取和写入对于在企业环境中创建规模化的东西是有帮助的。
RabbitMQ
RabbitMQ 是一个流行的开源消息代理,用 Erlang 编写。它使用一种称为高级消息队列协议(AMQP)的协议来通过其排队系统传递消息。话不多说,让我们设置一个 RabbitMQ 实例,并使用 Go 来传递消息到它和从它那里接收消息:
- 首先,我们需要使用 Docker 启动 RabbitMQ 实例:
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
-
然后,我们在我们的主机上运行了一个带有管理门户的 RabbitMQ 实例。
-
现在,我们可以使用 Go AMQP 库(
github.com/streadway/amqp
)来通过 Go 与我们的 RabbitMQ 系统传递消息。
我们将首先创建一个监听器。让我们一步一步地看这个过程:
- 首先,我们实例化
main
包并导入必要的依赖项,以及设置显式变量:
package main
import (
"log"
"github.com/streadway/amqp"
)
func main() {
var username = "guest"
var password = "guest"
var protocol = "amqp://"
var host = "0.0.0.0"
var port = ":5672/"
var queueName = "go-queue"
- 然后,我们创建到
amqp
服务器的连接:
connectionString := protocol + username + ":" + password + "@" + host + port
connection, err := amqp.Dial(connectionString)
if err != nil {
log.Printf("Could not connect to Local RabbitMQ instance on " + host)
}
defer connection.Close()
ch, err := connection.Channel()
if err != nil {
log.Printf("Could not connect to channel")
}
defer ch.Close()
- 接下来,我们声明我们正在监听的队列,并从队列中消费消息:
queue, err := ch.QueueDeclare(queueName, false, false, false, false, nil)
if err != nil {
log.Printf("Could not declare queue : " + queueName)
}
messages, err := ch.Consume(queue.Name, "", true, false, false, false, nil)
if err != nil {
log.Printf("Could not register a consumer")
}
listener := make(chan bool)
go func() {
for i := range messages {
log.Printf("Received message: %s", i.Body)
}
}()
log.Printf("Listening for messages on %s:%s on queue %s", host, port, queueName)
<-listener
}
- 现在,我们可以创建发送函数。同样,我们声明我们的包并导入我们的依赖项,以及设置我们的变量:
package main
import (
"log"
"github.com/streadway/amqp"
)
func main() {
var username = "guest"
var password = "guest"
var protocol = "amqp://"
var host = "0.0.0.0"
var port = ":5672/"
var queueName = "go-queue"
- 我们使用了与我们的监听器中使用的相同的连接方法。在生产实例中,我们可能会将其抽象化,但在这里包含它是为了方便理解:
connectionString := protocol + username + ":" + password + "@" + host + port
connection, err := amqp.Dial(connectionString)
if err != nil {
log.Printf("Could not connect to Local RabbitMQ instance on " + host)
}
defer connection.Close()
ch, err := connection.Channel()
if err != nil {
log.Printf("Could not connect to channel")
}
defer ch.Close()
- 然后,我们声明我们想要使用的队列并将消息主体发布到该队列:
queue, err := ch.QueueDeclare(queueName, false, false, false, false, nil)
if err != nil {
log.Printf("Could not declare queue : " + queueName)
}
messageBody := "Hello Gophers!"
err = ch.Publish("", queue.Name, false, false,
amqp.Publishing{
ContentType: "text/plain",
Body: []byte(messageBody),
})
log.Printf("Message sent on queue %s : %s", queueName, messageBody)
if err != nil {
log.Printf("Message not sent successfully on queue %s", queueName, messageBody)
}
}
- 创建了这两个程序后,我们可以测试它们。我们将使用一个 while true 循环迭代我们的消息发送程序:
在完成这些操作后,我们应该能看到消息进入我们的接收器:
我们还可以通过查看位于http://0.0.0.0:15672
的 RabbitMQ 管理门户的输出来查看此活动的输出,默认情况下使用 guest 作为用户名和密码:
该门户为我们提供了有关 RabbitMQ 作业队列的各种不同信息,从排队的消息数量,发布/订阅模型状态,到有关 RabbitMQ 系统的各个部分(连接、通道、交换和队列)的结果。了解这个排队系统的工作原理将有助于您,如果您将来需要与 RabbitMQ 队列通信的话。
总结
在本章中,我们学习了使用分层和质心算法进行集群化,使用 goroutines 作为队列,使用缓冲通道作为作业队列,以及实现第三方排队系统(Kafka 和 RabbitMQ)。
学习所有这些集群和作业队列技术将帮助您更好地使用算法和分布式系统,并解决计算机科学问题。在下一章中,我们将学习如何使用 Prometheus 导出器、APMs、SLIs/SLOs 和日志来衡量和比较不同版本的代码质量。
第十五章:跨版本比较代码质量
在编写、调试、分析和监视您的 Go 代码之后,您需要长期监视您的应用程序性能回归。如果您无法继续提供基础架构中其他系统所依赖的性能水平,那么向您的代码添加新功能是没有用的。
在本章中,我们将学习以下主题:
-
利用 Go Prometheus 导出器
-
应用程序性能监控(APM)工具
-
服务级指标和服务级目标(SLIs和SLOs)
-
利用日志记录
了解这些概念应该有助于驱使您在长期内编写高性能的代码。在处理大规模项目时,工作通常不会很好地扩展。拥有 10 倍数量的工程师通常并不意味着能够提供 10 倍的产出。能够以编程方式量化代码性能在软件团队增长并向产品添加功能时非常重要。宣传高性能代码总是会受到积极的评价,使用本章描述的一些技术将有助于您长期改进代码性能,无论您是在企业环境中工作还是在一个小型开源项目中。
Go Prometheus 导出器 - 从您的 Go 应用程序导出数据
跟踪应用程序长期变化的最佳方法之一是使用时间序列数据来监视并警告我们重要的变化。Prometheus
(prometheus.io/
)是执行此任务的一个很好的方法。Prometheus 是一个开源的时间序列监控工具,通过 HTTP 使用拉模型来驱动监控和警报。它是用 Go 编写的,并且为 Go 程序提供了一流的客户端库。以下步骤展示了 Go Prometheus HTTP 库的一个非常简单的实现:
- 首先,我们实例化我们的包并导入我们需要的库:
package main
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
- 然后,在我们的
main
函数中,我们实例化一个新的服务器,并让它提供一个返回 Prometheus 处理程序(promhttp.Handler()
)的NewServeMux
:
func main() {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":1234", mux)
}
在这之后,我们可以看到我们从默认的 Prometheus 导出器返回值。这些都有很好的注释,并包括以下内容:
-
- Go 垃圾收集信息
-
Goroutine 信息
-
Go 环境版本
-
Go 内存统计
-
Go CPU 利用率统计
-
HTTP 处理程序统计
- 接下来,我们构建我们 Go 服务的二进制文件:
GOOS=linux go build promExporter.go
- 接下来,我们创建一个 docker 网络来链接我们的服务:
docker network create prometheus
- 然后我们创建我们的 Prometheus 导出器服务:
docker build -t promexporter -f Dockerfile.promExporter .
- 接下来,在我们的 Docker 主机上运行我们的 Prometheus 导出器服务:
docker run -it --rm --name promExporter -d -p 1234:1234 --net prometheus promexporter
在下面的截图中,我们可以看到这个响应的截断输出。出于简洁起见,我们排除了注释和内置的 Go 统计信息。您可以在服务器的响应中看到键-值响应:
在设置了这个服务器之后,我们可以以给定的节奏监视它。我们可以在容器中运行我们的度量服务和 Prometheus,并让它们相互通信。我们可以为我们的 Prometheus 容器使用一个简单的prometheus.yml
定义:
如果您想要使用除了 docker 主机之外的 IP 地址或主机名,您可以在 YAML 的scrape_configs
->static_configs
->targets
部分中用 IP 地址或主机名替换promExporter
。
- 在我们构建了二进制文件之后,我们可以创建两个单独的 Dockerfile:一个用于包含我们的 Prometheus 导出器服务的容器,另一个用于包含我们的 Prometheus 服务的容器。我们的 Prometheus 服务的 Dockerfile 采用基线 Prometheus 镜像,并将我们的 YAML 配置添加到图像的适当位置。我们的
Dockerfile.promservice
配置如下:
FROM prom/prometheus
ADD prometheus.yml /etc/prometheus/
- 一旦我们创建了
Dockerfile.promservice
,我们就可以构建我们的 Prometheus 服务:
docker build -t prom -f Dockerfile.promservice .
- 然后我们可以在我们的 Docker 主机上运行我们的 Prometheus 服务:
docker run -it --rm --name prom -d -p 9090:9090 --net prometheus prom
现在我们在本地环境上运行了一个 Prometheus 实例。
- 在我们的 Prometheus 服务运行起来后,我们可以访问
http://[IPADDRESS]:9090/
,就能看到我们的 Prometheus 实例:
- 我们可以通过查看相同 URL 中的
/targets
路径来验证我们正在抓取我们的目标:
- 接下来,我们可以向我们的主机发出一些请求:
for i in {1..10}; do curl -s localhost:1234/metrics -o /dev/null; done
- 接下来,我们可以在我们的 Prometheus 实例中看到我们的
curl
的结果:
通过这些结果,我们可以看到我们提供的总 HTTP 响应次数,其中包括 200、500 和 503 状态码。我们的示例很简单,但我们可以在这里使用许多不同类型的指标来验证我们的任何假设。在本章后面的 SLI/SLO 示例中,我们将进行更多涉及指标收集的示例。
在下一节中,我们将讨论 APM 以及如何在维护高性能分布式系统中使用它。
APM – 监控你的分布式系统性能
今天市场上有许多 APM 工具。它们经常用于随时间监视软件的性能和可靠性。在撰写本书时,Go 语言可用的一些产品如下:
-
Elastic APM agent:
www.elastic.co/guide/en/apm/agent/go/current/index.html
-
New Relic APM:
newrelic.com/golang
-
Datadog:
docs.datadoghq.com/tracing/setup/go/
-
SignalFX:
docs.signalfx.com/en/latest/apm/apm-instrument/apm-go.html
-
AppDynamics :
www.appdynamics.com/supported-technologies/go
-
Honeycomb APM:
docs.honeycomb.io/getting-data-in/go/
-
AWS XRay:
docs.aws.amazon.com/xray/latest/devguide/xray-sdk-go.html
-
Google 的 APM 产品套件:
cloud.google.com/apm/
这些工具大多是闭源和付费服务。聚合分布式跟踪是一个困难的价值主张。这里列出的供应商(以及一些未提及的供应商)结合了数据存储、聚合和分析,以便为 APM 提供一站式服务。我们还可以使用我们在第十三章中创建的 OpenCensus/Zipkin 开源示例,在我们的系统中执行分布式跟踪。在我们的代码库周围实现跨度可以帮助我们监视长期的应用程序性能。
让我们来看一个 Google 的 APM 解决方案示例。在撰写本文时,Google Cloud 每月提供 250 万个跨度摄取和 2500 万个跨度扫描,这对于一个示例来说已经足够了。
Google Cloud 环境设置
首先,我们需要创建一个 GCP 项目并检索应用凭据:
- 首先,我们要登录
console.cloud.google.com/
。登录后,我们可以点击页面顶部的项目选择器下拉菜单:
- 然后,我们可以在屏幕右上角创建一个新项目,如下截图所示:
-
然后,我们可以访问服务账号密钥页面
console.cloud.google.com/apis/credentials/serviceaccountkey
,这将让我们创建一个服务账号密钥。 -
我们可以为我们的应用程序创建一个服务帐号密钥。确保您选择 Cloud Trace 代理,因为这对我们向 Google Cloud Trace 添加跟踪是必要的。这在以下截图中有所体现:
-
点击创建后,浏览器将提示我们下载新的凭据。供参考,我们将称此密钥为
high-performance-in-go-tracing.json
。您可以根据需要命名密钥。 -
一旦我们将此密钥保存在本地,我们可以将其转换为环境变量。在您的终端中,输入以下命令:
export GOOGLE_APPLICATION_CREDENTIALS=/home/bob/service-accounts-private-key.json
这将把您的服务帐号凭据保存为一个特殊的环境变量,GOOGLE_APPLICATION_CREDENTIALS
,我们将在下一个示例中使用它。
Google Cloud Trace 代码
一旦我们的应用程序凭据全部设置好,我们就可以开始编写我们的第一个将被我们的 APM 捕获的跟踪:
- 首先,我们实例化必要的包并设置服务器主机/端口常量:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"contrib.go.opencensus.io/exporter/stackdriver"
"go.opencensus.io/trace"
)
const server = ":1234"
- 接下来,在我们的
init()
函数中,我们设置了 StackDriver 导出器,并注册了我们的跟踪器以对每个进来的网络请求进行采样。在生产环境中,我们可能应该对更少的请求进行采样,因为采样会给我们的请求增加额外的延迟:
func init() {
exporter, err := stackdriver.NewExporter(stackdriver.Options{
ProjectID: os.Getenv("GOOGLE_CLOUD_PROJECT"),
})
if err != nil {
log.Fatal("Can't initialize GOOGLE_CLOUD_PROJECT environment
variable", err)
}
trace.RegisterExporter(exporter)
trace.ApplyConfig(trace.Config{DefaultSampler:
trace.AlwaysSample()})
}
- 接下来,我们将有一个休眠函数,它接受一个上下文,休眠,并向最终用户写入一条消息。在这个函数中,我将跨度的结束推迟到函数的末尾:
func sleep(ctx context.Context, w http.ResponseWriter, r *http.Request) {
_, span := trace.StartSpan(ctx, "sleep")
defer span.End()
time.Sleep(1 * time.Second)
fmt.Fprintln(w, "Done Sleeping")
}
- 我们的 GitHub 请求函数向
github.com
发出请求,并将状态返回给我们的最终用户。在这个函数中,我明确调用了跨度的结束:
func githubRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) {
_, span := trace.StartSpan(ctx, "githubRequest")
defer span.End()
res, err := http.Get("https://github.com")
if err != nil {
log.Fatal(err)
}
res.Body.Close()
fmt.Fprintln(w, "Request to https://github.com completed with a status of: ", res.Status)
span.End()
}
我们的主函数设置了一个执行githubRequest
和sleep
函数的 HTTP 处理程序函数:
func main() {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, span := trace.StartSpan(context.Background(), "function/main")
defer span.End()
githubRequest(ctx, w, r)
sleep(ctx, w, r)
})
http.Handle("/", h)
log.Printf("serving at : %s", server)
err := http.ListenAndServe(server, nil)
if err != nil {
log.Fatal("Couldn't start HTTP server: %s", err)
}
}
- 我们执行主函数后,向
localhost:1234
发出请求并看到响应:
- 之后,我们访问 Google Cloud 控制台并选择我们创建的跟踪:
在这个跟踪示例中,我们可以看到各种相关细节:
-
已经采取的所有跟踪样本(我在这里添加了一堆不同的样本来填充字段)。
-
我们的请求流的瀑布图。对于我们的示例来说有点小,只有网络请求和休眠,但是在分布式系统中传递上下文时,这个图很快就会变得更大。
-
每个跟踪的摘要。如果您点击图表中的一个跟踪条,您可以查看有关特定跟踪的更多详细信息。
将分布式跟踪作为 APM 解决方案添加到确定花费最多时间的网络请求的位置中可能非常有帮助。找到现实生活中的瓶颈通常比深入日志更实际。Google 的 APM 还可以让您根据您所做的跟踪运行报告。在您进行了 100 多次请求之后,您可以执行分析报告并查看结果。密度分布延迟图表显示了您的请求延迟在图表中的位置。我们的示例应该有大致相似的结果,因为我们进行了长时间的休眠,并且只向外部服务发出了一个请求。我们可以在以下截图中看到密度分布图:
我们还可以在此门户中查看累积延迟,这将显示比x轴上的值短的请求的百分比:
我们还可以看到与相关请求相关的延迟配置文件:
此外,我们还可以看到分布式系统中的感知瓶颈:
这些分析工具帮助我们推断出我们在分布式系统中可以进行改进的地方。APM 帮助许多公司向客户交付高性能的应用程序。这些工具非常有价值,因为它们从客户体验的角度来看待性能。在下一节中,我们将讨论使用 SLIs 和 SLOs 设定目标。
SLIs 和 SLOs - 设定目标
SLIs 和 SLOs 是由 Google 引入计算机科学领域的两种范式。它们在 SRE 工作手册中有定义
(landing.google.com/sre/sre-book/chapters/service-level-objectives/
)是衡量计算系统中可操作项目的绝佳方式。这些测量通常遵循 Google 的四个黄金信号:
-
延迟:请求完成所需的时间(通常以毫秒为单位衡量)
-
流量:您的服务接收的流量量(通常以每秒请求次数来衡量)
-
错误:失败请求占总请求的百分比(通常用百分比来衡量)
-
饱和度:硬件饱和度的测量(通常以排队请求计数来衡量)
这些测量结果可以用来创建一个或多个 SLA。这些通常提供给期望从您的应用程序中获得特定服务水平的客户。
我们可以使用 Prometheus 来测量这些指标。Prometheus 有很多不同的计数方法,包括饱和度计、计数器和直方图。我们将使用所有这些不同的工具来测量我们系统中的这些指标。
为了测试我们的系统,我们将使用hey
负载生成器。这是一个类似于我们在之前章节中使用的ab
的工具,但是对于这种特定情况,它会更好地显示我们的分布。我们可以通过运行以下命令来获取它:
go get -u github.com/rakyll/hey
我们需要搭建我们的 Prometheus 服务,以便读取其中一些值。如果您的服务还没有从我们之前的示例中搭建起来,我们可以执行以下命令:
docker build -t slislo -f Dockerfile.promservice .
docker run -it --rm --name slislo -d -p 9090:9090 --net host slislo
这将使我们的 Prometheus 实例搭建起来并测量请求:
- 我们的代码首先实例化
main
包并导入必要的 Prometheus 包:
package main
import (
"math/rand"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
- 然后我们在我们的
main
函数中收集饱和度、请求和延迟数字。我们使用饱和度计、请求计数器和延迟直方图:
saturation := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "saturation",
Help: "A gauge of the saturation golden signal",
})
requests := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "requests",
Help: "A counter for the requests golden signal",
},
[]string{"code", "method"},
)
latency := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "latency",
Help: "A histogram of latencies for the latency golden
signal",
Buckets: []float64{.025, .05, 0.1, 0.25, 0.5, 0.75},
},
[]string{"handler", "method"},
)
- 然后我们创建我们的
goldenSignalHandler
,它会随机生成一个 0 到 1 秒的延迟。为了更好地显示我们的信号,如果随机数能被 4 整除,我们返回 404 错误状态,如果能被 5 整除,我们返回 500 错误。然后我们返回一个响应并记录请求已完成。
我们的goldenSignalChain
将这些指标联系在一起:
goldenSignalChain := promhttp.InstrumentHandlerInFlight
(saturation,promhttp.InstrumentHandlerDuration
(latency.MustCurryWith(prometheus.Labels{"handler": "signals"}),
promhttp.InstrumentHandlerCounter(requests, goldenSignalHandler),
),
)
- 然后我们在 Prometheus 中注册所有的测量(饱和度、请求和延迟),处理我们的 HTTP 请求,并启动我们的 HTTP 服务器:
prometheus.MustRegister(saturation, requests, latency)
http.Handle("/metrics", promhttp.Handler())
http.Handle("/signals", goldenSignalChain)
http.ListenAndServe(":1234", nil)
}
- 在执行
go run SLISLO.go
启动 HTTP 服务器后,我们可以向我们的 HTTP 服务器发出hey
请求。hey
调用的输出在以下截图中可见。请记住,这些都是随机值,如果您执行相同的测试,结果将不同:
然后我们可以查看我们的个体黄金信号。
测量流量
要测量我们的流量,我们可以使用 Prometheus 查询sum(rate(requests[1m]))
。
我们可以在任何给定的时间间隔内测量速率。以几种不同的方式配置这个速率,看看哪种对您系统的要求最有利。
测量延迟
要测量延迟,我们可以查看latency_bucket
Prometheus 查询。我们的请求被分成了一个包含不同延迟数字的直方图,这个查询反映了这一点。
测量错误
要衡量系统中的错误数量,我们需要找到具有成功响应代码的请求与没有成功响应代码的请求的比率。我们可以使用以下查询找到这个比率sum(requests {code!="200"}) / (sum(requests {code="200"})) + sum(requests {code!="200"})
。
这个比率是重要的监控指标。计算机系统会出现故障,人们会发出不正确的请求,但您的 200 响应与非 200 响应的比率应该相对较小。
衡量饱和度
我们可以使用饱和度
的Prometheus查询来衡量饱和度。我们想要验证我们的系统是否饱和,这个查询可以帮助我们执行这个操作。
Grafana
我们可以将所有这些黄金信号封装到 Grafana 仪表板中。我们可以通过调用在本地运行 Grafana 来运行 Grafana:
docker run -it --rm --name grafana -d -p 3000:3000 --net prometheus grafana/grafana
我们需要通过访问http://localhost:3000
并使用默认的用户名和密码组合来登录 Grafana 门户网站:
用户名:admin
密码:admin
登录后,我们可以设置新的密码。
登录后,我们点击页面顶部的添加数据源,并在下一页选择 Prometheus。然后输入我们的本地 IP 地址并点击保存并测试。如果一切正常,我们应该在屏幕底部看到数据源正在工作的弹出窗口:
完成后,我们访问localhost:3000/dashboard/import
。
然后我们在右上角选择上传.json 文件,并上传为此仪表板创建的 JSON 文件,位于github.com/bobstrecansky/HighPerformanceWithGo/blob/master/15-code-quality-across-versions/SLISLO/four_golden_signals_grafana_dashboard.json
。
上传了这个 JSON 文件后,我们导入这个数据源,就能够看到我们的请求速率、持续延迟桶、错误率和饱和度图表,如下面的截图所示:
了解这些统计数据可以帮助维护稳定的系统。在捕获了这些统计数据之后,您可以使用 Prometheus Alertmanager 来设置您感兴趣的监控阈值的警报。
有关配置 Alertmanager 的更多信息,请访问
prometheus.io/docs/alerting/alertmanager/
在下一节中,我们将学习如何跟踪我们的数据,也称为日志记录。
日志记录-跟踪您的数据
记录系统中发生的事件的日志记录是创建高性能软件系统的重要组成部分。能够记录和验证编程系统中的事件是确保您在应用程序的各个版本中保持代码质量的一个很好的方法。日志通常可以快速显示软件中的错误,并且能够快速消化这些信息通常可以帮助降低您的平均恢复时间(MTTR)。
Go 有许多不同的日志包。以下是一些最受欢迎的包:
-
Go 维护者提供的标准内置日志包
-
Glog 包:
github.com/golang/glog
-
Uber 的 Zap 包:
github.com/uber-go/zap
-
零分配 JSON 记录器:
github.com/rs/zerolog
-
Logrus 包:
github.com/sirupsen/logrus
我们将以 Zap 包作为示例,因为基准测试表明。通常使用标准库记录器就足够了(如果你注意到了,这是我迄今为止在书中用于记录的包)。拥有诸如 Zap 这样的结构化日志包可以弥补愉快的体验,因为它提供了一些标准库记录器无法直接提供的功能,例如以下内容:
-
日志级别
-
结构化日志(特别是 JSON)
-
类型化日志
在日志记录器之间进行比较基准测试时,它的性能表现最佳。Zap 有两种不同类型的日志记录可用,即 sugared 记录器和结构化记录器。结构化记录器性能略高,而 sugared 记录器类型较松散。由于这是一本关于性能的书,我们将看一下结构化记录器,因为它的性能更高,但这两种记录选项都非常适合生产使用。
拥有具有不同日志级别的记录器很重要,因为它可以帮助您确定哪些日志需要紧急关注,哪些日志只是返回信息。这还可以根据日志的紧急程度为您的团队设置优先级,当您达到日志拐点时,可以确定修复的紧急程度。
具有可结构化的日志对于将其摄入到其他系统中非常有帮助。JSON 日志记录迅速变得越来越受欢迎,因为诸如以下的日志聚合工具接受 JSON 日志记录:
-
ELK Stack(ElasticSearch,Logstash 和 Kibana)
-
Loggly
-
Splunk
-
Sumologic
-
Datadog
-
Google Stackdriver 日志记录
正如我们在 APM 解决方案中看到的,我们可以利用这些日志记录服务在集中位置聚合大量日志,无论是在本地还是在云中。
拥有类型化日志可以让您以对程序或业务有意义的方式组织日志数据。保持日志记录的一致性可以让系统操作员和站点可靠性工程师更快地诊断问题,从而缩短生产事故的 MTTR。
让我们看一个使用 Zap 的日志示例:
- 首先,我们实例化我们的包并导入
time
包和 Zap 记录器:
package main
import (
"time"
"go.uber.org/zap"
)
- 然后,我们设置一个生产配置,将日志返回到
stdout
(遵循十二要素应用程序流程)。这些通常可以发送到诸如 Fluentd(www.fluentd.org/
)之类的日志路由器,我们可以测试 Zap 中提供的所有不同日志级别:
func main() {
c := zap.NewProductionConfig()
c.OutputPaths = []string{"stdout"}
logger, _ := c.Build()
logger.Debug("We can use this logging level to debug. This won't be printed, as the NewProduction logger only prints info and above log levels.")
logger.Info("This is an INFO message for your code. We can log individual structured things here", zap.String("url", "https://reddit.com"), zap.Int("connectionAttempts", 3), zap.Time("requestTime", time.Now()))
logger.Warn("This is a WARNING message for your code. It will not exit your program.")
logger.Error("This is an ERROR message for your code. It will not exit your program, but it will print your error message -> ")
logger.Fatal("This is a Fatal message for your code. It will exit your program with an os.Exit(1).")
logger.Panic("This is a panic message for your code. It will exit your program. We won't see this execute because we have already exited from the above logger.Fatal log message. This also exits with an os.Exit(1)")
}
运行记录器后,我们可以看到一些非常干净的 JSON 输出。我们还可以使用诸如 jq(stedolan.github.io/jq/
)之类的实用程序,以便在本地环境中轻松消耗这些输出:
正如我们提到的,在您的 Go 应用程序中拥有结构化、分级的记录器将帮助您更快速、更有效地进行故障排除。
摘要
在本章中,我们讨论了比较代码质量的不同方法:
-
利用 Go Prometheus 导出器
-
APM 工具
-
SLIs 和 SLOs
-
利用日志记录
利用所有这些技术可以帮助您确定应用程序的性能不如预期的地方。了解这些情况可以帮助您快速迭代并产生最好的软件。
在本书的过程中,您已经了解了应用程序性能及其与 Go 的关系。我希望这本书能帮助您在编写应用程序时考虑 Web 性能。始终将性能放在首要位置。每个人都喜欢高性能的应用程序,希望这本书能帮助您作为开发人员尽自己的一份力。
标签:指南,fmt,go,高性能,func,Go,main,我们 From: https://www.cnblogs.com/apachecn/p/18172884