Go语言-pprof调优实战 | 青训营笔记

192 阅读8分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第1篇笔记。笔记内容包括我自己对课程内容的一些理解、对课程中代码的一些实践以及遇到的一些问题和bug

高质量编程

编程原则

  • 简单性
  • 可读性
  • 生产力

编码规范

  • 代码格式
  • 注释
  • 命名规范
  • 控制流程
  • 错误和异常处理

代码格式

  • gofmt:go语言自动格式化工具
  • goimports:gofmt加上依赖包管理

注释

  • 代码作用
    • 适合注释公共符号,比如open(),需要解释。
    • 有的函数他的函数名就可以传递足够的信息,就不需要太多的注释
  • 代码如何做的
    • 实现过程
  • 代码实现的原因
    • 解释代码的外部因素
    • 提供代码未表达的额外的上下文信息
  • 代码什么情况下会出错
    • 解释代码的限制条件

命名规范

variable:

  • 简洁
  • 缩略词全大写但当其位于变量开头而且不需要导出时,使用全小写
    • 例如使用ServeHTTP而非ServeHttp。HTTP时缩略词要大写
    • 使用XMLHTTPRequest或者xmlHTTPRequest。如果不需要导出,可以使用后一种
  • 变量距离其被使用的地方越远,则需要携带更多的上下文信息
    • 如全局变量名称需要更多上下文信息
//good
func (c *Clinet) send(req *Request,deadline time.Time)

//bad

function:

  • 函数名不携带包名上下文信息
//good
func Serve(I net.Listener,handler Handler) error
//bad
func ServeHttp(I net.Listener,handler Handler) error
//函数名和报名往往成对出现(http.Serve),所以没必要在函数名中出现HTTP
  • 函数名尽量简短
  • 当名为foo的包某个函数返回类型Foo时,可以省略类型信息,不导致歧义
  • 当名为foo的包某个函数返回类型T时,可以在函数名中加入类型信息。这个我不太理解,老师也没有举例

package:

必须满足:

  • 只有小写字母
  • 简短并包含一定上下文信息
  • 不与标准库同名

尽量满足:

  • 不适用常用变量名作为包名。如使用bufio而不是buf
  • 使用单数而非复数。
  • 谨慎使用缩写

控制流程

  • 避免嵌套,保证正常流程清晰
//bad
if foo{
	return x
} else{
	return nil
}
//两个分支都有return,不需要else
//good
if foo{
	return x
}
return nil
  • 尽量保持正常代码路径为最小缩进
    • 优先处理错误情况/特殊情况,尽早返回或者继续循环来减少嵌套
//bad
func OneFunc() error{
	err:=doSomeThing()
	if err == nil {
		err:=doAnotherThing()
		if err==nil{
			return nil//正常返回
		}
		return err
	}
	return err
}
//good
func OneFunc() error{
	if err:=doSomeThing();err != nil {
		return err
	}
	if err:=doAnotherThing();err!=nil{
		return err
	}
	return nil //正常返回
}
  • 线性逻辑,处理逻辑尽量走直线,避免复杂的嵌套分支
  • 正常代码沿着屏幕向下移动
  • 提升代码可维护性和可读性
  • 故障问题大多出现在复杂的条件语句和循环语句中3

错误和异常处理

简单错误:
  • 仅出现一次的错误,在其他地方不需要捕获该错误
  • 优先使用errors.New来创建匿名变量表示简单错误
  • 如有格式化需求,使用fmt.Errorf
func defaultCheckRedirect(req *Request,via []*Request) error{
	if len(via)>=10{
		return errors.New("stopped after 10 redirects")
	}
	return nil
}
错误的Wrap和Unwrap
  • 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成了一个error跟踪链
  • 在fmt.Errorf中使用%w关键字来讲一个错误关联至错误链中
list,_:=c.GetBytes(cache.Subkey(a.actionID,"srcfiles"))
if err!=nil{
	return fmt.Errorf("reading srcfiles list:%w",err)
}
错误判定
  • errors.Is:用于判断一个错误是否为特定错误,不同于==,该方法可以判断错误链上的所有错误是否包含特定的错误
data,err=lockedfile.Read(targ)
if errors.Is(err,fs.ErrNotExist){
	if errors.Is(err,fs.ErrNotExist)
	return []byte{},nil
}
return data,err
  • errors.As:在错误链上获取特定种类的错误
