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

77 阅读3分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 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浏览器查看

image-20230116203501846

拉取代码运行后,可以在浏览器打开xxxx:6060/debuf/pprof (我是vscode + remote),如果是本机的话localhost就行

image-20230116204110369

运行程序后,在终端查看

命令行工具

输入查看CPU运行情况

 go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"

image-20230116204611027

结束之后,输入top命令展示:

image-20230116204658813

  • flat:当前函数本身的执行耗时
  • flat%:flat占CPU总时间的比例
  • sum%:上面每一行的flat%总和
  • cum:当前函数本身加上其调用函数的总耗时
  • cum%:cum占CPU总时间的比例

Flat == Cum,函数没有调用其他函数

Flat == 0,函数中只有其他函数调用

list命令根据正则表达式查找代码行

image-20230116205241493

web命令调用关系可视化

可视化工具

在使用了上述命令行工具后,在$HOME/路径下会生成pprof文件夹,里面存放着信息:

image-20230117101704895

image-20230117101755755

然后进入这个文件夹,在终端输入:

go tool pprof -http=:8080 文件名

就会在浏览器生成可视化工具

top:

image-20230116212020437

Source:

image-20230116212108037

火焰图

需要安装PProf原生工具

go install github.com/google/pprof@latest

然后像上面一样,在$HOME/用户名/pprof目录下执行

pprof -http=:8080 文件名

会在浏览器打开

image-20230117102617066

性能调优案例

可能需要优化的服务:

  • 业务服务优化
  • 基础库优化
  • Go语言优化

业务服务优化

服务:能单独部署,承载一定功能的程序

依赖:Service A的功能实现依赖Service B的响应结果,称为Service A依赖Service B

调用链路:能支持一个接口请求的相关服务的集合及其相互之间的依赖关系

基础库:公共的工具包、中间件

流程:

  1. 建议服务性能评估手段

    • 服务性能评估:单独benchmark无法满腹复杂的业务逻辑、不同负载情况下性能表现差异
    • 请求流量构造:不同请求参数覆盖逻辑不同、线上真实流量情况
    • 压测范围:单机、集群
    • 性能数据采集:单机、集群
  2. 分析业务数据,定位性能瓶颈

    • 使用库不规范
    • 高并发场景优化不足
  3. 终点优化项改造

    • 正确性是基础
    • 响应数据diff:线上请求数据录制回放、新旧接口逻辑数据diff
  4. 效果优化验证

    • 重复压测验证
    • 上线评估优化效果

基础库优化

  1. 分析基础库核心逻辑和性能瓶颈

    • 设计完善改造方案
    • 数据按需获取
    • 数据序列化协议优化
  2. 内部压测验证

  3. 推广业务服务落地验证

Go语言优化

编译器&运行时优化

  1. 优化内存分配策略
  2. 优化代码编译流程,生成更高效的程序
  3. 内部压测验证
  4. 推广业务服务落地验证

优点:

  • 接入简单
  • 通用性强

Go内存管理

自动内存管理

相关概念:

Mutator:业务线程,分配新对象,修改对象指向关系

Collector:GC线程,找到存活对象,回收死亡对象的内存空间

Serial GC:只有一个collector

Paraller GC:支持多个collectors同时回收的GC算法

Concurrent GC:mutator(s)和collector(s)可以同时执行,必须要感知对象指向关系的改变

image-20230117113842851

评价GC算法:

  • 安全性(基本要求),如上图的b对象必须标记
  • 吞吐率(花在GC的时间):1- GC时间/程序执行总时间
  • 暂停时间(业务是否感知)
  • 内存开销(GC元数据开销)

追踪垃圾回收

对象被回收的条件:指针指向关系不可达的对象

image-20230117114617936

  • 标记根对象:静态变量、全局变量、常量、线程栈等

  • 标记:找到可达对象。求指针指向关系的传递闭包:从根对象触发,找到所有可达对象

  • 清理:所有不可达对象。三个策略:

    • 将所有存活对象复制到另外的内存空间copying collection

    • image-20230117114822181

    • 将死亡对象的内存标记为”可分配“mark-sweep collection

      image-20230117114802769

    • 移动并整理存活对象mark-compact collection

      image-20230117114906706

分代GC

分代假说:很多对象在分配出来后很快就不再使用了

每个对象都有年龄:经历过GC的次数

目的:对年轻和老年的对象,制定不同的GC策略,降低整体内存管理的开销

不同的年龄对象处于heap的不同区域

  • 年轻代:常规的对象分配,由于存活对象很少,可以采用copying collection
  • 老年代:对象趋于一直活着,反复复制开销很大,可以采用mark-sweep collection

引用计数

每个对象都有一个与之关联的引用数目

对象存活的条件:当且仅当引用数大于0

image-20230117115642954

优点:

  • 内存管理的操作被平摊到程序执行过程中
  • 内存管理不需要了解runtime的实现细节

缺点:

  • 维护开销大,通过原子操作才能保证对引用计数操作的原子性和可见性

  • 无法回收环形数据结构——weak reference

    image-20230117120124833

  • 内存开销:每个对象都引入的额外内存空间存储引用数目

  • 回收内存时,依然可能引发暂停

Go内存分配

分块

目标:为对象在heap上分配内存

  • 调用系统调用mmap() 向OS申请一大块内存,例如4MB
  • 先将内存划分为大块,例如8KB,乘坐msapn
  • 再将大块分为特定大小的小块,用于对象分配
  • noscan mspan:分配不含指针的独享
  • scan msapn:分配包含指针的对象

对象分配:根据对象的大小,选择最合适的块返回

image-20230117121118392

缓存

借鉴TCMalloc:thread caching

image-20230117121202524

每个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

image-20230117125955642

Bump pointer(指针碰撞)风格对象分配

  • 无须和其他分配请求互斥
  • 分配动作简单高效
if top + size <= end {
	addr := top
	top += size
	return addr
}

一个GAB对于Go内存管理来说是一个大对象,本质上是将多个小对象的分配合并成一次大对象的分配,但是GAB的对象分配方式会导致内存被延迟释放。

解决办法:移动GAB中的存活的对象,本质上用copying GC的算法管理小对象

image-20230117131542015

编译器和静态分析

编译器:

image-20230117131915331

静态分析:不执行程序代码,推导程序的行为,分析程序的性质

  • 控制流:程序执行的流程

    image-20230117132240316

  • 数据流:数据在控制流上的传递

通过分析控制流和数据流,可以知道更多关于程序的性质

过程内的分析:仅在函数内部进行分析

过程间分析:考虑函数调用时参数传递和返回值的数据流和控制流

Go编译器优化

函数内联

将被调用函数的函数体callee的副本替换到调用位置caller上,同时重写代码以反映参数的绑定

优点:

  • 消除函数调用开销,例如传递参数、保存寄存器等
  • 将过程间分析转化为过程内分析,帮助其他优化,如逃逸分析

逃逸分析

分析代码中指针的动态作用域:指针在何处可以被访问

大致思路:

  • 从对像分配处出发,沿着控制流,观察对象的数据流

  • 若发现指针p在当前作用域s:

    • 作为参数传递给其他函数
    • 传递给全局变量
    • 传递给其他的goroutine
    • 传递给已逃逸的指针指向的对象
  • 则指针p指向的对象逃逸出s,反之则没有逃逸出s

Beast Mode

Go函数内联受到的限制较多

  • 语言特性,interface,defer等限制了函数内联
  • 内联策略十分保守

调整函数内联的策略,使更多的函数被内联。函数内联拓展了函数边界,更多对象不逃逸