这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天
1. 高质量编程
高质量
- 保证各种边界条件被考虑到:鲁棒性
- 异常处理正确:稳定性
- 易读易维护:代码规范
编程原则
-
简单性
- 消除多余的复杂性:比如go删除了if判断表达式的括号
- 消除不必要的嵌套:后期排错时也方便排查
-
可读性
- 代码是给人读的。无论代码写的短,还是详细,机器读到的意思都是一样的。
-
高效率
- 实际生产中,都是多人合作完成的项目
1.1 高质量 - 代码格式
go工具
go fmt以及go imports可以自动规范代码格式。对于一些IDE,如Goland可以配置触发器,在保存文件时(运行代码),会自动调用go工具,对代码进行格式化。
go imports可以对import内容进行分类和排序,还可以对import进行tidy,即自动删除没使用到的引用。go fmt则是把代码按照规范的格式进行调整。
1.2 注释
注释应该做的
- 注释应该解释代码作用
- 注释应该解释代码如何做的
- 注释应该解释代码实现的原因
- 注释应该解释代码什么情况会出错
公共符号:全局变量/常量,函数等。
应该适当的对代码进行解释。如果函数体很简单,并且可以见名知意,就不用继续解释了。
对于那些函数内的逻辑,如果一眼看不出是干啥的。可以适当注释。解释代码是如何做的。
如果逻辑很简单,一眼就可以看出是做什么的,那么无需注释。
如果一个逻辑内部凭空出现了一个语句,应当注释其上下文。
比如一些网络的包,经常可以看见注释了RFCxxxx的提案内容。或者注释了兼容性问题等。有了这些上下文,就使得这些语句有迹可循。
一些固定格式(比如parse系列,format系列,或者具有特定表达式的函数,比如crontab等)的内容,应当注释使用例子,并且举例出错误的使用例导致的错误
公共符号始终要注释
包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释
任何既不明显也不简短的公共功能必须予以注释
无论长度或复杂程度如何,对库中的任何函数都必须进行注释
特例,如果在接口中已经详细注释了方法,那么无需注释接口实现的方法。
比如已经在接口中注释了Read方法的使用说明,此时Read虽然还没有实现。但是在Read的实现中,如果就是最基本的实现,无需注释。如果要对Read进行额外的扩展,那么只需要注释扩展了啥,而不需要对Read的基本功能继续注释。
1.3 变量命名
- 简洁 > 冗长。如果是局部变量,并且作用域很短,没有上下文和歧义,短比长好。比如for循环的索引,用i好于index。而当局部变量作用域比较大时,用一个具体的变量名就好于用一个简洁的变量名
- 对于缩略词,只有一种情况下全小写:当缩略词位于首部,并且是包内私有变量:比如
xmlReader。其余情况均全大写。 - 变量定义和被引用的位置相隔越远,需要保留的上下文信息就越多:例子==>一些常量的名字都巨长。这是因为这些常量到处都会被访问,如果名字很短很简单,势必造成开发人员的迷惑性。
函数名规范
- 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
- 函数名尽量简短
- 当名为 foo 的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义
- 当名为 foo 的包某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息
例子:比如包名为ipc,那么我们名为
IpcServer的结构,需要化简为Server。因为在使用时,一定是命名空间.结构名这么使用,所以
ipc.IpcServer造成了冗余,不如ipc.Server来的方便。但是如果是
ipc.DubboServer,就需要注明上下文。
包(命名空间)规范
- 只由小写字母组成。不包含大写字母和下划线等字符
- 简短并包含一定的上下文信息。例如 schema、task
- 不要与标准库同名。例如不要使用 sync 或者 strings
以下规则尽量满足,以标准库包名为例
- 不使用常用变量名作为包名。例如使用 bufio 而不是 buf
- 使用单数而不是复数。例如使用 encoding 而不是 encodings
- 谨慎地使用缩写。例如使用 fmt 在不破坏上下文的情况下比 format 更加简
1.4 控制流程
- 避免不必要的分支嵌套
好处:如果else分支里还有分支,那么就少了一层嵌套
错误处理的格式为
if err != nil { // handleErr,通常是退出 }如果判断逻辑为 "如果没错误,则继续进行"。而go基本每个方法都返回err,如果按照这样的方式,那么分支将及其冗长
1.5 异常处理
简单错误
- 通常不会导致系统直接崩溃。用panic抛出的错误会导致程序崩溃
- 可以使用
errors.New直接创建一个匿名变量。这也是go返回err变量常用做法 - 必要时可以用
fmt.Errorf创建格式化的错误信息
go1.13引入了对错误的包装,类似java的错误嵌套。(常见的caused by)在go中,可以用
%w将错误包装为另一个错误return fmt.Errorf("xxx, %w", err) // 实际上,这条语句把err包装为了另一个匿名错误,并返回,这个匿名错误携带了比err更丰富的信息 // 此错误可以继续被包装,成为一个错误链
errors.Is()可以判断某个错误是否存在于一个错误链。因为所有错误链的表示形式仍然是一个err对象。如果把错误链中比较底层的错误和高层错误直接比较,肯定是不成的。如果要用底层错误is高层错误,才可以。因为高层错误是底层错误多次包装后的错误。
errors.As()的用法很像go的类型断言。输入一个错误链对象(实际上就是error类型),以及一个具体错误类型的指针。如果具体错误类型被错误链包含,则将转换后的具体类型错误传出。并返回true
panic/recover
- 不建议大量使用panic。因为panic意味着程序会崩溃。
- 通常,在初始化工作中会使用到panic。因为初始化失败意味着整个系统也没必要启动,直接退出即可。
- recover只能作用于defer域,只在当前goroutine生效。也就是,就算父goroutine声明了错误处理的recover调用,此调用也不会被其创建的子goroutine复用。
// recover() 本身返回一个error接口对象。配合go的类型断言 // t, ok := obj.(T) // 如果obj可以转换为T,则返回转换后的t对象,和true。否则返回nil和false if e, ok := recover().(PathError); ok { }- 通常可以配合类型断言使用,并且多数情况下用于记录错误信息(打印堆栈)。因为既然捕获到了错误,就有报告错误的必要,否则都不知道哪里错了。
2. 性能优化
2.1 基准测试
性能优化建议
-
容量预分配
在使用数组切片时,尽量根据业务逻辑的需要,预分配一个容量。减少扩容次数
减少扩容次数的目的
- 每次扩容都会导致底层数组的深拷贝,每次拷贝相当于一次内存申请和数据迁移
- 如果提前分配大小,有效减少了扩容次数和深拷贝次数
-
使用深拷贝代替数组重切片
s := make([]int, 11223344) // 超大的切片 s2 := s[:2] // 只有三个元素的切片实际上,无论是s还是s2,其内部的
unsafe.Pointer类型,指向底层存储结构的指针都是一个。只不过s2通过数组切片的len属性控制了长度。所以,如果s使用完了,但是s2没用完,仍然会导致超大切片存在引用,不会被释放。因此,在声明s2时,应当建立一个全新的切片,进行深拷贝。
-
map预分配
对于map的预分配,不仅减少了申请内存的次数,还减少了执行次数。
这是因为map的扩容,不仅进行内存申请和拷贝,还要进行rehash,这都是大开销
-
string操作
常见的字符串操作
-
直接使用
+操作符- 性能最差:由于go的不可变设计,因此每一个新的字符串,都会创建一份拷贝,然后进行拼接返回。内存中存在大量的字符串常量
-
使用
strings.Builder- 性能最优:调用此对象涉及两个过程 1.直接操作底层的byte[] 2.将byte[]转换为string。这个对象转换为string的方式十分c语言。将byte[]的指针视为string的指针,然后取出内容。这是一个O(1)的操作
*(string *)(unsafe.Pointer(&b.buf))
-
使用
bytes. Buffer-
性能优于
+低于strings.Builder。原因:同样是两个过程1.直接操作底层的byte[] 2.将byte[]转换为string。不过在过程二,是比较go语言的,即分配一个切片承装缓冲区,然后转换为string。由于涉及一次切片分配,重申请了一块空间
-
-
对于方法二和三,同样支持预分配。因为底层的[]byte数组本质也是一个切片,可以用
builder/buf.Grow方法进行预分配,减少扩容
-
-
空结构体
我们可能看到过
interface{},这其实就是一个匿名结构。我们用type xxx interface{}实际上就是给一个接口/结构体命名。这里我们把类型作为第一类值并作为右值。因此struct {}是一个匿名结构,而struct {}{}就是这个匿名结构的实例对象。- 所有
struct {}{}的内存地址一样。
功能:由于空结构体不占内存空间,所以如果我们想使用set,可以声明一个
map[key] struct{}达到set的功能,// 一个空结构体的匿名对象 struct {}{} - 所有
-
使用atomic包
该包下使用汇编语言级别的(硬件级别)的加锁指令。效率很高。
如果我们要对一个变量进行加锁,最好使用atomic包。而sync包主要用于对一段逻辑进行加锁。
2.2 性能检测工具pprof
下载测试代码
go get github.com/wolfogre/go-pprof-practice
import ( "log" "net/http" _ "net/http/pprof" "os" "runtime" "time" "github.com/wolfogre/go-pprof-practice/animal" ) func main() { log.SetFlags(log.Lshortfile | log.LstdFlags) log.SetOutput(os.Stdout) runtime.GOMAXPROCS(1) runtime.SetMutexProfileFraction(1) runtime.SetBlockProfileRate(1) go func() { if err := http.ListenAndServe(":6060", nil); err != nil { log.Fatal(err) } os.Exit(0) }() for { for _, v := range animal.AllAnimals { v.Live() } time.Sleep(time.Second) } } 想使用
pprof,只需要引入net/http/pprof包,就会自动注册一个路由我们访问
$ip:port/debug/pprof时,就会出现如下的内容
点进去我们发现都是pprof从运行时获取到的数据,是机器易读的形式。根据这些数据,我们可以用图形化工具将其转换为人类易读形式。
在程序内引入
pprof的功能为
- 引入了pprof的路由,并且引入了handler。此handler负责定时 向go runtime获取数据,并以纯文本格式进行展示
我们如果想看到更细致的结果,需要使用go工具包的pprof工具对这些数据进行解析。
工具
go tool pprof的使用方式 : 最后面跟着一个由net/http/pprof路由暴露出的端点。就能自动对这个端点的内容进行分析我们发现
profile端点的描述:CPU的大体信息,可以直接通过GET请求访问。我们这里指定second=10,意味着10秒刷新一次数据。使用后进入
pprof命令行。使用top可以打印出运行时函数执行信息这里的flat和cum的配合,可以优雅的绘制出整个函数调用链和时间线。
我们要用
list Eat可以展示出Eat方法具体的代码执行在linux下跑了一下,可以看到web图形化页面。可以看到,图形化页面充分展示了调用流程,从runtime.main调用到最后,可以看到在
mouse.Steal方法里,申请了1GB + 512MB的内存未释放,造成内存使用高的结果。
TOP:查看函数执行的cpu使用率
GRAPH: 函数执行调用图和时序图
FLAME GRAPH :火焰图,展示了沿途协程的调用栈和调用时间,在分析协程时常用
SOURCE:源代码。
端点profile用于检查cpu,那么端点heap自然是检查堆内存使用。
http://localhost:6060/debug/pprof/heap使用go tool pprof监控此端点我们可以指定
-http=:8080在本地开启一个图形化的web页面,和使用web指令是一样的。同样需要graphviz库。
端点goroutine用于检查协程。同样的
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine可以监控协程信息协程一般使用的图表为火焰图,可以直观看出哪个协程的执行时间不对劲(过长?重复?) 从而进行优化
火焰图
- 每一列都是一个协程
- 每一块都是一个函数调用
- 因此一列中,从上往下就是这个协程的调用栈。
- 横坐标代表执行时间长度。我们可以看到root -> wolf.Drink -> time.Sleep这个协程明显执行时间长于其他协程,这说明这个协程睡了好一会。因此造成了性能瓶颈。
同理,我们可以分析mutex:因为竞争锁导致的阻塞时间,以及block:因为管道阻塞的时间。
二者有一定的舍弃指标,比如block会舍弃小于1.4s的阻塞,而mutex也会舍弃一定比例的阻塞。
这是为了方便我们的分析,而把一些细小的case给忽视掉。
2.3 net/http/pprof干了啥?
分析这个东西干了啥,我们要根据其功能,推测其实现。最后再去看源码
首先,cpu profile是pprof向cpu注册一个定时器,定时器定期发送SIGPROF信号。
收到信号的进程注册了信号处理器
sigaction(SIGPROF, handler),这个处理器将进程堆栈记录,并输出到缓冲区。缓冲区定期向输出流写入数据
而对heap的分析,则是监控了go的垃圾回收器。
因为go是自动内存管理,所以heap信息只能分析gc。
对于协程,go也有对应的协程任务队列,只需要在特定时刻stw,记录此时的协程信息,然后记录协程此时的上下文信息,最后退出即可、注意,对于线程的选择,为用户态线程和main函数入口runtime.main线程,其他runtime开头的线程不在记录范围内。
对于mutex和block。同理,只不过在采样时,会丢弃不符合规格数据,过滤掉没有特点的数据
3. 优化案例
建立评估手段:使用benchmark基准测试?使用压测?判断指标?压测工具返回结果?构造输入数据?压测范围?采集数据范围?这些因素都要考虑进去,最后建立一个比较好的评估模型,之后就可以进行评估测试。
案例一:标准库使用不当
分析发现:json解析goroutine的火焰图长度较长,这意味着json解析花了大量时间。经过分析发现,是因为每个goroutine都要反序列化一个配置文件,实际上,这个配置文件只需要反序列化一次,没有使用缓存。
案例二:高峰期性能下降,低峰期性能水平保持较好
分析发现:高峰期对mutex的分析中,因为同步上报指标产生的数据竞争加锁耗时较长,拖累了整个系统。但是低峰期中,数据竞争少,锁开销小、解决方式:转为异步上报。也就是说,在这种数据一致性要求较差的环节中,可以避免加锁。
进行优化后,可以立即上线吗?
- 性能优化的前提是保持正确性,因此要录制优化前的输入输出,并对优化后的结果进行测试。如果成功才可以上线
可以直接按照最优性能上线吗?
- 我们应该关注性能数据
- 逐步提高性能阈值
- 关注服务监控
避免服务上线操之过急
案例三:分析调用链路
目前微服务通常使用rpc进行服务通信,那么服务通信可不可以异步化,可不可以缓存避免重复请求,或者进行请求合并减少网络io次数
使用AB实验:
即采取两套上线环境,对比二者性能差距,进行优化
进行go编译器优化
类似jvm的调优,优点为
- 可以进行内存设置
- 可以进行编译优化
- 接入简单,只需要调整命令行参数/环境变量,通用性高