这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天
本次编程学习的基本环境配置如下
OS: macOS 13.1
IDE: Goland 2022.3
Go Version: 1.18
重点内容
- 如何编写更简洁清晰的代码
- 常见Go语言程序优化手段
- 熟悉Go语言性能分析工具
- 了解工程中性能优化的原则和流程
详细介绍
高质量编程
简介
- 高质量意味着: 编写的代码能够达到正确可靠、简洁清晰的目标
- 正确性是首要目标,对于所有可预见的异常情况要有处理
- 代码是给人看的, 要实现地尽可能简单,易读易维护,方便之后的快速支持和评估
编程原则
- 简单性:消除“多余的复杂性”,以简单清晰的逻辑编写代码
- 可读性:编写可维护代码的第一步就是确保代码可读
- 生产力:团队整体工作效率非常重要。
编码规范
几个比较重要的规则
代码格式
- 推荐使用
gofmt自动化格式代码, 很多IDE都支持 goimports也是Go官方提供的工具, 相当于gofmt加上依赖包管理
注释
注释的功能:
- 应该解释代码作用
- 应该解释代码如何做的
- 应该解释代码实现的原因, 解释代码的外部因素, 提供额外的上下文
- 应该解释代码什么情况会出错和限制条件
好的代码有很多注释, 坏的代码需要很多注释
其他注释规则
- 公共符号始终要注释, 无论方法的复杂程度如何
- 不需要注释实现接口的代码
- 代码是最好的注释. 注释应该提供代码未表达出来的上下文信息
命名规范
- 简洁, 不要冗长
- 缩略词全大写, 但当其位于变量开头, 而且不需要导出的时候,使用全小写
- 变量距离其被使用的地方越远, 则需要携带越多的上下文信息
// Good
func (c *Client) send(req *Request, deadline time.Time)
// Bad
func (c *Client) send(req *Request, t time.Time)
- 函数名不需要携带包名的上下文信息
- 函数名尽可能剪短
- 返回值类型人尽皆知的时候, 函数名称应省略类型信息, 需要强调的时候, 加入类型信息
package只由小写字母组成, 简单并包含一定的上下文信息, 不要与标准库同名- 尽量不要用常用变量名作为包名, 使用单数而不是复数, 尽量不适用缩写, 使用缩写应该不破坏上下文
核心目标是降低阅读理解代码的成本
重点考虑上下文信息, 设计简洁清晰的名称
Good naming is like a good joke, if you have to explain it, it's not funny.
控制流程
- 避免嵌套, 保持正常流程清晰. 去除冗余的
else
if foo{
return x
}else{
return nil
}
// good
if foo {
return x
}
return nil;
- 尽量保持正常代码路径为最小缩进
// Bad
func OneFunc() error{
err := doSomething()
if err == nil{
err := doAnotherThing()
if err == nil{
return nil // normal case
}
return err
}
return err
}
// Good
func OneFunc() error{
if err := doSomething();err != nil{
return err
}
if err := doAnotherThing();err != nil{
return err
}
// add other case..
// normal case
return nil;
}
- 线性原理. 处理逻辑尽量走直线, 避免复杂的嵌套分支
- 正常流程代码沿着屏幕向下移动
- 提升代码可维护性和可读性
- 故障问题大多出现在复杂的流程控制语句中
错误和异常处理
- 简单的错误值得是仅出现一次的错误, 且在其他地方不需要捕获该错误
- 优先使用errors.New 来创建匿名变量来直接表示简单错误
- 如果格式化的需求, 使用fmt.Errorf()
- 错误的Wrap实际上是提供了一个error嵌套另一个error的能力, 从而生成一个error的跟踪链.
- 在
fmt.Errorf中使用"%w"将一个错误关联到错误链中 - errors.Is()用于判断错误链上是否有指定类型的错误
- 在错误链上获取特定种类的错误, 使用
errors.As
- 不建议在业务代码中使用
panic, 除了在程序启动的时候 - 建议函数不包含
recover会造成程序崩溃。 - 若问题可以被屏蔽获取解决, 建议使用
error代替panic recover只能在被defer的函数中使用, 嵌套无法生效, 只能在当前goroutine中生效recover在log中记录当前的调用栈, 记录有用的上下文信息- 错误处理的时候, 需要提供简明的上下文信息链,方便定位及问题
func main(){
if true{
defer fmt.Printf("1")
}else{
defer fmt.Printf("2")
}
defer fmt.Printf("3")
// 最终输出 31
}
性能优化建议
性能表现需要实际数据衡量, GO语言提供了支持基准性能测试的benchmark工具
slice
- slice预分配内存,
make()时给cap信息 - 切片本质是一个数组片段的描述,包括数组长度,片段的长度,片段的容量等。
- 如果容量不够,
append会引起扩容 - 切片操作不会复制切片指向的元素
- 创建一个新的切片会复用原来切片的底层数组(用的是
unsafe.Pointer)
6. 在已有切片上创建切片, 不会创建新的底层数组
7. 原切片较大,代码在原切片期初上创建小切片,原切片数组在内存中有引用,内存不会释放
map
- 同样, 可以对map预分配
- 提前分配好空间可以减少内存拷贝和Rehash的消耗
字符串处理
- 拼接字符串性能: 使用
+<< 使用ByteBuffer< 使用strings.Builer - 字符串在Go语言中是不可变类型, 占用内存大小是固定的
- 使用+都会重新分配内存
- 后两者都是
[]byte, 有自己的内存分配策略 bytes.Buffer转换成字符串时重新申请了一块空间, 而strings.Builder直接将底层的b.buf作为字符串返回, 因此strings.Builder更快一点- 如果字符串的长度已知的话, 可以用
Grow方法预分配内存(对ByteBuffer和strings.Builer)
节省内存空间
- 空结构体不占用任何内存空间
- 可以用map空结构体实现Set, 即使设置成
bool类型也是会占据一个字节
atomic包
- 原子变量(硬件) > 加锁 (软件, OS, 2倍以上)
- 锁用来保护一段逻辑,
atomic用来维护一个变量 - 对于非数值操作,可以使用
atomic.Value, 能承载一个inferface{}
不要单纯地追求程序性能, 底层优化手段可能会对程序正确性产生影响
性能调优实战
性能调优简介
- 要依靠数据而不是猜测
- 要定位最大瓶颈而不是细枝末节
- 不要过早优化
- 不要过度优化
性能分析工具 pprof 实战
- 希望知道应用在什么地方耗费了多少CPU、Memory,可以用
pprof
pprof功能简介
pprof排查实战
需要先下载代码
"net/http/pprof"会 自动注册pprof的handler到http server
// main.go
package main
import (
"log"
"net/http"
_ "net/http/pprof" // 自动注册 pprof 的handler 到 http server
"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) // 限制CPU使用数
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的web页面
启动服务器, 并在浏览器中打开http://localhost:6060/debug/pprof/
排查CPU性能瓶颈
- 资源管理器可以看到
CPU占用率在43.4%, 这可太高了
- 使用
pprof命令:go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
- flat 当前函数本身的执行耗时
- flat% flat占CPU总时间的比例
- sum% 上面每一行的
flat%总和 - cum 当前函数本身加上其调用时间的总耗时
- cum% 当前函数本身加上其调用时间的总耗时占CPU总时间的比例
可以看到, tiger.Eat占首位, 需要优化
- Flat == Cum, 函数不调用任何其他函数
- Flat == 0, 函数中只有其他函数的调用
使用list命令, 根据指定的正则表达式查找代码行, 查看代码详情
使用web命令, 调用关系可视化, 也可以看到Eat占用时间最长
将Eat中无意义的循环去除, 重启服务器, 此时CPU占用就正常了
排查内存瓶颈
程序的内存占用仍然很高, 可以用pprof查一下有没有内存方面的问题
- 执行
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap", 这样, 可以在8080端口提供图形化界面, 更方便了
- 图形界面的
View也像终端一样, 提供了一些展示方法, 如top
- 可以看到, 是Mouse.Steal占用了大量内存,在中间的搜索框中输入Steal, 选择Source查看代码,这里不断地对做append消耗内存. 同样地, 注释相关代码后恢复正常
- 程序还有别的内存问题, 可以在页面的Sample页中继续排查, 最后发现, Dog.Run()会申请内存16M内存但是不用, 很快就被释放了.
Goroutine的问题
如此简单的代码, 竟然有106个Goroutine
- 在终端中输入
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine", 分析goroutine, 首页给出的调用图太长了, 不太直观, 可以用火焰图
- Flame graph更加直观一些, 每一段小块代表一个函数, 越长占用的时间越多, 可以看到
Wolf.Drink()占用的时间最长
- 搜索一下
Wolf.Drink(), 开10个协程, 等待30s退出
- 注释掉, 重启, 现在只有 5 个 goroutine 了
同理可以解决锁的问题, 不再赘述
阻塞的解决
有一些简单的过滤条件, 全量数据展示不方便定位问题, 如果想看全部的, 直接在首页点进去看就可以
可以看到, 这个阻塞是和http/server相关的, 无需处理
pprof的采样过程和原理
CPU采样过程
- 采样对象: 函数调用和它们占用的时间
- 采样率: 100次/s, 固定值
- 一共有三个相关角色:进程本身、操作系统和写缓冲。启动采样时,进程向OS注册一个定时器,OS会每隔10ms向进程发送一个SIGPROF信号, 进程接收到信号后就会对当前的调用栈进行记录。 与此同时,进程会启动一个写缓冲的goroutine,它会每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流。 当采样停止时,进程向OS取消定时器,不再接收信号,写缓冲读取不到新的堆栈时,结束输出。
堆内存的采样
- 通过内存分配器在堆上分配和释放的内存,记录分配/释放的大小和数量, 栈上内存没有弄
- 采样率:每分配512KB记录一次,可在运行开头修改,1为每次分配均记录
- 采样时间:从程序运行开始到采样时
- 采样指标:alloc.space,alloc.objects,.inuse_.space,inuse_objects
- 计算方式:inuse=alloc-free
Goroutine的采样
- 记录所有用户发起的且在运行中的Goroutine的runtime.main的调用栈信息
- ThreadCreate记录程序创建的所有系统线程的信息
阻塞
- 采样阻塞操作的次数和耗时(采样争抢锁的次数和耗时)
- 采样率:阻塞耗时超过阈值的才会被记录,1为每次阻塞均记录(固定比例的锁操作, 每次加锁均记录)
性能调优案例
介绍实际业务服务性能优化的案例, 对逻辑相对复杂的程序进行性能调优
1. 业务服务优化
2. 基础库优化
3. Go语言优化
基本概念
- 服务:能单独部署,承载一定功能的程序
- 依赖:Service A的功能实现依赖Service B的响应结果,称为Service A依赖Service B
- 调用链路:能支持一个接口请求的相关服务集合及其相互之间的依赖关系
- 基础库:公共的工具包、中间件
单个服务的优化流程
- 建立服务性能评估指标
- 服务性能评估方式
- 单独Benchmark无法满足复杂逻辑分析
- 不同负载情况下性能表现差异
- 请求流量构造
- 不同请求参数覆盖逻辑不通
- 线上真实流量情况
- 压测 ==> 给出压测报告
- 单机器压测
- 集群压测
- 性能数据采集
- 单机性能数据
- 集群性能数据
- 分析性能数据, 定位性能瓶颈
- 使用库不规范, 序列化, 日志
- 高并发场景优化不足, 不同CPU利用率下的采样数据 可以对比
- 重点优化项改造
- 正确性是基础
- 线上请求数据录制回放, 新旧逻辑接口数据diff
- 优化效果验证
- 重复压测验证
- 上线评估优化效果
- 关注服务监控
- 逐步放量
- 收集性能数据
进一步优化, 服务整体链路分析
- 规范上游服务接口调用, 明确场景需求
- 分析链路, 通过业务流程优化提升服务性能
基础库优化
- 分析基础库的核心逻辑和性能瓶颈
- 设计完善改造方案
- 数据按需获取
- 更高效的数据序列化协议
- 内部压测验证
- 推广业务服务落地验证
Go语言优化
- 编译器&运行时优化
- 优化内存分配策略
- 优化代码编译流程, 生成更高效的程序
- 内部压测验证
- 推广业务服务落地验证
- 优点 3. 接入简单, 只需要调整编译配置 4. 通用性强
总结
今天主要学习了高质量编程的一些原则, pprof工具的使用以及常见的性能调优方法
引用
- 掘金字节内部课:juejin.cn/course/byte…