0%

《Go 语言精进之路》读书笔记(08):测试、性能剖析与调试

Go 语言推崇 面向工程 的设计哲学并自带强大的且为人所称道的工具链。这篇文章将学习 Go 在单元测试、性能测试以及代码调试方面的最佳实践方案。

理解包内测试与包外测试的差别

Go 语言在工具链和标准库中提供对测试的原生支持,这也算是 Go 在工程实践方面的一个创新。

在 Go 中我们针对包编写测试代码。测试代码与包代码放在同一个目录下,并且 Go 要求所有测试代码都存放在以 *_test.go 结尾的文件中。

go test 命令也是通过同样的方式将包代码与包测试代码区分开来。go test 将所有包目录下的 *_test.go 编译成一个临时的二进制文件,并执行该文件,后者将执行各个测试源文件中名字为 TestXxx 的函数所代表的测试用例并输出测试执行结果。

我们把将测试代码放在与被测包同名的包中的测试方法称为 包内测试。可以使用如下命令查看哪些源文件使用了包内测试:

1
go list -f={{.TestGoFiles}} .

我们把将测试代码放在名为 被测包包名+_test 的包中的测试方法称为 包外测试,使用如下命令查看哪些源文件使用了包外测试:

1
go list -f={{.XTestGoFiles}} .

包内测试与包外测试

由于 Go 构建工具链在编译包时会自动根据文件名是否具有 _test.go 后缀将包源文件和包的测试文件分开,测试代码不会进入包正常构建的范围内。因此测试代码使用与被测包名相同的包内测试方法是一个很正常的选择。

包内测试这种方法本质上是一种白盒测试方法,由于测试代码与被测源码在同一包内,因此测试代码可以访问该包下的所有符号。因此包内测试可以很容易达到较高的测试覆盖率。但是实践中,包内测试也会经常遇到如下问题:

  • 测试代码自身需要经常性的维护:包内测试的白盒测试本质上意味着这是一种面向实现的测试,而包内部实现逻辑又是易变的,这也意味着采用包内测试的测试代码也需要经常性的维护
  • 采用包内测试可能还会遇到一个问题:包循环引用

与包内测试本质是 面向实现的白盒测试 不同,包外测试的本质是一种面向接口的黑盒测试。这里的接口就是被测包对外导出的 API,而对外导出的 API 一般都是稳定的。这一本质让包外测试代码与被测代码充分解耦,使得针对这些导出 API 进行测试的包外测试代码表现出十分健壮的特性,即很少随着被测代码内部实现的调整而需要变更。

而且包外测试将测试代码放入不同于被测包的独立包的同时,也使得包外测试不再像包内测试那样存在 包循环引用 的问题。

包外测试这种纯黑盒测试还有一个功能域之外的好处,就是可以更加聚焦地从用户视角验证被测包导出 API 的设计的合理性和易用性。

当然包外测试也有缺点,由于仅能通过导出 API 这一有限的窗口并结合构造特定的数据来验证被测包行为。在这样的约束下,容易出现测试覆盖不足的问题。解决该问题的一个惯用法是 安插后门,该后门就是前面提到的 export_test.go 文件。该文件的代码位于被测包名下,但是它既不会包含在正式的产品代码中(因为位于 _test.go 文件中),而又不包含任何测试代码,仅用于将被测包的内部符号在测试阶段暴露给包外测试代码,或者定义一些辅助包外测试的代码:

1
2
3
4
5
// export_test.go

package fmt
var IsSpace = isSpace
var Parsenum = parsenum

export_test.go 相当于在测试阶段扩展了包外测试代码的视野,让很多本来很难覆盖到的测试路径变得容易了,进而让包外测试覆盖更多被测试包中的执行路径。

go test 完全支持对被测包同时运用包内测试和包外测试两种方法:

  • 包外测试由于将被测试代码放入独立的包中,它更适合编写偏向集成测试的用例
  • 包内测试更聚焦于内部测试的逻辑,通过给函数/方法传入一些特意构造的数据的方式来验证内部逻辑的正确性

从实际开发的角度来说,还是更优先使用包外测试,因为包外测试可以:

  • 优先保证被测试包导出 API 的正确性
  • 可从用户角度验证导出 API 的有效性
  • 保持测试代码的健壮性,尽可能地降低对测试代码维护的投入
  • 不失灵活,可以通过 export_test.go 这个 后门 来导出我们需要的内部符号

有层次地组织测试代码

接下来将聚焦如何组织测试包内的测试代码。

经典模式–平铺

go test 命令会执行 _test.go 中符合 TextXxx 命名规则的函数进而实现测试代码的执行。go test 并没有对测试代码的组织提出任何约束条件。于是最简单直接的组织测试代码的方式就是平铺。此时测试函数各自独立,测试函数之间没有层级关系,所有测试平铺在顶层。测试函数名称既用来区分测试,又用来关联测试。在 go test 命令中,我们还可以给 -run 选项提供正则表达式来匹配并选择执行哪些测试函数。

