十二、优化
本章涵盖
- 研究机械同情心的概念
- 了解堆与栈并减少分配
- 使用标准 Go 诊断工具
- 了解垃圾收集器的工作原理
- 跑GO里面的 Docker 和 Kubernetes
在我们开始这一章之前,一个免责声明:在大多数情况下,编写可读、清晰的代码比编写优化但更复杂、更难理解的代码要好。优化通常是有代价的,我们建议您遵循软件工程师 Wes Dyer 的这句名言:
使其正确,使其清晰,使其简洁,使其快速,按此顺序。
这并不意味着禁止优化应用的速度和效率。例如,我们可以尝试识别需要优化的代码路径,因为有必要这样做,比如让我们的客户满意或者降低我们的成本。在本章中,我们将讨论常见的优化技术;有些是特定要去的,有些不是。我们还讨论了识别瓶颈的方法,这样我们就不会盲目工作。
十一、测试
本章涵盖
- 对测试进行分类,使它们更加健壮
- 使 Go 测试具有确定性
- 使用实用工具包,如
httptest
和iotest
- 避免常见的基准错误
- 改进测试流程
测试是项目生命周期的一个重要方面。它提供了无数的好处,比如建立对应用的信心,充当代码文档,以及使重构更容易。与其他一些语言相比,Go 拥有强大的编写测试的原语。在这一章中,我们将关注那些使测试过程变得脆弱、低效和不准确的常见错误。
11.1 #82:没有对测试进行分类
测试金字塔是一个将测试分成不同类别的模型(见图 11.1)。单元测试占据了金字塔的底部。大多数测试应该是单元测试:它们编写成本低,执行速度快,并且具有很高的确定性。通常,当我们走的时候
在金字塔的更高层,测试变得越来越复杂,运行越来越慢,并且更难保证它们的确定性。
图 11.1 测试金字塔的一个例子
一种常见的技术是明确要运行哪种测试。例如,根据项目生命周期的阶段,我们可能希望只运行单元测试或者运行项目中的所有测试。不对测试进行分类意味着潜在的浪费时间和精力,并且失去了测试范围的准确性。本节讨论了在 Go 中对测试进行分类的三种主要方法。
11.1.1 构建标签
分类测试最常见的方法是使用构建标签。构建标签是 Go 文件开头的特殊注释,后面跟一个空行。
例如,看看这个bar.go
文件:
//go:build foo
package bar
这个文件包含了foo
标签。请注意,一个包可能包含多个带有不同构建标记的文件。
注从 Go 1.17 开始,语法//
+build foo
被//go:build foo
取代。目前(Go 1.18),gofmt
同步这两种形式来帮助迁移。
构建标签主要用于两种情况。首先,我们可以使用build
标签作为构建应用的条件选项:例如,如果我们希望只有在启用了cgo
的情况下才包含源文件(cgo
是一种让包调用 C 代码的方法),我们可以添加//go:build
cgo`
build标签。第二,如果我们想要将一个测试归类为集成测试,我们可以添加一个特定的构建标志,比如
integration`。
下面是一个db_test.go
文件示例:
//go:build integration
package db
import (
"testing"
)
func TestInsert(t *testing.T) {
// ...
}
这里我们添加了integration`
build`标签来分类这个文件包含集成测试。使用构建标签的好处是我们可以选择执行哪种测试。例如,让我们假设一个包包含两个测试文件:
我们刚刚创建的文件:
db_test.go
另一个不包含构建标签的文件:
contract_test.go
如果我们在这个包中运行go test
而没有任何选项,它将只运行没有构建标签的测试文件(contract_test.go):
$ go test -v .
=== RUN TestContract
--- PASS: TestContract (0.01s)
PASS
然而,如果我们提供了integration
标签,运行go test
也将包括db_test.go
:
$ go test --tags=integration -v .
=== RUN TestInsert
--- PASS: TestInsert (0.01s)
=== RUN TestContract
--- PASS: TestContract (2.89s)
PASS
因此,运行带有特定标签的测试包括没有标签的文件和匹配这个标签的文件。如果我们只想运行集成测试呢?一种可能的方法是在单元测试文件上添加一个否定标记。例如,使用!integration
意味着只有当integration
标志不启用时,我们才想要包含测试文件(contract_test.go):
//go:build !integration
package db
import (
"testing"
)
func TestContract(t *testing.T) {
// ...
}
使用这种方法,
带
integration
标志运行go test
仅运行集成测试。在没有
integration
标志的情况下运行go test
只会运行单元测试。
让我们讨论一个在单个测试层次上工作的选项,而不是一个文件。
11.1.2 环境变量
正如 Go 社区的成员 Peter Bourgon 所提到的,build
标签有一个主要的缺点:缺少一个测试被忽略的信号(参见 mng.bz/qYlr
)。在第一个例子中,当我们在没有构建标志的情况下执行go test
时,它只显示了被执行的测试:
$ go test -v .
=== RUN TestUnit
--- PASS: TestUnit (0.01s)
PASS
ok db 0.319s
如果我们不小心处理标签的方式,我们可能会忘记现有的测试。出于这个原因,一些项目喜欢使用环境变量来检查测试类别的方法。
例如,我们可以通过检查一个特定的环境变量并可能跳过测试来实现TestInsert
集成测试:
func TestInsert(t *testing.T) {
if os.Getenv("INTEGRATION") != "true" {
t.Skip("skipping integration test")
}
// ...
}
如果INTEGRATION
环境变量没有设置为true
,测试将被跳过,并显示一条消息:
$ go test -v .
=== RUN TestInsert
db_integration_test.go:12: skipping integration test // ❶
--- SKIP: TestInsert (0.00s)
=== RUN TestUnit
--- PASS: TestUnit (0.00s)
PASS
ok db 0.319s
❶ 显示跳过测试的消息
使用这种方法的一个好处是明确哪些测试被跳过以及为什么。这种技术可能没有build
标签使用得广泛,但是它值得了解,因为正如我们所讨论的,它提供了一些优势。
接下来,让我们看看另一种分类测试的方法:短模式。
11.1.3 短模式
另一种对测试进行分类的方法与它们的速度有关。我们可能必须将短期运行的测试与长期运行的测试分离开来。
作为一个例子,假设我们有一组单元测试,其中一个非常慢。我们希望对慢速测试进行分类,这样我们就不必每次都运行它(特别是当触发器是在保存一个文件之后)。短模式允许我们进行这种区分:
func TestLongRunning(t *testing.T) {
if testing.Short() { // ❶
t.Skip("skipping long-running test")
}
// ...
}
❶ 将测试标记为长期运行
使用testing.Short
,我们可以在运行测试时检索是否启用了短模式。然后我们使用Skip
来跳过测试。要使用短模式运行测试,我们必须通过-short
:
% go test -short -v .
=== RUN TestLongRunning
foo_test.go:9: skipping long-running test
--- SKIP: TestLongRunning (0.00s)
PASS
ok foo 0.174s
执行测试时,明确跳过TestLongRunning
。请注意,与构建标签不同,该选项适用于每个测试,而不是每个文件。
总之,对测试进行分类是成功测试策略的最佳实践。在本节中,我们已经看到了三种对测试进行分类的方法:
在测试文件级别使用构建标签
使用环境变量来标记特定的测试
基于使用短模式的测试步速
我们还可以组合方法:例如,如果我们的项目包含长时间运行的单元测试,使用构建标签或环境变量来分类测试(例如,作为单元或集成测试)和短模式。
在下一节中,我们将讨论为什么启用-race
标志很重要。
11.2 #83:不启用竞争标志
在错误#58“不理解竞争问题”中,我们将数据竞争定义为当两个 goroutines 同时访问同一个变量时发生,至少有一个变量被写入。我们还应该知道,Go 有一个标准的竞争检测工具来帮助检测数据竞争。一个常见的错误是忘记了这个工具的重要性,没有启用它。这一节讨论竞争检测器捕捉什么,如何使用它,以及它的局限性。
在 Go 中,竞争检测器不是编译期间使用的静态分析工具;相反,它是一个发现运行时发生的数据竞争的工具。要启用它,我们必须在编译或运行测试时启用-race
标志。例如:
$ go test -race ./...
一旦启用了竞争检测器,编译器就会检测代码来检测数据竞争。插装指的是编译器添加额外的指令:在这里,跟踪所有的内存访问并记录它们何时以及如何发生。在运行时,竞争检测器监视数据竞争。但是,我们应该记住启用竞争检测器的运行时开销:
内存使用量可能会增加 5 到 10 倍。
执行时间可能增加 2 到 20 倍。
由于这种开销,通常建议只在本地测试或持续集成(CI)期间启用竞争检测器。在生产中,我们应该避免使用它(或者只在金丝雀释放的情况下使用它)。
如果检测到竞争,Go 会发出警告。例如,这个例子包含了一个数据争用,因为i
可以同时被读取和写入:
package main
import (
"fmt"
)
func main() {
i := 0
go func() { i++ }()
fmt.Println(i)
}
使用-race
标志运行该应用会记录以下数据竞争警告:
==================
WARNING: DATA RACE
Write at 0x00c000026078 by goroutine 7: // ❶
main.main.func1()
/tmp/app/main.go:9 +0x4e
Previous read at 0x00c000026078 by main goroutine: // ❷
main.main()
/tmp/app/main.go:10 +0x88
Goroutine 7 (running) created at: // ❸
main.main()
/tmp/app/main.go:9 +0x7a
==================
❶ 指出由 goroutine 7 写入
❷ 指出由主 goroutine读取
❸ 指出了 goroutine 7 的创建时间
让我们确保阅读这些信息时感到舒适。Go 总是记录以下内容:
被牵连的并发 goroutine:这里是主 goroutine 和 goroutine 7。
代码中出现访问的地方:在本例中,是第 9 行和第 10 行。
创建这些 goroutine 的时间:goroutine 7 是在
main()
中创建的。
注意在内部,竞争检测器使用向量时钟,这是一种用于确定事件部分顺序的数据结构(也用于分布式系统,如数据库)。每一个 goroutine 的创建都会导致一个向量时钟的产生。该工具在每次存储器访问和同步事件时更新向量时钟。然后,它比较向量时钟以检测潜在的数据竞争。
竞争检测器不能捕捉假阳性(一个明显的数据竞争,而不是真正的数据竞争)。因此,如果我们得到警告,我们知道我们的代码包含数据竞争。相反,它有时会导致假阴性(遗漏实际的数据竞争)。
关于测试,我们需要注意两件事。首先,竞争检测器只能和我们的测试一样好。因此,我们应该确保针对数据竞争对并发代码进行彻底的测试。其次,考虑到可能的假阴性,如果我们有一个测试来检查数据竞争,我们可以将这个逻辑放在一个循环中。这样做增加了捕获可能的数据竞争的机会:
func TestDataRace(t *testing.T) {
for i := 0; i < 100; i++ {
// Actual logic
}
}
此外,如果一个特定的文件包含导致数据竞争的测试,我们可以使用!race`
build`标签将其从竞争检测中排除:
//go:build !race
package main
import (
"testing"
)
func TestFoo(t *testing.T) {
// ...
}
func TestBar(t *testing.T) {
// ...
}
只有在禁用竞争检测器的情况下,才会构建该文件。否则,整个文件不会被构建,所以测试不会被执行。
总之,我们应该记住,如果不是强制性的,强烈推荐使用并发性为应用运行带有-race
标志的测试。这种方法允许我们启用竞争检测器,它检测我们的代码来捕捉潜在的数据竞争。启用时,它会对内存和性能产生重大影响,因此必须在特定条件下使用,如本地测试或 CI。
下面讨论与和执行模式相关的两个标志:parallel
和shuffle
。
11.3 #84:不使用测试执行模式
在运行测试时,go
命令可以接受一组标志来影响测试的执行方式。一个常见的错误是没有意识到这些标志,错过了可能导致更快执行或更好地发现可能的 bug 的机会。让我们来看看其中的两个标志:parallel
和shuffle
。
11.3.1 并行标志
并行执行模式允许我们并行运行特定的测试,这可能非常有用:例如,加速长时间运行的测试。我们可以通过调用t.Parallel
来标记测试必须并行运行:
func TestFoo(t *testing.T) {
t.Parallel()
// ...
}
当我们使用t.Parallel
标记一个测试时,它与所有其他并行测试一起并行执行。然而,在执行方面,Go 首先一个接一个地运行所有的顺序测试。一旦顺序测试完成,它就执行并行测试。
例如,以下代码包含三个测试,但其中只有两个被标记为并行运行:
func TestA(t *testing.T) {
t.Parallel()
// ...
}
func TestB(t *testing.T) {
t.Parallel()
// ...
}
func TestC(t *testing.T) {
// ...
}
运行该文件的测试会产生以下日志:
=== RUN TestA
=== PAUSE TestA // ❶
=== RUN TestB
=== PAUSE TestB // ❷
=== RUN TestC // ❸
--- PASS: TestC (0.00s)
=== CONT TestA // ❹
--- PASS: TestA (0.00s)
=== CONT TestB
--- PASS: TestB (0.00s)
PASS
❶ 暂停TestA
❷ 暂停TestB
❸ 运行TestC
❹ 恢复TestA
和TestB
TestC
第一个被处决。TestA
和TestB
首先被记录,但是它们被暂停,等待TestC
完成。然后两者都被恢复并并行执行。
默认情况下,可以同时运行的最大测试数量等于GOMAXPROCS
值。为了序列化测试,或者,例如,在进行大量 I/O 的长时间运行的测试环境中增加这个数字,我们可以使用的-parallel
标志来改变这个值:
$ go test -parallel 16 .
这里,并行测试的最大数量被设置为 16。
现在让我们看看运行 Go 测试的另一种模式:shuffle
。
11.3.2 混洗标志
从 Go 1.17 开始,可以随机化测试和基准的执行顺序。有什么道理?编写测试的最佳实践是将它们隔离开来。例如,它们不应该依赖于执行顺序或共享变量。这些隐藏的依赖关系可能意味着一个可能的测试错误,或者更糟糕的是,一个在测试过程中不会被发现的错误。为了防止这种情况,我们可以使用和-shuffle
标志来随机化测试。我们可以将其设置为on
或off
来启用或禁用测试混洗(默认情况下禁用):
$ go test -shuffle=on -v .
然而,在某些情况下,我们希望以相同的顺序重新运行测试。例如,如果在 CI 期间测试失败,我们可能希望在本地重现错误。为此,我们可以传递用于随机化测试的种子,而不是将on
传递给-shuffle
标志。我们可以通过启用详细模式(-v
)在运行混洗测试时访问这个种子值:
$ go test -shuffle=on -v .
-test.shuffle 1636399552801504000 // ❶
=== RUN TestBar
--- PASS: TestBar (0.00s)
=== RUN TestFoo
--- PASS: TestFoo (0.00s)
PASS
ok teivah 0.129s
❶ 种子值
我们随机执行测试,但是go
test
打印种子值:1636399552801504000
。为了强制测试以相同的顺序运行,我们将这个种子值提供给shuffle
:
$ go test -shuffle=1636399552801504000 -v .
-test.shuffle 1636399552801504000
=== RUN TestBar
--- PASS: TestBar (0.00s)
=== RUN TestFoo
--- PASS: TestFoo (0.00s)
PASS
ok teivah 0.129s
测试以相同的顺序执行:TestBar
然后是TestFoo
。
一般来说,我们应该对现有的测试标志保持谨慎,并随时了解最近 Go 版本的新特性。并行运行测试是减少运行所有测试的总执行时间的一个很好的方法。并且shuffle
模式可以帮助我们发现隐藏的依赖关系,这可能意味着在以相同的顺序运行测试时的测试错误,甚至是看不见的 bug。
11.4 #85:不使用表驱动测试
表驱动测试是一种有效的技术,用于编写精简的测试,从而减少样板代码,帮助我们关注重要的东西:测试逻辑。本节通过一个具体的例子来说明为什么在使用 Go 时表驱动测试是值得了解的。
让我们考虑下面的函数,它从字符串中删除所有的新行后缀(\n
或\r\n
):
func removeNewLineSuffixes(s string) string {
if s == "" {
return s
}
if strings.HasSuffix(s, "\r\n") {
return removeNewLineSuffixes(s[:len(s)-2])
}
if strings.HasSuffix(s, "\n") {
return removeNewLineSuffixes(s[:len(s)-1])
}
return s
}
这个函数递归地删除所有前导的\r\n
和\n
后缀。现在,假设我们想要广泛地测试这个函数。我们至少应该涵盖以下情况:
输入为空。
输入以
\n
结束。输入以
\r\n
结束。输入以多个
\n
结束。输入结束时没有换行符。
以下方法为每个案例创建一个单元测试:
func TestRemoveNewLineSuffix_Empty(t *testing.T) {
got := removeNewLineSuffixes("")
expected := ""
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine(t *testing.T) {
got := removeNewLineSuffixes("a\r\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithNewLine(t *testing.T) {
got := removeNewLineSuffixes("a\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithMultipleNewLines(t *testing.T) {
got := removeNewLineSuffixes("a\n\n\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithoutNewLine(t *testing.T) {
got := removeNewLineSuffixes("a\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
每个函数都代表了我们想要涵盖的一个特定案例。然而,有两个主要缺点。首先,函数名更复杂(TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine
有 55 个字符长),这很快会影响函数测试内容的清晰度。第二个缺点是这些函数之间的重复量,因为结构总是相同的:
谓
removeNewLineSuffixes
。定义期望值。
比较数值。
记录错误信息。
如果我们想要改变这些步骤中的一个——例如,将期望值作为错误消息的一部分包含进来——我们将不得不在所有的测试中重复它。我们写的测试越多,代码就越难维护。
相反,我们可以使用表驱动测试,这样我们只需编写一次逻辑。表驱动测试依赖于子测试,一个测试函数可以包含多个子测试。例如,以下测试包含两个子测试:
func TestFoo(t *testing.T) {
t.Run("subtest 1", func(t *testing.T) { // ❶
if false {
t.Error()
}
})
t.Run("subtest 2", func(t *testing.T) { // ❷
if 2 != 2 {
t.Error()
}
})
}
❶ 进行第一个子测试,称为子测试 1
❷ 进行第二个子测试,称为子测试 2
TestFoo
函数包括两个子测试。如果我们运行这个测试,它显示了subtest 1
和subtest 2
的结果:
--- PASS: TestFoo (0.00s)
--- PASS: TestFoo/subtest_1 (0.00s)
--- PASS: TestFoo/subtest_2 (0.00s)
PASS
我们还可以使用和-run
标志运行一个单独的测试,并将父测试名与子测试连接起来。例如,我们可以只运行subtest 1
:
$ go test -run=TestFoo/subtest_1 -v // ❶
=== RUN TestFoo
=== RUN TestFoo/subtest_1
--- PASS: TestFoo (0.00s)
--- PASS: TestFoo/subtest_1 (0.00s)
❶ 使用-run
标志只运行子测试 1
让我们回到我们的例子,看看如何使用子测试来防止重复测试逻辑。主要想法是为每个案例创建一个子测试。变化是存在的,但是我们将讨论一个映射数据结构,其中键代表测试名称,值代表测试数据(输入,预期)。
表驱动测试通过使用包含测试数据和子测试的数据结构来避免样板代码。下面是一个使用映射的可能实现:
func TestRemoveNewLineSuffix(t *testing.T) {
tests := map[string]struct { // ❶
input string
expected string
}{
`empty`: { // ❷
input: "",
expected: "",
},
`ending with \r\n`: {
input: "a\r\n",
expected: "a",
},
`ending with \n`: {
input: "a\n",
expected: "a",
},
`ending with multiple \n`: {
input: "a\n\n\n",
expected: "a",
},
`ending without newline`: {
input: "a",
expected: "a",
},
}
for name, tt := range tests { // ❸
t.Run(name, func(t *testing.T) { // ❹
got := removeNewLineSuffixes(tt.input)
if got != tt.expected {
t.Errorf("got: %s, expected: %s", got, tt.expected)
}
})
}
}
❶ 定义了测试数据
❷ :映射中的每个条目代表一个子测试。
❸ 在映射上迭代
❹ 为每个映射条目运行一个新的子测试
tests
变量是一个映射。关键是测试名称,值代表测试数据:在我们的例子中,输入和预期的字符串。每个映射条目都是我们想要覆盖的一个新的测试用例。我们为每个映射条目运行一个新的子测试。
这个测试解决了我们讨论的两个缺点:
每个测试名现在是一个字符串,而不是 Pascal 大小写函数名,这使得它更容易阅读。
该逻辑只编写一次,并在所有不同的情况下共享。修改测试结构或者增加一个新的测试需要最小的努力。
关于表驱动测试,我们需要提到最后一件事,它也可能是错误的来源:正如我们前面提到的,我们可以通过调用t.Parallel
来标记一个并行运行的测试。我们也可以在提供给t.Run
的闭包内的子测试中这样做:
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel() // ❶
// Use tt
})
}
❶ 标记了并行运行的子测试
然而,这个闭包使用了一个循环变量。为了防止类似于错误#63 中讨论的问题,“不小心使用 goroutines 和循环变量”,这可能导致闭包使用错误的tt
变量的值,我们应该创建另一个变量或影子tt
:
for name, tt := range tests {
tt := tt // ❶
t.Run(name, func(t *testing.T) {
t.Parallel()
// Use tt
})
}
❶ 跟踪tt
,使其位于循环迭代的局部
这样,每个闭包都会访问它自己的tt
变量。
总之,如果多个单元测试有相似的结构,我们可以使用表驱动测试来共同化它们。因为这种技术防止了重复,它使得改变测试逻辑变得简单,并且更容易添加新的用例。
接下来,我们来讨论如何在 Go 中防止片状测试。
11.5 #86:在单元测试中睡眠
古怪的测试是一个不需要任何代码改变就可以通过和失败的测试。古怪的测试是测试中最大的障碍之一,因为它们调试起来很昂贵,并且削弱了我们对测试准确性的信心。在 Go 中,在测试中调用time.Sleep
可能是可能出现问题的信号。例如,并发代码经常使用睡眠进行测试。这一部分介绍了从测试中移除睡眠的具体技术,从而防止我们编写出易变的测试。
我们将用一个函数来说明这一部分,该函数返回值并启动一个在后台执行任务的 goroutine。我们将调用一个函数来获取一片Foo
结构,并返回最佳元素(第一个)。与此同时,另一个 goroutine 将负责调用带有第n
个Foo
元素的Publish
方法:
type Handler struct {
n int
publisher publisher
}
type publisher interface {
Publish([]Foo)
}
func (h Handler) getBestFoo(someInputs int) Foo {
foos := getFoos(someInputs) // ❶
best := foos[0] // ❷
go func() {
if len(foos) > h.n { // ❸
foos = foos[:h.n]
}
h.publisher.Publish(foos) // ❹
}()
return best
}
❶ 得到Foo
切片
❷ 保留第一个元素(为了简单起见,省略了检查foos
的长度)
❸ 只保留前n
个Foo
结构
❹ 调用Publish
方法
Handler
结构包含两个字段:一个n
字段和一个用于发布第一个n
Foo
结构的publisher
依赖项。首先我们得到一片Foo
;但是在返回第一个元素之前,我们旋转一个新的 goroutine,过滤foos
片,并调用Publish
。
我们如何测试这个函数?编写声明响应的部分非常简单。但是,如果我们还想检查传递给Publish
的是什么呢?
我们可以模仿publisher
接口来记录调用Publish
方法时传递的参数。然后,我们可以在检查记录的参数之前睡眠几毫秒:
type publisherMock struct {
mu sync.RWMutex
got []Foo
}
func (p *publisherMock) Publish(got []Foo) {
p.mu.Lock()
defer p.mu.Unlock()
p.got = got
}
func (p *publisherMock) Get() []Foo {
p.mu.RLock()
defer p.mu.RUnlock()
return p.got
}
func TestGetBestFoo(t *testing.T) {
mock := publisherMock{}
h := Handler{
publisher: &mock,
n: 2,
}
foo := h.getBestFoo(42)
// Check foo
time.Sleep(10 * time.Millisecond) // ❶
published := mock.Get()
// Check published
}
❶ 在检查传递给Publish
的参数之前,睡眠了 10 毫秒
我们编写了一个对publisher
的模拟,它依赖于一个互斥体来保护对published
字段的访问。在我们的单元测试中,我们调用time.Sleep
在检查传递给Publish
的参数之前留出一些时间。
这种测试本来就不可靠。不能严格保证 10 毫秒就足够了(在本例中,有可能但不能保证)。
那么,有哪些选项可以改进这个单元测试呢?首先,我们可以使用重试来周期性地断言给定的条件。例如,我们可以编写一个函数,将一个断言作为参数,最大重试次数加上等待时间,定期调用该函数以避免繁忙循环:
func assert(t *testing.T, assertion func() bool,
maxRetry int, waitTime time.Duration) {
for i := 0; i < maxRetry; i++ {
if assertion() { // ❶
return
}
time.Sleep(waitTime) // ❷
}
t.Fail() // ❸
}
❶ 检查断言
❷ 在重试前睡眠
❸ 经过多次尝试后,最终失败了
该函数检查提供的断言,并在一定次数的重试后失败。我们也使用time.Sleep
,但是我们可以用这段代码来缩短睡眠时间。
举个例子,让我们回到TestGetBestFoo
:
assert(t, func() bool {
return len(mock.Get()) == 2
}, 30, time.Millisecond)
我们不是睡眠 10 毫秒,而是每毫秒睡眠一次,并配置最大重试次数。如果测试成功,这种方法可以减少执行时间,因为我们减少了等待时间。因此,实现重试策略是比使用被动睡眠更好的方法。
注意一些测试库,如testify
,提供重试功能。例如,在testify
中,我们可以使用Eventually
函数,它实现了最终应该成功的断言和其他特性,比如配置错误消息。
另一个策略是使用通道来同步发布Foo
结构的 goroutine 和测试 goroutine。例如,在模拟实现中,我们可以将这个值发送到一个通道,而不是将接收到的切片复制到一个字段中:
type publisherMock struct {
ch chan []Foo
}
func (p *publisherMock) Publish(got []Foo) {
p.ch <- got // ❶
}
func TestGetBestFoo(t *testing.T) {
mock := publisherMock{
ch: make(chan []Foo),
}
defer close(mock.ch)
h := Handler{
publisher: &mock,
n: 2,
}
foo := h.getBestFoo(42)
// Check foo
if v := len(<-mock.ch); v != 2 { // ❷
t.Fatalf("expected 2, got %d", v)
}
}
❶ 发送收到的参数
❷ 比较了这些参数
发布者将接收到的参数发送到通道。同时,测试 goroutine 设置模拟并基于接收到的值创建断言。我们还可以实现一个超时策略,以确保如果出现问题,我们不会永远等待mock.ch
。例如,我们可以将select
与time.After
一起使用。
我们应该支持哪个选项:重试还是同步?事实上,同步将等待时间减少到最低限度,如果设计得好的话,可以使测试完全确定。
如果我们不能应用同步,我们也许应该重新考虑我们的设计,因为我们可能有一个问题。如果同步确实不可能,我们应该使用重试选项,这是比使用被动睡眠来消除测试中的不确定性更好的选择。
让我们继续讨论如何在测试中防止剥落,这次是在使用时间 API 的时候。
11.6 #87:没有有效地处理时间 API
一些函数必须依赖于时间 API:例如,检索当前时间。在这种情况下,编写脆弱的单元测试可能会很容易失败。在本节中,我们将通过一个具体的例子来讨论选项。我们的目标并不是涵盖所有的用例及技术,而是给出关于使用时间 API 编写更健壮的函数测试的指导。
假设一个应用接收到我们希望存储在内存缓存中的事件。我们将实现一个Cache
结构来保存最近的事件。此结构将公开三个方法,这些方法执行以下操作:
追加事件
获取所有事件
在给定的持续时间内修剪事件(我们将重点介绍这种方法)
这些方法中的每一个都需要访问当前时间。让我们使用time.Now()
编写第三种方法的第一个实现(我们将假设所有事件都按时间排序):
type Cache struct {
mu sync.RWMutex
events []Event
}
type Event struct {
Timestamp time.Time
Data string
}
func (c *Cache) TrimOlderThan(since time.Duration) {
c.mu.RLock()
defer c.mu.RUnlock()
t := time.Now().Add(-since) // ❶
for i := 0; i < len(c.events); i++ {
if c.events[i].Timestamp.After(t) {
c.events = c.events[i:] // ❷
return
}
}
}
❶ 从当前时间中减去给定的持续时间
❷ 负责整理这些事件
我们计算一个t
变量,它是当前时间减去提供的持续时间。然后,因为事件是按时间排序的,所以一旦到达时间在t
之后的事件,我们就更新内部的events
片。
我们如何测试这种方法?我们可以依靠当前时间使用time.Now
来创建事件:
func TestCache_TrimOlderThan(t *testing.T) {
events := []Event{ // ❶
{Timestamp: time.Now().Add(-20 * time.Millisecond)},
{Timestamp: time.Now().Add(-10 * time.Millisecond)},
{Timestamp: time.Now().Add(10 * time.Millisecond)},
}
cache := &Cache{}
cache.Add(events) // ❷
cache.TrimOlderThan(15 * time.Millisecond) // ❸
got := cache.GetAll() // ❹
expected := 2
if len(got) != expected {
t.Fatalf("expected %d, got %d", expected, len(got))
}
}
❶ 利用time.Now()
创建事件。
❷ 将这些事件添加到缓存中
❸ 整理了 15 毫秒前的事件
❹ 检索所有事件
我们使用time.Now()
将一部分事件添加到缓存中,并增加或减少一些小的持续时间。然后,我们将这些事件调整 15 毫秒,并执行断言。
这种方法有一个主要缺点:如果执行测试的机器突然很忙,我们可能会修剪比预期更少的事件。我们也许能够增加提供的持续时间,以减少测试失败的机会,但这样做并不总是可能的。例如,如果时间戳字段是在添加事件时生成的未导出字段,该怎么办?在这种情况下,不可能传递特定的时间戳,最终可能会在单元测试中添加睡眠。
问题和TrimOlderThan
的实现有关。因为它调用了time.Now()
,所以实现健壮的单元测试更加困难。让我们讨论两种使我们的测试不那么脆弱的方法。
第一种方法是使检索当前时间的方法成为对Cache
结构的依赖。在生产中,我们会注入真正的实现,而在单元测试中,我们会传递一个存根。
有多种技术可以处理这种依赖性,比如接口或函数类型。在我们的例子中,因为我们只依赖一个方法(time.Now()
),我们可以定义一个函数类型:
type now func() time.Time
type Cache struct {
mu sync.RWMutex
events []Event
now now
}
now
类型是一个返回time.Time
的函数。在工厂函数中,我们可以这样传递实际的time.Now
函数:
func NewCache() *Cache {
return &Cache{
events: make([]Event, 0),
now: time.Now,
}
}
因为now
依赖项仍未导出,所以外部客户端无法访问它。此外,在我们的单元测试中,我们可以通过基于预定义的时间注入func() time.Time
的假实现来创建一个Cache
结构:
func TestCache_TrimOlderThan(t *testing.T) {
events := []Event{ // ❶
{Timestamp: parseTime(t, "2020-01-01T12:00:00.04Z")},
{Timestamp: parseTime(t, "2020-01-01T12:00:00.05Z")},
{Timestamp: parseTime(t, "2020-01-01T12:00:00.06Z")},
}
cache := &Cache{now: func() time.Time { // ❷
return parseTime(t, "2020-01-01T12:00:00.06Z")
}}
cache.Add(events)
cache.TrimOlderThan(15 * time.Millisecond)
// ...
}
func parseTime(t *testing.T, timestamp string) time.Time {
// ...
}
❶ 基于特定的时间戳创建事件
❷ 注入一个静态函数来固定时间
在创建新的Cache
结构时,我们根据给定的时间注入now
依赖。由于这种方法,测试是健壮的。即使在最坏的情况下,这个测试的结果也是确定的。
使用全局变量
我们可以通过一个全局变量来检索时间,而不是使用字段:
var now = time.Now // ❶
❶ 定义了全局变量now
一般来说,我们应该尽量避免这种易变的共享状态。在我们的例子中,这将导致至少一个具体的问题:测试将不再是孤立的,因为它们都依赖于一个共享的变量。因此,举例来说,测试不能并行运行。如果可能的话,我们应该将这些情况作为结构依赖的一部分来处理,促进测试隔离。
这个解决方案也是可扩展的。比如函数调用time.After
怎么办?我们可以添加另一个after
依赖项,或者创建一个将两个方法Now
和After
组合在一起的接口。然而,这种方法有一个主要的缺点:例如,如果我们从一个外部包中创建一个单元测试,那么now
依赖就不可用(我们在错误 90“没有探索所有的 Go 测试特性”中探讨了这一点)。
在这种情况下,我们可以使用另一种技术。我们可以要求客户端提供当前时间,而不是将时间作为未报告的依赖项来处理:
func (c *Cache) TrimOlderThan(now time.Time, since time.Duration) {
// ...
}
为了更进一步,我们可以将两个函数参数合并到一个单独的time.Time
中,该参数代表一个特定的时间点,直到我们想要调整事件:
func (c *Cache) TrimOlderThan(t time.Time) {
// ...
}
由调用者来计算这个时间点:
cache.TrimOlderThan(time.Now().Add(time.Second))
而在测试中,我们也必须通过相应的时间:
func TestCache_TrimOlderThan(t *testing.T) {
// ...
cache.TrimOlderThan(parseTime(t, "2020-01-01T12:00:00.06Z").
Add(-15 * time.Millisecond))
// ...
}
这种方法是最简单的,因为它不需要创建另一种类型和存根。
一般来说,我们应该谨慎测试使用time
API 的代码。这可能是一扇为古怪的测试敞开的大门。在本节中,我们看到了两种处理方法。我们可以将time
交互作为依赖的一部分,通过使用我们自己的实现或依赖外部库,我们可以在单元测试中伪造这种依赖;或者我们可以修改我们的 API,要求客户提供我们需要的信息,比如当前时间(这种技术更简单,但是更有限)。
现在让我们讨论两个与测试相关的有用的 Go 包:httptest
和iotest
。
11.7 #88:不使用测试实用工具包
标准库提供了用于测试的实用工具包。一个常见的错误是没有意识到这些包,并试图重新发明轮子或依赖其他不方便的解决方案。本节研究其中的两个包:一个在使用 HTTP 时帮助我们,另一个在进行 I/O 和使用读取器和写入器时使用。
11.7.1 httptest
包
httptest
包(pkg.go.dev/net/http/httptest
)为客户端和服务器端的 HTTP 测试提供了工具。让我们看看这两个用例。
首先,让我们看看httptest
如何在编写 HTTP 服务器时帮助我们。我们将实现一个处理器,它执行一些基本的操作:编写标题和正文,并返回一个特定的状态代码。为了清楚起见,我们将省略错误处理:
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-API-VERSION", "1.0")
b, _ := io.ReadAll(r.Body)
_, _ = w.Write(append([]byte("hello "), b...)) // ❶
w.WriteHeader(http.StatusCreated)
}
❶ 将hello
与请求正文连接起来
HTTP 处理器接受两个参数:请求和编写响应的方式。httptest
包为两者提供了实用工具。对于请求,我们可以使用 HTTP 方法、URL 和正文使用httptest.NewRequest
构建一个*http.Request
。对于响应,我们可以使用httptest.NewRecorder
来记录处理器中的变化。让我们编写这个处理器的单元测试:
func TestHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://localhost", // ❶
strings.NewReader("foo"))
w := httptest.NewRecorder() // ❷
Handler(w, req) // ❸
if got := w.Result().Header.Get("X-API-VERSION"); got != "1.0" { // ❹
t.Errorf("api version: expected 1.0, got %s", got)
}
body, _ := ioutil.ReadAll(wordy) // ❺
if got := string(body); got != "hello foo" {
t.Errorf("body: expected hello foo, got %s", got)
}
if http.StatusOK != w.Result().StatusCode { // ❻
t.FailNow()
}
}
❶ 构建请求
❷ 创建了响应记录器
❸ 调用Handler
❹ 验证 HTTP 报头
❺ 验证 HTTP 正文
❻ 验证 HTTP 状态代码
使用httptest
测试处理器并不测试传输(HTTP 部分)。测试的重点是用请求和记录响应的方法直接调用处理器。然后,使用响应记录器,我们编写断言来验证 HTTP 头、正文和状态代码。
让我们看看硬币的另一面:测试 HTTP 客户端。我们将编写一个负责查询 HTTP 端点的客户机,该端点计算从一个坐标开车到另一个坐标需要多长时间。客户端看起来像这样:
func (c DurationClient) GetDuration(url string,
lat1, lng1, lat2, lng2 float64) (
time.Duration, error) {
resp, err := c.client.Post(
url, "application/json",
buildRequestBody(lat1, lng1, lat2, lng2),
)
if err != nil {
return 0, err
}
return parseResponseBody(resp.Body)
}
这段代码对提供的 URL 执行 HTTP POST 请求,并返回解析后的响应(比如说,一些 JSON)。
如果我们想测试这个客户呢?一种选择是使用 Docker 并启动一个模拟服务器来返回一些预先注册的响应。然而,这种方法使得测试执行缓慢。另一个选择是使用httptest.NewServer
来创建一个基于我们将提供的处理器的本地 HTTP 服务器。一旦服务器启动并运行,我们可以将它的 URL 传递给GetDuration
:
func TestDurationClientGet(t *testing.T) {
srv := httptest.NewServer( // ❶
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"duration": 314}`)) // ❷
},
),
)
defer srv.Close() // ❸
client := NewDurationClient()
duration, err :=
client.GetDuration(srv.URL, 51.551261, -0.1221146, 51.57, -0.13) // ❹
if err != nil {
t.Fatal(err)
}
if duration != 314*time.Second { // ❺
t.Errorf("expected 314 seconds, got %v", duration)
}
}
❶ 启动 HTTP 服务器
❷ 注册处理器来服务响应
❸ 关闭了服务器
❹ 提供了服务器 URL
❺ 验证了响应
在这个测试中,我们创建了一个带有返回314
秒的静态处理器的服务器。我们还可以根据发送的请求做出断言。此外,当我们调用GetDuration
时,我们提供启动的服务器的 URL。与测试处理器相比,这个测试执行一个实际的 HTTP 调用,但是它的执行只需要几毫秒。
我们还可以使用 TLS 和httptest.NewTLSServer
启动一个新的服务器,并使用httptest.NewUnstartedServer
创建一个未启动的服务器,这样我们就可以延迟启动它。
让我们记住在 HTTP 应用的上下文中工作时httptest
是多么有用。无论我们是编写服务器还是客户端,httptest
都可以帮助我们创建高效的测试。
11.7.2 iotest
包
iotest
包(pkg.go.dev/testing/iotest
)实现了测试读者和作者的实用工具。这是一个很方便的包,但 Go 开发者经常会忘记。
当实现一个自定义的io.Reader
时,我们应该记得使用iotest.TestReader
来测试它。这个实用函数测试读取器的行为是否正确:它准确地返回读取的字节数,填充提供的片,等等。如果提供的阅读器实现了像io.ReaderAt
这样的接口,它还会测试不同的行为。
假设我们有一个自定义的LowerCaseReader
,它从给定的输入io.Reader
中流出小写字母。下面是如何测试这个读者没有行为不端:
func TestLowerCaseReader(t *testing.T) {
err := iotest.TestReader(
&LowerCaseReader{reader: strings.NewReader("aBcDeFgHiJ")}, // ❶
[]byte("acegi"), // ❷
)
if err != nil {
t.Fatal(err)
}
}
❶ 提供了一个io.Reader
❷ 期望
我们通过提供自定义的LowerCaseReader
和一个期望来调用iotest.TestReader
:小写字母acegi
。
iotest
包的另一个用例是,以确保使用读取器和写入器的应用能够容忍错误:
iotest.ErrReader
创建一个io.Reader
返回一个提供的错误。iotest.HalfReader
创建一个io.Reader
,它只读取从io.Reader
请求的一半字节。iotest.OneByteReader
创建一个io.Reader
,用于从io.Reader
中读取每个非空字节。iotest.TimeoutReader
创建一个io.Reader
,在第二次读取时返回一个没有数据的错误。后续调用将会成功。iotest.TruncateWriter
创建一个io.Writer
写入一个io.Writer
,但在n
字节后静默停止。
例如,假设我们实现了以下函数,该函数从读取器读取所有字节开始:
func foo(r io.Reader) error {
b, err := io.ReadAll(r)
if err != nil {
return err
}
// ...
}
我们希望确保我们的函数具有弹性,例如,如果提供的读取器在读取期间失败(例如模拟网络错误):
func TestFoo(t *testing.T) {
err := foo(iotest.TimeoutReader( // ❶
strings.NewReader(randomString(1024)),
))
if err != nil {
t.Fatal(err)
}
}
❶ 使用iotest.TimeoutReader
包装提供的io.Reader
。
我们用io.TimeoutReader
包装一个io.Reader
。正如我们提到的,二读会失败。如果我们运行这个测试来确保我们的函数能够容忍错误,我们会得到一个测试失败。实际上,io.ReadAll
会返回它发现的任何错误。
知道了这一点,我们就可以实现我们的自定义readAll
函数,它可以容忍多达n
个错误:
func readAll(r io.Reader, retries int) ([]byte, error) {
b := make([]byte, 0, 512)
for {
if len(b) == cap(b) {
b = append(b, 0)[:len(b)]
}
n, err := r.Read(b[len(b):cap(b)])
b = b[:len(b)+n]
if err != nil {
if err == io.EOF {
return b, nil
}
retries--
if retries < 0 { // ❶
return b, err
}
}
}
}
❶ 容忍重试
这个实现类似于io.ReadAll
,但是它也处理可配置的重试。如果我们改变初始函数的实现,使用自定义的readAll
而不是io.ReadAll
,测试将不再失败:
func foo(r io.Reader) error {
b, err := readAll(r, 3) // ❶
if err != nil {
return err
}
// ...
}
❶ 表示最多可重试三次
我们已经看到了一个例子,在从io.Reader
中读取数据时,如何检查一个函数是否能够容忍错误。我们依靠的iotest
包进行了测试。
当使用io.Reader
和io.Writer
进行 I/O 和工作时,让我们记住iotest
包有多方便。正如我们所看到的,它提供了测试自定义io.Reader
行为的实用工具,并针对读写数据时出现的错误测试我们的应用。
下一节讨论一些可能导致编写不准确基准的常见陷阱。
11.8 #89:编写不准确的基准
一般来说,我们永远不要去猜测性能。当编写优化时,许多因素可能会发挥作用,即使我们对结果有强烈的意见,测试它们也不是一个坏主意。然而,编写基准并不简单。编写不准确的基准并基于它们做出错误的假设可能非常简单。本节的目标是检查导致不准确的常见和具体的陷阱。
在讨论这些陷阱之前,让我们简单回顾一下基准在 Go 中是如何工作的。基准的框架如下:
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
foo()
}
}
函数名以前缀Benchmark
开头。被测函数(foo
)在循环for
中被调用。b.N
代表可变的迭代次数。当运行一个基准时,Go 试图使它与请求的基准时间相匹配。基准时间默认设置为 1 秒,可通过-benchtime
标志进行更改。b.N
从 1 开始;如果基准在 1 秒内完成,b.N
增加,基准再次运行,直到b.N
与benchtime
大致匹配:
$ go test -bench=.
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFoo-4 73 16511228 ns/op
在这里,基准测试花费了大约 1 秒钟,foo
被执行了 73 次,平均执行时间为 16511228 纳秒。我们可以使用-benchtime
改变基准时间:
$ go test -bench=. -benchtime=2s
BenchmarkFoo-4 150 15832169 ns/op
foo
被执行死刑的人数大约是前一次基准期间的两倍。
接下来,我们来看看一些常见的陷阱。
11.8.1 不重置或暂停计时器
在某些情况下,我们需要在基准循环之前执行操作。这些操作可能需要相当长的时间(例如,生成大量数据),并且可能会显著影响基准测试结果:
func BenchmarkFoo(b *testing.B) {
expensiveSetup()
for i := 0; i < b.N; i++ {
functionUnderTest()
}
}
在这种情况下,我们可以在进入循环之前使用ResetTimer
方法:
func BenchmarkFoo(b *testing.B) {
expensiveSetup()
b.ResetTimer() // ❶
for i := 0; i < b.N; i++ {
functionUnderTest()
}
}
❶ 重置基准计时器
调用ResetTimer
将测试开始以来运行的基准时间和内存分配计数器清零。这样,可以从测试结果中丢弃昂贵的设置。
如果我们必须不止一次而是在每次循环迭代中执行昂贵的设置,那该怎么办?
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
expensiveSetup()
functionUnderTest()
}
}
我们不能重置计时器,因为这将在每次循环迭代中执行。但是我们可以停止并恢复基准计时器,围绕对expensiveSetup
的调用:
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer() // ❶
expensiveSetup()
b.StartTimer() // ❷
functionUnderTest()
}
}
❶ 暂停基准计时器
❷ 恢复基准计时器
这里,我们暂停基准计时器来执行昂贵的设置,然后恢复计时器。
注意,这种方法有一个问题需要记住:如果被测函数与设置函数相比执行速度太快,基准测试可能需要太长时间才能完成。原因是到达benchtime
需要比 1 秒长得多的时间。基准时间的计算完全基于functionUnderTest
的执行时间。因此,如果我们在每次循环迭代中等待很长时间,基准测试将会比 1 秒慢得多。如果我们想保持基准,一个可能的缓解措施是减少benchtime
。
我们必须确保使用计时器方法来保持基准的准确性。
11.8.2 对微观基准做出错误的假设
微基准测试测量一个微小的计算单元,并且很容易对它做出错误的假设。比方说,我们不确定是使用atomic.StoreInt32
还是atomic.StoreInt64
(假设我们处理的值总是适合 32 位)。我们希望编写一个基准来比较这两种函数:
func BenchmarkAtomicStoreInt32(b *testing.B) {
var v int32
for i := 0; i < b.N; i++ {
atomic.StoreInt32(&v, 1)
}
}
func BenchmarkAtomicStoreInt64(b *testing.B) {
var v int64
for i := 0; i < b.N; i++ {
atomic.StoreInt64(&v, 1)
}
}
如果我们运行该基准测试,下面是一些示例输出:
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4 197107742 5.682 ns/op
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4 213917528 5.134 ns/op
我们很容易认为这个基准是理所当然的,并决定使用atomic.StoreInt64
,因为它似乎更快。现在,为了做一个公平的基准测试,我们颠倒一下顺序,先测试atomic.StoreInt64
,再测试atomic.StoreInt32
。以下是一些输出示例:
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4 224900722 5.434 ns/op
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4 230253900 5.159 ns/op
这一次,atomic.StoreInt32
效果更好。发生了什么事?
在微基准的情况下,许多因素都会影响结果,例如运行基准时的机器活动、电源管理、散热以及指令序列的更好的高速缓存对齐。我们必须记住,许多因素,即使在我们的 Go 项目范围之外,也会影响结果。
注意,我们应该确保执行基准测试的机器是空闲的。但是,外部流程可能在后台运行,这可能会影响基准测试结果。出于这个原因,像perflock
这样的工具可以限制基准测试消耗多少 CPU。例如,我们可以用总可用 CPU 的 70%来运行基准测试,将 30%分配给操作系统和其他进程,并减少机器活动因素对结果的影响。
一种选择是使用-benchtime
选项增加基准时间。类似于概率论中的大数定律,如果我们运行基准测试很多次,它应该倾向于接近它的期望值(假设我们忽略了指令缓存和类似机制的好处)。
另一种选择是在经典的基准工具之上使用外部工具。例如,benchstat
工具,是golang.org/x
库的的一部分,它允许我们计算和比较关于基准执行的统计数据。
让我们使用和-count
选项运行基准测试 10 次,并将输出传输到一个特定的文件:
$ go test -bench=. -count=10 | tee stats.txt
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32-4 234935682 5.124 ns/op
BenchmarkAtomicStoreInt32-4 235307204 5.112 ns/op
// ...
BenchmarkAtomicStoreInt64-4 235548591 5.107 ns/op
BenchmarkAtomicStoreInt64-4 235210292 5.090 ns/op
// ...
然后我们可以对这个文件运行benchstat
:
$ benchstat stats.txt
name time/op
AtomicStoreInt32-4 5.10ns ± 1%
AtomicStoreInt64-4 5.10ns ± 1%
结果是一样的:两个函数平均需要 5.10 纳秒来完成。我们还可以看到给定基准的执行之间的百分比变化:1%。这个指标告诉我们,两个基准都是稳定的,让我们对计算出的平均结果更有信心。因此,对于我们测试的使用情况(在特定机器上的特定 Go 版本中),我们可以得出其执行时间与atomic .StoreInt64
相似的结论,而不是得出atomic.StoreInt32
更快或更慢的结论。
总的来说,我们应该对微基准保持谨慎。许多因素会显著影响结果,并可能导致错误的假设。增加基准测试时间或使用benchstat
等工具重复执行基准测试并计算统计数据,可以有效地限制外部因素并获得更准确的结果,从而得出更好的结论。
我们还要强调的是,如果另一个系统最终运行了该应用,那么在使用在给定机器上执行的微基准测试的结果时,我们应该小心。生产系统的行为可能与我们运行微基准测试的系统大相径庭。
11.8.3 不注意编译器优化
另一个与编写基准相关的常见错误是被编译器优化所愚弄,这也可能导致错误的基准假设。在这一节中,我们来看看 Go issue 14813 ( github.com/golang/go/issues/14813
,也是 Go 项目成员戴夫·切尼讨论过的)的人口计数函数(计算设置为1
的位数的函数):
const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101
func popcnt(x uint64) uint64 {
x -= (x >> 1) & m1
x = (x & m2) + ((x >> 2) & m2)
x = (x + (x >> 4)) & m4
return (x * h01) >> 56
}
这个函数接受并返回一个uint64
。为了对这个函数进行基准测试,我们可以编写以下代码:
func BenchmarkPopcnt1(b *testing.B) {
for i := 0; i < b.N; i++ {
popcnt(uint64(i))
}
}
然而,如果我们执行这个基准测试,我们得到的结果低得惊人:
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4 1000000000 0.2858 ns/op
0.28 纳秒的持续时间大约是一个时钟周期,所以这个数字低得不合理。问题是开发人员对编译器优化不够仔细。在这种情况下,测试中的函数足够简单,可以作为内联的候选函数:这是一种用被调用函数的正文替换函数调用的优化,让我们可以避免函数调用,它占用的内存很小。一旦函数被内联,编译器会注意到该调用没有副作用,并将其替换为以下基准:
func BenchmarkPopcnt1(b *testing.B) {
for i := 0; i < b.N; i++ {
// Empty
}
}
基准现在是空的——这就是为什么我们得到了接近一个时钟周期的结果。为了防止这种情况发生,最佳实践是遵循以下模式:
在每次循环迭代中,将结果赋给一个局部变量(基准函数上下文中的局部变量)。
将最新结果赋给一个全局变量。
在我们的例子中,我们编写了以下基准:
var global uint64 // ❶
func BenchmarkPopcnt2(b *testing.B) {
var v uint64 // ❷
for i := 0; i < b.N; i++ {
v = popcnt(uint64(i)) // ❸
}
global = v // ❹
}
❶ 定义了一个全局变量
❷ 定义了一个局部变量
❸ 将结果赋给局部变量
❹ 将结果赋给全局变量
global
是全局变量,而v
是局部变量,其作用域是基准函数。在每次循环迭代中,我们将popcnt
的结果赋给局部变量。然后我们将最新的结果赋给全局变量。
注意为什么不把popcnt
调用的结果直接分配给global
来简化测试呢?写入一个全局变量比写入一个局部变量要慢(我们在错误#95“不理解栈和堆”中讨论了这些概念)。因此,我们应该将每个结果写入一个局部变量,以限制每次循环迭代期间的内存占用。
如果我们运行这两个基准测试,我们现在会得到显著不同的结果:
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4 1000000000 0.2858 ns/op
BenchmarkPopcnt2-4 606402058 1.993 ns/op
BenchmarkPopcnt2
是基准的准确版本。它保证我们避免了内联优化,内联优化会人为地降低执行时间,甚至会删除对被测函数的调用。依赖BenchmarkPopcnt1
的结果可能会导致错误的假设。
让我们记住避免编译器优化愚弄基准测试结果的模式:将被测函数的结果赋给一个局部变量,然后将最新的结果赋给一个全局变量。这种最佳实践还可以防止我们做出不正确的假设。
11.8.4 被观察者效应所迷惑
在物理学中,观察者效应是观察行为对被观察系统的扰动。这种影响也可以在基准测试中看到,并可能导致对结果的错误假设。让我们看一个具体的例子,然后尝试减轻它。
我们想要实现一个函数来接收一个由int64
元素组成的矩阵。这个矩阵有固定的 512 列,我们想计算前八列的总和,如图 11.2 所示。
图 11.2 计算前八列的总和
为了优化,我们还想确定改变列数是否有影响,所以我们还实现了第二个函数,有 513 列。实现如下:
func calculateSum512(s [][512]int64) int64 {
var sum int64
for i := 0; i < len(s); i++ { // ❶
for j := 0; j < 8; j++ { // ❷
sum += s[i][j] // ❸
}
}
return sum
}
func calculateSum513(s [][513]int64) int64 {
// Same implementation as calculateSum512
}
❶ 遍历每一行
❷ 遍历前八列
❸ 增加sum
我们遍历每一行,然后遍历前八列,并增加一个返回的sum
变量。calculateSum513
中的实现保持不变。
我们希望对这些函数进行基准测试,以确定在给定固定行数的情况下哪一个函数的性能最高:
const rows = 1000
var res int64
func BenchmarkCalculateSum512(b *testing.B) {
var sum int64
s := createMatrix512(rows) // ❶
b.ResetTimer()
for i := 0; i < b.N; i++ {
sum = calculateSum512(s) // ❷
}
res = sum
}
func BenchmarkCalculateSum513(b *testing.B) {
var sum int64
s := createMatrix513(rows) // ❸
b.ResetTimer()
for i := 0; i < b.N; i++ {
sum = calculateSum513(s) // ❹
}
res = sum
}
❶ 创建了一个 512 列的矩阵
❷ 计算总数
❸ 创建了一个 513 列的矩阵
❹ 计算总数
我们希望只创建一次矩阵,以限制结果的影响。因此,我们在循环外调用createMatrix512
和createMatrix513
。我们可能期望结果是相似的,因为我们只希望迭代前八列,但实际情况并非如此(在我的机器上):
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4 81854 15073 ns/op
BenchmarkCalculateSum513-4 161479 7358 ns/op
具有 513 列的第二个基准测试快了大约 50%。同样,因为我们只迭代了前八列,所以这个结果相当令人惊讶。
为了理解这种差异,我们需要理解 CPU 缓存的基础知识。简而言之,CPU 由不同的缓存组成(通常是 L1、L2 和 L3)。这些高速缓存降低了从主存储器访问数据的平均成本。在某些情况下,CPU 可以从主存储器中取出数据,并将其复制到 L1。在这种情况下,CPU 试图将calculateSum
感兴趣的矩阵子集(每行的前八列)读入 L1。但是,在一种情况下(513 列),矩阵适合内存,而在另一种情况下(512 列),则不适合。
注意解释原因不在本章的范围内,但是我们在错误#91“不理解 CPU 缓存”中来看这个问题
回到基准测试,主要问题是我们在两种情况下都重复使用相同的矩阵。因为函数重复了成千上万次,所以当它接收一个普通的新矩阵时,我们不测量函数的执行。相反,我们测量一个函数,该函数获取一个矩阵,该矩阵已经包含缓存中存在的单元的子集。因此,因为calculateSum513
导致缓存未命中更少,所以它有更好的执行时间。
这是观察者效应的一个例子。因为我们一直在观察一个被反复调用的 CPU 绑定函数,所以 CPU 缓存可能会发挥作用并显著影响结果。在这个例子中,为了防止这种影响,我们应该在每个测试期间创建一个矩阵,而不是重用一个:
func BenchmarkCalculateSum512(b *testing.B) {
var sum int64
for i := 0; i < b.N; i++ {
b.StopTimer()
s := createMatrix512(rows) // ❶
b.StartTimer()
sum = calculateSum512(s)
}
res = sum
}
❶ 在每次循环迭代中都会创建一个新矩阵
现在,在每次循环迭代中都会创建一个新矩阵。如果我们再次运行基准测试(并调整benchtime
——否则执行时间太长),结果会更接近:
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4 1116 33547 ns/op
BenchmarkCalculateSum513-4 998 35507 ns/op
我们没有做出calculateSum513
更快的错误假设,而是看到两个基准测试在接收新矩阵时会产生相似的结果。
正如我们在本节中看到的,因为我们重用了相同的矩阵,CPU 缓存显著影响了结果。为了防止这种情况,我们必须在每次循环迭代中创建一个新的矩阵。一般来说,我们应该记住,观察测试中的函数可能会导致结果的显著差异,特别是在低级别优化很重要的 CPU 绑定函数的微基准环境中。强制基准在每次迭代期间重新创建数据是防止这种影响的好方法。
在本章的最后一节,让我们看看一些关于GO测试的常见技巧。
11.9 #90:没有探索所有的 Go 测试功能
在编写测试时,开发人员应该了解 Go 的特定测试特性和选项。否则,测试过程可能不太准确,甚至效率更低。这一节讨论的主题可以让我们在编写 Go 测试时更加舒适。
11.9.1 代码覆盖率
在开发过程中,直观地看到测试覆盖了代码的哪些部分是很方便的。我们可以使用的-coverprofile
标志来访问这些信息:
$ go test -coverprofile=coverage.out ./...
这个命令创建一个coverage.out
文件,然后我们可以使用go tool cover
打开它:
$ go tool cover -html=coverage.out
该命令打开 web 浏览器并显示每行代码的覆盖率。
默认情况下,只对当前被测试的包进行代码覆盖率分析。例如,假设我们有以下结构:
/myapp
|_ foo
|_ foo.go
|_ foo_test.go
|_ bar
|_ bar.go
|_ bar_test.go
如果foo.go
的某个部分只在bar_test.go
中测试,默认情况下,它不会显示在覆盖率报告中。要包含它,我们必须在myapp
文件夹中,并且使用-coverpkg
标志:
go test -coverpkg=./... -coverprofile=coverage.out ./...
我们需要记住这个特性来查看当前的代码覆盖率,并决定哪些部分值得更多的测试。
注意在跟踪代码覆盖率时要保持谨慎。拥有 100%的测试覆盖率并不意味着一个没有 bug 的应用。正确地推理我们的测试覆盖的内容比任何静态的阈值更重要。
11.9.2 不同包的测试
当编写单元测试时,一种方法是关注行为而不是内部。假设我们向客户端公开一个 API。我们可能希望我们的测试关注于从外部可见的东西,而不是实现细节。这样,如果实现发生变化(例如,如果我们将一个函数重构为两个),测试将保持不变。它们也更容易理解,因为它们展示了我们的 API 是如何使用的。如果我们想强制执行这种做法,我们可以使用不同的包。
在 Go 中,一个文件夹中的所有文件应该属于同一个包,只有一个例外:一个测试文件可以属于一个_test
包。例如,假设下面的counter.go
源文件属于counter
包:
package counter
import "sync/atomic"
var count uint64
func Inc() uint64 {
atomic.AddUint64(&count, 1)
return count
}
测试文件可以存在于同一个包中,并访问内部文件,比如count
变量。或者它可以存在于一个counter_test
包中,比如这个counter_test.go
文件:
package counter_test
import (
"testing"
"myapp/counter"
)
func TestCount(t *testing.T) {
if counter.Inc() != 1 {
t.Errorf("expected 1")
}
}
在这种情况下,测试是在一个外部包中实现的,不能访问内部包,比如count
变量。使用这种实践,我们可以保证测试不会使用任何未导出的元素;因此,它将着重于测试公开的行为。
11.9.3 实用函数
在编写测试时,我们可以用不同于生产代码的方式处理错误。例如,假设我们想要测试一个函数,它将一个Customer
结构作为参数。因为Customer
的创建将被重用,为了测试,我们决定创建一个特定的createCustomer
函数。该函数将返回一个可能的错误,并附带一个Customer
:
func TestCustomer(t *testing.T) {
customer, err := createCustomer("foo") // ❶
if err != nil {
t.Fatal(err)
}
// ...
}
func createCustomer(someArg string) (Customer, error) {
// Create customer
if err != nil {
return Customer{}, err
}
return customer, nil
}
❶ 创建一个Customer
并检查错误
我们使用createCustomer
实用函数创建一个客户,然后我们执行剩下的测试。然而,在测试函数的上下文中,我们可以通过将*testing.T
变量传递给实用函数来简化错误管理:
func TestCustomer(t *testing.T) {
customer := createCustomer(t, "foo") // ❶
// ...
}
func createCustomer(t *testing.T, someArg string) Customer {
// Create customer
if err != nil {
t.Fatal(err) // ❷
}
return customer
}
❶ 调用效用函数并提供t
❷ 如果我们不能创建一个客户,就直接失败了
如果不能创建一个Customer
,那么createCustomer
会直接测试失败,而不是返回一个错误。这使得TestCustomer
写起来更小,读起来更容易。
让我们记住这个关于错误管理和测试的实践来改进我们的测试。
11.9.4 安装和拆卸
在某些情况下,我们可能需要准备一个测试环境。例如,在集成测试中,我们启动一个特定的 Docker 容器,然后停止它。我们可以为每个测试或每个包调用安装和拆卸函数。幸运的是,在GO中,两者都有可能。
为了每次测试都这样做,我们可以使用defer
调用安装函数和拆卸函数作为预操作:
func TestMySQLIntegration(t *testing.T) {
setupMySQL()
defer teardownMySQL()
// ...
}
也可以注册一个在测试结束时执行的函数。例如,让我们假设TestMySQLIntegration
需要调用createConnection
来创建数据库连接。如果我们希望这个函数也包含拆卸部分,我们可以使用t.Cleanup
来注册一个清理函数:
func TestMySQLIntegration(t *testing.T) {
// ...
db := createConnection(t, "tcp(localhost:3306)/db")
// ...
}
func createConnection(t *testing.T, dsn string) *sql.DB {
db, err := sql.Open("mysql", dsn)
if err != nil {
t.FailNow()
}
t.Cleanup( // ❶
func() {
_ = db.Close()
})
return db
}
❶ 注册了一个要在测试结束时执行的函数
测试结束时,执行提供给t.Cleanup
的关闭。这使得未来的单元测试更容易编写,因为它们不会负责关闭db
变量。
注意,我们可以注册多个清理函数。在这种情况下,它们将被执行,就像我们使用defer
一样:后进先出。
为了处理每个包的安装和拆卸,我们必须使用TestMain
函数。下面是TestMain
的一个简单实现:
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
这个特定的函数接受一个*testing.M
参数,该参数公开了一个运行所有测试的Run
方法。因此,我们可以用安装和拆卸数围绕这个调用:
func TestMain(m *testing.M) {
setupMySQL() // ❶
code := m.Run() // ❷
teardownMySQL() // ❸
os.Exit(code)
}
❶ 安装 MySQL
❷ 负责测试
❸ 拆卸 MySQL
这段代码在所有测试之前启动 MySQL 一次,然后将其关闭。
使用这些实践来添加安装和拆卸函数,我们可以为我们的测试配置一个复杂的环境。
总结
使用构建标志、环境变量或者短模式对测试进行分类使得测试过程更加有效。您可以使用构建标志或环境变量来创建测试类别(例如,单元测试与集成测试),并区分短期和长期运行的测试,以决定执行哪种测试。
在编写并发应用时,强烈建议启用
-race
标志。这样做可以让您捕捉到可能导致软件错误的潜在数据竞争。使用
-parallel
标志是加速测试的有效方法,尤其是长时间运行的测试。使用
-shuffle
标志来帮助确保测试套件不依赖于可能隐藏 bug 的错误假设。表驱动测试是一种有效的方法,可以将一组相似的测试分组,以防止代码重复,并使未来的更新更容易处理。
使用同步来避免睡眠,以使测试不那么不稳定,更健壮。如果同步是不可能的,考虑重试的方法。
理解如何使用时间 API 处理函数是使测试不那么容易出错的另一种方法。您可以使用标准技术,比如将时间作为隐藏依赖项的一部分来处理,或者要求客户端提供时间。
httptest
包有助于处理 HTTP 应用。它提供了一组测试客户机和服务器的实用工具。iotest
包帮助编写io.Reader
并测试应用是否能够容忍错误。关于基准:
- 使用时间方法保持基准的准确性。
- 增加
benchtime
或使用benchstat
等工具在处理微基准时会有所帮助。 - 如果最终运行应用的系统与运行微基准测试的系统不同,请小心微基准测试的结果。
- 确保被测函数会导致副作用,防止编译器优化在基准测试结果上欺骗你。
- 为了防止观察者效应,强制基准重新创建 CPU 绑定函数使用的数据。
使用带有
-coverprofile
标志的代码覆盖率来快速查看哪部分代码需要更多的关注。将单元测试放在一个不同的包中,以强制编写关注于公开行为而不是内部的测试。
使用
*testing.T
变量而不是经典的if
err !=
nil
来处理错误使得代码更短,更容易阅读。你可以使用安装和拆卸函数来配置一个复杂的环境,比如在集成测试的情况下。
12.1 #91:不了解 CPU 缓存
当赛车手不一定要当工程师,但一定要有机械同情心。
——三届 F1 世界冠军杰基·斯图瓦特创建的一个术语
简而言之,当我们了解一个系统是如何被设计使用的,无论是 F1 赛车、飞机还是计算机,我们都可以与设计保持一致,以获得最佳性能。在本节中,我们将讨论一些具体的例子,在这些例子中,对 CPU 缓存如何工作的机械同情可以帮助我们优化 Go 应用。
12.1.1 CPU 架构
首先让我们了解一下 CPU 架构的基础知识,以及为什么 CPU 缓存很重要。我们将以英特尔酷睿 i5-7300 为例。
现代 CPU 依靠缓存来加速内存访问,大多数情况下通过三个缓存级别:L1、L2 和 L3。在 i5-7300 上,这些高速缓存的大小如下:
L1: 64 KB
L2: 256 KB
三级:4 MB
i5-7300 有两个物理内核,但有四个逻辑内核(也称为虚拟内核或线程)。在英特尔家族中,将一个物理内核划分为多个逻辑内核称为超线程。
图 12.1 给出了英特尔酷睿 i5-7300 的概述(Tn
代表线程n
)。每个物理核心(核心 0 和核心 1)被分成两个逻辑核心(线程 0 和线程 1)。L1 缓存分为两个子缓存:L1D 用于数据,L1I 用于指令(每个 32 KB)。缓存不仅仅与数据相关,当 CPU 执行一个应用时,它也可以缓存一些指令,理由相同:加速整体执行。
图 12.1 i5-7300 具有三级高速缓存、两个物理内核和四个逻辑内核。
存储器位置越靠近逻辑核心,访问速度越快(参见 mng.bz/o29v
):
L1:大约 1 纳秒
L2:大约比 L1 慢 4 倍
L3:大约比 L1 慢 10 倍
CPU 缓存的物理位置也可以解释这些差异。L1 和 L2被称为片上,这意味着它们与处理器的其余部分属于同一块硅片。相反,L3 是片外,这部分解释了与 L1 和 L2 相比的延迟差异。
对于主内存(或 RAM),平均访问速度比 L1 慢 50 到 100 倍。我们可以访问存储在 L1 上的多达 100 个变量,只需支付一次访问主存储器的费用。因此,作为 Go 开发人员,一个改进的途径是确保我们的应用使用 CPU 缓存。
12.1.2 高速缓存行
理解高速缓存行的概念至关重要。但是在介绍它们是什么之前,让我们了解一下为什么我们需要它们。
当访问特定的内存位置时(例如,通过读取变量),在不久的将来可能会发生以下情况之一:
相同的位置将被再次引用。
将引用附近的存储位置。
前者指时间局部性,后者指空间局部性。两者都是称为引用位置的原则的一部分。
例如,让我们看看下面这个计算一个int64
切片之和的函数:
func sum(s []int64) int64 {
var total int64
length := len(s)
for i := 0; i < length; i++ {
total += s[i]
}
return total
}
在这个例子中,时间局部性适用于多个变量:i
、length
和total
。在整个迭代过程中,我们不断地访问这些变量。空间局部性适用于代码指令和切片s
。因为一个片是由内存中连续分配的数组支持的,在这种情况下,访问s[0]
也意味着访问s[1]
、s[2]
等等。
时间局部性是我们需要 CPU 缓存的部分原因:加速对相同变量的重复访问。然而,由于空间局部性,CPU 复制我们称之为缓存行,而不是将单个变量从主内存复制到缓存。
高速缓存行是固定大小的连续内存段,通常为 64 字节(8 个int64
变量)。每当 CPU 决定从 RAM 缓存内存块时,它会将内存块复制到缓存行。因为内存是一个层次结构,当 CPU 想要访问一个特定的内存位置时,它首先检查 L1,然后是 L2,然后是 L3,最后,如果该位置不在这些缓存中,则检查主内存。
让我们用一个具体的例子来说明获取内存块。我们第一次用 16 个int64
元素的切片调用sum
函数。当sum
访问s[0]
时,这个内存地址还不在缓存中。如果 CPU 决定缓存这个变量(我们在本章后面也会讨论这个决定),它会复制整个内存块;参见图 12.2。
图 12.2 访问s[0]
使 CPU 复制 0x000 内存块。
首先,访问s[0]
会导致缓存未命中,因为地址不在缓存中。这种错过被称为一种强制错过。但是,如果 CPU 获取 0x000 存储块,访问从 1 到 7 的元素会导致缓存命中。当sum
访问s[8]
时,同样的逻辑也适用(见图 12.3)。
图 12.3 访问s[8]
使 CPU 复制 0x100 内存块。
同样,访问s8
会导致强制未命中。但是如果将0x100
内存块复制到高速缓存行中,也会加快对元素 9 到 15 的访问。最后,迭代 16 个元素导致 2 次强制缓存未命中和 14 次缓存命中。
CPU 缓存策略
你可能想知道当 CPU 复制一个内存块时的确切策略。例如,它会将一个块复制到所有级别吗?只去 L1?在这种情况下,L2 和 L3 怎么办?
我们必须知道存在不同的策略。有时缓存是包含性的(例如,L2 数据也存在于 L3 中),有时缓存是排他性的(例如,L3 被称为牺牲缓存,因为它只包含从 L2 逐出的数据)。
一般来说,这些策略都是 CPU 厂商隐藏的,知道了不一定有用。所以,这些问题我们就不深究了。
让我们看一个具体的例子来说明 CPU 缓存有多快。我们将实现两个函数,它们在迭代一片int64
元素时计算总数。在一种情况下,我们将迭代每两个元素,在另一种情况下,迭代每八个元素:
func sum2(s []int64) int64 {
var total int64
for i := 0; i < len(s); i+=2 { // ❶
total += s[i]
}
return total
}
func sum8(s []int64) int64 {
var total int64
for i := 0; i < len(s); i += 8 { // ❷
total += s[i]
}
return total
}
❶ 迭代每两个元素
❷ 迭代每八个元素
除了迭代之外,这两个函数是相同的。如果我们对这两个函数进行基准测试,我们的直觉可能是第二个版本会快四倍,因为我们需要增加的元素少了四倍。然而,运行基准测试表明sum8
在我的机器上只快了 10%:仍然更快,但是只快了 10%。
原因与缓存行有关。我们看到一个缓存行通常是 64 字节,包含多达 8 个int64
变量。这里,这些循环的运行时间是由内存访问控制的,而不是增量指令。在第一种情况下,四分之三的访问导致缓存命中。因此,这两个函数的执行时间差异并不明显。这个例子展示了为什么缓存行很重要,以及如果我们缺乏机械的同情心,我们很容易被我们的直觉所欺骗——在这个例子中,是关于 CPU 如何缓存数据的。
让我们继续讨论引用的局部性,看一个使用空间局部性的具体例子。
12.1.3 结构切片与切片结构
本节看一个比较两个函数执行时间的例子。第一个将一部分结构作为参数,并对所有的a
字段求和:
type Foo struct {
a int64
b int64
}
func sumFoo(foos []Foo) int64 { // ❶
var total int64
for i := 0; i < len(foos); i++ { // ❷
total += foos[i].a
}
return total
}
❶ 获取Foo
切片
❷ 对每个Foo
进行迭代,并对每个字段求和
sumFoo
接收Foo
的一部分,并通过读取每个a
域来增加total
。
第二个函数也计算总和。但是这一次,参数是一个包含片的结构:
type Bar struct {
a []int64 // ❶
b []int64
}
func sumBar(bar Bar) int64 { // ❷
var total int64
for i := 0; i < len(bar.a); i++ { // ❸
total += bar.a[i] // ❹
}
return total
}
❶ a
和b
现在是切片。
❷ 接收单个结构
❸ 遍历bar
❹ 增加了total
sumBar
接收一个包含两个切片的Bar
结构:a
和b
。它遍历a
的每个元素来增加total
。
我们期望这两个函数在速度上有什么不同吗?在运行基准测试之前,让我们在图 12.4 中直观地看看内存的差异。两种情况的数据量相同:切片中有 16 个Foo
元素,切片中有 16 个Bar
元素。每个黑条代表一个被读取以计算总和的int64
,而每个灰条代表一个被跳过的int64
。
图 12.4 切片结构更紧凑,因此需要迭代的缓存行更少。
在sumFoo
的情况下,我们收到一个包含两个字段a
和b
的结构片。因此,我们在内存中有一连串的a
和b
。相反,在sumBar
的情况下,我们收到一个包含两个片的结构,a
和b
。因此,a
的所有元素都是连续分配的。
这种差异不会导致任何内存压缩优化。但是这两个函数的目标都是迭代每个a
,这样做在一种情况下需要四个缓存行,在另一种情况下只需要两个缓存行。
如果对这两个函数进行基准测试,sumBar
更快(在我的机器上大约快 20%)。主要原因是更好的空间局部性,这使得 CPU 从内存中获取更少的缓存行。
这个例子演示了空间局部性如何对性能产生重大影响。为了优化应用,我们应该组织数据,以从每个单独的缓存行中获得最大的价值。
但是,使用空间局部性就足以帮助 CPU 了吗?我们仍然缺少一个关键特征:可预测性。
12.1.4 可预测性
可预测性是指 CPU 预测应用将如何加速其执行的能力。让我们看一个具体的例子,缺乏可预测性会对应用性能产生负面影响。
再一次,让我们看两个对元素列表求和的函数。第一个循环遍历一个链表并对所有值求和:
type node struct { // ❶
value int64
next *node
}
func linkedList(n *node) int64 {
var total int64
for n != nil { // ❷
total += n.value // ❸
n = n.next
}
return total
}
❶ 链表数据结构
❷ 迭代每个节点
❸ 增加total
这个函数接收一个链表,遍历它,并增加一个总数。
另一方面,让我们再来看一下sum2
函数,它迭代一个片,两个元素中的一个:
func sum2(s []int64) int64 {
var total int64
for i := 0; i < len(s); i+=2 { // ❶
total += s[i]
}
return total
}
❶ 迭代每两个元素
让我们假设链表是连续分配的:例如,由单个函数分配。在 64 位架构中,一个字的长度是 64 位。图 12.5 比较了函数接收的两种数据结构(链表或切片);深色的条代表
我们用来增加总数的int64
元素。
图 12.5 在内存中,链表和切片以类似的方式压缩。
在这两个例子中,我们面临类似的压缩。因为链表是由一连串的值和 64 位指针元素组成的,所以我们使用两个元素中的一个来增加总和。同时,sum2
的例子只读取了两个元素中的一个。
这两个数据结构具有相同的空间局部性,因此我们可以预期这两个函数的执行时间相似。但是在片上迭代的函数要快得多(在我的机器上大约快 70%)。原因是什么?
要理解这一点,我们得讨论一下大步走的概念。跨越与 CPU 如何处理数据有关。有三种不同类型的步幅(见图 12.6):
单位步幅——我们要访问的所有值都是连续分配的:比如一片
int64
元素。这一步对于 CPU 来说是可预测的,也是最有效的,因为它需要最少数量的缓存行来遍历元素。恒定步幅——对于 CPU 来说仍然是可预测的:例如,每两个元素迭代一次的切片。这个步幅需要更多的缓存行来遍历数据,因此它的效率比单位步幅低。
非单位步幅——CPU 无法预测的一个步幅:比如一个链表或者一片指针。因为 CPU 不知道数据是否是连续分配的,所以它不会获取任何缓存行。
图 12.6 三种类型的步幅
对于sum2
,我们面对的是一个不变的大步。但是,对于链表来说,我们面临的是非单位跨步。即使我们知道数据是连续分配的,CPU 也不知道。因此,它无法预测如何遍历链表。
由于不同的步距和相似的空间局部性,遍历一个链表比遍历一个值要慢得多。由于更好的空间局部性,我们通常更喜欢单位步幅而不是常数步幅。但是,无论数据如何分配,CPU 都无法预测非单位步幅,从而导致负面的性能影响。
到目前为止,我们已经讨论了 CPU 缓存速度很快,但明显小于主内存。因此,CPU 需要一种策略来将内存块提取到缓存行。这种策略称为缓存放置策略和会显著影响性能。
12.1.5 缓存放置策略
在错误#89“编写不准确的基准测试”中,我们讨论了一个矩阵示例,其中我们必须计算前八列的总和。在这一点上,我们没有解释为什么改变列的总数会影响基准测试的结果。这听起来可能违反直觉:因为我们只需要读取前八列,为什么改变总列数会影响执行时间?让我们来看看这一部分。
提醒一下,实现如下:
func calculateSum512(s [][512]int64) int64 { // ❶
var sum int64
for i := 0; i < len(s); i++ {
for j := 0; j < 8; j++ {
sum += s[i][j]
}
}
return sum
}
func calculateSum513(s [][513]int64) int64 { // ❷
// Same implementation as calculateSum512
}
❶ 接收 512 列的矩阵
❷ 接收 513 列的矩阵
我们迭代每一行,每次对前八列求和。当这两个函数每次都用一个新矩阵作为基准时,我们没有观察到任何差异。然而,如果我们继续重用相同的矩阵,calculateSum513
在我的机器上大约快 50%。原因在于 CPU 缓存以及如何将内存块复制到缓存行。让我们对此进行检查,以了解这种差异。
当 CPU 决定复制一个内存块并将其放入缓存时,它必须遵循特定的策略。假设 L1D 缓存为 32 KB,缓存行为 64 字节,如果将一个块随机放入 L1D,CPU 在最坏的情况下将不得不迭代 512 个缓存行来读取一个变量。这种缓存叫做全关联。
为了提高从 CPU 缓存中访问地址的速度,设计人员在缓存放置方面制定了不同的策略。让我们跳过历史,讨论一下今天使用最广泛的选项:组关联缓存,其中依赖于缓存分区。
为了使下图更清晰,我们将简化问题:
我们假设 L1D 缓存为 512 字节(8 条缓存线)。
矩阵由 4 行 32 列组成,我们将只读取前 8 列。
图 12.7 显示了这个矩阵如何存储在内存中。我们将使用内存块地址的二进制表示。同样,灰色块代表我们想要迭代的前 8 个int64
元素。剩余的块在迭代过程中被跳过。
图 12.7 存储在内存中的矩阵,以及用于执行的空缓存
每个存储块包含 64 个字节,因此有 8 个int64
元素。第一个内存块从 0x000000000000 开始,第二个从 0001000000000(二进制 512)开始,依此类推。我们还展示了可以容纳 8 行的缓存。
请注意,我们将在错误#94“不知道数据对齐”中看到,切片不一定从块的开头开始。
使用组关联高速缓存策略,高速缓存被划分为多个组。我们假设高速缓存是双向组关联的,这意味着每个组包含两行。一个内存块只能属于一个集合,其位置由内存地址决定。为了理解这一点,我们必须将内存块地址分成三个部分:
块偏移是基于块大小的。这里块的大小是 512 字节,512 等于
2^9
。因此,地址的前 9 位代表块偏移(BO)。集合索引表示一个地址所属的集合。因为高速缓存是双向组关联的,并且包含 8 行,所以我们有
8 / 2 = 4
个组。此外,4 等于2^2
,因此接下来的两位表示集合索引(SI)。地址的其余部分由标签位(TB)组成。在图 12.7 中,为了简单起见,我们用 13 位来表示一个地址。为了计算 TB,我们使用
13 - BO - SI
。这意味着剩余的两位代表标签位。
假设该函数启动并试图读取属于地址 000000000000 的s[0][0]
。因为这个地址还不在高速缓存中,所以 CPU 计算它的集合索引并将其复制到相应的高速缓存集合中(图 12.8)。
图 12.8 内存地址 000000000000 被复制到集合 0。
如前所述,9 位代表块偏移量:这是每个内存块地址的最小公共前缀。然后,2 位表示集合索引。地址为 0000000000000 时,SI 等于 00。因此,该存储块被复制到结合 0。
当函数从s[0][1]
读取到s[0][7]
时,数据已经在缓存中。CPU 是怎么知道的?CPU 计算存储块的起始地址,计算集合索引和标记位,然后检查集合 0 中是否存在 00。
接下来函数读取s[0][8]
,这个地址还没有被缓存。所以同样的操作发生在复制内存块 0100000000000(图 12.9)。
图 12.9 内存地址 010000000000 被复制到集合 0。
该存储器的集合索引等于 00,因此它也属于集合 0。高速缓存线被复制到组 0 中的下一个可用线。然后,再一次,从s[1][1]
到s[1][7]
的读取导致缓存命中。
现在事情越来越有趣了。该函数读取s[2][0]
,该地址不在缓存中。执行相同的操作(图 12.10)。
图 12.10 内存地址 1000000000000 替换集合 0 中的现有缓存行。
设置的索引再次等于 00。但是,set 0 已满 CPU 做什么?将内存块复制到另一组?不会。CPU 会替换现有缓存线之一来复制内存块 1000000000000。
缓存替换策略依赖于 CPU,但它通常是一个伪 LRU 策略(真正的 LRU(最久未使用)会太复杂而难以处理)。在这种情况下,假设它替换了我们的第一个缓存行:000000000000。当迭代第 3 行时,这种情况重复出现:内存地址 1100000000000 也有一个等于 00 的集合索引,导致替换现有的缓存行。
现在,让我们假设基准程序用一个从地址 000000000000 开始指向同一个矩阵的片来执行函数。当函数读取s[0][0]
时,地址不在缓存中。该块已被替换。
基准测试将导致更多的缓存未命中,而不是从一次执行到另一次执行都使用 CPU 缓存。这种类型的缓存未命中被称为冲突未命中:如果缓存没有分区,这种未命中就不会发生。我们迭代的所有变量都属于一个集合索引为 00 的内存块。因此,我们只使用一个缓存集,而不是分布在整个缓存中。
之前我们讨论了跨越的概念,我们将其定义为 CPU 如何遍历我们的数据。在这个例子中,这个步距被称为临界步距:它导致访问具有相同组索引的存储器地址,这些地址因此被存储到相同的高速缓存组。
让我们回到现实世界的例子,用两个函数calculateSum512
和calculateSum513
。基准测试是在一个 32 KB 的八路组关联 L1D 缓存上执行的:总共 64 组。因为高速缓存行是 64 字节,所以关键步距等于64 × 64B = 4 KB
。四 KB 的int64
类型代表 512 个元素。因此,我们用 512 列的矩阵达到了一个临界步长,所以我们有一个差的缓存分布。同时,如果矩阵包含 513 列,它不会导致关键的一步。这就是为什么我们在两个基准测试中观察到如此巨大的差异。
总之,我们必须意识到现代缓存是分区的。根据步距的不同,在某些情况下只使用一组,这可能会损害应用性能并导致冲突未命中。这种跨步叫做临界跨步。对于性能密集型应用,我们应该避免关键步骤,以充分利用 CPU 缓存。
请注意,我们的示例还强调了为什么我们应该注意在生产系统之外的系统上执行微基准测试的结果。如果生产系统具有不同的缓存架构,性能可能会有很大不同。
让我们继续讨论 CPU 缓存的影响。这一次,我们在编写并发代码时看到了具体的效果。
12.2 #92:编写导致错误共享的并发代码
到目前为止,我们已经讨论了 CPU 缓存的基本概念。我们已经看到,一些特定的缓存(通常是 L1 和 L2)并不在所有逻辑内核之间共享,而是特定于一个物理内核。这种特殊性会产生一些具体的影响,比如并发性和错误共享的概念,这会导致性能显著下降。让我们通过一个例子来看看什么是虚假分享,然后看看如何防止它。
在这个例子中,我们使用了两个结构,Input
和Result
:
type Input struct {
a int64
b int64
}
type Result struct {
sumA int64
sumB int64
}
目标是实现一个count
函数,该函数接收Input
的一部分并计算以下内容:
所有
Input.a
字段的总和变成Result.sumA
所有
Input.b
字段的总和变成Result.sumB
为了举例,我们实现了一个并发解决方案,其中一个 goroutine 计算sumA
,另一个计算sumB
:
func count(inputs []Input) Result {
wg := sync.WaitGroup{}
wg.Add(2)
result := Result{} // ❶
go func() {
for i := 0; i < len(inputs); i++ {
result.sumA += inputs[i].a // ❷
}
wg.Done()
}()
go func() {
for i := 0; i < len(inputs); i++ {
result.sumB += inputs[i].b // ❸
}
wg.Done()
}()
wg.Wait()
return result
}
❶ 初始化Result
结构
❷ 计算sumA
❸ 计算sumB
我们旋转了两个 goroutines:一个迭代每个a
字段,另一个迭代每个b
字段。从并发的角度来看,这个例子很好。例如,它不会导致数据竞争,因为每个 goroutine 都会增加自己的数据
可变。但是这个例子说明了降低预期性能的错误共享概念。
让我们看看主内存(见图 12.11)。因为sumA
和sumB
是连续分配的,所以在大多数情况下(八分之七),两个变量都被分配到同一个内存块。
图 12.11 在这个例子中,sumA
和sumB
是同一个内存块的一部分。
现在,让我们假设机器包含两个内核。在大多数情况下,我们最终应该在不同的内核上调度两个线程。因此,如果 CPU 决定将这个内存块复制到一个缓存行,它将被复制两次(图 12.12)。
图 12.12 每个块都被复制到核心 0 和核心 1 上的缓存行。
因为 L1D (L1 数据)是针对每个内核的,所以两条缓存线都是复制的。回想一下,在我们的例子中,每个 goroutine 更新它自己的变量:一边是sumA
,另一边是sumB
(图 12.13)。
图 12.13 每个 goroutine 更新它自己的变量。
因为这些缓存行是复制的,所以 CPU 的目标之一是保证缓存一致性。例如,如果一个 goroutine 更新sumA
而另一个读取sumA
(在一些同步之后),我们期望我们的应用获得最新的值。
然而,我们的例子并没有做到这一点。两个 goroutines 都访问它们自己的变量,而不是共享的变量。我们可能希望 CPU 知道这一点,并理解这不是冲突,但事实并非如此。当我们写缓存中的变量时,CPU 跟踪的粒度不是变量:而是缓存行。
当一个缓存行在多个内核之间共享,并且至少有一个 goroutine 是写线程时,整个缓存行都会失效。即使更新在逻辑上是独立的,也会发生这种情况(例如,sumA
和sumB
)。这就是错误共享的问题,它降低了性能。
注意在内部,CPU 使用 MESI 协议来保证缓存一致性。它跟踪每个高速缓存行,标记它已修改、独占、共享或无效(MESI)。
关于内存和缓存,需要理解的最重要的一个方面是,跨内核共享内存是不真实的——这是一种错觉。这种理解来自于我们并不认为机器是黑匣子;相反,我们试图对潜在的层次产生机械的同情。
那么我们如何解决虚假分享呢?有两种主要的解决方案。
第一个解决方案是使用我们已经展示过的相同方法,但是确保sumA
和sumB
不属于同一个缓存行。例如,我们可以更新Result
结构,在字段之间添加填充。填充是一种分配额外内存的技术。因为int64
需要 8 字节的分配和 64 字节长的缓存行,所以我们需要64–8 = 56
字节的填充:
type Result struct {
sumA int64
_ [56]byte // ❶
sumB int64
}
❶ 填充
图 12.14 显示了一种可能的内存分配。使用填充,sumA
和sumB
将总是不同存储块的一部分,因此是不同的高速缓存行。
图 12.14 sumA
和sumB
是不同内存块的一部分。
如果我们对两种解决方案进行基准测试(有和没有填充),我们会发现填充解决方案明显更快(在我的机器上大约快 40%)。这是一个重要的改进,因为在两个字段之间添加了填充以防止错误的共享。
第二个解决方案是重新设计算法的结构。例如,不是让两个 goroutines 共享同一个结构,我们可以让它们通过通道交流它们的本地结果。结果基准与填充大致相同。
总之,我们必须记住,跨 goroutines 共享内存是最低内存级别的一种错觉。当至少有一个 goroutine 是写线程时,如果缓存行在两个内核之间共享,则会发生假共享。如果我们需要优化一个依赖于并发的应用,我们应该检查假共享是否适用,因为这种模式会降低应用的性能。我们可以通过填充或通信来防止错误共享。
下一节讨论 CPU 如何并行执行指令,以及如何利用这种能力。
12.3 #93:不考虑指令级并行性
指令级并行是另一个可以显著影响性能的因素。在定义这个概念之前,我们先讨论一个具体的例子,以及如何优化。
我们将编写一个接收两个int64
元素的数组的函数。这个函数将迭代一定次数(一个常数)。在每次迭代期间,它将执行以下操作:
递增数组的第一个元素。
如果第一个元素是偶数,则递增数组的第二个元素。
这是 Go 版本:
const n = 1_000_000
func add(s [2]int64) [2]int64 {
for i := 0; i < n; i++ { // ❶
s[0]++ // ❷
if s[0]%2 == 0 { // ❸
s[1]++
}
}
return s
}
❶ 迭代n
次
❷ 递增s[0]
❸ 如果s[0]
是偶数,递增s[1]
循环中执行的指令如图 12.15 所示(一个增量需要一个读操作和一个写操作)。指令的顺序是连续的:首先我们递增s[0]
;然后,在递增s[1]
之前,我们需要再次读取s[0]
。
图 12.15 三个主要步骤:增量、检查、增量
注意这个指令序列与汇编指令的粒度不匹配。但是为了清楚起见,我们使用一个简化的视图。
让我们花点时间来讨论指令级并行(ILP)背后的理论。几十年前,CPU 设计师不再仅仅关注时钟速度来提高 CPU 性能。他们开发了多种优化,包括 ILP,它允许开发人员并行执行一系列指令。在单个虚拟内核中实现 ILP 的处理器称为超标量处理器。例如,图 12.16 显示了一个 CPU 执行一个由三条指令组成的应用,I1
、I2
和I3
。
*执行一系列指令需要不同的阶段。简而言之,CPU 需要解码指令并执行它们。执行由执行单元处理,执行单元执行各种操作和计算。
图 12.16 尽管是按顺序写的,但这三条指令是并行执行的。
在图 12.16 中,CPU 决定并行执行这三条指令。注意,并非所有指令都必须在单个时钟周期内完成。例如,读取已经存在于寄存器中的值的指令将在一个时钟周期内完成,但是读取必须从主存储器获取的地址的指令可能需要几十个时钟周期才能完成。
如果顺序执行,该指令序列将花费以下时间(函数t(x)
表示 CPU 执行指令x
所花费的时间):
total time = t(I1) + t(I2) + t(I3)
由于 ILP,总时间如下:
total time = max(t(I1), t(I2), t(I3))
理论上,ILP 看起来很神奇。但是这也带来了一些挑战叫做冒险。
举个例子,如果I3
将一个变量设置为 42,而I2
是条件指令(例如if
foo
==
1
)怎么办?理论上,这个场景应该防止并行执行I2
和I3
。此称为 a 控制冒险或分支冒险。在实践中,CPU 设计者使用分支预测来解决控制冒险。
例如,CPU 可以计算出在过去的 100 次中有 99 次条件为真;因此,它将并行执行I2
和I3
。在错误预测(I2
恰好为假)的情况下,CPU 将刷新其当前执行流水线,确保没有不一致。这种刷新会导致 10 到 20 个时钟周期的性能损失。
其他类型的冒险会阻止并行执行指令。作为软件工程师,我们应该意识到这一点。例如,让我们考虑下面两条更新寄存器(用于执行操作的临时存储区)的指令:
I1
将寄存器 A 和 B 中的数字加到 C 中。I2
将寄存器 C 和 D 中的数字加到 D 中。
因为I2
取决于关于寄存器 C 的值的I1
的结果,所以两条指令不能同时执行。I1
必须在I2
前完成。这被称为一数据冒险。为了处理数据冒险,CPU 设计者想出了一个叫做转发的技巧,即基本上绕过了对寄存器的写入。这种技术不能解决问题,而是试图减轻影响。
请注意,当流水线中至少有两条指令需要相同的资源时,还有和结构冒险。作为 Go 开发人员,我们不能真正影响这些种类的冒险,所以我们不在本节讨论它们。
现在我们对 ILP 理论有了一个不错的理解,让我们回到我们最初的问题,把注意力集中在循环的内容上:
s[0]++
if s[0]%2 == 0 {
s[1]++
}
正如我们所讨论的,数据冒险会阻止指令同时执行。让我们看看图 12.17 中的指令序列;这次我们强调说明之间的冒险。
图 12.17 说明之间的冒险类型
由于的if
语句,该序列包含一个控制冒险。然而,正如所讨论的,优化执行和预测应该采取什么分支是 CPU 的范围。还有多重数据危害。正如我们所讨论的,数据冒险阻止 ILP 并行执行指令。图 12.18 从 ILP 的角度显示了指令序列:唯一独立的指令是s[0]
检查和s[1]
增量,因此这两个指令集可以并行执行,这要归功于分支预测。
图 12.18 两个增量都是顺序执行的。
增量呢?我们能改进代码以减少数据冒险吗?
让我们编写另一个版本(add2
)来引入一个临时变量:
func add(s [2]int64) [2]int64 { // ❶
for i := 0; i < n; i++ {
s[0]++
if s[0]%2 == 0 {
s[1]++
}
}
return s
}
func add2(s [2]int64) [2]int64 { // ❷
for i := 0; i < n; i++ {
v := s[0] // ❸
s[0] = v + 1
if v%2 != 0 {
s[1]++
}
}
return s
}
❶ 第一版
❷ 第二版
❸ 引入了一个新的变量来固定s[0]
值
在这个新版本中,我们将s[0]
的值固定为一个新变量v
。之前我们增加了s[0]
,并检查它是否是偶数。为了复制这种行为,因为v
是基于s[0]
,为了增加s[1]
,我们现在检查v
是否是奇数。
图 12.19 比较了两个版本的危害。步骤的数量是相同的。最大的区别是关于数据冒险:s[0]
增量步骤和检查v
步骤现在依赖于相同的指令(read
s[0]
into
v
)。
图 12.19 一个显著的区别:检查步骤v
的数据冒险
为什么这很重要?因为它允许 CPU 提高并行度(图 12.20)。
图 12.20 在第二个版本中,两个增量步骤可以并行执行。
尽管有相同数量的步骤,第二个版本增加了可以并行执行的步骤数量:三个并行路径而不是两个。同时,应该优化执行时间,因为最长路径已经减少。如果我们对这两个函数进行基准测试,我们会看到第二个版本的速度有了显著的提高(在我的机器上大约提高了 20%),这主要是因为 ILP。
让我们后退一步来结束这一节。我们讨论了现代 CPU 如何使用并行性来优化一组指令的执行时间。我们还研究了数据冒险,它会阻止并行执行指令。我们还优化了一个 Go 示例,减少了数据冒险的数量,从而增加了可以并行执行的指令数量。
理解 Go 如何将我们的代码编译成汇编,以及如何使用 ILP 等 CPU 优化是另一个改进的途径。在这里,引入一个临时变量可以显著提高性能。这个例子演示了机械共鸣如何帮助我们优化 Go 应用。
让我们也记住对这种微优化保持谨慎。因为 Go 编译器一直在发展,所以当 Go 版本发生变化时,应用生成的程序集也可能发生变化。
下一节讨论数据对齐的效果。
12.4 #94:不知道数据对齐
数据对齐是一种安排如何分配数据的方式,以加速 CPU 的内存访问。不了解这个概念会导致额外的内存消耗,甚至降低性能。本节讨论这个概念,它适用的地方,以及防止代码优化不足的技术。
为了理解数据对齐是如何工作的,让我们首先讨论一下没有它会发生什么。假设我们分配了两个变量,一个int32
(32 字节)和一个int64
(64 字节):
var i int32
var j int64
在没有数据对齐的情况下,在 64 位架构上,这两个变量的分配如图 12.21 所示。j
变量分配可以用两个词来概括。如果 CPU 想要读取j
,它将需要两次内存访问,而不是一次。
图 12.21 j
两个字上的分配
为了避免这种情况,变量的内存地址应该是其自身大小的倍数。这就是数据对齐的概念。在 Go 中,对齐保证如下:
byte
、uint8
、int8
: 1 字节uint16
,int16
: 2 字节uint32
、int32
、float32
: 4 字节uint64
、int64
、float64
、complex64
: 8 字节complex128
: 16 字节
所有这些类型都保证是对齐的:它们的地址是它们大小的倍数。例如,任何int32
变量的地址都是 4 的倍数。
让我们回到现实世界。图 12.22 显示了i
和j
在内存中分配的两种不同情况。
图 12.22 在这两种情况下,j
都与自己的尺寸对齐。
在第一种情况下,就在i
之前分配了一个 32 位变量。因此,i
和j
被连续分配。第二种情况,32 位变量在i
之前没有分配(例如,它是一个 64 位变量);所以,i
是一个字的开头。考虑到数据对齐(地址是 64 的倍数),不能将j
与i
一起分配,而是分配给下一个 64 的倍数。灰色框表示 32 位填充。
接下来,让我们看看填充何时会成为问题。我们将考虑以下包含三个字段的结构:
type Foo struct {
b1 byte
i int64
b2 byte
}
我们有一个byte
类型(1 字节),一个int64
(8 字节),还有另一个byte
类型(1 字节)。在 64 位架构上,该结构被分配在内存中,如图 12.23 所示。b1
先分配。因为i
是一个int64
,所以它的地址必须是 8 的倍数。所以不可能在 0x01 和b1
一起分配。下一个是 8 的倍数的地址是什么?0x08。b2
分配给下一个可用地址,该地址是 1: 0x10 的倍数。
图 12.23 该结构总共占用 24 个字节。
因为结构的大小必须是字长的倍数(8 字节),所以它的地址不是 17 字节,而是总共 24 字节。在编译期间,Go 编译器添加填充以保证数据对齐:
type Foo struct {
b1 byte
_ [7]byte // ❶
i int64
b2 byte
_ [7]byte // ❶
}
❶ 由编译器添加
每次创建一个Foo
结构,它都需要 24 个字节的内存,但是只有 10 个字节包含数据——剩下的 14 个字节是填充。因为结构是一个原子单元,所以它永远不会被重组,即使在垃圾收集(GC)之后;它将总是占用 24 个字节的内存。请注意,编译器不会重新排列字段;它只添加填充以保证数据对齐。
如何减少分配的内存量?经验法则是重新组织结构,使其字段按类型大小降序排列。在我们的例子中,int64
类型首先是,然后是两个byte
类型:
type Foo struct {
i int64
b1 byte
b2 byte
}
图 12.24 显示了这个新版本的Foo
是如何在内存中分配的。i
先分配,占据一个完整的字。主要的区别是现在b1
和b2
可以在同一个单词中共存。
图 12.24 该结构现在占用了 16 个字节的内存。
同样,结构必须是字长的倍数;但是它只占用了 16 个字节,而不是 24 个字节。我们仅仅通过移动i
到第一个位置就节省了 33%的内存。
如果我们使用第一个版本的Foo
结构(24 字节)而不是压缩的,会有什么具体的影响?如果保留了Foo
结构(例如,内存中的Foo
缓存),我们的应用将消耗额外的内存。但是,即使没有保留Foo
结构,也会有其他影响。例如,如果我们频繁地创建Foo
变量并将它们分配给堆(我们将在下一节讨论这个概念),结果将是更频繁的 GC,影响整体应用性能。
说到性能,空间局部性还有另一个影响。例如,让我们考虑下面的sum
函数,它将一部分Foo
结构作为参数。该函数对切片进行迭代,并对所有的i
字段(int64
)求和:
func sum(foos []Foo) int64 {
var s int64
for i := 0; i < len(foos); i++ {
s += foos[i].i // ❶
}
return s
}
❶ 对所有i
字段求和
因为一个片由一个数组支持,这意味着一个Foo
结构的连续分配。
让我们讨论一下两个版本的Foo
的后备数组,并检查两个缓存行的数据(128 字节)。在图 12.25 中,每个灰色条代表 8 个字节的数据,较暗的条是i
变量(我们要求和的字段)。
图 12.25 因为每个缓存行包含更多的i
变量,迭代Foo
的一个片需要更少的缓存行。
正如我们所见,在最新版本的Foo
中,每条缓存线都更加有用,因为它平均包含 33%以上的i
变量。因此,迭代一个Foo
片来对所有的int64
元素求和会更有效。
我们可以用一个基准来证实这一观察。如果我们使用 10,000 个元素的切片运行两个基准测试,使用最新的Foo
结构的版本在我的机器上大约快 15%。与改变结构中单个字段的位置相比,速度提高了 15%。
让我们注意数据对齐。正如我们在本节中所看到的,重新组织 Go 结构的字段以按大小降序排列可以防止填充。防止填充意味着分配更紧凑的结构,这可能会导致优化,如减少 GC 的频率和更好的空间局部性。
下一节讨论栈和堆之间的根本区别以及它们为什么重要。
12.5 #95:不了解栈与堆
在 Go 中,一个变量既可以分配在栈上,也可以分配在堆上。这两种类型的内存有着根本的不同,会对数据密集型应用产生重大影响。让我们来看看这些概念和编译器在决定变量应该分配到哪里时所遵循的规则。
12.5.1 栈与堆
首先,让我们讨论一下栈和堆的区别。栈是默认内存;它是一种后进先出(LIFO)的数据结构,存储特定 goroutine 的所有局部变量。当一个 goroutine 启动时,它会获得 2 KB 的连续内存作为其栈空间(这个大小会随着时间的推移而变化,并且可能会再次改变)。但是,这个大小在运行时不是固定的,可以根据需要增加或减少(但是它在内存中始终保持连续,从而保持数据局部性)。
当 Go 进入一个函数时,会创建一个栈帧,表示内存中只有当前函数可以访问的区间。让我们看一个具体的例子来理解这个概念。这里,main
函数将打印一个sumValue
函数的结果:
func main() {
a := 3
b := 2
c := sumValue(a, b) // ❶
println(c) // ❷
}
//go:noinline // ❸
func sumValue(x, y int) int {
z := x + y
return z
}
❶ 调用sumValue
函数
❷ 打印了结果
❸ 禁用内联
这里有两点需要注意。首先,我们使用println
内置函数代替fmt.Println
,这将强制在堆上分配c
变量。其次,我们在sumValue
函数上禁用内联;否则,函数调用不会发生(我们在错误#97“不依赖内联”中讨论了内联)。
图 12.26 显示了a
和b
分配后的栈。因为我们执行了main
,所以为这个函数创建了一个栈框架。在这个栈帧中,两个变量a
和b
被分配给栈。所有存储的变量都是有效的地址,这意味着它们可以被引用和访问。
图 12.26 a
和b
分配在栈上。
图 12.27 显示了如果我们进入函数到语句会发生什么。Go 运行时创建一个新的栈框架,作为当前 goroutine 栈的一部分。x
和y
被分配在当前栈帧的z
旁边。
图 12.27 调用sumValue
创建一个新的栈框架。
前一个栈帧(main)
包含仍被视为有效的地址。我们不能直接访问a
和b
;但是如果我们有一个指针在a
上,例如,它将是有效的。我们不久将讨论指针。
让我们转到main
函数的最后一条语句:println
。我们退出了sumValue
函数,那么它的栈框架会发生什么变化呢?参见图 12.28。
图 12.28 删除了sumValue
栈框架,并用main
中的变量代替。在本例中,x
已被c
擦除,而y
和z
仍在内存中分配,但无法访问。
栈帧没有完全从内存中删除。当一个函数返回时,Go 不需要花时间去释放变量来回收空闲空间。但是这些先前的变量不能再被访问,当来自父函数的新变量被分配到栈时,它们替换了先前的分配。从某种意义上说,栈是自清洁的;它不需要额外的机制,比如 GC。
现在,让我们做一点小小的改变来理解栈的局限性。该函数将返回一个指针,而不是返回一个int
:
func main() {
a := 3
b := 2
c := sumPtr(a, b)
println(*c)
}
//go:noinline
func sumPtr(x, y int) *int { // ❶
z := x + y
return &z
}
❶ 返回了一个指针
main
中的c
变量现在是一个*int
类型。在调用sumPtr
之后,让我们直接转到最后一个println
语句。如果z
在栈上保持分配状态会发生什么(这不可能)?参见图 12.29。
图 12.29c
变量引用一个不再有效的地址。
如果c
引用的是z
变量的地址,而z
是在栈上分配的,我们就会遇到一个大问题。该地址将不再有效,加上main
的栈帧将继续增长并擦除z
变量。出于这个原因,栈是不够的,我们需要另一种类型的内存:堆。
内存堆是由所有 goroutines 共享的内存池。在图 12.30 中,三个 goroutineG1
、G2
和G3
都有自己的栈。它们都共享同一个堆。
图 12.30 三个 goroutines 有自己的栈,但共享堆
在前面的例子中,我们看到z
变量不能在栈上生存;因此,是逃逸到堆里。如果在函数返回后,编译器不能证明变量没有被引用,那么该变量将被分配到堆中。
我们为什么要关心?理解栈和堆的区别有什么意义?因为这对性能有很大的影响。
正如我们所说的,栈是自清洁的,由一个单独的 goroutine 访问。相反,堆必须由外部系统清理:GC。分配的堆越多,我们给 GC 的压力就越大。当 GC 运行时,它使用 25%的可用 CPU 容量,并可能产生毫秒级的“停止世界”延迟(应用暂停的阶段)。
我们还必须理解,在栈上分配对于 Go 运行时来说更快,因为它很简单:一个指针引用下面的可用内存地址。相反,在堆上分配需要更多的努力来找到正确的位置,因此需要更多的时间。
为了说明这些差异,让我们对sumValue
和sumPtr
进行基准测试:
var globalValue int
var globalPtr *int
func BenchmarkSumValue(b *testing.B) {
b.ReportAllocs() // ❶
var local int
for i := 0; i < b.N; i++ {
local = sumValue(i, i) // ❷
}
globalValue = local
}
func BenchmarkSumPtr(b *testing.B) {
b.ReportAllocs() // ❸
var local *int
for i := 0; i < b.N; i++ {
local = sumPtr(i, i) // ❹
}
globalValue = *local
}
❶ 报告堆分配
❷ 按值求和
❸ 报告堆分配
❹ 用指针求和
如果我们运行这些基准测试(并且仍然禁用内联),我们会得到以下结果:
BenchmarkSumValue-4 992800992 1.261 ns/op 0 B/op 0 allocs/op
BenchmarkSumPtr-4 82829653 14.84 ns/op 8 B/op 1 allocs/op
sumPtr
比sumValue
大约慢一个数量级,这是用堆代替栈的直接后果。
注意这个例子表明使用指针来避免复制并不一定更快;这要看上下文。到目前为止,在本书中,我们只通过语义的棱镜讨论了值和指针:当值必须被共享时使用指针。在大多数情况下,这应该是遵循的规则。还要记住,现代 CPU 复制数据的效率非常高,尤其是在同一个缓存行中。让我们避免过早的优化,首先关注可读性和语义。
我们还应该注意,在之前的基准测试中,我们调用了b.ReportAllocs()
,它强调了堆分配(栈分配不计算在内):
B/op:
每次操作分配多少字节allocs/op:
每次操作分配多少
接下来,我们来讨论变量逃逸到堆的条件。
12.5.2 逃逸分析
冒险分析是指编译器执行的决定一个变量应该分配在栈上还是堆上的工作。让我们看看主要的规则。
当一个分配不能在栈上完成时,它在堆上完成。尽管这听起来像是一个简单的规则,但记住这一点很重要。例如,如果编译器不能证明函数返回后变量没有被引用,那么这个变量就被分配到堆上。在上一节中,sumPtr
函数返回了一个指向在函数作用域中创建的变量的指针。一般来说,向上共享会将冒险到堆中。
但是相反的情况呢?如果我们接受一个指针,如下例所示,会怎么样?
func main() {
a := 3
b := 2
c := sum(&a, &b)
println(c)
}
//go:noinline
func sum(x, y *int) int { // ❶
return *x + *y
}
❶ 接受指针
sum
接受两个指针指向父级中创建的变量。如果我们移到sum
函数中的return
语句,图 12.31 显示了当前栈。
图 12.31x
和y
变量引用有效地址。
尽管是另一个栈帧的一部分,x
和y
变量引用有效地址。所以,a
和b
就不用逃了;它们可以留在栈中。一般来说,向下共享停留在栈上。
以下是变量可以冒险到堆的其他情况:
全局变量,因为多个 goroutines 可以访问它们。
发送到通道的指针:
type Foo struct{ s string } ch := make(chan *Foo, 1) foo := &Foo{s: "x"} ch <- foo
在这里,
foo
逃到了垃圾堆里。发送到通道的值所引用的变量:
type Foo struct{ s *string } ch := make(chan Foo, 1) s := "x" bar := Foo{s: &s} ch <- bar
因为
s
通过它的地址被Foo
引用,所以在这些情况下它会冒险到堆中。如果局部变量太大,无法放入栈。
如果一个局部变量的大小未知。例如,
s
:=
make([]int,
10)
可能不会冒险到堆中,但s
:=
make([]int,
n)
会,因为它的大小是基于变量的。如果使用
append
重新分配切片的后备数组。
尽管这个列表为我们理解编译器的决定提供了思路,但它并不详尽,在未来的 Go 版本中可能会有所改变。为了确认一个假设,我们可以使用-gcflags
来访问编译器的决定:
$ go build -gcflags "-m=2"
...
./main.go:12:2: z escapes to heap:
在这里,编译器通知我们z
变量将逃逸到堆中。
理解堆和栈之间的根本区别对于优化 Go 应用至关重要。正如我们已经看到的,堆分配对于 Go 运行时来说更加复杂,需要一个带有 GC 的外部系统来释放数据。在一些数据密集型应用中,堆管理会占用高达 20%或 30%的总 CPU 时间。另一方面,栈是自清洁的,并且对于单个 goroutine 来说是本地的,这使得分配更快。因此,优化内存分配可以有很大的投资回报。
理解逸出分析的规则对于编写更高效的代码也是必不可少的。一般来说,向下共享停留在栈上,而向上共享则转移到堆上。这应该可以防止常见的错误,比如我们想要返回指针的过早优化,例如,“为了避免复制”让我们首先关注可读性和语义,然后根据需要优化分配。
下一节讨论如何减少分配。
12.6 不知道如何减少分配
减少分配是加速 Go 应用的常用优化技术。本书已经介绍了一些减少堆分配数量的方法:
优化不足的字符串连接(错误#39):使用
strings.Builder
而不是+
操作符来连接字符串。无用的字符串转换(错误#40):尽可能避免将
[]byte
转换成字符串。切片和图初始化效率低(错误#21 和#27):如果长度已知,则预分配切片和图。
更好的数据结构对齐以减少结构大小(错误#94)。
作为本节的一部分,我们将讨论三种减少分配的常用方法:
改变我们的 API
依赖编译器优化
使用
sync.Pool
等工具
12.6.1 API 的变化
第一个选择是在我们提供的 API 上认真工作。让我们举一个具体的例子io.Reader
接口:
type Reader interface {
Read(p []byte) (n int, err error)
}
Read
方法接受一个片并返回读取的字节数。现在,想象一下如果io.Reader
接口被反过来设计:传递一个表示需要读取多少字节的int
并返回一个片:
type Reader interface {
Read(n int) (p []byte, err error)
}
语义上,这没有错。但是在这种情况下,返回的片会自动逃逸到堆中。我们将处于上一节描述的共享情况。
Go 设计者使用向下共享的方法来防止自动将切片逃逸到堆中。因此,由调用者来提供切片。这并不一定意味着这个片不会被逃逸:编译器可能已经决定这个片不能留在栈上。然而,由调用者来处理它,而不是由调用的Read
方法引起的约束。
有时,即使是 API 中的微小变化也会对分配产生积极的影响。当设计一个 API 时,让我们注意上一节描述的逃逸分析规则,如果需要,使用-gcflags
来理解编译器的决定。
12.6.2 编译器优化
Go 编译器的目标之一就是尽可能优化我们的代码。这里有一个关于映射的具体例子。
在 Go 中,我们不能使用切片作为键类型来定义映射。在某些情况下,特别是在做 I/O 的应用中,我们可能会收到我们想用作关键字的[]byte
数据。我们必须先将它转换成一个字符串,这样我们就可以编写下面的代码:
type cache struct {
m map[string]int // ❶
}
func (c *cache) get(bytes []byte) (v int, contains bool) {
key := string(bytes) // ❷
v, contains = c.m[key] // ❸
return
}
❶ 包含字符串的映射
❷ 将[]byte
转换为字符串
❸ 使用字符串值查询映射
因为get
函数接收一个[]byte
切片,所以我们将其转换成一个key
字符串来查询映射。
然而,如果我们使用string(bytes)
查询映射,Go 编译器会实现一个特定的优化:
func (c *cache) get(bytes []byte) (v int, contains bool) {
v, contains = c.m[string(bytes)] // ❶
return
}
❶ 使用string(bytes)
直接查询映射
尽管这是几乎相同的代码(我们直接调用string(bytes)
而不是传递变量),编译器将避免进行这种字节到字符串的转换。因此,第二个版本比第一个快。
这个例子说明了看起来相似的函数的两个版本可能导致遵循 Go 编译器工作的不同汇编代码。我们还应该了解优化应用的可能的编译器优化。我们需要关注未来的 Go 版本,以检查是否有新的优化添加到语言中。
12.6.3 sync.Pool
如果我们想解决分配数量的问题,另一个改进的途径是使用sync.Pool
。我们应该明白sync.Pool
不是一个缓存:没有我们可以设置的固定大小或最大容量。相反,它是一个重用公共对象的池。
假设我们想要实现一个write
函数,它接收一个io.Writer
,调用一个函数来获取一个[]byte
片,然后将它写入io.Writer
。我们的代码如下所示(为了清楚起见,我们省略了错误处理):
func write(w io.Writer) {
b := getResponse() // ❶
_, _ = w.Write(b) // ❷
}
❶ 收到一个[]byte
的响应
❷ 写入io.Writer
这里,getResponse
在每次调用时返回一个新的[]byte
片。如果我们想通过重用这个片来减少分配的次数呢?我们假设所有响应的最大大小为 1,024 字节。这种情况,我们可以用sync.Pool
。
创建一个sync.Pool
需要一个func()
any
工厂函数;参见图 12.32。sync.Pool
暴露两种方法:
Get() any
——从池中获取一个对象Put(any)
——将对象返回到池中
图 12.32 定义了一个工厂函数,它在每次调用时创建一个新对象。
如果池是空的,使用Get
创建一个新对象,否则重用一个对象。然后,在使用该对象之后,我们可以使用Put
将它放回池中。图 12.33 显示了先前定义的工厂的一个例子,当池为空时有一个Get
,当池不为空时有一个Put
和一个Get
。
图 12.33 Get
创建一个新对象或从池中返回一个对象。Put
将对象返回到池中。
什么时候从水池中排出物体?没有特定的方法可以做到这一点:它依赖于 GC。每次 GC 之后,池中的对象都被销毁。
回到我们的例子,假设我们可以更新getResponse
函数,将数据写入给定的片,而不是创建一个片,我们可以实现另一个版本的依赖于池的write
方法:
var pool = sync.Pool{
New: func() any { // ❶
return make([]byte, 1024)
},
}
func write(w io.Writer) {
buffer := pool.Get().([]byte) // ❷
buffer = buffer[:0] // ❸
defer pool.Put(buffer) // ❹
getResponse(buffer) // ❺
_, _ = w.Write(buffer)
}
❶ 创建了一个池并设置了工厂函数
❷ 从池中获取或创建[]byte
❸ 重置了缓冲区
❹ 把缓冲区放回池
❺ 将响应写入提供的缓冲区
我们使用sync.Pool
结构定义一个新的池,并设置工厂函数来创建一个长度为 1024 个元素的新的[]byte
。在write
函数中,我们试图从池中检索一个缓冲区。如果池是空的,该函数创建一个新的缓冲区;否则,它从缓冲池中选择一个任意的缓冲区并返回它。关键的一步是使用buffer[:0]
重置缓冲区,因为该片可能已经被使用。然后我们将调用Put
将切片放回池中。
在这个新版本中,调用write
不会导致为每个调用创建一个新的[]byte
片。相反,我们可以重用现有的已分配片。在最坏的情况下——例如,在 GC 之后——该函数将创建一个新的缓冲区;但是,摊余分配成本会减少。
综上所述,如果我们频繁分配很多同类型的对象,可以考虑使用sync.Pool
。它是一组临时对象,可以帮助我们避免重复重新分配同类数据。并且sync.Pool
可供多个 goroutines 同时安全使用。
接下来,让我们讨论内联的概念,以了解这种计算机优化是值得了解的。
12.7 #97:不依赖内联
内联是指用函数体替换函数调用。现在,内联是由编译器自动完成的。理解内联的基本原理也是优化应用特定代码路径的一种方式。
让我们来看一个内联的具体例子,它使用一个简单的sum
函数将两种int
类型相加:
func main() {
a := 3
b := 2
s := sum(a, b)
println(s)
}
func sum(a int, b int) int { // ❶
return a + b
}
❶ 内联了这个函数
如果我们使用-gcflags
运行go
build
,我们将访问编译器对sum
函数做出的决定:
$ go build -gcflags "-m=2"
./main.go:10:6: can inline sum with cost 4 as:
func(int, int) int { return a + b }
...
./main.go:6:10: inlining call to sum func(int, int) int { return a + b }
编译器决定将调用内联到sum
。因此,前面的代码被替换为以下代码:
func main() {
a := 3
b := 2
s := a + b // ❶
println(s)
}
❶ 用它的正文代替了对sum
的调用
内联只对具有一定复杂性的函数有效,也称为内联预算。否则,编译器会通知我们该函数太复杂,无法内联:
./main.go:10:6: cannot inline foo: function too complex:
cost 84 exceeds budget 80
内联有两个主要好处。首先,它消除了函数调用的开销(尽管自 Go 1.17 和基于寄存器的调用约定以来,开销已经有所减少)。其次,它允许编译器进行进一步的优化。例如,在内联一个函数后,编译器可以决定最初应该在堆上逃逸的变量可以留在栈上。
问题是,如果这种优化是由编译器自动应用的,那么作为 Go 开发者,我们为什么要关心它呢?答案在于中间栈内联的概念。
栈中内联是关于调用其他函数的内联函数。在 Go 1.9 之前,内联只考虑叶函数。现在,由于栈中内联,下面的foo
函数也可以被内联:
func main() {
foo()
}
func foo() {
x := 1
bar(x)
}
因为foo
函数不太复杂,编译器可以内联它的调用:
func main() {
x := 1 // ❶
bar(x)
}
❶ 用正文代替
多亏了中间栈内联,作为 Go 开发者,我们现在可以使用快速路径内联的概念来区分快速和慢速路径,从而优化应用。让我们看一个在sync.Mutex
实现中发布的具体例子来理解这是如何工作的。
在中间栈内联之前,Lock
方法的实现如下:
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
// Mutex isn't locked
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Mutex is already locked
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// ... // ❶
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
}
❶ 复杂逻辑
我们可以区分两条主要路径:
如果互斥没有被锁定(
atomic.CompareAndSwapInt32
为真),快速路径如果互斥体已经锁定(
atomic.CompareAndSwapInt32
为假),慢速路径
然而,无论采用哪种方法,由于函数的复杂性,它都不能内联。为了使用中间栈内联,Lock
方法被重构,因此慢速路径位于一个特定的函数中:
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
m.lockSlow() // ❶
}
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// ...
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
}
互斥体已经锁定的❶路径
由于这一改变,Lock
方法可以被内联。好处是没有被锁定的互斥体现在被锁定了,而不需要支付调用函数的开销(速度提高了 5%左右)。当互斥体已经被锁定时,慢速路径不会改变。以前它需要一个函数调用来执行这个逻辑;它仍然是一个函数调用,这次是对lockSlow
的调用。
这种优化技术是关于区分快速和慢速路径。如果快速路径可以内联,而慢速路径不能内联,我们可以在专用函数中提取慢速路径。因此,如果没有超出内联预算,我们的函数是内联的候选函数。
内联不仅仅是我们不应该关心的不可见的编译器优化。正如在本节中所看到的,理解内联是如何工作的以及如何访问编译器的决定是使用快速路径内联技术进行优化的一条途径。如果执行快速路径,在专用函数中提取慢速路径可以防止函数调用。
下一节将讨论常见的诊断工具,这些工具可以帮助我们理解在我们的 Go 应用中应该优化什么。
12.8 #98:不使用 Go 诊断工具
Go 提供了一些优秀的诊断工具,帮助我们深入了解应用的执行情况。这一节主要关注最重要的部分:概要分析和执行跟踪器。这两个工具都非常重要,应该成为任何对优化感兴趣的 Go 开发者的核心工具集的一部分。我们先讨论侧写。
12.8.1 概要分析
评测提供了对应用执行的深入了解。它允许我们解决性能问题、检测竞争、定位内存泄漏等等。这些见解可以通过以下几个方面收集:
CPU
——决定应用的时间花在哪里Goroutine
——报告正在进行的 goroutines 的栈跟踪Heap
——报告堆内存分配,以监控当前内存使用情况并检查可能的内存泄漏Mutex
——报告锁争用,以查看我们代码中使用的互斥体的行为,以及应用是否在锁定调用上花费了太多时间Block
——显示 goroutines 阻塞等待同步原语的位置
剖析是通过使用一个叫做剖析器的工具来实现的。先来了解一下如何以及何时启用pprof
;然后,我们讨论最重要的概要文件类型。
启用pprof
启用pprof
有几种方法。例如,我们可以使用net/http/pprof
包通过 HTTP:
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof" // ❶
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // ❷
fmt.Fprintf(w, "")
})
log.Fatal(http.ListenAndServe(":80", nil))
}
❶ 空白导入pprof
❷ 公开了一个 HTTP 端点
导入net/http/pprof
会导致一个副作用,即允许我们到达pprof
URL,http://host/debug/pprof
。注意启用pprof
即使在生产中也是安全的(go.dev/doc/diagnostics#profiling
)。影响性能的配置文件,如 CPU 配置文件,默认情况下不会启用,也不会连续运行:它们只在特定的时间段内激活。
既然我们已经看到了如何公开一个pprof
端点,让我们讨论一下最常见的概要文件。
CPU 分析
CPU 性能分析器依赖于 OS 和信令。当它被激活时,默认情况下,应用通过SIGPROF
信号要求操作系统每隔 10 ms 中断一次。当应用接收到一个SIGPROF
时,它会挂起当前的活动,并将执行转移到分析器。分析器收集数据,例如当前的 goroutine 活动,并聚合我们可以检索的执行统计信息。然后停止,继续执行,直到下一个SIGPROF
。
我们可以访问/debug/pprof/profile
端点来激活 CPU 分析。默认情况下,访问此端点会执行 30 秒的 CPU 分析。在 30 秒内,我们的应用每 10 毫秒中断一次。注意,我们可以更改这两个默认值:我们可以使用seconds
参数向端点传递分析应该持续多长时间(例如,/debug/pprof/profile?seconds=15
),并且我们可以改变中断率(甚至到小于 10 ms)。但是在大多数情况下,10 ms 应该足够了,在减小这个值(意味着增加速率)时,我们应该小心不要损害性能。30 秒钟后,我们下载了 CPU 分析器的结果。
基准测试期间的 CPU 性能分析
我们还可以使用的-cpuprofile
标志来启用 CPU 分析器,比如在运行基准测试时:
$ go test -bench=. -cpuprofile profile.out
该命令生成的文件类型与可以通过/debug/pprof/profile 下载的文件类型相同。
从这个文件中,我们可以使用go tool
导航到结果:
$ go tool pprof -http=:8080 <file>
该命令打开一个显示调用图的 web UI。图 12.34 显示了一个来自应用的例子。箭头越大,说明这条路越热。然后,我们可以浏览该图表,获得执行洞察。
图 12.34 30 秒内应用的调用图
例如,图 12.35 中的图表告诉我们,在 30 秒内,decode
方法(*FetchResponse
接收器)花费了 0.06 秒。在这 0.06 秒中,RecordBatch.decode
用了 0.02 秒,makemap
(创建映射)用了 0.01 秒。
图 12.35 示例调用图
我们还可以通过不同的表示从 web 用户界面访问这类信息。例如,顶视图按执行时间对函数进行排序,而火焰图可视化了执行时间层次结构。UI 甚至可以逐行显示源代码中昂贵的部分。
注意,我们还可以通过命令行深入分析数据。然而,在这一节中,我们将重点放在 web UI 上。
借助这些数据,我们可以大致了解应用的行为方式:
太多对
runtime.mallogc
的调用意味着过多的小堆分配,我们可以尽量减少。花在通道操作或互斥锁上的时间太多,可能表明存在过多的争用,这会损害应用的性能。
在
syscall.Read
或syscall.Write
上花费太多时间意味着应用在内核模式下花费大量时间。致力于 I/O 缓冲可能是一条改进的途径。
这些是我们可以从 CPU 性能分析器中获得的洞察。理解最热门的代码路径并识别瓶颈是很有价值的。但是它不会确定超过配置的速率,因为 CPU 性能分析器是以固定的速度执行的(默认为 10 毫秒)。为了获得更细粒度的洞察力,我们应该使用跟踪,我们将在本章后面讨论。
注:我们还可以给不同的函数贴上标签。例如,想象一个从不同客户端调用的公共函数。为了跟踪两个客户端花费的时间,我们可以使用pprof.Labels
。
堆分析
堆分析允许我们获得关于当前堆使用情况的统计数据。与 CPU 分析一样,堆分析也是基于样本的。我们可以改变这个速率,但是我们不应该太细,因为我们降低的速率越多,堆分析收集数据的工作量就越大。默认情况下,对于每 512 KB 的堆分配,对样本进行一次分析。
如果我们到达/debug/pprof/heap/
但是,我们可以使用debug/pprof/heap/?debug=0
,然后用go tool
(与上一节相同的命令)打开它,使用 web UI 导航到数据。
图 12.36 堆积图
图 12.36 显示了一个堆图的例子。调用MetadataResponse .decode
方法导致分配 1536 KB 的堆数据(占总堆的 6.32%)。然而,这 1536 KB 中有 0 个是由这个函数直接分配的,所以我们需要检查第二个调用。TopicMetadata.decode
方法分配了 1536 KB 中的 512 KB 其余的 1024 KB 用另一种方法分配。
这就是我们如何浏览调用链,以了解应用的哪个部分负责大部分堆分配。我们还可以看看不同的样本类型:
alloc_objects
——分配的对象总数alloc_space
——分配的内存总量inuse_objects
——已分配未释放的对象数量inuse_space
——已分配但尚未释放的内存量
堆分析的另一个非常有用的功能是跟踪内存泄漏。对于基于 GC 的语言,通常的过程如下:
触发 GC。
下载堆数据。
等待几秒钟/几分钟。
触发另一个 GC。
下载另一个堆数据。
比较。
在下载数据之前强制执行 GC 是防止错误假设的一种方法。例如,如果我们在没有首先运行 GC 的情况下看到保留对象的峰值,我们就不能确定这是一个泄漏还是下一个 GC 将收集的对象。
使用pprof
,我们可以下载一个堆概要文件,同时强制执行 GC。Go 中的过程如下:
转到
/debug/pprof/heap?gc=1
(触发 GC 并下载堆配置文件)。等待几秒钟/几分钟。
再次转到
/debug/pprof/heap?gc=1
。使用
go tool
比较两个堆配置文件:
$ go tool pprof -http=:8080 -diff_base <file2> <file1>
图 12.37 显示了我们可以访问的数据类型。例如,newTopicProducer
方法(左上)持有的堆内存量已经减少了(–513 KB)。相比之下,updateMetadata
(右下角)持有的数量增加了(+512 KB)。缓慢增加是正常的。例如,第二个堆配置文件可能是在服务调用过程中计算出来的。我们可以重复这个过程或等待更长时间;重要的部分是跟踪特定对象分配的稳定增长。
图 12.37 两种堆配置文件的区别
注意,与堆相关的另一种类型的分析是allocs
,它报告分配情况。堆分析显示了堆内存的当前状态。为了深入了解应用启动以来的内存分配情况,我们可以使用分配分析。如前所述,因为栈分配的成本很低,所以它们不是这种分析的一部分,这种分析只关注堆。
Goroutines 剖析
goroutine
配置文件报告应用中所有当前 goroutines 的栈跟踪。我们可以用debug/pprof/goroutine/?debug=0
,再次使用go tool
。图 12.38 显示了我们能得到的信息种类。
图 12.38 Goroutine 图
我们可以看到应用的当前状态以及每个函数创建了多少个 goroutines。在这种情况下,withRecover
创建了 296 个正在进行的 goroutine(63%),其中 29 个与对responseFeeder
的调用相关。
如果我们怀疑 goroutine 泄密,这种信息也是有益的。我们可以查看 goroutine 性能分析器数据,了解系统的哪个部分是可疑的。
块剖析
block
配置文件报告正在进行的 goroutines 阻塞等待同步原语的位置。可能性包括
在无缓冲通道上发送或接收
发送到完整通道
从空通道接收
互斥竞争
网络或文件系统等待
块分析还记录了一个 goroutine 等待的时间,可以通过debug/pprof/block
访问。如果我们怀疑阻塞调用损害了性能,这个配置文件会非常有用。
默认情况下,block
配置文件是不启用的:我们必须调用runtime.SetBlockProfileRate
来启用它。此函数控制报告的 goroutine 阻塞事件的比例。一旦启用,分析器将继续在后台收集数据,即使我们不调用debug/pprof/block
端点。如果我们想设置一个较高的比率,我们就要谨慎,以免影响性能。
完整的 goroutine 栈转储
如果我们面临死锁或者怀疑 goroutine 处于阻塞状态,那么完整的 goroutine 栈转储(debug/pprof/goroutine/?debug=2
)创建所有当前 goroutine 栈跟踪的转储。作为第一个分析步骤,这可能很有帮助。例如,以下转储显示 Sarama goroutine 在通道接收操作中被阻塞了 1,420 分钟:
goroutine 2494290 [chan receive, 1420 minutes]:
github.com/Shopify/sarama.(*syncProducer).SendMessages(0xc00071a090,
➥{0xc0009bb800, 0xfb, 0xfb})
/app/vendor/github.com/Shopify/sarama/sync_producer.go:117 +0x149
互斥剖析
最后一种配置文件类型与阻塞有关,但仅与互斥有关。如果我们怀疑我们的应用花费大量时间等待锁定互斥体,从而损害执行,我们可以使用互斥体分析。可以通过/debug/pprof/mutex 访问它。
该配置文件的工作方式类似于阻塞。默认情况下它是禁用的:我们必须使用runtime.SetMutexProfileFraction
来启用它,它控制所报告的互斥争用事件的比例。
以下是关于概要分析的一些附加说明:
我们没有提到
threadcreate
剖面,因为从 2013 年开始就坏了(github.com/golang/go/issues/6104
)。确保一次只启用一个分析器:例如,不要同时启用 CPU 和堆分析。这样做会导致错误的观察。
pprof
是可扩展的,我们可以使用pprof.Profile
创建自己的自定义概要文件。
我们已经看到了最重要的配置文件,它们可以帮助我们了解应用的性能以及可能的优化途径。一般来说,建议启用pprof
,即使是在生产环境中,因为在大多数情况下,它在它的占用空间和我们可以从中获得的洞察力之间提供了一个极好的平衡。一些配置文件,比如 CPU 配置文件,会导致性能下降,但只在它们被启用的时候。
现在让我们看看执行跟踪器。
12.8.2 执行跟踪器
执行跟踪器是一个工具,它用go tool
捕捉广泛的运行时事件,使它们可用于可视化。这有助于:
了解运行时事件,例如 GC 如何执行
了解 goroutines 如何执行
识别并行性差的执行
让我们用错误#56 中给出的一个例子来试试,“思考并发总是更快。”我们讨论了归并排序算法的两个并行版本。第一个版本的问题是并行性差,导致创建了太多的 goroutines。让我们看看跟踪器如何帮助我们验证这一陈述。
我们将为第一个版本编写一个基准,并使用-trace
标志来执行它,以启用执行跟踪器:
$ go test -bench=. -v -trace=trace.out
注意我们还可以使用/debug/pprof/trace?debug=0
的pprof
端点下载远程跟踪文件。 。
这个命令创建一个trace.out
文件,我们可以使用go tool
打开它:
$ go tool trace trace.out
2021/11/26 21:36:03 Parsing trace...
2021/11/26 21:36:31 Splitting trace...
2021/11/26 21:37:00 Opening browser. Trace viewer is listening on
http://127.0.0.1:54518
web 浏览器打开,我们可以单击 View Trace 查看特定时间段内的所有跟踪,如图 12.39 所示。这个数字代表大约 150 毫秒,我们可以看到多个有用的指标,比如 goroutine 计数和堆大小。堆大小稳定增长,直到触发 GC。我们还可以观察每个 CPU 内核的 Go 应用的活动。时间范围从用户级代码开始;然后执行“停止世界”,占用四个 CPU 内核大约 40 毫秒。
图 12.39 显示了 goroutine 活动和运行时事件,如 GC 阶段
关于并发,我们可以看到这个版本使用了机器上所有可用的 CPU 内核。然而,图 12.40 放大了 1 毫秒的一部分,每个条形对应于一次 goroutine 执行。拥有太多的小竖条看起来不太好:这意味着执行的并行性很差。
图 12.40 太多的小横条意味着并行执行效果不佳。
图 12.41 放大到更近,以查看这些 goroutines 是如何编排的。大约 50%的 CPU 时间没有用于执行应用代码。空白表示 Go 运行时启动和编排新的 goroutines 所需的时间。
图 12.41 大约 50%的 CPU 时间用于处理 goroutine 开关。
让我们将其与第二种并行实现进行比较,后者大约快一个数量级。图 12.42 再次放大到 1 毫秒的时间范围。
图 12.42 空格数量明显减少,证明 CPU 被更充分的占用。
每个 goroutine 都需要更多的时间来执行,并且空格的数量已经显著减少。因此,与第一个版本相比,CPU 执行应用代码的时间要多得多。每一毫秒的 CPU 时间都得到了更有效的利用,这解释了基准测试的差异。
请注意,跟踪的粒度是每个例程,而不是像 CPU 分析那样的每个函数。然而,可以使用包来定义用户级任务,以获得每个函数或函数组的洞察力。
例如,假设一个函数计算一个斐波那契数,然后使用atomic
将其写入一个全局变量。我们可以定义两种不同的任务:
var v int64
ctx, fibTask := trace.NewTask(context.Background(), "fibonacci") // ❶
trace.WithRegion(ctx, "main", func() {
v = fibonacci(10)
})
fibTask.End()
ctx, fibStore := trace.NewTask(ctx, "store") // ❷
trace.WithRegion(ctx, "main", func() {
atomic.StoreInt64(&result, v)
})
fibStore.End()
❶ 创建了一个斐波那契任务
❷ 创建一个存储任务
使用go
tool
,我们可以获得关于这两个任务如何执行的更精确的信息。在前面的 trace UI 中(图 12.42),我们可以看到每个 goroutine 中每个任务的边界。在用户定义的任务中,我们可以遵循持续时间分布(见图 12.43)。
图 12.43 用户级任务的分布
我们看到,在大多数情况下,fibonacci
任务的执行时间不到 15 微秒,而store
任务的执行时间不到 6309 纳秒。
在上一节中,我们讨论了我们可以从 CPU 概要分析中获得的各种信息。与我们可以从用户级跟踪中获得的数据相比,主要的区别是什么?
CPU 性能分析:
- 以样本为基础。
- 每个函数。
- 不会低于速率(默认为 10 毫秒)。
用户级跟踪:
- 不基于样本。
- 逐例程执行(除非我们使用
runtime/trace
包)。 - 时间执行不受任何速率的约束。
总之,执行跟踪器是理解应用如何执行的强大工具。正如我们在归并排序示例中看到的,我们可以识别出并行性差的执行。然而,跟踪器的粒度仍然是每一个例程,除非我们手动使用runtime/trace
与 CPU 配置文件进行比较。在优化应用时,我们可以同时使用概要分析和执行跟踪器来充分利用标准的 Go 诊断工具。
下一节讨论 GC 如何工作以及如何调优。
12.9 #99:不了解 GC 如何工作
垃圾收集器(GC)是简化开发人员生活的 Go 语言的关键部分。它允许我们跟踪和释放不再需要的堆分配。因为我们不能用栈分配来代替每个堆分配,所以理解 GC 如何工作应该是 Go 开发人员优化应用的工具集的一部分。
12.9.1 概念
GC 保存了一个对象引用树。Go GC 基于标记-清除算法,该算法依赖于两个阶段:
标记阶段——遍历堆中的所有对象,并标记它们是否仍在使用
清除阶段——从根开始遍历引用树,并释放不再被引用的对象块
当 GC 运行时,它首先执行一组动作,导致停止世界(准确地说,每个 GC 两次停止世界)。也就是说,所有可用的 CPU 时间都用于执行 GC,从而暂停了我们的应用代码。按照这些步骤,它再次启动这个世界,恢复我们的应用,同时运行一个并发阶段。出于这个原因,Go GC 被称为并发标记和清除:它的目标是减少每个 GC 周期的停止世界操作的数量,并且主要与我们的应用并发运行。
清理器
Go GC 还包括一种在消耗高峰后释放内存的方法。假设我们的应用基于两个阶段:
导致频繁分配和大量堆的初始化阶段
具有适度分配和小堆的运行时阶段
如何处理大堆只在应用启动时有用,而在那之后没有用的事实呢?这是作为 GC 的一部分使用所谓的定期清理器来处理的。一段时间后,GC 检测到不再需要这么大的堆,所以它释放一些内存并将其返回给操作系统。
注意如果清理器不够快,我们可以使用debug.FreeOSMemory()
手动强制将内存返回给操作系统。
重要的问题是,GC 周期什么时候运行?与 Java 等其他语言相比,Go 配置仍然相当简单。它依赖于单个环境变量:GOGC
。该变量定义了在触发另一个 GC 之前,自上次 GC 以来堆增长的百分比;默认值为 100%。
让我们看一个具体的例子,以确保我们理解。让我们假设刚刚触发了一个 GC,当前的堆大小是 128 MB。如果GOGC=100
,当堆大小达到 256 MB 时,触发下一次垃圾收集。默认情况下,每当堆大小加倍时,就会执行一次 GC。此外,如果在最后 2 分钟内没有执行 GC,Go 将强制运行一个 GC。
如果我们用生产负载分析我们的应用,我们可以微调GOGC
:
减少它会导致堆增长更慢,增加 GC 的压力。
相反,碰撞它会导致堆增长得更快,从而减轻 GC 的压力。
GC 痕迹
我们可以通过设置GODEBUG
环境变量来打印 GC 轨迹,比如在运行基准测试时:
$ GODEBUG=gctrace=1 go test -bench=. -v
启用gctrace
会在每次 GC 运行时向stderr
写入跟踪。
让我们通过一些具体的例子来理解 GC 在负载增加时的行为。
12.9.2 示例
假设我们向用户公开一些公共服务。在中午 12:00 的高峰时段,有 100 万用户连接。然而,联网用户在稳步增长。图 12.44 表示平均堆大小,以及当我们将GOGC
设置为100
时何时触发 GC。
图 12.44 联网用户的稳步增长
因为GOGC
被设置为100
,所以每当堆大小加倍时,GC 都会被触发。在这种情况下,由于用户数量稳步增长,我们应该全天面对可接受数量的 GC(图 12.45)。
图 12.45 GC 频率从未达到大于中等的状态。
在一天开始的时候,我们应该有适度数量的 GC 周期。当我们到达中午 12:00 时,当用户数量开始减少时,GC 周期的数量也应该稳步减少。在这种情况下,保持GOGC
到100
应该没问题。
现在,让我们考虑第二个场景,100 万用户中的大多数在不到一个小时内连接;参见图 12.46。上午 8:00,平均堆大小迅速增长,大约一小时后达到峰值。
图 12.46 用户突然增加
在这一小时内,GC 周期的频率受到严重影响,如图 12.47 所示。由于堆的显著和突然的增加,我们在短时间内面临频繁的 GC 循环。即使 Go GC 是并发的,这种情况也会导致大量的停顿期,并会造成一些影响,例如增加用户看到的平均延迟。
图 12.47 在一个小时内,我们观察到高频率的 GCs。
在这种情况下,我们应该考虑将GOGC
提高到一个更高的值,以减轻 GC 的压力。注意,增加GOGC
并不会带来线性的好处:堆越大,清理的时间就越长。因此,使用生产负载时,我们在配置GOGC
时应该小心。
在颠簸更加严重的特殊情况下,调整GOGC
可能还不够。例如,我们不是在一个小时内从 0 到 100 万用户,而是在几秒钟内完成。在这几秒钟内,GC 的数量可能会达到临界状态,导致应用的性能非常差。
如果我们知道堆的峰值,我们可以使用一个技巧,强制分配大量内存来提高堆的稳定性。例如,我们可以在main.go
中使用一个全局变量强制分配 1 GB:
var min = make([]byte, 1_000_000_000) // 1 GB
这样的分配有什么意义?如果GOGC
保持在100
,而不是每次堆翻倍时触发一次 GC(同样,这在这几秒钟内发生得非常频繁),那么 Go 只会在堆达到 2 GB 时触发一次 GC。这应该会减少所有用户连接时触发的 GC 周期数,从而减少对平均延迟的影响。
我们可以说,当堆大小减小时,这个技巧会浪费大量内存。但事实并非如此。在大多数操作系统上,分配这个min
变量不会让我们的应用消耗 1 GB 的内存。调用make
会导致对mmap()
的系统调用,从而导致惰性分配。例如,在 Linux 上,内存是通过页表虚拟寻址和映射的。使用mmap()
在虚拟地址空间分配 1 GB 内存,而不是物理空间。只有读取或写入会导致页面错误,从而导致实际的物理内存分配。因此,即使应用在没有任何连接的客户端的情况下启动,它也不会消耗 1 GB 的物理内存。
注意,我们可以使用ps
这样的工具来验证这种行为。
为了优化 GC,理解它的行为是很重要的。作为 Go 开发者,我们可以使用GOGC
来配置何时触发下一个 GC 周期。大多数情况下,保持在100
应该就够了。但是,如果我们的应用可能面临导致频繁 GC 和延迟影响的请求高峰,我们可以增加这个值。最后,在出现异常请求高峰时,我们可以考虑使用将虚拟堆大小保持在最小的技巧。
本章最后一节讨论了在 Docker 和 Kubernetes 中运行 Go 的影响。
12.10 #100:不了解在 Docker 和 Kubernetes 中运行GO的影响
根据 2021 年 Go 开发者调查(go.dev/blog/survey2021-results
),用 Go 编写服务是最常见的用法。同时,Kubernetes 是部署这些服务最广泛使用的平台。理解在 Docker 和 Kubernetes 中运行 Go 的含义是很重要的,这样可以防止常见的情况,比如 CPU 节流。
我们在错误#56“思考并发总是更快”中提到,GOMAXPROCS
变量定义了负责同时执行用户级代码的操作系统线程的限制。默认情况下,它被设置为操作系统可见的逻辑 CPU 内核的数量。这在 Docker 和 Kubernetes 的上下文中意味着什么?
假设我们的 Kubernetes 集群由八核节点组成。当在 Kubernetes 中部署一个容器时,我们可以定义一个 CPU 限制来确保应用不会消耗所有的主机资源。例如,以下配置将 cpu 的使用限制为 4,000 个毫 CPU(或毫核心),因此有四个 CPU 核心:
spec:
containers:
- name: myapp
image: myapp
resources:
limits:
cpu: 4000m
我们可以假设,当部署我们的应用时,GOMAXPROCS
将基于这些限制,因此将具有值4
。但事实并非如此;它被设置为主机上逻辑核心的数量:8
。那么,有什么影响呢?
Kubernetes 使用完全公平调度器(CFS)作为进程调度器。CFS 还用于强制执行 Pod 资源的 CPU 限制。在管理 Kubernetes 集群时,管理员可以配置这两个参数:
cpu.cfs_period_us
(全局设置)cpu.cfs_quota_us
(设定每 Pod)
前者规定了一个期限,后者规定了一个配额。默认情况下,周期设置为 100 毫秒。同时,默认配额值是应用在 100 毫秒内可以消耗的 CPU 时间。限制设置为四个内核,这意味着 400 毫秒(4 × 100
毫秒)。因此,CFS 将确保我们的应用在 100 毫秒内不会消耗超过 400 毫秒的 CPU 时间。
让我们想象一个场景,其中多个 goroutines 当前正在四个不同的线程上执行。每个线程被调度到不同的内核(1、3、4 和 8);参见图 12.48。
图 12.48 每 100 毫秒,应用消耗的时间不到 400 毫秒
在第一个 100 毫秒期间,有四个线程处于忙碌状态,因此我们消耗了 400 毫秒中的 400 毫秒:100%的配额。在第二阶段,我们消耗 400 毫秒中的 360 毫秒,以此类推。一切都很好,因为应用消耗的资源少于配额。
但是,我们要记住GOMAXPROCS
是设置为8
的。因此,在最坏的情况下,我们可以有八个线程,每个线程被安排在不同的内核上(图 12.49)。
图 12.49 在每 100 毫秒期间,CPU 在 50 毫秒后被节流。
每隔 100 毫秒,配额设置为 400 毫秒,如果 8 个线程忙于执行 goroutines,50 毫秒后,我们达到 400 毫秒的配额(8 × 50 毫秒 = 400 毫秒
)。会有什么后果?CFS 将限制 CPU 资源。因此,在下一个周期开始之前,不会再分配 CPU 资源。换句话说,我们的应用将被搁置 50 毫秒。
例如,平均延迟为 50 毫秒的服务可能需要 150 毫秒才能完成。这可能会对延迟造成 300%的损失。
那么,有什么解决办法呢?先关注 Go 第 33803 期(github.com/golang/go/issues/33803
)。也许在 Go 的未来版本中,GOMAXPROCS
将会支持 CFS。
今天的一个解决方案是依靠由github.com/uber-go/automaxprocs
制作的名为automaxprocs
的库。我们可以通过向main.go
中的go.uber.org/automaxprocs
添加一个空白导入来使用这个库;它会自动设置GOMAXPROCS
来匹配 Linux 容器的 CPU 配额。在前面的例子中,GOMAXPROCS
将被设置为4
而不是8
,因此我们将无法达到 CPU 被抑制的状态。
总之,让我们记住,目前,Go 并不支持 CFS。GOMAXPROCS
基于主机,而不是基于定义的 CPU 限制。因此,我们可能会达到 CPU 被抑制的状态,从而导致长时间的暂停和重大影响,例如显著的延迟增加。在 Go 能够感知 CFS 之前,一种解决方案是依靠automaxprocs
自动将GOMAXPROCS
设置为定义的配额。
总结
了解如何使用 CPU 缓存对于优化 CPU 密集型应用非常重要,因为 L1 缓存比主内存快 50 到 100 倍。
了解缓存线概念对于理解如何在数据密集型应用中组织数据至关重要。CPU 不会一个字一个字地获取内存;相反,它通常将内存块复制到 64 字节的缓存行。要充分利用每个单独的缓存行,请实现空间局部性。
使代码对 CPU 可预测也是优化某些函数的有效方法。例如,CPU 的单位步幅或常量步幅是可预测的,但是非单位步幅(例如,一个链表)是不可预测的。
为了避免关键的一步,因此只利用缓存的一小部分,请注意缓存是分区的。
知道较低级别的 CPU 缓存不会在所有内核之间共享有助于避免性能下降的模式,例如在编写并发代码时的错误共享。分享内存是一种错觉。
使用指令级并行(ILP)来优化代码的特定部分,以允许 CPU 执行尽可能多的并行指令。识别数据冒险是主要步骤之一。
记住在GO中,基本类型是根据它们自己的大小排列的,这样可以避免常见的错误。例如,请记住,按大小降序重新组织结构的字段可以产生更紧凑的结构(更少的内存分配和潜在的更好的空间局部性)。
在优化 Go 应用时,理解堆和栈之间的根本区别也应该是您核心知识的一部分。栈分配几乎是免费的,而堆分配速度较慢,并且依赖 GC 来清理内存。
减少分配也是优化 Go 应用的一个重要方面。这可以通过不同的方式来实现,比如仔细设计 API 以防止共享,理解常见的 Go 编译器优化,以及使用
sync.Pool
。使用快速路径内联技术有效减少调用函数的分摊时间。
依靠分析和执行跟踪器来了解应用的执行情况以及需要优化的部分。
了解如何调优 GC 可以带来多种好处,比如更有效地处理突然增加的负载。
为了帮助避免部署在 Docker 和 Kubernetes 中时的 CPU 节流,请记住 Go 不支持 CFS。
最后的话
恭喜你完成了《100 个 Go 错误以及如何避免它们》。我真诚地希望你喜欢读这本书,它将对你的个人和/或专业项目有所帮助。
记住,犯错是学习过程的一部分,正如我在序言中强调的,它也是本书灵感的重要来源。归根结底,重要的是我们从中学习的能力。
如果你想继续讨论,可以在推特上关注我:@teivah。***