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

66 阅读6分钟

这是我参与「第五届青训营」伴学笔记创作活动的第 3 天

前言

本章主要介绍了高质量编程和代码的性能调优,通过本章,你将理解如何写出更清晰简洁的代码。在工作中,好的代码可以让人更容易理解代码的开发流程,便于在此基础上进行开发,出现问题的概率也比较低,让团队之间的开发效率更高效。在实际工作过程中,如何对代码的性能问题进行优化,也会让代码的编译过程更加的有效率,节省不一必要的资源浪费。那么,接下来就让我们一起去学习吧!

高质量编程

简介

什么是高质量?

即编写的代码能够达到正确可靠、简洁清晰的目标可称为高质量代码。

高质量标准:

  • 正确性:是否考虑各种边界条件,错误的调用是否能够处理。
  • 可靠性:异常情况或者错误的处理策略是否明确,依赖的服务出现异常是否能够处理。
  • 简洁:逻辑是否简单,后续调整功能或新增功能是否能够快速支持。
  • 清晰:其他人在阅读理解代码的时候是否能清楚明白,重构或者修改功能是否不会担心出现无法预料的问题。

编程原则

高质量代码可以应用的领域众多,不局限于环境的限制。即使各个编程语言的特性和语法各不相同,但高质量代码编译规范遵循的原则上是可以互通的。作为程序员在编码上也应遵照这一规范,方便后期开发。

  • 简单性

    • 消除“多余的复杂性”,以简单清晰的逻辑编写代码。
    • 不理解的代码无法修复改进。
  • 可读性

    • 代码是写给人看的,而不是机器。
    • 编写可维护代码的第一步是确保代码可读。
  • 生产力

    • 团队整体工作效率非常重要。

编码规范

如何编写高质量的Go代码?

  • 代码格式

    • 推荐使用gofmt自动格式化代码
    • gofmt:Go语言官方提供的工具,能自动化格式化Go语言代码为官方统一风格,Goland内置其功能,直接开启即可在保存文件时自动格式化。
    • goimports:也是Go语言官方提供的工具,实际等于gofmt加上依赖包管理,自动增删依赖的包引用、将依赖包按字母排序并分类。
  • 注释 --->Good code has lots of comments,bad code requires lost of comments 好的代码有很多注释,坏的代码需要很多注释。

    • 注释应该解释代码作用
    • 注释应该解释代码如何做的
    • 注释应该解释代码实现的原因
    • 注释应该解释代码什么情况会出错
    • 公共符号始终要注释
  • 命名规范

    • 简介胜于冗长。

    • 缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写。

      • 使用ServeHTTP不是ServeHttp
      • 使用XMLHTTPRequest或者xmlHTTPRequest
    • 变量距离其被使用的地方越远,则需要携带越多的上下文信息

      • 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义。
    • 函数名不携带包名上下文信息,尽量简短。

    • package只由小写字母组成,不包含大写字母和下划线等字符;尽量简短并包含一些上下文信息;不与标准库同名。

  • 控制流程

    • 避免嵌套,保持正常流程清晰(如两个分支都包含return语句,则可以去除冗余的else)。
    • 尽量保持正常代码路径为最小缩进,减少嵌套
  • 错误和异常处理

    • 简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误。

    • 优先使用erros.New来创建匿名变量来直接表示简单错误。

    • 如果有格式化的需求,使用fmt.Errof

    请看以下例子,

    •       
      func defaultCheckRedirect(req *Request, via []*Request)error{
          if len(via) >= 10 {
              // 使用errors.New创建匿名函数来表示错误
              return errors.New("stopped after 10 redirects.")
          }
              return nil  // 返回空值
          }
      
    • 错误的WrapUnwrap

      • 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的跟踪链。
      • fmt.Errorf中使用%w关键字来将一个错误关联至错误链中。
      • 看以下例子,
      •    list, _, err := c.GetBytes(cache.Subkey(a.actionID,"srcfiles"))
               if err != nil {
               return fmt.Errorf("reading srcfiles list: %w", err)
           }
        
    • 错误判定

      • 判定一个错误是否为特定错误,使用erros.ls
      • 不同意使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误。
      •   data, err = lockedfile.Read(targ)
              if errors.Is(err, fs.ErrNotExist) {
              return []byte{}, nil
          }
          return data, err
        
      • 在错误链上获取特定种类的错误,使用erros.As。
      •  if _, err := os.Open("non-existing"); err != nil {
             var pathError *fs.PathError
             if errors.As(err, &pathError) {
                 fmt.Println("Failed at path:", pathError.Path)
             } else {
                 fmt.Println(err)
             }
         }
        
    • panic

      • 不建议在业务代码中使用panic,它出现表示程序无法正常工作了
      • 若问题可以解决或屏蔽,建议使用error代替panic
      • 当程序启动阶段发生不可逆转的错误时,可以在initmain函数中使用panic
    • recover

      生效条件:

      • recover只能在defer(注意:defer的语句是后进先出的)函数中使用
      • 嵌套无法生效
      • 只在当前goroutine生效
      • 如果需要更多的上下文信息,可以recover后在log中记录当前调用栈

性能优化建议