平铺模式的优点是:

  • 简单:没有额外的抽象,上手容易
  • 独立:每个测试函数都是独立的,互不关联,避免相互干扰

xUnit 家族模式

xUnix(例如 JUnit、PyUnit)家族单元测试框架对测试代码组织形式主要有:测试套件(Test Suit)和测试用例(Test Case)两个层级。一个测试工程(Test Project)通常包含多个测试套件,而一个测试套件中又包含多个测试用例。

Go1.7 引入了对 subtest 的支持,让我们在 Go 中也可以使用上面的方式组织 Go 测试代码。此时:

  • 形如 TestXxx 的测试函数对应着测试套件,一般对应被测包的一个导出函数或方法的所有测试都放入一个测试套件中
  • 形如 testXxx 的测试函数则对应测试用例,并作为测试套件所对应的测试函数内部的子测试(subtest)

测试固件

测试固件(test fixture)是指一个人造的、确定性的环境,一个测试用例或者一个测试套件下的一组测试用例在这个环境中进行测试,其测试结果是可重复的(多次测试运行的结果是相同的)。我们一般使用 setUp 和 tearDown来代表测试固件的创建与拆除。

在平铺模式下,由于每个测试函数都是相互独立的,运行每个 TestXXxx 测试函数时,如果有对测试固件的需求,我们都需要为每个 TestXxx 测试函数单独创建和销毁测试固件。

1
2
3
4
5
6
7
8
9
10
11
12
13
package demo_test

func setUp(testName string) func() {
fmt.Printf("\tsetUp fixture for %s\n", testName)
return func() {
fmt.Printf("\ttearDown fixture for %s\n", testName)
}
}

func TestFunc1(t *testing.T) {
defer setUp(t.Name())()
fmt.Printf("\tExcute test: %s\n", t.Name())
}

在 setUp 中返回匿名函数来实现 tearDown 的好处是,可以在 setUp 中利用闭包特性在两个函数间共享一些变量,避免了包级别变量的使用。

Go 1.14 版本中 testing 包增加了 testing.Cleanup 方法,为测试固件的销毁提供了包级原生的支持:

1
2
3
4
5
6
7
8
9
func setUp() func() {
...
return func() {
}
}

func TestFunc1(t *testing.T) {
t.Cleanup(setUp())
}

上述方式也适用于测试套件,我们可以尝试采用测试套件来减少测试固件的重复创建:将对测试固件需求相同的一组测试用例放在一个测试套件中,这样就可以针对测试套件来创建和销毁测试固件了。

有时候我们需要将所有测试函数放入一个更大范围的测试固件环境中执行,这就是包级别测试固件。在 Go1.4 版本以前,仅能够在 init 函数中创建测试固件,而无法销毁包级别测试固件。Go1.4 版本引入了 TestMain 方法,使得包级别测试固件的创建和销毁成为可能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// chapter8/sources/classic_package_level_testfixture_test.go
package demo_test

......

func pkgSetUp(pkgName string) func() {
fmt.Printf("package SetUp fixture for %s\n", pkgName)
return func() {
fmt.Printf("package TearDown fixture for %s\n", pkgName)
}
}

func TestMain(m *testing.M) {
defer pkgSetUp("package demo_test")()
m.Run()
}
  • 在所有测试函数运行之前,包级别测试固件被创建
  • 在所有测试函数运行完成后,包级别测试固件被销毁

在这样的测试代码组织方式下,我们仍然可以单独为每个测试套件、测试用来创建和销毁测试固件,从而形成一种多层次的、更灵活的测试固件设置体系。

优先编写表驱动的测试

接下来将聚焦于测试函数的内部代码该如何编写。

Go 测试代码的一般逻辑

Go 的测试函数就是一个普通的 Go 函数,Go 仅对测试函数的函数名和函数原型有特定要求,对在测试函数 TestXxx 或其子测试函数(subtest)中如何编写测试逻辑并没有显式的约束。对测试失败与否的判断在于测试代码逻辑是否进入了包含 Error/Errorf、Fatal/Fatalf 等方法调用的代码分支。一旦进入这些分支,即代表测试失败:

  • Error/Errorf 并不会立刻终止当前 goroutine 的执行,还会继续执行 goroutine 后续的测试
  • Fatal/Fatalf 会立刻终止当前 goroutine 的测试执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package compare_test

import "strings"
import "testing"

func TestCompare(t *testing.T) {
a, b := "a", "a"

cmp := strings.Compare(a, b)
if cmp != 0 {
t.Errorf("want %v, but Compare(%q, %q)= %v", 0, a, b, cmp)
}

b = "b"
cmp = strings.Compare(a, b)
if cmp == 0 {
t.Fatalf("not want %v, but Compare(%q, %q)= %v", 0, a, b, cmp)
}
}

