Go语言高质量编程与性能调优
前言
面向用户
- 掌握 Go 语言基础
- 使用 Go 编写过应用
笔者环境
- macos 10.15.7
- Golang 1.18
- GoLand 2022.01
读完本文可以获得
-
如何编写更简洁清晰的代码,控制流要简洁,减少嵌套,处理错误要合理。
-
常用 GO 语言程序优化手段,使用预分配内存,减少动态扩容带来的影响 。
-
熟悉 GO 程序性能分析工具,使用pprof分析CPU、内存、阻塞、锁争用等情况。
一、高质量编程与编码规范
1.1 简介
编写的代码能够达到正确可靠、简洁清晰的目标可称之为高质量代码
- 各种边界条件考虑完备
- 异常情况处理,稳定性保证
- 易读易维护
1.2 编码规范
- 代码格式
- 注释
- 命名规范
- 控制流程
- 错误和异常处理
1.2.1 代码格式
gofmt是 Go 官方提供的工具,推荐使用 gofmt 自动格式化代码为官方统一风格
- 使用 tab 缩进
- 大括号单独一行
- 运算符空格分隔
- 关键字后有空格
- 点号和括号紧邻
- 操作符对称格式化
- 参数左对齐或单行展开
- 代码间隔空行合理
- 变量函数名大小写区分导出
- 代码行简短灵活
使用快捷键command +option+L
自动格式化代码。
goimports也是 Go 官方提供的工具,等于 gofmt 加上依赖包管理,自动增删依赖的包引用,将依赖包按字母排序并分类
1.2.2 注释
注释应该做哪些?
-
解释代码作用
适合注释在公共符号(变量、常量、函数以及结构)
-
解释代码实现的原因
解释代码的外部因素和提供额外上下文信息
-
介绍代码什么情况下会出错
适合解释代码的限制条件
小结
- 代码是最好的注释
- 注释应该提供代码未表达出的上下文信息
1.2.3 命名规范
变量variable
- 简洁优于冗长
usrAge //好过 userAge
- 缩略词全大写,除非不导出则小写
// 导出的缩写词
XMLReq
// 不导出的缩写词
xmlReq
- 全局变量需要更明确的上下文
// 局部变量
count
// 全局变量
userCount
// 用户缓存数量
userCacheCount
// 文章阅读总数
articleReadTotal
函数function
- 函数名不携带包名上下文信息
package foo
import "fmt"
// 正确 - 对包内函数使用简短命名
func PrintUser(name string) {
fmt.Println("User:", name)
}
// 错误 - 不要重复包前缀
func foo.PrintUser(name string) {
fmt.Println("User:", name)
}
func main() {
// 正确 - 直接调用 PrintUser
PrintUser("张三")
// 错误 - 不要加包前缀
foo.PrintUser("李四")
}
- 函数名保持简短
func getUser() // 而不是 getUserProfile()
- 返回包名相关类型可省略类型信息
// package foo
func Foo() Foo // 可省略为 func Foo()
- 返回非包名相关类型则加上类型信息
// package foo
func GetId() int64 // 加上返回类型信息int64
包package
- 只有小写字母组成。
- 简短并包含一定的上下文信息。例如 schema、task等
- 不要与标准库同名
- 使用单数而不是复数
总结
- 核心目标是降低阅读理解代码的成本
- 重点考虑上下文信息,设计简洁清晰的名称
1.2.4 控制流程
避免嵌套
// 错误示例
func process(items []Item) {
for _, item := range items {
if item.IsValid() {
if item.InStock() {
if item.NeedProcess() {
// 处理逻辑
}
}
}
}
}
// 改进示例
func process(items []Item) {
// 过滤无效项
validItems := filterValid(items)
// 过滤库存不足项
inStockItems := filterInStock(validItems)
// 过滤不需要处理项
needProcessItems := filterNeedProcess(inStockItems)
// 处理
for _, item := range needProcessItems {
// 处理逻辑
}
}
// 工具函数
func filterValid(items []Item) []Item {
// 过滤代码
}
// 其他过滤函数
主要改进:
- 提取出独立函数处理每个条件判断
- 主流程只保留一个for循环
- 避免多层if嵌套
保持正常代码路径为最小缩进
优先处理错误情况/特殊情况,尽早返回或继续循环来减少嵌套
func process(data []byte) error {
if len(data) == 0 {
return errors.New("empty data")
}
if err := validate(data); err != nil {
return err
}
// 正常代码路径
result := doSomething(data)
if err := moreValidate(result); err != nil {
return err
}
return nil
}
在这个例子中
-
先处理错误情况:空数据和验证失败
-
通过提前返回,避免增加缩进
-
主路径只有一行正常逻辑
-
后续再处理额外错误场景
相比之下,嵌套多个if会增加缩进层级。
小结
- 线性原理,处理逻辑尽量走直线,避免复杂的嵌套分支
- 正常流程代码沿着屏幕向下移动
- 提升代码可维护性和可读性
- 故障问题大多出现在复杂的条件语句和循环语句中
1.2.5 错误和异常处理
简单错误
-
简单错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误
-
优先使用errors.New来创建匿名变量来直接表示简单错误
if len(data) == 0 { return errors.New("data is empty") }
-
需要格式化的错误信息,使用 fmt.Errorf
err := fmt.Errorf("data %v is invalid", data) return err
错误的wrap和unwrap
- wrap实际上是提供一个error嵌套另一个error的能力,从而生成一个error的跟踪链。wrap的目的是提供错误追踪链,把原错误保留下来。
- 在fmt.Errorf可以通过%w关键字来将一个错误关联至错误链中
err1 := errors.New("origin error")
err2 := fmt.Errorf("read failed: %w", err1)
这里err2把err1包装起来,err1成为err2的原错误cause。
后续可以通过errors.Is等方式来检测和访问原错误
errors.Is(err2, err1) // true
errors.Unwrap(err2) // 返回 err1
所以错误wrap可以保留下层错误信息,方便调试分析。
错误判定- error.Is
- 使用error.Is来判定错误链中是否包含特定类型错误
- 不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的类型错误
err1 := fmt.Errorf("file not found")
err2 := fmt.Errorf("read file failed: %w", err1)
if errors.Is(err2, syscall.ENOENT) {
// 文件未找到错误
}
if errors.Is(err2, err1) {
// 读取文件失败并且文件不存在
}
这里通过errors.Is检测错误
- 可以检查整个错误链
- 不仅限于直接比较错误对象
与之相比,使用==判断就只能对比对象是否完全一致
if err2 == err1 {
// 仅检查err1对象,如果是wrapped则无法匹配
}
错误判定- error.As
error.As用于在错误链上获取特定种类型的错误
err := fmt.Errorf("read file error: %w", fs.PathError{Path: "filename"})
// 1. errors.Is 检查错误链中是否包含某类型错误
if errors.Is(err, fs.PathError) {
fmt.Println("Is path error")
}
// 2. errors.As 获取特定错误实例
var pathErr fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("Filename:", pathErr.Path)
}
errors.Is仅检查错误链中是否存在fs.PathError这种类型的错误。
errors.As在errors.Is的基础上,还会把该类型的错误实例保存到路径错误变量中,这样就可以获取到详细的错误信息。
总结一下主要区别:
- errors.Is 检查类型,返回bool
- errors.As 检查类型,返回第一个匹配的实例指针
error.As有多个匹配
如果错误链中存在多个相同类型的错误,那么如何使用errors.As获取特定的错误实例呢?
对于这种场景,我们可以通过errors.As逐个遍历错误链来获取所有指定类型的错误实例。基本方法如下
var err1, err2 MyError
for err := someError; errors.As(err, &err1); err = errors.Unwrap(err) {
// 处理 err1实例
// ...
if errors.As(err, &err2) {
// 处理 err2实例
}
}
主要步骤
-
定义需要获取的错误实例变量err1、err2
-
通过errors.As逐层获取实例,并解包到下层错误
-
利用返回值判断是否获取实例成功
-
通过errors.Unwrap访问下层错误链继续获取
通过这种逐层遍历的方式,可以获取错误链中所有的指定类型的错误实例。
panic
-
正常业务代码中应避免使用panic,这样可以避免程序崩溃。
-
调用含有panic的函数需要用recover处理,否则会造成崩溃。
-
如果有可处理的问题应该用error而不是panic。
panic更适合不可恢复的错误
if err != nil {
panic("config file missing")
}
但对于可处理的问题,应使用error
if err != nil {
return err
}
panic会中断程序流程,应仅在严重错误时使用。error可以优雅地处理问题。
综上, 对可处理问题使用error,确保程序健壮性。
recover
recover()通过在defer中调用,用于捕获panic并恢复正常执行
-
recover只能在defer函数中使用
当有panic发生时,可以通过recover来捕获panic,从而避免程序崩溃。
func f() { recover() // 无效 defer func() { recover() // 有效 }() }
-
嵌套recover无法捕获上层panic
func f() { defer func() { func() { recover() // 无效 }() }() }
-
只对当前goroutine生效
func f() { go func() { recover() // 对其他goroutine无效 }() }
-
defer遵循后进先出
defer func1() defer func2() // 先执行
小结
- error尽可能提供简洁的上下文信息链,方便定位问题
- panic用于真正的异常情况
- recover生效的范围是在当前goroutine的被defer的函数中生效
二、性能优化建议
2.1 简介
- 性能调优的前提是满足正确可靠、简洁清晰等质量因素
- 性能调优是综合评估,有时候时间效率和空间效率可能对立
2.2 Benchmark
-
性能表现需要通过实际的数据来衡量和对比,而不是主观猜测。
-
Go语言通过testing包中的Benchmark功能支持编写基准测试用例。
-
可以跑多次测试来计算平均时间和标准差。
-
支持设置循环次数,使基准测试更稳定可靠。
-
可以方便地比较优化前后的性能差异。
一个基本的基准测试样例
-
基准测试用例文件名以_test.go结尾
-
基准测试函数名以Benchmark开头
-
使用*testing.B作为参数
-
在循环体内调用需要测试的代码
-
运行go test -bench来执行
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
// 测试目标代码
}
}
只需要运行go test -bench
,即可自动执行基准测试。
所以Go语言内置的benchmark测试可以非常方便地帮助我们进行代码性能测试。
基准测试结果结果说明
例如运行
go test -benchmem
其中在默认的基准测试中,并没有开启测量内存分配次数的选项,需要加上-benchmem参数开启。
输出如下
BenchmarkFoo-8 2000000000 0.46 ns/op 0 B/op 0 allocs/op
表示:
- BenchmarkFoo:基准测试的名称
- 8:GOMAXPROCS的值,机器的核心数,也是并发度
- 2000000000:执行的总次数
- 0.46 ns/op:每次操作的平均耗时
- B/op:每次操作分配的内存字节
- allocs/op:每次操作的内存分配次数
其中关键指标是ns/op,表示每次执行测试函数的平均时间。
其他说明
- ns/op值越低表示执行越快
- 高并发时需要足够大的运行次数才能稳定结果
- 可以对比优化前后的结果
综上,ns/op是最关键的指,反映每个操作的执行耗时。执行的总次数说明了基准测试的稳定性。
所以测试时需要确保运行次数足够大,结果才具有参考价值。
2.3 Slice 预分配内存
2.3.1 slice扩容
slice本质是一个数组片段的描述
- 数组指针
- 片段的长度
- 片段的容量
type slice struct{
array unsafe.Pointer
len int
cap int
}
Go语言中的slice在追加数据时,如果超过当前的容量,会触发扩容机制,主要过程是
- 根据新的长度计算新的容量,通常为旧容量的2倍
- 用新的容量分配一块新的底层数组
- 将原来的数据拷贝到新的数组
- slice指向这个新数组,长度和容量更新(之前的底层数组还在,但不再被该slice使用,后面会被自动GC)
例如
arr := [4]int{1, 2, 3, 4}
slice := arr[:2] // len=2, cap=4
newSlice := append(slice, 5)
这里append导致扩容,具体过程是
- 计算新slice容量,比如新cap=8
- 新分配一个array,长度为新cap
- 拷贝原数据到新array
- 新slice引用新array
扩容需要数据拷贝,会带来性能损耗。
如果知道大概需要的容量,可以通过make(slice, len, cap)
一次性分配足够大的容量,来避免多次扩容。
2.3.2 底层数组未释放的危害
从已有slice创建新slice,会共享底层数组,不会新分配内存。
如果原slice很大,即使新slice很小,大数组也不会被回收。
这很容易导致内存占用问题。
bigSlice := make([]byte, 1<<20)
smallSlice := bigSlice[:1<<10]
这里smallSlice引用bigSlice底层1MB数组的前1024B。
这个1MB的大数组不会被回收,造成浪费。
解决方法是
- 及时归还不再需要的大slice
- 使用copy()拷贝slice数据
- 显示设置slice的容量(cap),会为这个新slice单独分配一块底层数组,而不是共享原数组。
2.3.3 指定cap VS 省略cap
尽可能在使用make()初始化切片时提供容量(cap)信息
下面使用benchmark比较省略和指定cap的性能。
func BenchmarkNoCap(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 1024)
}
}
func BenchmarkWithCap(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 1024, 1024)
}
}
测试结果
$ go test -benchmem -bench .
BenchmarkNoCap-8 10000000 153 ns/op 64 B/op 1 allocs/op
BenchmarkWithCap-8 300000000 5.33 ns/op 0 B/op 0 allocs/op
可以看到提前设置cap可以大幅提高性能,减少内存分配次数。
这个示例证明
- 针对slice预分配内存非常重要
- benchmark可以量化这种优化带来的收益
为什么给slice预分配容量可以带来这么大的性能提升
- 默认slice会按需增长容量,需要进行拷贝和分配内存。
- 而预分配容量可以避免多次增长和拷贝开销。
- 内存分配和GC压力也会减小。
- 初期分配容量的时候可以直接获取一大块内存。
通过预分配容量,可以减少slice扩容时的复制开销,也可以有效利用底层内存分配,这是性能提升的主要原因。
2.4 map 预分配内存
根据map预分配容量的原则,用benchmark比较省略和指定cap的性能。
func BenchmarkNoCap(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int)
}
}
func BenchmarkWithCap(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int, 10000)
}
}
测试结果:
BenchmarkNoCap-8 3000000 449 ns/op 192 B/op 1 allocs/op
BenchmarkWithCap-8 500000000 3.59 ns/op 0 B/op 0 allocs/op
可以看出提前分配容量可以显著提高速度,减少内存分配。
所以,与slice类似。map也需要尽量预分配容量。
这可以有效避免map动态增长带来的重新hash开销。
2.5 空结构体
-
空结构体struct{}实例,不占据任何的内存空间
-
可以作为各种场景下的占位符使用
-
节省资源
根据使用空结构体可以优化内存的原则,编写一个benchmark进行比较
type Empty struct{} func BenchmarkWithData(b *testing.B) { data := struct{ a int b string c float64 }{} for i := 0; i < b.N; i++ { _ = data } } func BenchmarkEmpty(b *testing.B) { empty := Empty{} for i := 0; i < b.N; i++ { _ = empty } }
测试结果:
BenchmarkWithData-8 500000000 3.46 ns/op 0 B/op 0 allocs/op BenchmarkEmpty-8 1000000000 0.28 ns/op 0 B/op 0 allocs/op
可以看出,空结构体可以优化出较低的内存分配和更快速度。
-
空结构体本身具有很强的语义,即这里不需要任何值,仅作为占位符
实现Set,可以考虑用map来替代,只用到map的键,如 mySet:=make(map[int]{})
这里是一个使用map实现Set的示例
type IntSet struct { data map[int]struct{} } func NewIntSet() *IntSet { return &IntSet{ data: make(map[int]struct{}), } } func (set *IntSet) Add(x int) { set.data[x] = struct{}{} } func (set *IntSet) Contains(x int) bool { _, ok := set.data[x] return ok } func main() { s := NewIntSet() s.Add(1) s.Add(2) fmt.Println(s.Contains(1)) // true fmt.Println(s.Contains(3)) // false }
这里我们只使用map的key来保存元素,value使用空结构体。
相比使用切片,map可以快速判断元素是否存在。
-
2.6 atomic包
- 锁的实现是通过操作系统来实现,属于系统调用
- atomic操作是通过硬件实现,效率比较高
- sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
- 对于非数值操作,可以使用atomic.Value,能承载一个interface{}
使用atomic进行计数比较非阻塞计数,编写benchmark进行比较
var i int64
func BenchmarkMutex(b *testing.B) {
var m sync.Mutex
for n := 0; n < b.N; n++ {
m.Lock()
i++
m.Unlock()
}
}
func BenchmarkAtomic(b *testing.B) {
var a atomic.Int64
for n := 0; n < b.N; n++ {
atomic.AddInt64(&a, 1)
}
}
比较结果:
BenchmarkMutex-8 5000000 324 ns/op
BenchmarkAtomic-8 10000000 184 ns/op
可以看出atomic的效率更高,因为
- mutex需要线程在获取锁前进行等待和调度,这会降低性能。
- 而atomic通过硬件级别的原子指令实现,可以直接修改值,无需等待。
- atomic只需要一个CPU指令,mutex需要线程调度和上下文切换。
- atomic还可以防止被优化和重排,mutex无法保证这一点。
- 所以atomic避免了mutex的锁调度和上下文切换开销。
atomic提供了无锁的原子操作,对于高并发场景很有用。它可以规避复杂的同步问题,使代码更简洁。
2.7 小结
- 避免常见的性能陷阱可以保证大部分程序的性能
- 普通应用代码,不要一味追求程序的性能
- 越高级越底层的优化手段越容易出现问题
- 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能
三、性能优化工具
3.1 性能调优原则
- 要依靠数据而不是猜测
- 要定位最大瓶颈而不是细枝末节
- 不要过早优化
- 不要过渡优化
3.2 pprof
pprof是Go运行时提供的可视化性能分析工具,可以用来分析程序的CPU、内存等使用情况。
主要功能有
-
CPU分析:找出程序占用CPU时间最多的部分
-
内存分析:检测内存异常分配情况
-
阻塞分析:识别造成阻塞的调用
-
竞争检测:检测资源冲突情况
使用方式
-
添加import _ “net/http/pprof”
-
通过web接口访问/debug/pprof数据
-
使用go tool pprof分析数据
go tool pprof http://host:port/debug/pprof/heap # 获取最近的堆内存分析报告
3.3 搭建 pprof实践项目
3.3.1 前置准备
- clone github.com/wolfogre/go… ,该项目提前埋入一些炸弹代码,会产生可观测的性能问题
- 该项目运行后,会占用1个CPU核心和超过1GB的内存
3.3.2 编译运行项目
进入 /debug/pprof/,在该浏览器查看各项指标
3.4 pprof 排查实战
3.4.1 CPU
1. 查看资源占用
打开资源管理器查看测试项目的CPU占用
2. 使用go tool pprof获取最近10秒的profiling数据
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10
默认情况下,pprof接口提供的分析结果是从程序启动一直累积汇总的 profiling数据。
但在大多数情况下,我们只需要最近一小段时间的分析,以方便查找突发的问题。
所以通过seconds参数可以限定只分析最近指定秒数的数据,而不是全部。
下面是该命令执行后返回的最近10秒内CPU的使用情况
3.使用TopN 命令,查看占用资源最多的前N个函数
参数含义
-
flat: 当前函数自身占用CPU的时间
-
flat%:当前函数占总CPU时间的百分比
-
sum%: 前若干函数的flat%之和
-
cum:当前函数及其调用链占用CPU总时间
-
cum%:当前函数及其调用链占总CPU时间的百分比
-
当flat==cum时,表示该函数中没有调用其他函数
-
当flat==0时,表示该函数里只有其他函数的调用
比较重要的是flat%和cum%。它们从不同层面反应函数的占用CPU时间。
因此,上图显示github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat
该函数占用较大的CPU时间,因此我们要进行代码定位。
4. 使用命令list Regex,根据指定的正则表达式查找代码行
- 在pprof提示符下输入list 函数名 即可查看该函数实现
list Eat
我们看到有一个空的for循环占用了4秒左右的CPU时间
4.04s 4.12s 24: for i := 0; i < loop; i++ {
. . 25: // do nothing
. . 26: }
- 可以添加行号范围来只查看部分实现
list Eat:1,10
只会输出Eat函数行1-10的代码。
- 可以通过 Regex 来过滤函数名
list Eat.*
它会输出所有函数名匹配Eat.*正则表达式的函数实现代码,包括
- EatApple
- EatBanana
- EatGrape
- 等所有以Eat开头的函数
-
list默认输出前10行,可以通过 -len=n 参数调整行数。
list Eat -len=20
这里通过
-len=20
将Eat函数的输出代码行数指定为20行
5. 使用web命令,查看可视化调用图
该错误信息是因为go tool pprof 在使用web命令打开图形界面时,会使用Graphviz来生成调用图,而因为未安装Graphviz软件,将导致的无法执行dot命令。
通过Homebrew安装
brew install graphviz
6. 注释Eat函数的for循代码
通过list命令,我们获取了Eat函数的代码(默认前10行),在输出中,我们可以获取到Eat函数的的文件路径
github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat in /Users/spaceqi/Desktop/ByteDanceCamp/code/ajust/go-pprof-practice-master/animal/felidae/tiger/tiger.go
我们将for循环代码进行注释,然后再运行,再查看资源管理器
我们发现CPU占用已经降低到了0.4%。
3.4.2 Head-堆内存
1. 查看堆内存
我们发现内存占用高达7个GB,接着我们使用pprof帮助我们查看内存方面的问题
2. 通过可视化调用图查看内存占用情况
在控制台输入下列命令
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
打开浏览器进入localhost:8080/ui/
如果提示你Could not execute dot; may need to install graphviz.
是因为go tool pprof 在使用web命令打开图形界面时,会使用Graphviz来生成调用图,而因为未安装Graphviz软件,将导致的无法执行dot命令。
通过Homebrew安装
brew install graphviz
3. 进入Top页面,查看占用资源最多的函数
点击VIEW->Top
,与排查CPU的占用时的TOPN命令类似
从图中我们发现
Steal
函数自身占用了768MB- 剩余3个函数的
Flat
属性为0,表示在他们的函数中调用了其他的函数,所以整个函数的调用链占用&=768MB,这里推测调用的函数是Steal
4. 进入Source页面,查看指定函数的代码行
点击VIEW->Source
,与排查CPU的占用时的Source命令类似
从图中,我们发现Live
函数调用了Steal
函数,而Steal
函数中的for循环在不断的扩展切片,导致内存的增大
for len(m.buffer)*constant.Mi < max {
m.buffer = append(m.buffer, [constant.Mi]byte{})
}
5. 注释代码
因此,这里将注释for循环内的代码。根据Source
页面提示的Steal
函数的位置,当前目录下的animal/muridae/mouse.(*Mouse).Steal
重新运行项目,然后再次输入
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
打开浏览器进入localhost:8080/ui/
6. Sample指标说明
pprof工具的Web页面中的sample部分主要包含4个不同维度的内存分析数据
-
inuse_space 当前使用的内存大小(默认使用)
它展示当前应用实时正在使用的内存大小,可以看到每个函数占用的内存情况。
-
alloc_space 累计分配的内存大小
它展示从应用启动至当前,各函数累计申请过的内存总大小,包括之前已释放的内存。
-
inuse_objects 当前使用的对象数量
它统计每个函数当前占用的对象数量,反映内存使用情况。
-
alloc_objects 累计分配的对象数量
它统计每个函数累计申请过的对象总数,包含之前已释放的对象。
总结一下
- inuse主要统计当前实时使用的内存/对象
- alloc主要统计累计分配的内存/对象,包含之前释放过的
- inuse更直观,alloc可以帮助发现内存使用不当的点。
点击SAMPLE->alloc_space
切换
图中显示Dog.Run
函数占用了累计分配的内存的91%,这是有问题的,进入VIEW->Source
图中显示,下面的代码,一直在创建一个16MB大小的切片,且不使用,创建后就会被回收,会影响GC性能
_ = make([]byte, 16*constant.Mi)
7. 注释代码
将下列代码注释后
重新运行项目,并重新运行go tool pprof命令,重新拉取最新的分析报告
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
刷新pprof的Web页面
3.4.3 goroutine-协程
处理完堆内存后,我们再次进入 /debug/pprof/,在该浏览器查看各项指标,然后我们看到goroutine有16个(每台机器运行结果不一定相同),goroutine过多也会导致内存泄露。
1. 进入web页面查看可视化调用图
使用pprof工具,获取最新的协程分析
go tool pprof -http=:8080 http://host:port/debug/pprof/goroutine
打开浏览器进入localhost:8080/ui/
从图中发现Wolf.Drink
函数间接占用了80%的协程
2. 火山图
点击进入VIEW->Flame Graph
- 由上到下表示调用顺序
- 每一块代表一个函数,越长代表占用CPU的时间更长
- 火焰图是动态的,可以点击块进行分析
从火焰图也能看出,Wolf.Drink
函数占用了最长的CPU时间
3. 进入Source页面,查看指定函数的代码行
进入VIEW->Source
,搜索wolf
从图中发现,每创建一个协程,将睡眠30秒,这也意味着一个协程的生命周期至少30秒以上,且长时间处于阻塞状态会占用内存和CPU资源
go func() {
time.Sleep(30 * time.Second)
}()
4. 注释代码
将下列代码注释后
重新运行项目,再次进入 /debug/pprof/
3.4.4 mutex-锁
1. 进入web页面查看可视化调用图
在控制台输入下列命令
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex
打开浏览器进入localhost:8080/ui/
图中显示Wolf.Howl
函数中有一个匿名函数func1
,该函数中包含一个延迟1秒,且包含一个锁
2. 进入Source页面,查看指定函数的代码行
进入VIEW->Source
,搜索wolf
上图显示,这个延迟发生在wolf结构体的Howl方法中,这个方法创建了一个互斥锁m,并在一个匿名函数func1中释放它。然而,这个匿名函数在释放锁之前,先睡眠了一秒,这就造成了Howl方法无法立即获取锁,从而导致延迟。
3. 注释代码
将下列代码注释,然后重新运行项目
运行pprof工具
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex
刷新Web页面
3.4.5 block-阻塞
1. 进入web页面查看可视化调用图
在控制台输入下列命令
go tool pprof -http=:8080 http://localhoust:6060/debug/pprof/block
打开浏览器进入localhost:8080/ui/
显示了一个运行时的chanrecv1函数,它在1秒内占用了100%的CPU时间。该函数是由Cat.Pee
函数直接调用的。
2. 进入Source页面,查看指定函数的代码行
进入VIEW->Source
,
上图显示time.After它会创建一个定时channel,在指定时间后发送一个值,通过 <- channel接收这个定时值,会阻塞等待定时完成,这样就会使Pee()
方法至少执行1秒钟。
4. 注释代码
将下列代码注释,然后重新运行项目
运行pprof工具
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/block
刷新Web页面
小结
四、总结
- 高质量编程要点
- 遵循代码规范,格式化代码,添加注释
- 命名要规范,表达变量、函数的语义
- 控制流要简洁,减少嵌套,处理错误要合理
- 避免panic,使用error处理可恢复问题
- 性能调优建议
- 使用基准测试比较优化前后效果
- 预分配内存,减少动态扩容带来的影响
- 使用atomic避免锁的开销
- 调优要遵循依靠数据,定位主要瓶颈的原则
- 性能调优工具
- pprof可以分析CPU、内存、阻塞、锁争用等情况
- 基于pprof的数据进行火焰图分析,定位热点函数
- 注释或优化热点代码,解决性能问题
-
调优要在保证程序正确性、清晰性的前提下进行,避免过早或过度优化。
-
合理利用Go语言的并发、标准库和工具可以使程序高效简洁。