if _,err:=os.Opne("non-existing");err!=nil{
	var pathError *fs.PathError
	if errors.As(err,&pathError){
		fmt.Println("Failed at path:",pathError.Path)
	}else{
		fmt.Println(err)
	}
}
  • panic(运行时恐慌):是一种只会在程序运行时才回抛出来的异常。在panic被抛出之后,如果没有在程序里添加任何保护措施的话,程序就会在打印出panic的详情,终止运行。
    • 不建议在业务代码中使用panic
    • 调用函数不包含recover会造成程序崩溃
    • 若问题可以被屏蔽或解决,建议使用error代替panic
    • 当程序启动阶段发生不可逆转的错误时,可以在init或者main函数中使用panic
  • recover:Recover 是一个Go语言的内建函数,可以让进入宕机流程中的 goroutine 恢复过来,recover 仅在延迟函数 defer 中有效,在正常的执行过程中,调用 recover 会返回 nil 并且没有其他任何效果,如果当前的 goroutine 陷入恐慌,调用 recover 可以捕获到 panic 的输入值,并且恢复正常的执行。
    • recover只能在被defer的函数中使用
    • 嵌套无法生效
    • 只在当前的goroutine生效
    • defer的语句是后进先出
func (s *ss) Token(skipSpace bool,f func(rune) bool)(tok []byte,err error){
	defer func(){
		if e:=recover();e!=nil{
			if se,ok:=e.(scanError);pk{
				err=se.err
			}else{
				panic(e)
			}
		}
	}()
}
  • 如果需要更多的上下文信息,可以recover后再log中记录当前调用栈

性能优化建议

Benchmark

//fib.go
func Fib(n int) int {
	if n < 2 {
		return n
	}
	return Fib(n-1) + Fib(n-2)
}

//fib_test.go
func BenchmarkFib10(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Fib(10)
	}
}

//test
//输入命令行 go test -bench.= -benchmem
C:\lty\GO\workspace\bd\3\fib>go test -bench=. -benchmem
goos: windows
goarch: amd64
pkg: 3/fib
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkFib10-16        5251640               232.6 ns/op             0 B/op          0 allocs/op
PASS
ok      3/fib   1.490s

对于

BenchmarkFib10-16(测试函数名,-16表示GOMAXPROCS的值为16,默认为CPU核数)
5251640(一共执行的次数)
232.6 ns/op(每次花费的时间)
0 B/op(每次申请的内存)
0 allocs/op(每次执行申请几次内存)

Slice

Slice预分配内存

  • 尽可能使用make()初始化切片时提供容量信息
//未指定容量大小
data:=make([]int,0)
//指定容量大小
data:=make([]int,0,size)
//后者比前者速度更快,内存分配次数更少
  • 切片本质是一个数组片段的描述。如果预分配内存不够,添加元素会先进行一次扩容的操作再添加,因此影响性能
  • 已有基础上创建切片,会引起大内存未释放的问题,可使用copy代替re-slice
//bad
func GetLastBySlice(origin []int) []int {
	return origin[len(origin)-2:]
}
//good
func GetLastByCopy(origin []int) []int {
	result := make([]int, 2)
	copy(result, origin[len(origin)-2:])
	return result
}

第一个方式占用的内存远大于第二种

Map

map预分配内存的情况和slice类似

//bad
data:=make(map[int]int)
//good
data:make(map[int]int,size)
//后者性能更好

向map种添加元素会触发map扩容,提前分配好空间可以减少内存拷贝和Rehash的消耗

字符串处理

三种字符串拼接方法:

  • +
  • strings.Builder
  • bytes.Buffer

其中+最差,其他两个相近,strings.Builder更快(ppt写错了) 分析:

  • 字符串是不可变类型,占用内存大小是固定的

  • 使用+每次都会重新分配内存

  • strings.Builder,bytes.Buffer底层都是[]byte数组

  • 内存扩容策略,不需要每次拼接重新分配内存

  • 而bytes.Buffer转化字符串时重新申请了一块空间

  • strings.Builder直接将底层的[]byte转换成字符串类型返回,所以稍快 可采用预分配策略优化性能

func PreStrBuilder(n int, str string) string {
	var builder strings.Builder
	builder.Grow(n * len(str))
	for i := 0; i < n; i++ {
		builder.WriteString(str)
	}
	return builder.String()
}

func PreByteBuffer(n int, str string) string {
	buf := new(bytes.Buffer)
	buf.Grow(n * len(str))
	for i := 0; i < n; i++ {
		buf.WriteString(str)
	}
	return buf.String()
}

空结构体

使用空结构体节省内存:

  • 空结构体struct{}不占据任何空间,可以做占位符使用
  • 比如通过map实现set,只需要map的key,不需要value,可以通过struct{}作为占位符减少内存占用