所以 Go 测试代码的一般逻辑是:针对给定的输入数据,比较被测试函数/方法返回的实际结果值与预期值,如果有差异,则通过 testing 包提供的相关函数输出差异信息。

表驱动测试逻辑

如下展示了一种测试代码设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import "testing"

func TestCompare(t *testing.T) {
compareTests := []struct {
a, b string
i int
}{
{"", "", 0},
{"a", "", 1},
{"", "a", -1},
}

for _, tt := range compareTests {
cmp := strings.Compare(tt.a, tt.b)
if cmp != tt.i {
t.Errorf("want %v, but Compare(%q, %q) = %v", tt.i, tt.a, tt.b, cmp)
}
}
}

这个自定义结构体类型的切片就是一个表(自定义结构体类型的字段就是列),而基于这个数据表的测试设计和实现则被称为 表驱动的测试

表驱动测试的优点

虽然表驱动测试是 Go 测试代码的一个惯用法,但表驱动测试本身是与编程语言无关的,它具有如下优点:

  • 简单紧凑
  • 数据即测试:表驱动测试的实质是数据驱动的测试,扩展输入数据集即扩展测试
  • 结合子测试后,可单独运行某个数据项的测试(结合 go test -run 选项)

表驱动测试实践中的注意事项

表除了可以是通过自定义结构体的切片来实现,也可以使用基于自定义结构体的其他集合类型(如 map)等来实现。但是使用 map 作为数据表时要注意,表内数据项的测试先后顺序是不确定的(这是由 map 类型自身性质决定的)。

另外,为了在表测试驱动的测试中快速从输出的结果中定位导致测试失败的表项,我们需要在测试失败的输出结果中输出数据表项的唯一标识,例如输出数据表项在数据表中的偏移量来定位,或者通过名字来区分不同的数据项。

另外,由于表驱动测试共享相同的判断逻辑,所以我们需要选择使用 Errorf 或者 Fatalf。一般来说:

  • 如果一个数据项导致的测试失败不会对后续数据项的测试结果造成影响,那么推荐 Errorf
  • 否则如果数据项导致的测试失败会直接影响到后续数据项的测试结果,那么可以使用 Fatalf 让测试尽快结束

使用 testdata 管理测试依赖的外部数据文件

测试固件是 Go 测试执行所需要的上下文环境,其中测试依赖的外部数据文件就是一种常见的测试固件(可以理解为静态测试固件,因为无需在测试代码中为其单独编写固件的创建和清理辅助函数)。接下来介绍 Go 管理测试依赖的外部数据文件所采用的一些惯例和最佳实践。

testdata 目录

Go 语言规定:Go 工具链将忽略名为 testdata 的目录,这样开发者在编写测试时,就可以在名为 testdata 的目录下存放和管理测试代码依赖的数据文件。而 go test 命令在执行时会将被测试程序包源码所在目录设置为其工作目录,因此可以通过如下方式定位充当测试固件的数据文件:

1
f, err := os.Open("testdata/data-001.txt")

除此之外,还经常将预期结果数据保存在文件中并放置在 testdata 下,然后再测试代码中将被测对象输出的数据与这些预置在文件中的数据进行比较。

golden 文件惯用法

Go 标准库为我们提供了一种 golden 文件惯用法:将预期数据采集到文件的过程与测试代码融合在一起。例如在测试用例中通过一个 flag 来控制是否需要更新 golden 文件:

1
2
3
4
5
6
var update = flag.Bool("update", false, "update .golden file")

golden := filepath.Join("testdata", "t.golden")
if *update {
ioutil.WriteFile(golden, got, 0644)
}

正确运用 fake、stub 和 mock 等辅助单元测试

测试代码除了可能依赖外部数据文件,还可能会依赖外部业务组件或服务。一般来说,为被测试对象建立真实依赖的外部组件或者服务是不明智的,因为这种测试运行失败的概率要远大于其运行成功的概率,失去了其存在的意义。

为了让这类被测试代码运行下去,需要为这些被测试代码提供其依赖的外部组件或者服务的替身。替身不必与真实组件或服务完全相同,只需要提供与真实组件或服务相同的接口,只要被测试代码认为它是真实的即可。

fake:真实组件或服务的简化实现版替身

fake 测试是指采用真实组件或服务的简化版实现作为替身,以满足被测代码的外部依赖需求。

使用 fake 替身进行测试的最常见理由是在测试环境无法构造被测代码所依赖的外部组件或服务,或者这些组件/服务有副作用。但是 fake 的实现通常有一个缺点:并不具备在测试前对返回结果进行预设置的能力(或者说预设置能力仅限于设置单一的返回值,即无论调用多少次,传入什么参数,返回值都是一个)。

stub:对返回结果有一定预设控制能力的替身

