这是我参与「第三届青训营 -后端场」笔记创作活动的的第3篇笔记
一、本堂课重点内容
1.高质量编程
2.性能调优实战
二、 详细知识点介绍
2.1 高质量编程定义:
编写的代码能够达到正确可靠,简洁清晰的目标可称之为高质量代码。
2.1.1要求:正确性:各种边界条件是否考虑完备
可靠性:异常情况处理,稳定性保证
简洁: 逻辑是否简单,后续调整功能或新增功能是否能够快速支持。
2.1.2原则:
简单性: 清除“多余的复杂性”,以简单清晰的逻辑编写代码。 不理解的代码无法修复改进。
可读性:代码是给人看的,而不是机器 ,编写可维护代码的第一步是确保代码可读
生产力:团队整体工作效率非常重要。
2.2 编码规范
2.2.1代码格式:推荐使用gofmt自动格式化代码
gofmt:Go语言官方提供的工具,能自动格式化Go语言代码为官方统一风格
goimports:也是Go语言官方提供的工具,实际等于gofmt加上依赖包管理,可以自动增删依赖的包引用,将依赖包按字母序排序并分类。
2.2.2 注释:
注释规范:
1. 注释应该解释代码作用
2. 注释应该解释代码如何做的
3. 注释应该解释代码实现的原因
4. 注释应该解释代码什么情况会出错
注释规则:公共符号始终要注释
1. 包中声明的的每个公共的符号:变量,常量,函数以及结构都需要添加注释
2. 任何不明显也不简短的公共功能必须予以注释
3. 无论长度或复杂程度如何,对库中的任何函数都必须进行注释
4. 例外:不需要注释实现接口的方法。
2.2.3 命名规范:
变量命名:
1.简洁胜于冗长
2.缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写
·例如使用ServeHTTP而不是ServeHttp
·使用XMLHTTPRequest或者xmlHTTPRequest
3.变量距离其被使用的地方越远,则需要携带越多的上下文信息
·全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义
函数命名:
1. 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
2.函数名尽量简短
3.当名为foo的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义
4.当名为foo的包某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息
包命名:
1. 只有小写字母组成,不包含大写字母和下划线等字符。
2. 简短并包含一定的上下文信息,例如schema,task等
3. 不要与标准库同名,例如不要用sync或strings
4. 不使用常用变量名作为包名,例如使用buffio而不是buf
5. 使用单数而不是复数,例如使用encoding而不是encodings
6. 谨慎地使用缩写。例如使用fmt在不被坏上下文的情况下比format更加简短
2.2.4 控制流程:
1. 避免嵌套,保持正常流程清晰
2. 尽量保持正常代码路径为最小缩进(happy ending 不缩进)
错误和异常处理:
-
简单错误:简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误。优先使用errors.New来创建匿名变量来直接表示简单错误。如果有格式化的需求,使用fmt.Errorf
-
错误的Wrap和Unwrap:错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的限踪链。
在fmt.Errorf中使用:ow关键字来将一个错误关联至错误链中。
- 错误判定:判定一个错误是否为特定错误,使用errors..ls
不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误。
4.panic:不建议在业务代码中使用panic
调用函数不包含recover会造成程序崩溃
若问题可以被屏蔽或解决,建议使用error代替panic
当程序启动阶段发生不可逆转的错误时,可以在init或main函数中使用panic
5.recover:只能在defer的函数中使用, 嵌套无法生效
只在当前gorontine生效
defer的语句是后进先出
2.3 性能优化建议:
2.3.1:slice预分配内存:尽可能在使用make()初始化切片时提供容量信息。
2.3.2:大内存未释放:在已有切片基础上创建切片,不会创建新的底层数组
场景:原切片较大,代码在原切片基础上新建小切片。
原底层数组在内存中有引用,得不到释放。
2.3.3:map预分配内存
分析:不新向map中添加元素的掾作会触发map的扩容
提前分配好空问可以减少内存拷贝和Rehash的消耗
建议根据实际需求提前预估好需要的空间
2.3.4:使用strings.Builder
分析:字符串在Go语言中是不可变类型,占用内存大小是固定的
使用 + 每次都会重新分配内存
strings.Builder,bytes.Buffer底层都是[]byte数组
内存扩容策略,不需要每次拼接重新分配内存
2.3.5:使用空结构体节省内存
分析:空结构体struct{}实例不占据任何的内存空问
可作为各种场景下的占位符使用
节省资源
空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符
2.3.6:atomic包
锁的实现是通过操作条统来实现,属于系统调用
atomic操作是通过硬件实现,效率比锁高
sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
对于非数值操作,可以使用atomic,Value,能承载一个interface{}
2.4 性能调优
2.4.1性能调优原则
要根据数据而不是猜测
要定位最大瓶颈而不是细枝末节
不要过早优化
不要过度优化
2.4.2 性能分析工具pprof
1.功能简介:
2.采样原理
一共有三个相关角色:进程本身、操作系统和写缓冲。
启动采样时,进程向OS注册一个定时器,OS会每隔10ms向进程发送一个SIGPROF信号,进程接收到信号后就会对当前的调用栈进行记录。
与此同时,进程会启动一个写缓冲的goroutine它会每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流。
当采样停止时,进程向OS取消定时器,不再接收信号,写缓冲读取不到新的堆栈时,结束输出。
提到内存指标的时候说的都是「堆内存」而不是「内存」,这是因为pprof的内存采样是有局限性的
内存采样在实现上依赖了内存分配器的记录,所以它只能记录在堆上分配,且会参与GC的内存,一些其他的内存分配,例如调用结束就会回收的栈内存、一些更底层使用cgo调用分配的内存,是不会被内存采样记录的。
它的采样率是一个大小,默认每分配512KB内存会采样一次,采样率是可以在运行开头调整的,设为1则为每次分配都会记录。
与CPU和goroutine都不同的是,内存的采样是一个持续的过程,它会记录从程序运行起的所有分配或释放的内存大小和对象数量,并在采样时遍历这些结果进行汇总。
Goroutie:采样会记录所有用户发起,也就是入口不是runtime开头的goroutine,以及main函数所在goroutine的信息和创建这些goroutine的调用栈。他们实现上非常的相似,都是在STW后,遍历所有goroutine/所有线程的列表(图中的m就是GMP模型中的M,在golang中和线程一一对应)并输出堆栈,最后start the world继续运行,这个采样时立刻书法的全量记录,你可以通过比较两个时间点的差值来得到某一时间段的指标。
最后是阻塞和锁竞争这两种采样。这两个指标在流程和原理上也,非常相似。
这两个采样记录的都是对应操作发生的调用栈、次数和耗时,不过这两个指标的采样率含义并不相同:
阻塞操作的采样率是一个「阈值」,消耗超过阈值时间的阻塞操作才会被记录,1为每次操作都会记录。记得炸弹程序的代码吗?里面设置了rate=1
锁竞争的采样率是一个「比例」,运行时会通过随机数来只记录固定比例的锁操作,1为每次操作都会记录。
它们在实现上也是基本相同的。都是一个「主动上报」的过程。
在阻塞操作或锁操作发生时,会计算出消耗的时间,连同调用栈一起主动上报给采样器,采样器会根据采样率可能会丢弃-些记录。
在采样时,采样器会遍历已经记录的信息,统计出具体操作的次数、调用浅和总耗时。和堆内存一样,你可以对比两个时间点的差值计算出段时间内的操作指标。
2.5:性能调优案例-业务服务优化
流程:
建立服务性能评估手段
分析性能数据,定位性能瓶颈
重点优化项改造
优化效果验证
三、实践练习例子
3.1搭建pprof项目:
main.go初始化http服务和pprof接口的代码
3.2浏览器查看指标:
在浏览器中打开http:/localhost:6060/debug/pprof,可以看到这样的页面,这就是我们刚刚引入的net/http/pprof注入的入口了。页面上展示了可用的程序运行采样数据,下面也有简单说明,分别是:
allocs:内存分配情况
blocks:阻塞操作情况
cmdline程序启动命令及
goroutine:当前所有goroutine的堆栈信息
heap:堆上内存使用情况(同alloc)
utex:锁竞争操作情况
profile:CPU占用情况
threadcreate:当前所有创建的系统进程的堆栈信息
trace:程序运行追踪信息
3.3 CPU
输入top查看CPU占用最高的函数
五列从左到右分别是:
Fat:当前函数的占用
Flat%:Flat占总量的比例
Sum%:上面所有行的Flat%总和
Cum(Cumulative):当前函数加上其调用函数的总占用
Cum%:Cum占总量的比例
表格前面描述了采样的总体信息。默认会展示资源占用最高的10个函数,如果只需要查看最高的N个函数,可以输入topN,例如查看最高的3个调用,输入top3可以看到表格的第一行里,Tiger.Eat函数本身占用3.56秒的CPU时间,占总时间的95.44%,显然问题就是这里引起的
从这张表中可以看到,flat和cum有时候是相等的,有的是不等的,有的一边直接为零了。
Cum-Flat得到的是函数中调用其他函数所消耗的资源,所以在函数中没有对其他函数进行调用时,Cum-Flat=0,也就是Flat=cum。相应地,函数中除了调用另外的函数,没有其他逻辑时,Fat=0。
接着,输入list Eat查找这个函数,看看具体是哪里出了问题。List命令会根据后面给定的正则表达式查找代码,并按行展示出每一行的占用。如图。第24行有一个100亿次的空循环,占用了3.56秒的时间,定位成功。
3.4:调用关系可视化
除了这两种视图之外,我们还可以输入wb命令,生成一张调用关系图,默认会使用浏览器打开。图中除了每个节点的资源占用以外,还会将他们的调用关系穿起来。
图中最明显的就是方框最红最大,线条最粗的*Tiger.Eat函数,比top视图更直观些。到这里,CPU的炸弹已经定位完成,我们输入q退出终端。
3.5:堆内存
通过-http=:8080参数,可以开启pprof自带的WebUI性能指标会以网页的形式呈现。
切换到sourse视图,可以看见页面展示了刚刚看到的四个调用和具体的源码视图。
3.6:alloc_space
我们打开sample菜单,会发现堆内存实际上提供了四种指标:
在堆内存采样中,默认展示的是inuse_space视图,只展示当前持有的内存,但如果有的内存已经释放,这时inuse采样就不会展示了。我们切换到alloc_spacef指标。后续分析下alloc的内存问题。
马上就定位到了*Dog.Run()中还藏着一个「炸弹」,它会每次申请16MB大小的内存,并且已经累计申请了超过3.5GB内存。在Top视图中还能看到这个函数被内联了。
看看定位到的函数,果然,这个函数在反复申请16MB的内存,但因为是无意义的申请,分配结束之后会马上被GC,所以在inuse采样中并不会体现出来。
3.7: goroutine
Golang是-门自带垃圾回收的语言,一般情况下内存泄露是没那么容易发生的
但是有-种例外:goroutine,是很容易泄露的,进而会导致内存泄露。所以接下来,我们来看看goroutine的使用情况。
打开http:/ocalhost:6060/debug/pprof/,发现「炸弹」程序已经有105条goroutine:在运行了,这个量级并不是很大,但对于一个简单的小程序来说还是很不正常的。
3.8:火焰图:
打开View菜单,切换到Flame Graph视图。可以看到,刚才的节点被堆叠了起来
图中,自顶向下展示了各个调用,表示各个函数调用之间的层级关系。每一行中,条形越长代表消耗的资源占比越多。显然,那些「又平又长」的节点是占用资源多的节点。
可以看到,*Wolf.Drink()这个调用创建了超过90%的goroutine,问题可能在这里
火焰图是非常常用的性能分析工具,在程序逻辑复杂的情况下很有用,可以重点熟悉
3.9:Mutex-锁
修改链接后缀,改成mutex,然后打开网页观察,发现存在1个锁操作
同样地,在Graph视图中定位到出问题的函数在Wof.Howl()。然后在Source视图中定位到具体那一行发生了锁竞争
在这个函数中,goroutine足足等待了1秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。
3.10:block阻塞
在程序中,除了锁的亮争会导致阻塞之外,还有很多逻辑(例如读取一个channe也会导致阻塞,在页面中可以看到阻塞操作还剩两个)。
链接地址末尾再换成block。
和刚才一样,Graph到Source的视图切换
可以看到,在*Cat.Pee()函数中读取了一个time,After()生成的channel,这就导致了这个goroutine实际上阻塞了1秒钟,而不是等待了1秒钟。
四、 课后个人总结
本节课主要介绍了实际工程开发中的高效开发规范,简洁,高效,易懂,方便维护是一个优秀的开发者应该做到的。而pprof这个性能调优工具可以从多方面观察程序的性能,为高质量开发提供了很好的辅佐作用。