func EmptyStructMap(n int){
	m:=make(map[int]struct{})
	
	for i:=0;i<n;i++{
		m[i]=struct{}{}
	}
}

func BoolMap(n int){
	m:=make(map[int]bool)
	
	for i:=0;i<n;i++{
		m[i]=False
	}
}
//前者的内存占用小于后者,因为即使是bool类型也要占用1个字节的空间

atomic包

type atomicCounter struct{
	i int32
}
func AtomicAddOne(c *atomicCounter){
	atomic.AddInt32(&c.i,1)
}
//----------------
type mutexCounter struct{
	i int32
	m sync.Mutex
}

func MutexAddOne(c *mutexCounter){
	c.m.Lock()
	c.i++
	c.m.Unlock
}
//前者的性能高于后者

原因:

  • 锁是通过操作系统实现的,属于系统调用
  • atomic操作是通过硬件实现的,效率比锁高
  • sync,Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
  • 对于非数值操作,可以使用atomic.Value,能承载一个interface{}

性能调优实战

性能分析工具pprof

func main() {
	log.SetFlags(log.Lshortfile | log.LstdFlags)
	log.SetOutput(os.Stdout)

	runtime.GOMAXPROCS(1)              //限制cpu使用数
	runtime.SetMutexProfileFraction(1) //开启锁调用跟踪
	runtime.SetBlockProfileRate(1)     //开启阻塞调用跟踪

	go func() {
		if err := http.ListenAndServe(":6060", nil); err != nil {
			log.Fatal(err)
		}
		os.Exit(0)
	}()
//……
}

image.png

pprof-排查实战

CPU

go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"

seconds=10代表采集10s

image.png

命令top
(pprof) top
Showing nodes accounting for 3.38s, 97.41% of 3.47s total
Dropped 31 nodes (cum <= 0.02s)
Showing top 10 nodes out of 15
      flat  flat%   sum%        cum   cum%
     3.19s 91.93% 91.93%      3.19s 91.93%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat
     0.18s  5.19% 97.12%      0.18s  5.19%  runtime.stdcall3
     0.01s  0.29% 97.41%      0.04s  1.15%  runtime.bgscavenge
         0     0% 97.41%      0.02s  0.58%  github.com/wolfogre/go-pprof-practice/animal/canidae/dog.(*Dog).Eat (inline)
         0     0% 97.41%      0.02s  0.58%  github.com/wolfogre/go-pprof-practice/animal/canidae/dog.(*Dog).Live
         0     0% 97.41%      3.19s 91.93%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Live
         0     0% 97.41%      3.22s 92.80%  main.main
         0     0% 97.41%      0.02s  0.58%  runtime.(*pageAlloc).scavenge
         0     0% 97.41%      0.20s  5.76%  runtime.(*pageAlloc).scavenge.func1
         0     0% 97.41%      0.20s  5.76%  runtime.(*pageAlloc).scavengeOne

flat: 当前函数本身的执行耗时

flat%: flat占CPU总时间的比例

sum%: 上面一行的flat%总和

cum: 指当前函数本身加上其调用函数的总耗时

cum%: cum占CPU总时间的比例

flat==cum:函数中没有调用其他函数

flat==0:函数中只有其他函数的调用

命令list(+参数):

根据正则表达式查找代码行

(pprof) list Eat
Total: 3.47s
ROUTINE ======================== github.com/wolfogre/go-pprof-practice/animal/canidae/dog.(*Dog).Eat in C:\lty\GO\pkg\mod\github.com\wolfogre\go-pprof-practice@v0.0.0-20190402114113-8ce266a210ee\animal\canidae\dog\dog.go
         0       20ms (flat, cum)  0.58% of Total
         .          .     21:   d.Run()
         .          .     22:   d.Howl()
         .          .     23:}
         .          .     24:
         .          .     25:func (d *Dog) Eat() {
         .       20ms     26:   log.Println(d.Name(), "eat")
         .          .     27:}
         .          .     28:
         .          .     29:func (d *Dog) Drink() {
         .          .     30:   log.Println(d.Name(), "drink")
         .          .     31:}