stub 也是一种替身概念,和 fake 替身相比,stub 替身增强了对替身返回结果的间接控制能力,这种控制可以通过测试前对调用结果预设置来实现。不过,stub 替身通常仅针对计划之内的结果进行设置,对计划之外的请求也无能为力。例如 Go 标准库的 httptest 包就可以提供用于测试的 Web 服务。

Github 上有一个名为 gostub 的第三方包,可以简化 stub 替身的管理和编写。如下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
func TestComposeAndSendWithSign(t *testing.T) {
sender := "test@example.com"
timestamp := "Mon, 04 May 2020 11:46:12 CST"

stubs := gostub.Stub(&getSign, func(sender string) string {
selfSignTxt := senderSigns[sender]
return selfSignTxt + "\n" + timestamp
})
defer stubs.Reset()
...
}

mock:专用于行为观察和验证的替身

和 fake、stub 替身相比,mock 替身更外强大:它除了能提供测试前的预设置返回结果和能力之外,还可以对 mock 替身对象在测试过程中的行为进行观察和验证。但是 mock 也存在应用局限:

  • mock 应用范围更窄,只能用于实现某个接口的实现类型的替身
  • 一般需要通过第三方框架实现 mock 替身,Go 官方维护了一个 mock 框架:gomck,它通过代码生成的方式实现某接口的替身类型

通过如下方式安装 gomock 框架,这个框架分成两部分:

1
go get github.com/golang/mock/mockgen
  • 一部分用于生成 mock 替身的 mockgen 二进制程序。该程序会安装到 $GOPATH/bin 目录下(需要确保该目录已经配置在 PATH 环境变量中)
  • 生成代码所要使用的 gomock 包

如下是一段 mock 核心示例代码:

1
2
3
mockMailer.EXPECT().SendMail("hello, mock test", sender,
"dest1@example.com",
"the test body\n"+senderSigns[sender]+"\n"+timestamp).Return(nil).Times(1)

mock 可以在测试之前对预期返回结果进行设置,对替身在测试过程中的行为进行验证。例如 Times(1) 意味着以该参数列表调用的 SendMail 在测试过程中仅被调用一次。

gomock 是一个通用的 mock 框架,社区还有一些专用的 mock 框架可用于快速创建 mock 替身。

使用模糊测试让潜在 bug 无处遁形

模糊测试(fuzz testing)是指半自动或自动地为程序提供非法的、非预期的、随机的数据,并监控程序在这些输入数据下是否会出现崩溃、断言失败、内存泄漏、安全漏洞等情况。模糊测试最适合那些处理复杂输入数据的程序,例如文件格式解析、网络协议解析等。

传统软件测试一般会针对被测目标的特性进行人工测试设计。在设计一些异常测试用例的时候,测试用例质量好坏往往取决于测试设计人员对被测系统的理解程度及其个人能力。而且当系统涉及的自身服务组件、中间件、第三方系统多且复杂,这些系统中的潜在 bug 或者组合后形成的 bug 是无法预知的。而将随机测试、边界测试、试探性攻击等测试技术集于一身的模糊测试对于上述传统测试技术存在的问题是一个很好的补充和解决方案。

go-fuzz 工具可以让 gopher 在 Go 语言中为被测代码建立模糊测试的条件。

go-fuzz 的初步工作原理

go-fuzz 是基于老牌模糊测试项目 afl-fuzz 的逻辑设计和实现的。go-fuzz 是将输入用例中的数据传给如下 Fuzz 函数,这样就无需反复重启程序:

1
func Fuzz(data []byte) int

go-fuzz 的工作流程如下:

  • 生成随机数据
  • 将上述数据作为输入传递给被测试程序
  • 观察是否有 crash,如果有 crash,则说明存在潜在 bug

go-fuzz 采用的是代码覆盖率引导的 fuzzing 算法。go-fuzz 的核心是对语料库的输入数据如何进行变化。go-fuzz 内部使用两种对语料库的输入数据进行变化的方法:

  • 突变(mutation):对语料库的字节进行小修改
  • 改写(versify):一种高级方法,可以学习文本结构,对输入进行简单分析,然后针对不同部分运用突变策略

go-fuzz 使用方法

通过如下方法安装 go-fuzz 的两个重要工具:go-fuzz 和 go-fuzz-build

1
2
$ go install github.com/dvyukov/go-fuzz/go-fuzz@latest
$ go install github.com/dvyukov/go-fuzz/go-fuzz-build@latest

这两个工具会被安装到 $GOROOT/bin 或者 $GOPATH/bin 目录下,因此需要确保系统的 PATH 环境变量中包含这两个路径。

假设待测试的 Go 包名为 foo,为了通过 go-fuzz 来为包 foo 建立模糊测试,一般会在 foo 下创建 fuzz.go 源文件,其内容模版如下:

1
2
3
4
5
6
7
// +build gofuzz

package foo

func Fuzz(data []byte) int {
// Your code here.
}

