高质量编程与性能调优实战 | 青训营笔记

102 阅读14分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第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 不缩进)

错误和异常处理:

  1. 简单错误:简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误。优先使用errors.New来创建匿名变量来直接表示简单错误。如果有格式化的需求,使用fmt.Errorf

  2. 错误的Wrap和Unwrap:错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的限踪链。

在fmt.Errorf中使用:ow关键字来将一个错误关联至错误链中。

  1. 错误判定:判定一个错误是否为特定错误,使用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.功能简介:

         

image.png               2.采样原理

image.png

一共有三个相关角色:进程本身、操作系统和写缓冲。

启动采样时,进程向OS注册一个定时器,OS会每隔10ms向进程发送一个SIGPROF信号,进程接收到信号后就会对当前的调用栈进行记录。

与此同时,进程会启动一个写缓冲的goroutine它会每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流。

当采样停止时,进程向OS取消定时器,不再接收信号,写缓冲读取不到新的堆栈时,结束输出。

image.png 提到内存指标的时候说的都是「堆内存」而不是「内存」,这是因为pprof的内存采样是有局限性的

内存采样在实现上依赖了内存分配器的记录,所以它只能记录在堆上分配,且会参与GC的内存,一些其他的内存分配,例如调用结束就会回收的栈内存、一些更底层使用cgo调用分配的内存,是不会被内存采样记录的。

它的采样率是一个大小,默认每分配512KB内存会采样一次,采样率是可以在运行开头调整的,设为1则为每次分配都会记录。

与CPU和goroutine都不同的是,内存的采样是一个持续的过程,它会记录从程序运行起的所有分配或释放的内存大小和对象数量,并在采样时遍历这些结果进行汇总。

image.png Goroutie:采样会记录所有用户发起,也就是入口不是runtime开头的goroutine,以及main函数所在goroutine的信息和创建这些goroutine的调用栈。他们实现上非常的相似,都是在STW后,遍历所有goroutine/所有线程的列表(图中的m就是GMP模型中的M,在golang中和线程一一对应)并输出堆栈,最后start the world继续运行,这个采样时立刻书法的全量记录,你可以通过比较两个时间点的差值来得到某一时间段的指标。

image.png 最后是阻塞和锁竞争这两种采样。这两个指标在流程和原理上也,非常相似。

这两个采样记录的都是对应操作发生的调用栈、次数和耗时,不过这两个指标的采样率含义并不相同:

阻塞操作的采样率是一个「阈值」,消耗超过阈值时间的阻塞操作才会被记录,1为每次操作都会记录。记得炸弹程序的代码吗?里面设置了rate=1

锁竞争的采样率是一个「比例」,运行时会通过随机数来只记录固定比例的锁操作,1为每次操作都会记录。

它们在实现上也是基本相同的。都是一个「主动上报」的过程。

在阻塞操作或锁操作发生时,会计算出消耗的时间,连同调用栈一起主动上报给采样器,采样器会根据采样率可能会丢弃-些记录。

在采样时,采样器会遍历已经记录的信息,统计出具体操作的次数、调用浅和总耗时。和堆内存一样,你可以对比两个时间点的差值计算出段时间内的操作指标。

2.5:性能调优案例-业务服务优化

              流程:

              建立服务性能评估手段     

              分析性能数据,定位性能瓶颈

              重点优化项改造

              优化效果验证

三、实践练习例子

3.1搭建pprof项目:

              main.go初始化http服务和pprof接口的代码

image.png

3.2浏览器查看指标:

image.png               在浏览器中打开http:/localhost:6060/debug/pprof,可以看到这样的页面,这就是我们刚刚引入的net/http/pprof注入的入口了。页面上展示了可用的程序运行采样数据,下面也有简单说明,分别是:

allocs:内存分配情况

blocks:阻塞操作情况

cmdline程序启动命令及

goroutine:当前所有goroutine的堆栈信息

heap:堆上内存使用情况(同alloc)

utex:锁竞争操作情况

profile:CPU占用情况

threadcreate:当前所有创建的系统进程的堆栈信息

trace:程序运行追踪信息

3.3 CPU

      

image.png        输入top查看CPU占用最高的函数

       五列从左到右分别是:

Fat:当前函数的占用

Flat%:Flat占总量的比例

Sum%:上面所有行的Flat%总和

Cum(Cumulative):当前函数加上其调用函数的总占用

Cum%:Cum占总量的比例