简介

  • 性能优化的前提是满足正确可靠、简洁清晰的质量因素。
  • 性能优化是综合评估,有时候时间效率和空间效率可能对立。

1.Benchmark基准测试

  • 性能表现需要实际数据衡量,Go语言提供了支持基准性能测试的benchmark工具,使用以下命令go test -bench=. -benchmen测试

// from fib.go
func Fib(n int) int {
    if n < 2 {
        return n
    }
    return Fib(n - 1) + Fib(n - 2)
}

// from fib_test.go
func BenchmarkFib10(b *testing.B) {
    // run the Fib funciton b.N times
    for n := 0; n < b.N; n++ {
        Fib(10
    }
}

运行结果如下:

image.png

2.slice预分配内存

  • 尽可能在使用make()初始化切片时提供容量信息。看下图,可以很清晰地观察到右边代码设置了容量大小,比左边代码的执行时间快了近3倍。

image.png 那么为什么有这么大的性能差异?原因就是

  • 切片本质上是一个数组片段的描述
    • 包括数组指针
    • 片段长度
    • 片段的容量(不改变内存分配情况下的最大长度)
  • 切片操作并不复制切片指向的元素
  • 创建一个新的切片会复制原来切片的底层数组

看以下图,当append之后的长度小于等于cap,将会直接利用原底层数组剩余的空间。当append大于cap,则会分配一块更大的区域来容纳新的底层数组。因此,为避免内存发生拷贝,如果知道最终切片的大小,预先设置cap的值能够避免额外的内存分配,获得更好的性能。

image.png

slice的另一陷阱:大内存未释放

  • 在已有基础上切片,不会创建新的底层数组
    • 原切片较大,代码在原切片基础上新建小切片。
    • 原底层数组在内存中引用,得不到释放。
  • 用copy替代re-slice,通过copy指向新的底层数组,origin不再引用,内存会被回收。

image.png

运行结果:

image.png

3.map预分配内存

  • 不断向map中添加元素的操作会触发map的扩容
  • 提前分配好空间可以减少内存拷贝和Rehash的消耗
  • 根据实际需要提前预估好需要的空间

image.png

4.使用stirng.Builder

image.png

image.png

  • 通过以上图片显示的三种字符串拼接方式比对,使用+拼接性能最差,string.Builder,bytes.Buffer相近,strings.Buffer更快一点。原因如下
    • 字符串在Go语言中是不可逆类型,占用内存大小是固定的。
    • 使用+每次都会重新分配内存。
    • strings.BUidler,bytes.Buffer底层都是[]byte数组 。
    • 内存扩容策略,不需要每次拼接重新分配内存。 那么为什么建议使用的是strings.Builder,其实string.Builder,bytes.Buffer相差不大。看下面例子,注意strings.Builder只有一次内存分配,而bytes.Buffer有两次

image.png

5.空结构体

  • 使用空结构体节省内存
    • 空结构体struct{}实例不占据任何的内存空间
    • 可作为各种场景下的占位符使用
      • 节省资源
      • 空结构体本身具备很强的语义,即不需要任何值,仅作为占位符

可以通过以下例子,自行比较

image.png

使用atomic包

go中在并发情况下必用到的包,可以基于原子性对数值进行操作,所以经常用来加减锁操作。

image.png

  • 锁的实现通过操作系统来实现,属于系统调用
  • atomic操作是通过硬件实现,效率较高

性能调优

简介

性能调优原则

  • 要依靠数据不是猜测
  • 要定位最大瓶颈而不是细枝末节
  • 不要过早优化和过度优化

性能分析工具pprof

  • 说明

    • 希望知道应用在声明什么地方耗费了多少CPU、Memory
    • pprof是用于可视化和分析性能分析数据的工具
  • pprof功能简介

image.png

  • pprof排查实战
    • 搭建pprof时间项目
    • 可通过git来下载https://github.com/wolfogre/go-pprof-practice
    • 运行http://localhost:6060/debug/pprof打开页面,查看数据

image.png 采样数据说明:

  • allocs:内存分配情况
  • blocks:阻塞操作情况
  • cmdline:程序启动命令及
  • goroutine:当前所有goroutine的堆栈信息
  • heap:堆上内存使用情况(同alloc)
  • mutex:锁竞争操作情况
  • profile: CPU占用情况
  • threadcreate:当前所有创建的系统线程的堆栈信息
  • trace:程序运行跟踪信息

cmd终端中输入命令go tool pprof + <采样链接>来启动采样, 例如:go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"

image.png

输入命令topN,查看占用资源最多的函数

image.png

函数意义如下

image.png

输入命令list Eat,根据指定的正则表达式查找代码行

image.png

输入命令web,生成一张调试图,默认使用浏览器打开。

image.png

能很明显看到一个红色大方框,输出q退出终端,把问题代码*Tiger.Eat函数注释掉后,打开活动监视器,可以发现CPU的进程已经降下来。

image.png

pprof采样过程和原理

CPU:

image.png

image.png

Heap:

image.png

Goroutine-协程 & ThreadCreate-线程创建:

image.png

Block-阻塞 & Mutex-锁:

image.png

引用

  • 掘金字节内部课-Go高质量编程及性能调优