go-fuzz-build 在构建用于 go-fuzz 命令输入的二进制文件时,会搜索带有 +build gofuzz 指示符的 Go 源文件以及其中的 Fuzz 函数。有时候待测试包的包内功能很多,一个 Fuzz 函数不够用,我们可以在 fuzztest 下建立多个目录来应对,每个目录各自为一个 go-fuzz 单元。

每个 go-fuzz 测试单元有一套 固定 的目录组合,其中:

  • corpus:存放输入数据预料的目录,在 go-fuzz 执行之前,可以放入初始预料
  • fuzz.go:为包含 Fuzz 函数的源文件
  • gen 目录:包含手工生成初始语料的 main.go 代码

go-fuzz-build 会根据 Fuzz 函数构建一个用于 go-fuzz 执行的 zip 包(例如这里就是 foo-fuzz.zip),包里包含了用途不同的三个文件:

  • cover.exe:被注入了代码测试覆盖率桩设施的二进制文件
  • sonar.exe:被注入了 sonar 统计桩设施的二进制文件
  • metadata:包含代码覆盖率统计、sonar的元数据以及一些整型、字符串字面值

一旦生成了 foo-fuzz.zip,就可以通过 go-fuzz 命令来运行模糊测试了。

1
2
$ cd fuzz1
$ go-fuzz -bin=./foo-fuzz.zip -workdir=./

如果 corpus 目录下没有初始预料数据,那么 go-fuzz 也会自行生成相关数据传递给 Fuzz 函数。并且采用遗传算法,不断基于 corpus 中的语料生成新的输入语料。

go-fuzz 还会在指定的 workdir 中创建另外两个目录:

  • crashers:存放代码崩溃时的相关信息,包括输入用例的二进制数据、输入数据的字符串形式,以及基于这个数据的输出数据
  • supressions:存放代码崩溃时对应的栈跟踪信息,方便开发人员快速定位 bug

如下是 go-fuzz 的一个完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// parse_complex.go
package parser

func ParseComplex(data []byte) bool {
if len(data) == 5 {
if data[0] == 'F' && data[1] == 'U' &&
data[2] == 'Z' && data[3] == 'Z' &&
data[4] == 'I' && data[5] == 'T' {
return true
}
}

return false
}
1
2
3
4
5
6
7
8
9
10
// fuzz.go
//go:build gofuzz
// +build gofuzz

package parser

func Fuzz(data []byte) int {
ParseComplex(data)
return 0
}
1
2
# go-fuzz-build .
# go-fuzz -bin=./parser-fuzz.zip -workdir=./

让模糊测试成为一等公民

Go 尚未将模糊测试当成一等公民对待,即还没有在 Go 工具链中原生支持模糊测试。模糊测试在 Go 中的应用还仅限于使用第三方的 go-fuzz 或者 google 开源的 gofuzz。

目前模糊测试代码无法像普通单元测试代码那样直接编写在 *_test.go 文件中,这也说明模糊测试并不是真正的 一等公民,但这始终是模糊测试在 Go 语言中的努力方向。

为被测对象建立性能基准

通过为被测对象建立性能基准,可以让我们判断是否需要对代码进行优化,同时根据这些性能基准数据判断出对代码所做的任何更改是否对代码性能有所影响。

性能基准测试在 Go 语言中是一等公民

性能基准测试在 Go 语言中和普通的单元测试一样是被原生支持的,因此得到的是 一等公民 待遇。可以在 *_test.go 中创建被测对象的性能基准测试,每个以 Benchmark 前缀开头的函数都会被当做一个独立的性能基准测试。

1
2
3
4
5
func BenchmarkJoinString(b *testing.B) {
for n := 0; n < b.N; n++ {
joinString(sl)
}
}

可以通过 go test -bench 命令来运行性能基准测试。

1
2
3
4
5
6
7
# go test -bench . string_join_test.go
goos: linux
goarch: amd64
cpu: Intel(R) Xeon(R) Platinum 8269CY CPU @ 2.50GHz
BenchmarkJoinString-4 12769330 96.92 ns/op
PASS
ok command-line-arguments 1.337s

上述输出中的 96.92 ns/op 表示这个基准测试中 for 循环的每次循环平均执行时间为 49.1ns(op 代表每次循环操作)。

性能基准测试还可以通过传入 -benchmem 命令行参数输出内存分配信息(与基准测试代码中显式调用 b.ReportAllocs 的效果是等价的)​。

顺序执行和并行执行的性能基准测试

根据是否并行执行,Go 的性能基准测试可以分为两类:

  • 顺序执行的性能基准测试
  • 并行执行的性能基准测试

上面示例的 BenchmarkJoinString 函数就是一个顺序执行的性能基准测试示例。默认情况下,每个性能基准测试函数的执行时间为 1s,可以使用 -benchtime 命令行参数来指定基准测试的执行时间,从而增加基准测试的迭代次数(即 b.N 的值)。除此之外,还可以通过 -count 来指定每个性能基准测试函数执行的次数。

