Go高质量编程 | 青训营笔记

101 阅读15分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 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 时,就会出现如下的内容

image.png

点进去我们发现都是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方法具体的代码执行 image.png 在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的调优,优点为

  • 可以进行内存设置
  • 可以进行编译优化
  • 接入简单,只需要调整命令行参数/环境变量,通用性高