ROUTINE ======================== github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat in C:\lty\GO\pkg\mod\github.com\wolfogre\go-pprof-practice@v0.0.0-20190402114113-8ce266a210ee\animal\felidae\tiger\tiger.go
     3.19s      3.19s (flat, cum) 91.93% of Total
         .          .     19:}
         .          .     20:
         .          .     21:func (t *Tiger) Eat() {
         .          .     22:   log.Println(t.Name(), "eat")
         .          .     23:   loop := 10000000000
     3.19s      3.19s     24:   for i := 0; i < loop; i++ {
         .          .     25:           // do nothing
         .          .     26:   }
         .          .     27:}
         .          .     28:
         .          .     29:func (t *Tiger) Drink() {
(pprof)

可以看出CPU性能消耗最大的部分再第24行

命令web

调用关系可视化 输入该命令可能会报错,是因为没有安装Graphviz导致,安装地址:graphviz.org/download

image.png

可见tiger.Eat占用CPU最多,我们animal/felidae/tiger/tiger.go中Live接口中的Eat方法已经定义的Eat方法都注释掉,再次查看

func (t *Tiger) Eat() {
	log.Println(t.Name(), "eat")
	// log.Println(t.Name(), "eat")
	// loop := 10000000000
	// for i := 0; i < loop; i++ {
	// 	// do nothing
	// }
}

image.png cpu的使用已经被稀释掉了

此处遇到一个问题:改动tiger后并没有发生变化,原因是没有改变引用包,main使用的tiger是github仓库中的而非本地的。修改所有import为本地内容后解决

Heap-堆内存

输入命令

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"

添加参数-http:8080后,命令行会直接打开浏览器查看可视化图

image.png 可见mouse Steal占用了大量的内存 同样,我们修改mouse.Steal方法

func (m *Mouse) Steal() {
	log.Println(m.Name(), "steal")
	// max := constant.Gi
	// for len(m.buffer)*constant.Mi < max {
	// 	m.buffer = append(m.buffer, [constant.Mi]byte{})
	// }
}

问题解决,内存占用得到稀释

image.png

之后查看sample->alloc space视图

image.png 发现Dog.run()分配了大量内存,将其内容注释掉

func (d *Dog) Run() {
	log.Println(d.Name(), "run")
	// _ = make([]byte, 16*constant.Mi)
}

image.png 问题解决

goroutine-协程

goroutine的泄露也会导致协程的泄露 输入命令

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine" 

通过view-flame graph,查看火焰图 从上到下表示顺序,每一个小块表示函数,小块的长度代表占用cpu的时间

image.png wolf.drink()占用cpu时间较长,我们修改该方法

func (w *Wolf) Drink() {
	log.Println(w.Name(), "drink")
	// for i := 0; i < 10; i++ {
	// 	go func() {
	// 		time.Sleep(30 * time.Second)
	// 	}()
	// }
}

image.png 问题解决

mutex-锁

都差不多,改一下后缀

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/mutex" 

image.pnghttp://localhost:8080/ui/source 中查看有问题的代码

image.png 修改代码

func (w *Wolf) Howl() {
	log.Println(w.Name(), "howl")

	m := &sync.Mutex{}
	m.Lock()
	go func() {
		time.Sleep(time.Second)
		// m.Unlock()
	}()
	m.Lock()
}

重新运行

image.png ok

block-阻塞

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/block" 

image.png 我这里似乎没有ppt上展示的问题

首页的block也没有cat相关的block

image.png 暂且不搞了,流程都差不多

pprof采样过程及原理

CPU

  • 采样对象:函数调用和它们占用的时间
  • 采样率:100次/秒,固定值
  • 采样时间:从手动启动到手动结束
流程:

开始采样 -> 设定信号处理器 -> 开启定时器 停止采样 -> 取消信号处理函数 -> 关闭定时器 97af6f3e55a91f34679afa2394f2e985.png

heap-堆内存

  • 采样程序通过内存分配器在堆上分配和释放内存,记录分配/释放的大小和数量
  • 每分配512KB记录一次,可在运行开头修改,1为每次分配均记录
  • 采样时间:从程序开始到采样时
  • 采样指标:alloc_space,alloc_object,inuse_space,inuse_object
  • 计算方式:inuse=alloc-free

Goroutine-协程 & ThreadCreate-线程创建

  • Goroutine:
    • 记录所有用户发起且在运行中的goroutine(即入口非runtime开头的)
    • runtime.main的调用栈信息
  • ThreadCreate:
    • 记录程序创建的所有系统线程的信息

Block-阻塞 &Mutex-锁

  • 阻塞:
    • 采样阻塞操作的次数和耗时
    • 采样率:阻塞耗时超过阈值才会被记录
  • 锁竞争
    • 采样争抢锁的次数和耗时
    • 采样率:指记录固定比例的锁操作,1为每次加锁均记录

性能调优案例

业务服务优化

流程:

  • 建立服务性能评估手段
  • 分析性能数据,定位性能瓶颈
  • 重点优化项改造
  • 优化效果验证

后面的内容没有什么实践性的内容,我对这些也不是太理解,就没有继续记了。可以看ppt