并行执行的性能基准测试代码写法如下:

1
2
3
4
5
6
7
func BenchmarkXxx(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// Your code here.
}
})
}

并行执行的基准测试主要用于为包含多 goroutine 同步设施(如互斥锁、读写锁、原子操作等)的被测代码建立性能基准。并行执行的性能基准测试会启动多个 goroutine 并行执行基准测试函数中的循环。

针对并行基准测试的每一轮执行,go test 都会启动 GOMAXPROCS(可以通过 -cpu 指定)数量的 新 goroutine,这些 goroutine 共同执行 b.N 次循环,每个 goroutine 会尽量相对均衡地分担循环次数。

使用性能基准比较工具

为了避免手工对比性能基准,Go 核心开发团队先后开发了两款性能基准比较工具:

  • benchcmp:上手快,简单易用。它接受两次性能基准测试结果文件作为输入,并将这两个文件中相同的基准测试的输出结果进行比较。但不关心这些结果数据在统计学层面上是否有效
  • benchstat:提高了性能基准数据比较的科学性,建议使用

排除额外干扰,让基准测试更加精确

每个基准测试都可能会运行多轮,每个 BenchmarkXxx 函数都可能被执行多次。testing.B 提供了多种灵活操控基准测试计时器的方法,通过这些方法可以排除掉额外干扰,让基准测试结果更能反映被测试代码的真实性能。

使用 pprof 对程序进行性能剖析

有了性能基准后,我们就可以直到代码是否遇到了性能瓶颈。对于那些确认了性能瓶颈的代码,我们需要知道瓶颈在哪里。Go 是 自带电池(battery included)的编程语言,Go 内置了对代码进行性能瓶颈的工具:pprof。

pprof 的工作原理

使用 pprof 对性能进行性能剖析的工作一般分为两个阶段:数据采集和数据剖析。

在数据采集阶段,Go 运行时会定期对剖析阶段所需要的不同类型数据进行采样记录。当前主要支持的采样数据有如下几种:

  • CPU 数据:它能帮助我们识别出代码关键路径上消耗 CPU 最多的函数。一旦启用 CPU 数据采样,Go 运行时会每隔一段短暂的时间就中断一次(由 SIGPROF 信号触发),记录下当前所有 goroutine 的函数栈信息
  • 堆内存分配数据:帮助了解 Go 程序当前和历史内存使用情况
  • 锁竞争数据:记录当前 Go 程序中互斥锁争用导致延迟的操作
  • 阻塞时间数据:记录 goroutine 在共享资源(一般是由同步原语保护)上的阻塞时间,也包括从无缓冲 channel 上收发数据等

性能数据的采样

采样不是免费的,因此一次采样尽量仅采集一种类型的数据,避免相互干扰。Go 目前主要支持两种性能数据采集方式:

  • 通过性能基准测试进行数据采集:我们可以通过执行性能基准测试采集到整个测试执行过程中有关被测方法的各类性能数据。在 go test 命令中增加一些命令行选项即可在执行性能基准测试的同时进行性能数据的采集。之后 go test 命令执行后会自动编译出一个与该测试对应的可执行文件,该可执行文件可以在性能数据剖析过程中提供剖析所需要的符号信息。
1
$go test -bench . xxx_test.go -cpuprofile=cpu.prof
  • 可以通过标准库 runtime/pprof 包提供的低级别 API 对独立程序进行性能数据采集。

如下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package main

import (
"flag"
"os"
"os/signal"
"runtime/pprof"
"sync"
"syscall"
"time"
)

var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")

func main() {
flag.Parse()

if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
return
}
defer f.Close()

if err := pprof.StartCPUProfile(f); err != nil {
return
}
defer pprof.StopCPUProfile()
}

var wg sync.WaitGroup
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
wg.Add(1)
go func() {
for {
select {
case <-c:
{
wg.Done()
return
}
default:
{
_ = "hello" + "world" + "!"
}
time.Sleep(10 * time.Millisecond)
}
}
}()

wg.Wait()
println("program exit!")
}
1
2
3
4
5
6
7
8
# go run main.go -help
Usage of /tmp/go-build3574322936/b001/exe/main:
-cpuprofile file
write cpu profile to file
# go run main.go -cpuprofile cpu.prof
^Cprogram exit!
# ls -l cpu.prof
-rw-r--r-- 1 root root 228 Sep 22 22:06 cpu.prof

Go 在 net/http/pprof 包中还提供了一种更为高级的针对独立程序的性能数据采集方式,这种方式尤其适合那些内置了 HTTP 服务的独立程序。net/http/pprof 包可以直接利用已有的 HTTP 服务对外提供用于性能数据采集的服务端点(endpoint)​。要采用某个 HTTP 服务的性能数据,只需要直接导入该包即可:

1
2
3
import (
_ "net/http/pprof"
)