表格前面描述了采样的总体信息。默认会展示资源占用最高的10个函数,如果只需要查看最高的N个函数,可以输入topN,例如查看最高的3个调用,输入top3可以看到表格的第一行里,Tiger.Eat函数本身占用3.56秒的CPU时间,占总时间的95.44%,显然问题就是这里引起的 image.png 从这张表中可以看到,flat和cum有时候是相等的,有的是不等的,有的一边直接为零了。

Cum-Flat得到的是函数中调用其他函数所消耗的资源,所以在函数中没有对其他函数进行调用时,Cum-Flat=0,也就是Flat=cum。相应地,函数中除了调用另外的函数,没有其他逻辑时,Fat=0。

image.png 接着,输入list Eat查找这个函数,看看具体是哪里出了问题。List命令会根据后面给定的正则表达式查找代码,并按行展示出每一行的占用。如图。第24行有一个100亿次的空循环,占用了3.56秒的时间,定位成功。

3.4:调用关系可视化

image.png 除了这两种视图之外,我们还可以输入wb命令,生成一张调用关系图,默认会使用浏览器打开。图中除了每个节点的资源占用以外,还会将他们的调用关系穿起来。

图中最明显的就是方框最红最大,线条最粗的*Tiger.Eat函数,比top视图更直观些。到这里,CPU的炸弹已经定位完成,我们输入q退出终端。

3.5:堆内存

image.png        通过-http=:8080参数,可以开启pprof自带的WebUI性能指标会以网页的形式呈现。

image.png 切换到sourse视图,可以看见页面展示了刚刚看到的四个调用和具体的源码视图。

3.6:alloc_space

image.png 我们打开sample菜单,会发现堆内存实际上提供了四种指标:

在堆内存采样中,默认展示的是inuse_space视图,只展示当前持有的内存,但如果有的内存已经释放,这时inuse采样就不会展示了。我们切换到alloc_spacef指标。后续分析下alloc的内存问题。

image.png 马上就定位到了*Dog.Run()中还藏着一个「炸弹」,它会每次申请16MB大小的内存,并且已经累计申请了超过3.5GB内存。在Top视图中还能看到这个函数被内联了。

看看定位到的函数,果然,这个函数在反复申请16MB的内存,但因为是无意义的申请,分配结束之后会马上被GC,所以在inuse采样中并不会体现出来。

3.7: goroutine

image.png Golang是-门自带垃圾回收的语言,一般情况下内存泄露是没那么容易发生的

但是有-种例外:goroutine,是很容易泄露的,进而会导致内存泄露。所以接下来,我们来看看goroutine的使用情况。

打开http:/ocalhost:6060/debug/pprof/,发现「炸弹」程序已经有105条goroutine:在运行了,这个量级并不是很大,但对于一个简单的小程序来说还是很不正常的。

3.8:火焰图:

image.png 打开View菜单,切换到Flame Graph视图。可以看到,刚才的节点被堆叠了起来

图中,自顶向下展示了各个调用,表示各个函数调用之间的层级关系。每一行中,条形越长代表消耗的资源占比越多。显然,那些「又平又长」的节点是占用资源多的节点。

可以看到,*Wolf.Drink()这个调用创建了超过90%的goroutine,问题可能在这里

火焰图是非常常用的性能分析工具,在程序逻辑复杂的情况下很有用,可以重点熟悉

3.9:Mutex-锁

image.png 修改链接后缀,改成mutex,然后打开网页观察,发现存在1个锁操作

同样地,在Graph视图中定位到出问题的函数在Wof.Howl()。然后在Source视图中定位到具体那一行发生了锁竞争

在这个函数中,goroutine足足等待了1秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。

3.10:block阻塞

image.png 在程序中,除了锁的亮争会导致阻塞之外,还有很多逻辑(例如读取一个channe也会导致阻塞,在页面中可以看到阻塞操作还剩两个)。

链接地址末尾再换成block。

image.png 和刚才一样,Graph到Source的视图切换

可以看到,在*Cat.Pee()函数中读取了一个time,After()生成的channel,这就导致了这个goroutine实际上阻塞了1秒钟,而不是等待了1秒钟。

四、 课后个人总结

本节课主要介绍了实际工程开发中的高效开发规范,简洁,高效,易懂,方便维护是一个优秀的开发者应该做到的。而pprof这个性能调优工具可以从多方面观察程序的性能,为高质量开发提供了很好的辅佐作用。