这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天
本节主要讲的理论
高质量编程
注释
注释的作用:
- 代码作用:适合注释代码作用
- 代码如何做:适合注释实现过程
- 代码实现的原因:适合解释代码的外部因素,提供额外上下文
- 代码什么时候会出错:适合解释代码的限制条件
公共符号始终要注释
- 包中表明的每个公共的符号、变量、常量、函数以及结构体都需要添加注释
- 任何既不明显也不见简短的公共功能必须注释
- 无论长度或复杂程度如何,库里的任何函数都必须注释
实现接口的方法可以不用注释
代码格式
推荐使用gofmt自动格式化代码
命名规范
变量Variable:
-
简洁胜于冗长
for index := 0; i < n ; i ++ {} for i := 0; i < n; i ++ {}i和index作用域相同,用i不用index
-
缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写
-
变量距离其被使用的地方越远,则需要携带越多的上下文信息
- 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义
函数function
- 函数名不携带包的上下文信息,因为包名和函数名总是成对出现的
- 函数名尽量简短
- 当名为foo的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义
- 当名为foo的包某个函数返回类型T时,可以在函数名中加入类型信息
包package
- 只有小写字母组成。不包括大写字母和下划线等字符
- 简短并包含一定的上下文信息。如schema、task等
- 不要与标准库名相同
- 不适用常用变量名为包名,比如使用bufio而不是buf
- 使用单数而不是负数,如使用encoding而不是encodings
- 谨慎的使用缩写,使用fmt在不破坏上下文的情况下比format更简短
控制流程
避免嵌套,保持正常流程运行
- 如果两个分支都包含return语句,则可以去掉冗余的else
尽量保持正常代码路径为最小缩进
错误和异常处理
简单错误:
- 优先使用errors.New来创建匿名变量来直接1表示简单错误
- 如果有格式化的需求,使用fmt.Errorf
错误的Wrap和Unwrap
- 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成了一个error的跟踪链
- 在fmt.Errorf中使用%w关键字来将一个错误关联至错误链中
错误判定:
-
errors.Is,判断一个错误是否为特定错误
-
不同于使用==,该方法可以判定错误链上的所有错误是否含有特定的错误
errors.Is(err, fs.ErrNotExit) -
在错误链上获取特定种类的错误,使用errors.As
errors.As(err, &pathError) -
panic,不建议在业务代码使用,但是当程序启动阶段发生不可逆的错误时,可以在init或main函数中使用panic
-
recover只能在被defer的函数中使用,嵌套无法生效,只在当前goroutine生效。如果需要更多的上下文信息,可以在recover后在log中记录当前的调用栈.
defer总是后进先出
func (t *treeFs) Open(name string) (f fs.File, err error) { defer func() { if e := recover(); e != nil { f = nil err = fmt.Errorf("gitfs panic: %v\n%s", e, debug.Stack()) } }() }
性能优化
Go语言提供了支持基准性能测试工具benchmark
Slice预分配内存
尽可能在使用mark()初始化切片时提供容量信息,与vector类似
如果函数返回切片的一部分,不释放大切片将会造成大内存未释放
map预分配内存
不断向map中添加元素的操作会触发map的扩容
提前分配好空间可以减少内存拷贝和Rehash的消耗
字符串处理
使用strings.Builder处理字符串
空结构体
使用空结构体节省内存
空结构体实例不占据任何内存空间,可作为各种场景下的占位符使用
m := make(map[int]struct{}) // 节省空间
m := make(map[int]bool)
实现set,也可以考虑用map来替代
多线程
使用atomic包,可以维护一个原子的变量(多用于多线程对计数器的加减)
锁的实现是通过操作系统实现的,属于系统调用,效率比atomic包通过硬件实现的效率低
性能调优工具pprof
原则:
- 依靠数据而不是猜测
- 要定位最大瓶颈而不是细枝末节
- 不要过早、过度优化
Web浏览器查看
拉取代码运行后,可以在浏览器打开xxxx:6060/debuf/pprof (我是vscode + remote),如果是本机的话localhost就行
运行程序后,在终端查看
命令行工具
输入查看CPU运行情况
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
结束之后,输入top命令展示:
- flat:当前函数本身的执行耗时
- flat%:flat占CPU总时间的比例
- sum%:上面每一行的flat%总和
- cum:当前函数本身加上其调用函数的总耗时
- cum%:cum占CPU总时间的比例
Flat == Cum,函数没有调用其他函数
Flat == 0,函数中只有其他函数调用
list命令根据正则表达式查找代码行
web命令调用关系可视化
可视化工具
在使用了上述命令行工具后,在$HOME/路径下会生成pprof文件夹,里面存放着信息:
然后进入这个文件夹,在终端输入:
go tool pprof -http=:8080 文件名
就会在浏览器生成可视化工具
top:
Source:
火焰图
需要安装PProf原生工具
go install github.com/google/pprof@latest
然后像上面一样,在$HOME/用户名/pprof目录下执行
pprof -http=:8080 文件名
会在浏览器打开
性能调优案例
可能需要优化的服务:
- 业务服务优化
- 基础库优化
- Go语言优化
业务服务优化
服务:能单独部署,承载一定功能的程序
依赖:Service A的功能实现依赖Service B的响应结果,称为Service A依赖Service B
调用链路:能支持一个接口请求的相关服务的集合及其相互之间的依赖关系
基础库:公共的工具包、中间件
流程:
-
建议服务性能评估手段
- 服务性能评估:单独benchmark无法满腹复杂的业务逻辑、不同负载情况下性能表现差异
- 请求流量构造:不同请求参数覆盖逻辑不同、线上真实流量情况
- 压测范围:单机、集群
- 性能数据采集:单机、集群
-
分析业务数据,定位性能瓶颈
- 使用库不规范
- 高并发场景优化不足
-
终点优化项改造
- 正确性是基础
- 响应数据diff:线上请求数据录制回放、新旧接口逻辑数据diff
-
效果优化验证
- 重复压测验证
- 上线评估优化效果
基础库优化
-
分析基础库核心逻辑和性能瓶颈
- 设计完善改造方案
- 数据按需获取
- 数据序列化协议优化
-
内部压测验证
-
推广业务服务落地验证
Go语言优化
编译器&运行时优化
- 优化内存分配策略
- 优化代码编译流程,生成更高效的程序
- 内部压测验证
- 推广业务服务落地验证
优点:
- 接入简单
- 通用性强
Go内存管理
自动内存管理
相关概念:
Mutator:业务线程,分配新对象,修改对象指向关系
Collector:GC线程,找到存活对象,回收死亡对象的内存空间
Serial GC:只有一个collector
Paraller GC:支持多个collectors同时回收的GC算法
Concurrent GC:mutator(s)和collector(s)可以同时执行,必须要感知对象指向关系的改变
评价GC算法:
- 安全性(基本要求),如上图的b对象必须标记
- 吞吐率(花在GC的时间):1- GC时间/程序执行总时间
- 暂停时间(业务是否感知)
- 内存开销(GC元数据开销)
追踪垃圾回收
对象被回收的条件:指针指向关系不可达的对象
-
标记根对象:静态变量、全局变量、常量、线程栈等
-
标记:找到可达对象。求指针指向关系的传递闭包:从根对象触发,找到所有可达对象
-
清理:所有不可达对象。三个策略:
-
将所有存活对象复制到另外的内存空间copying collection
-
-
将死亡对象的内存标记为”可分配“mark-sweep collection
-
移动并整理存活对象mark-compact collection
-
分代GC
分代假说:很多对象在分配出来后很快就不再使用了
每个对象都有年龄:经历过GC的次数
目的:对年轻和老年的对象,制定不同的GC策略,降低整体内存管理的开销
不同的年龄对象处于heap的不同区域
- 年轻代:常规的对象分配,由于存活对象很少,可以采用copying collection
- 老年代:对象趋于一直活着,反复复制开销很大,可以采用mark-sweep collection
引用计数
每个对象都有一个与之关联的引用数目
对象存活的条件:当且仅当引用数大于0
优点:
- 内存管理的操作被平摊到程序执行过程中
- 内存管理不需要了解runtime的实现细节
缺点:
-
维护开销大,通过原子操作才能保证对引用计数操作的原子性和可见性
-
无法回收环形数据结构——weak reference
-
内存开销:每个对象都引入的额外内存空间存储引用数目
-
回收内存时,依然可能引发暂停
Go内存分配
分块
目标:为对象在heap上分配内存
- 调用系统调用mmap() 向OS申请一大块内存,例如4MB
- 先将内存划分为大块,例如8KB,乘坐msapn
- 再将大块分为特定大小的小块,用于对象分配
- noscan mspan:分配不含指针的独享
- scan msapn:分配包含指针的对象
对象分配:根据对象的大小,选择最合适的块返回
缓存
借鉴TCMalloc:thread caching
每个p包含一个mcache用于快速分配,用于为绑定于p上的g分配对象
mcache管理一组mspan
当mcache中的mspan分配完毕,向mcentral申请带有未分配块的span
当mspan中没有分配的对象时,mspan会缓存在mcentral中,而不是立刻释放并归还给OS
优化
现状:
对象分配时非常高频的操作:每秒分配GB级别
小对象占比高
Go内存分配比较耗时:
- 分配路径长:g-> m -> p -> mcache -> mspan -> memory block ->return pointer
- pprof:对象分配的函数是最频繁调用的函数之一
优化方案:Bananced GC
每个g都绑定一大块内存(1KB),称作goroutine allocation buffer(GAB)
每个GAB用于noscan类型的小对象分配( < 128 B)
使用三个指针维护GAB: base, end, top
Bump pointer(指针碰撞)风格对象分配
- 无须和其他分配请求互斥
- 分配动作简单高效
if top + size <= end {
addr := top
top += size
return addr
}
一个GAB对于Go内存管理来说是一个大对象,本质上是将多个小对象的分配合并成一次大对象的分配,但是GAB的对象分配方式会导致内存被延迟释放。
解决办法:移动GAB中的存活的对象,本质上用copying GC的算法管理小对象
编译器和静态分析
编译器:
静态分析:不执行程序代码,推导程序的行为,分析程序的性质
-
控制流:程序执行的流程
-
数据流:数据在控制流上的传递
通过分析控制流和数据流,可以知道更多关于程序的性质
过程内的分析:仅在函数内部进行分析
过程间分析:考虑函数调用时参数传递和返回值的数据流和控制流
Go编译器优化
函数内联
将被调用函数的函数体callee的副本替换到调用位置caller上,同时重写代码以反映参数的绑定
优点:
- 消除函数调用开销,例如传递参数、保存寄存器等
- 将过程间分析转化为过程内分析,帮助其他优化,如逃逸分析
逃逸分析
分析代码中指针的动态作用域:指针在何处可以被访问
大致思路:
-
从对像分配处出发,沿着控制流,观察对象的数据流
-
若发现指针p在当前作用域s:
- 作为参数传递给其他函数
- 传递给全局变量
- 传递给其他的goroutine
- 传递给已逃逸的指针指向的对象
-
则指针p指向的对象逃逸出s,反之则没有逃逸出s
Beast Mode
Go函数内联受到的限制较多
- 语言特性,interface,defer等限制了函数内联
- 内联策略十分保守
调整函数内联的策略,使更多的函数被内联。函数内联拓展了函数边界,更多对象不逃逸