该包的 init 函数会向 HTTP 包的默认请求路由器 DefaultServeMux 注册多个服务端点和对应的处理函数,通过这些服务端点,可以在程序运行期间获取各种类型的性能采集数据。采集完成后,得到的数据文件会由浏览器自动下载到本地。

如果是非 HTTP 服务程序,可以在导入包的同时启动一个用于性能数据采集的 goroutine。

1
2
3
4
5
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:8080", nil))
}()
}

这种方式侵入性更小,代码也更为独立。可以在无需停止程序的情况下,通过预置好的各类性能数据采集服务端点即可随时进行性能数据采集。

性能数据的剖析

Go 工具链通过 pprof 子命令提供两种性能数据剖析方法:命令行交互式和 Web 图形化。

可以通过如下三种方式执行 go tool pprof 进入采用命令行交互式的性能数据剖析:

1
2
3
# go tool pprof xxx.test cpu.prof
# go tool pprof app cpu.prof
# go tool pprof http://localhost:8080/debug/pprof/profile

如下是一个例子:

1
2
3
4
5
6
7
# go tool pprof main cpu.prof
File: main
Type: cpu
Time: Sep 22, 2024 at 10:20pm (CST)
Duration: 1.86s, Total samples = 20ms ( 1.08%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

在命令行模式下,可以输入 top、list、png、web 等命令查看剖析结果。

go tool pprof 还提供了基于 web 图形化呈现所采集性能数据的方式。例如如下命令启动一个 Web 服务:

1
# go tool pprof -http=10.9.33.133:8080 main cpu.prof

对于通过 net/http/pprof 暴露性能数据采样端点的独立程序,同样可以采用基于Web的图形化页面进行性能剖析。例如:

1
2
3
4
$go tool pprof -http=:9090 http://localhost:8080/debug/pprof/profile
Fetching profile over HTTP from http://localhost:8080/debug/pprof/profile
Saved profile in /Users/tonybai/pprof/pprof.samples.cpu.001.pb.gz
Serving web UI on http://localhost:9090

使用 pprof 进行性能剖析的实例

上面我们重点介绍了 CPU 性能剖析,除了 CPU 优化,内存优化也是一种常见的应用程序性能优化手段。Go 程序内存分配一旦过频过多,就会增加 Go GC 的工作负荷,这不仅会增加 GC 所使用的 CPU 开销,还会导致 GC 延迟增大,从而影响应用的整体性能。

为了反映代码在并发下多个 goroutine 的阻塞情况,使用并发性能基准测试,同时对阻塞事件类型数据(block.prof)进行采样和剖析,能够发现应用程序在并发场景下的可能优化点。

使用 expvar 输出度量数据,辅助定位性能瓶颈点

Go 标准库提供的 expvar 包按照统一接口、统一数据格式、一致的指标定义方法输出自定义的度量数据。这些可以反映应用运行状态的数据也被称为应用的内省(introspection)数据。相比于通过查询应用外部特征而获取的探针类数据,内省数据可以传达更为丰富、更多的有关应用程序状态的上下文信息。

expvar 包的工作原理

Go 标准库中的 expvar 包提供了一种输出应用内部状态信息的标准化方案,它标准化了以下三方面的内容:

  • 数据输出接口形式
  • 输出数据的编码格式
  • 用户自定义性能指标的方法

Go 应用如果需要输出自身状态数据,需要以下面的形式导入 expvar 包:

1
import _ "expvar"

expvar 会在自己的 init 函数中向 http 包的默认请求路由器 DefaultServeMux 注册服务端点 /debug/vars,这个服务端点就是 expvar 提供给外部的获取应用内部状态的唯一标准接口。如果应用程序没有使用默认的路由器 DefaultServeMux,那么就需要手动将 expvar 包的服务端点注册到应用程序所使用的 路由器 上。如果应用程序没有启动 HTTP 服务,那么还需要在一个单独的 goroutine 中启动一个 HTTP 服务,这样 expvar 提供的服务才能生效。

expvar 包提供的内部状态服务端点返回的是标准的JSON格式数据。

自定义应用通过 expvar 输出的度量数据

expvar 包提供了 Publish 函数,该函数用于发布通过 debug/vars 服务端点输出的数据。Publish 函数的原型如下:

1
func Publish(name string, v Var)

v 的类型为 Var,这是一个接口类型:

1
2
3
type Var interface {
String() string
}

如下展示了自定义度量数据的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"expvar"
"fmt"
"net/http"
"strconv"
"sync/atomic"
)

type CustomVar struct {
value int64
}

func (v *CustomVar) String() string {
return strconv.FormatInt(atomic.LoadInt64(&v.value), 10)
}

func init() {
c := CustomVar{
value: 17,
}

expvar.Publish("CustomVar", &c)
}

func main() {
fmt.Println(http.ListenAndServe("10.9.33.133:8080", nil))
}

此时就可以看到自定义度量数据的输出:

