golang
程序性能优化指南 | 青训营
前言:这一篇青训营笔记旨在介绍在满足正确性、可靠性、健壮性、可读性等质量因素的前提下提高程序效率的性能优化建议,以及性能分析工具的使用,以及性能优化的实战案例。
基础性能评测工具benchmark
使用说明
当我们尝试去优化代码的性能时,首先得知道当前的性能怎么样。Go 语言标准库内置的 testing 测试框架提供了基准测试(benchmark)的能力,能让我们很容易地对某一段代码进行性能测试。
性能测试受环境的影响很大,为了保证测试的可重复性,在进行性能测试时,尽可能地保持测试环境的稳定。
- 机器处于闲置状态,测试时不要执行其他任务,也不要和其他人共享硬件资源。
- 机器是否关闭了节能模式,一般笔记本会默认打开这个模式,测试时关闭。
- 避免使用虚拟机和云主机进行测试,一般情况下,为了尽可能地提高资源的利用率,虚拟机和云主机 CPU 和内存一般会超分配,超分机器的性能表现会非常地不稳定。
下面是一个使用benchmark
进行简单性能分析的例子:
//位于./utils/fibo.go 是待测试的函数
package utils
func fibo(n int) int {
if n == 0 || n == 1 {
return 1
} else {
return fibo(n-2) + fibo(n-1)
}
}
//位于./utils/fibo_test.go 是进行测试的实际运行代码
package utils
import "testing"
func BenchmarkFibo(b *testing.B) {
//这里是进行b.N次性能测试
for n := 0; n < b.N; n++ {
fibo(50)
}
}
- benchmark 和普通的单元测试用例一样,都位于
_test.go
文件中。 - 函数名以
Benchmark
开头,参数是b *testing.B
。和普通的单元测试用例很像,单元测试函数名以Test
开头,参数是t *testing.T
。
go test /
用来运行某个 package 内的所有测试用例。
- 运行当前 package 内的用例:
go test example
或go test .
- 运行子 package 内的用例:
go test example/
或go test ./
- 如果想递归测试当前目录下的所有的 package:
go test ./...
或go test example/...
。
go test
命令默认不运行 benchmark 用例的,如果我们想运行 benchmark 用例,则需要加上 -bench
参数。例如:
❯ cd ./utils
❯ go test -bench .
goos: linux
goarch: amd64
pkg: github.com/6902140/gotest/utils
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkFibo-16 268 4481818 ns/op
PASS
ok github.com/6902140/gotest/utils 1.655s
benchmark 用例的参数 b *testing.B
,有个属性 b.N
表示这个用例需要运行的次数。b.N
对于每个用例都是不一样的。
那这个值是如何决定的呢?b.N
从 1 开始,如果该用例能够在 1s 内完成,b.N
的值便会增加,再次执行。b.N
的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快。
上述测试结果中包含了许多信息:
BenchmarkFibo-16 268 4481818 ns/op
例如这里的-16
即代表了cpu
的核心数量。268
代表一秒中之类的执行次数
可以通过 -cpu
参数改变 GOMAXPROCS
,-cpu
支持传入一个列表作为参数,例如:
❯ go test -bench='Fibo$' -cpu=1,2,4 .
goos: linux
goarch: amd64
pkg: github.com/6902140/gotest/utils
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkFibo 271 4419357 ns/op
BenchmarkFibo-2 270 4407453 ns/op
BenchmarkFibo-4 271 4423454 ns/op
PASS
ok github.com/6902140/gotest/utils 4.931s
我们不难发现,好像改变运算核心数量好像对计算性能提升不大,这主要是因为我们的代码使用的是串行逻辑,所以与运算核心数量关系不大。
对于性能测试来说,提升测试准确度的一个重要手段就是增加测试的次数。我们可以使用 -benchtime
和 -count
两个参数达到这个目的。
benchmark 的默认时间是 1s,那么我们可以使用 -benchtime
指定为 5s。例如:
❯ go test -bench='Fibo$' -benchtime=5s . ─╯
goos: linux
goarch: amd64
pkg: github.com/6902140/gotest/utils
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkFibo-16 1364 4389034 ns/op
PASS
ok github.com/6902140/gotest/utils 6.434s
❯ go test -bench='Fibo$' -benchtime=5s -count=4 . ─╯
goos: linux
goarch: amd64
pkg: github.com/6902140/gotest/utils
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkFibo-16 1345 4388535 ns/op
BenchmarkFibo-16 1287 4381509 ns/op
BenchmarkFibo-16 1362 4411898 ns/op
BenchmarkFibo-16 1358 4451096 ns/op
PASS
ok github.com/6902140/gotest/utils 25.412s
-benchmem
参数可以度量内存分配的次数。内存分配次数也性能也是息息相关的,例如不合理的切片容量,将导致内存重新分配,带来不必要的开销。
在下面的例子中,generateWithCap
和 generate
的作用是一致的,生成一组长度为 n 的随机序列。唯一的不同在于,generateWithCap
创建切片时,将切片的容量(capacity)设置为 n,这样切片就会一次性申请 n 个整数所需的内存。
// generate_test.go
package utils
import (
"math/rand"
"testing"
"time"
)
func generateWithCap(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0, n)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}
func generate(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}
func BenchmarkGenerateWithCap(b *testing.B) {
for n := 0; n < b.N; n++ {
generateWithCap(1000000)
}
}
func BenchmarkGenerate(b *testing.B) {
for n := 0; n < b.N; n++ {
generate(1000000)
}
}
❯ go test -bench='Generate' -benchmem .
goos: linux
goarch: amd64
pkg: github.com/6902140/gotest/utils
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkGenerateWithCap-16 139 8719440 ns/op 8003586 B/op 1 allocs/op
BenchmarkGenerate-16 69 14587565 ns/op 41678134 B/op 38 allocs/op
PASS
ok github.com/6902140/gotest/utils 3.107s
Generate
分配的内存是 GenerateWithCap
的 6 倍,设置了切片容量,内存只分配一次,而不设置切片容量,内存分配了 40 次。
一些golang
相关的基础优化建议
优化建议 - Slice:
切片的本质是一个数组片段的描述,包含以下三项:
- 数组指针
array unsafe.Pointer
- 片段的长度
len
- 片段的容量
cap
(不改变内存分配情况下的最大长度)
尽可能在使用 make()
初始化切片时提供容量信息。这是因为向切片中添加的元素数量超过默认容量会触发扩容机制,扩容是一个比较耗时的操作。
func PreAlloc(size int) {
data := make([]int, 0, size)
for k:= 0; k < size; k++ {
data = append(data, k)
}
}
🎈切片使用陷阱:大内存未释放
- 场景:
- 原切片较大,代码在原切片基础上新建小切片。
- 原底层数组在内存中有引用,得不到释放。
这是由于 Golang 中在已有切片的基础上创建切片,不会创建新的底层数组,而是直接复用原来的。如果只是需要用到其中的一小部分,复用原来的整个数组会导致占用较大的内存空间,建议使用 copy
替代 re-slice。
go复制代码// re-slice,占用空间较大:
func GetLastBySlice(origin []int) []int {
return origin[len(origin)-2:]
}
// copy,占用空间小,推荐使用:
func GetLastBySlice(origin []int) []int {
result := make([]int, 2]
copy(result, origin[len(origin)-2:])
return result
}
优化建议 - Map:
同样的,map 也建议预分配内存来避免扩容机制的时间开销。
- 不断向 map 中添加元素会触发 map 的扩容。
- 提前分配好空间可以减少内存拷贝和 Rehash 的消耗。
- 建议根据实际需求提前预估好需要的空间。
go复制代码func GetLastBySlice(origin []int) []int {
data := make(map[int]int, size)
for i := 0; i < size; i++ {
data[i] = 666
}
}
优化建议 - 字符串处理:
和 Java 语言类似,Golang 中直接使用 +
拼接字符串是一种十分低效的方式,因为字符串是不可变类型,使用 +
每次都会重新分配内存,推荐使用 strings.Builder
或 bytes.Buffer
操作字符串(strings.Builder
效率要更高一些)。
go复制代码// 使用加号拼接字符串,不推荐
func Plus(n int, str string) string {
s := ""
for i := 0; i < n; i++ {
s += str
}
return s
}
// 使用strings.Builder拼接字符串
func StrBuilder(n int, str string) string {
var builder strings.Builder
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}
此外 strings.Builder
和 bytes.Buffer
都支持使用 Grow()
函数预分配内存,在可以预知长度的情况下提前分配内存,可以提高字符串拼接的效率。
go复制代码// strings.Builder:
var builder strings.Builder
builder.Grow(n * len(str))
// bytes.Buffer:
buf := new(bytes.Buffer)
buf.Grow(n * len(str))
优化建议 - 空结构体:
使用空结构体 struct{}
可以节省内存。
- 空结构体实例不占据任何的内存空间。
- 可作为各种场景下的占位符使用。
- 节省资源。
- 空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符。
比如在实际的开发中,我们经常会使用到 Set 这种数据结构,然而 Golang 本身并不支持 Set,我们可以考虑用 map 来代替。换句话说我们只用到 map 的键,而不用它的值,那么值可以用 struct{}
类型占位。
go复制代码func EmptyStructMap(n int) {
m := make(map[int]struct{})
for i := 0; i < n; i++ {
m[i] = struct{}{}
}
}
优化建议 - atomic 包:
atomic 包主要用在多线程编程,相比于加锁的方式来保证并发安全,atomic 包效率更高。
- 锁的实现是通过操作系统来实现,属于系统调用。
- atomic 操作是通过硬件实现,效率比锁高。
sync.Mutex
应该用来保护一段逻辑,不仅仅用于保护一个变量,因此成本比较大。- 对于非数值操作,可以使用
atomic.Value
,能承载一个interface{}
。
go复制代码type atomicCounter struct {
i int32
}
func AtomicAddOne(c *atomicCounter) {
atomic.AddInt32(&c.i, 1)
}
性能优化分析工具pprof
使用说明
pprof 是用于可视化和分析性能、分析数据的工具,帮助我们了解应用在什么地方耗费了多少 CPU、内存等。
pprof - 排查实战
GitHub 上提供了 pprof 工具的实验项目,通过对项目的性能分析实战,帮助我们了解 pprof 工具的使用流程,便于我们今后分析一些更为复杂的程序。
🎈前置准备:
下载 GitHub 上的项目代码,该项目提前买入了一些炸弹代码,产生可观测的性能问题。
🚀项目传送门: wolfogre/go-pprof-practice: go pprof practice. (github.com)
🚀实战手册传送门: golang pprof 实战 | Wolfogre's Blog
🎈pprof 命令总结:
pprof 实战手册中给出了详细的项目实验步骤,这里就不再赘述了,主要记录以下课程中主讲老师给出的一些 pprof 的常用命令。
-
启动项目以后可以使用浏览器访问
localhost:6060/debug/pprof
查看指标,在项目的
main.go
文件中指定了 pprof 的访问端口:
go复制代码// 代码片段... go func() { // 启动一个 http server,注意 pprof 相关的 handler 已经自动注册过了 if err := http.ListenAndServe(":6060", nil); err != nil { log.Fatal(err) } os.Exit(0) }() // ...
-
pprof
提供了命令来在终端中获取采样数据:
shell 复制代码go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
随后可以使用接下来的各种命令来有针对性的获取特定资源的使用情况。
-
topN
命令查看占用资源最多的函数,会显示以下几点数据:
- flat - 当前函数本身的执行耗时;
- flat% - flat 占 CPU 总时间的比例;
- sum% - 上面每一行的 flat% 总和;
- cum - 指当前函数本身加上其调用函数的总耗时;
- cum% - cum 占 CPU 总时间的比例。
-
list
命令用来根据指定的正则表达式查找代码行。 -
web
命令用来将调用关系可视化展示。 -
pprof
的浏览器面板会将所有指标以平铺的方式展现,看起来并不直观,在
pprof
命令中加上一个可选项可以以可视化的方式展现监控数据。
-
查看堆内存:
shell 复制代码go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"
-
查看 goroutine:
shell 复制代码go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine"
-
性能调优
本次课程性能调优部分主通过实际业务服务性能优化的案例介绍了性能调优的思路,可以从三方面入手:业务服务优化、基础库优化、Go 语言优化。
业务服务优化
🎯基本概念:
- 服务:能单独部署。能承载一定功能的程序。
- 依赖:Service A 的功能实现依赖 Service B 的响应结果,称 Service A 依赖 Service B。
- 调用链路:能支持一个接口请求的相关服务集合及其相互之间的依赖关系。
- 基础库:公共的工具包、中间件。
☕流程:
基础库优化
☕流程:
- 完善设计改造方案;
- 数据按需获取;
- 数据序列化协议优化。
Go 语言优化
🍬编译器 & 运行时优化:
- 优化内存分配策略。
- 优化代码编译流程,生成更高效的程序。
- 内部测压验证。
- 推广业务服务落地验证。