1
2
3
4
5
{
"CustomVar": 17,
"cmdline": ["./main"],
"memstats": .....
}

我们在设计能够反映 Go 应用内部状态的自定义指标时,经常会设计如下两类指标:

  • 测量型:这类指标是数字,支持上下增减
  • 计数型:这类指标也是数字,特点是随着时间的推移,其数值不会减少

expvar 包提供了对常用指标类型的原生支持,例如 expvar.Int、expvar.Map 等类型。如果想将一个结构体类型当做一个复合指标直接输出,expvar 包也提供了很好的支持:

  • 通过实现一个返回 interface{} 类型的函数,并通过 Publish 函数将该函数发布出去
  • 这个返回 interface{} 类型的函数的返回值底层类型必须是一个支持序列化为 JSON 格式的类型

输出数据的展示

通过 /debug/vars 服务端点,可以得到标准 JSON 格式的应用内部状态数据。JSON 格式的文本很容易反序列化,开发者可以自行解析后使用。

社区开发者开发了一款 expvarmon 工具,支持将从 expvar 输出的数据以基于终端的图形化方式展示出来。

使用 Delve 调试 Go 代码

凡是软件,必有 bug,接下来学习 Go 调试领域应用即为广泛的工具:Delve。

关于调试,首先应该知道的几件事情

  • 调试前,首先做好心理准备
  • 预防 bug 的发生,降低 bug 的发生概率
    • 充分的代码检查:编译器(将警告级别调高),静态代码分析工具(例如 go vet)等
    • 为调试版增加断言
    • 充分的单元测试
    • 代码同级评审
  • bug 原因定位和修正
    • 收集现场数据
    • 定位问题所在
    • 修改并验证
  • 定期回顾 bug 列表,可能很多 bug 是能够在预防阶段就能规避,形成良性循环

go 调试工具的选择

我们可以通过文本输出(fmt.Print 等)调试 Go 代码和使用专业的调试器(如 Delve、GDB)调试 Go 代码,这两者互相补充、相辅相成。

通过 gccgo(与标准 Go 编译器不同的另外一个 go 编译器)编译而成的 Go 程序可以得到 GCC 成熟工具链的原生支持,包括可以使用 GDB 进行调试。对于标准 Go 编译器编译生成的 Go 程序,gdb 也能够进行调试,但是能力有限,因为 GDB 不了解 Go 程序:

  • Go 的栈惯例、线程模型、运行时等与 GDB 所了解的执行模型不同
  • 使用复杂,需要加载插件(runtime-gdb.py)才能更好地理解 Go 符号
  • GDB 无法识别一些 Go 类型信息等

Delve 是另外一个 Go 调试器,旨在为 Go 提供一个简单的、功能齐全、易于使用和调用的调试工具,是目前 Go 调试器事实标准。

Delve 调试基础、原理和架构

通过如下命令可以安装 delve,安装成功后,dlv 命令会出现在 $GOPATH/bin 目录下:

1
go install github.com/go-delve/delve/cmd/dlv@latest

安装成功后,常用的调试命令包括:

  • break:设置断点,支持设置条件断点
  • breakpoints:查看断点
  • continue:继续运行程序
  • print、whatis、regs、locals args、等命令:查看程序状态
  • next:执行下一行代码
  • step:步进
  • stack:查看调用栈信息
  • up/down:在调用栈的栈帧之间跳转
  • set:调试过程中修改变量的值
  • call:手动调用某个函数

在直接调试二进制文件时,Delve 会根据二进制文件中保存的源文件位置到对应的路径下寻找对应的源文件并展示对应源码。如果把那个路径下的源文件挪走,那么再通过 list 命令展示源码就会出现错误。

有时候调试二进制文件时会有报错,有可能是 Go 编译器对目标代码进行了优化,为了避免这个问题,可以在编译时加入关闭优化的标志位

1
# go build -gcflags=all="-N -l"  ...

Go 编译器以 DWARF 格式(一种标准的调试信息格式)写入目标二进制文件中的调试符号信息来了解被调试目标的源码信息,并实现了被调试目标进程中的地址、二进制文件中的调试符号及源码相关信息三者之间的关系映射。

并发、Coredump 文件与挂接进程调试

Delve 提供了调试命令,可以让我们在各个运行的 goroutine 之间切换:

  • goroutines:列出当前程序内的 goroutine 列表
  • goroutine :切换到指定的 goroutine 上下文

Delve 还提供了 thread 和 threads 命令,通过这两个命令我们可以查看当前启动的线程列表并在各个线程间切换。

使用 dlv core 命令可以对产生的 core 文件进行调试。

有时候我们需要对正在运行的 Go 应用程序进行调试,但是这样做有一些风险:因为一旦调试器 attach 到正在运行的进程,调试器就掌握了进程执行的指挥权,并且正在运行的 goroutine 都会暂停,等待调试器的进一步指令。使用 dlv attach <PID> 命令可以调试正在运行的 Go 程序。