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

56 阅读9分钟

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

一、本堂课重点内容

高质量编程

  • 高质量编程简介
  • 编码规范
  • 性能优化建议

性能调优实战

  • 性能调优简介
  • 性能分析工具pprof实战
  • 性能调优案例

二、详细知识点介绍

高质量编程

简介

高质量的概念其实偏主观,一般来说,满足以下四点的可认为是高质量代码:

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

编码原则

简单性

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

可读性

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

生产力

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

——Go语言开发者 Dave Cheney

编码规范

对于编码规范,Google和大规模采用Go的公司都有开源的编码规范文档。

下文将从以下五个部分进行介绍:

  • 代码格式
  • 注释
  • 命名规范
  • 控制流程
  • 错误和异常处理

代码格式

Go 语言官方提供了两种用于格式化的工具:

  • gofmt

可以自动格式化代码为官方统一风格,常见的IDE都支持配置

  • goimports

可以格式化以外还可以提供依赖包的管理,自动增删依赖的包引用、将依赖包按字母序排序并分类

注释

Good code has lots of comments, bad code requires lots of comments.

好的代码有很多注释,坏代码需要很多注释。

——Dave Thomas and Andrew Hunt

注释应该起到以下作用:

  • 解释代码作用

对于对外提供的函数,应该使用注释描述功能和用途,另外对于一些功能简单明显的省略注释。

  • 解释代码如何做

对于代码中逻辑复杂的,逻辑不明显的应该使用注释来进行说明实现过程。

  • 解释代码实现的原因

对于脱离上下文的代码,通常很难理解,因此应该使用注释来解释为什么这么做。

  • 解释代码什么情况下会出错

对于一些条件有限制的代码,应该使用注释来解释当输入非法时,该怎么处理。

另外,对于公共符号始终要注释,这里的公共符号包括变量、常量、函数以及结构体,注意实现接口的方法除外。

最后要牢记两点:

  • 代码是最好的注释
  • 注释应该提供代码未表达出来的信息

命名规范

  • 简洁胜于冗长
  • 缩略词全大写,除非位于开头且无需导出时,全小写
  • 变量距离被使用的地方越远,则需要携带越多的上下文信息

命名规范的核心目标是降低阅读理解代码的成本,应该重点考虑上下文信息,设计简洁清晰的名称。

Good naming is like a good joke. If you have to explain it, it's not funny.

好的命名就像一个好笑话。如果必须解释它,那就不好笑了。

——Dave Cheney

控制流程

  1. 避免嵌套,保持正常流程清晰
// Bad code
if foo {
    return x
} else {
    return nil
}

// Nice code
if foo {
    return x
}
return nil
  1. 尽量保持正常代码路径为最小缩进

优先处理错误,尽早返回。

// Bad code
func OneFunc() error {
    err := doSomething()
    if err == nil {
        err := doAnotherThing()
        if err == nil {
            return nil
        }
        return err
    }
}

// Nice code
func OneFunc() error {
    if err := doSomething(); err != nil {
        return err
    }
    if err := doAnotherThing(); err != nil {
        return err
    }
    return nil
}

小结

  • 线性原理,处理逻辑尽量走直线,避免复杂的嵌套分支
  • 正常流程代码沿着屏幕向下移动
  • 提升代码可维护性和可读性
  • 故障问题大多出现在复杂的条件语句和循环语句中

错误和异常处理

对于简单错误(仅出现一次的错误,且在其他地方不需要捕获该错误)

  • 优先使用errors.New来创建匿名变量来直接表示简单错误
  • 如有格式化需求,使用fmt.Errorf

错误的Wrap和Unwrap

  • 错误的Wrap实际上是提供一个error嵌套另一个error的能力,从而生成一个error的跟踪链
  • fmt.Errorf中使用%w关键字来将一个错误关联至错误链

错误判定

  • 判断一个错误是否为特定错误使用errors.Is
  • 不同于==,使用该方法可以判断错误链上是否含有该错误
  • 在错误链上获取特定种类的错误,使用errors.As

panic

  • 不建议业务中使用
  • 调用函数不包含recover会造成程序崩溃
  • 若问题可以被屏蔽或解决,建议使用error代替panic
  • 当程序启动阶段发生不可逆转的错误时,可以在initmain函数中使用panic

recover

  • 只能在defer中使用
  • 嵌套不生效
  • 只对当前goroutine有效
  • defer后入先出

小结

  • error尽可能提供简明的上下文信息链,方便定位问题
  • panic用于真正异常的情况
  • recover生效范围,在当前goroutine的被defer函数中生效

性能优化建议

测试工具

  • benchmark

使用方法go test -bench=. -benchmem

以下是优化建议:

  1. slice预分配内存(减少扩容次数)
  2. 在原切片基础上切片时,新切片会占用原始切片的底层数组,导致无法释放引用,使用make先新建切片,copy来将旧切片切片拷贝到新的切片上,这样就不会引用原来的底层数组,也就可以让底层数组释放。
  3. map预分配内存(减少Rehash次数)
  4. 使用strings.Builder来拼接字符串,对于拼接操作,bytes.Bufferstrings.Builder相近,使用+最慢。因为在Go语言中,字符串是不可变类型,占用的内存大小是固定的,每次使用+都会重新分配内存。而前两者底层都是[]byte,因此不需要每次拼接都重新分配内存(扩容除外)。而strings.Builder更快的原因是因为其直接将底层[]byte转为字符串类型返回(先转为*string,然后再使用*将其变为string类型),而bytes.Buffer转为字符串时申请了一块空间。
  5. 使用空结构体节省内存。因为空结构体struct{}实例不占据任何的内存空间,因此也可以作为占位符使用。实现Set时,可以使用map+空结构体。
  6. 使用atomic包来保证计数器的准确。与Mutex相比:
Mutexatomic
实现方法操作系统硬件
保护对象一段逻辑一个变量
特殊/非数值使用atomic.Value

性能调优实战

性能调优简介

性能调优原则

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

性能分析工具pprof实战

pprof是用于可视化、分析性能和分析数据的工具。

接下来介绍pprof采样的过程和原理

1. CPU

graph LR
开始采样 --> 设定信号处理函数
设定信号处理函数 --> 开启定时器
停止采样 --> 取消信号处理函数
取消信号处理函数 --> 关闭定时器

对于CPU采样,其会记录所有的调用栈和它们的占用时间。在采样时,进程每秒暂停100次,每次会记录当前的调用栈信息。汇总之后,根据调用栈在采样中出现的次数来推断运行的时间,其依赖信号实现。

启动采样时,进程向OS注册一个定时器,OS每隔10ms向进程发送一个SIGPROF信号,进程收到后就对当前调用栈进行记录。与此同时,进程同时会启动一个写缓存的goroutine,它每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流,当停止采样时,进程向OS取消定时器,不再接收信号,写缓冲读取不到新堆栈信息时就结束输出。

2. Heap内存

之所以写的是“Heap内存”而不是“内存”,是因为内存采样时是依赖内存分配器的记录,所以只能记录堆上分配,且会参与GC的内存,一些其他的内存分配,例如调用就结束就会回收的栈内存、一些更底层使用cgo调用分配的内存,是不会被内存采样记录的。其采样率默认512KB采样一次,其为一个持续的过程,记录从程序运行起的所有分配或释放的内存大小和对象数量,并在采样时汇总。

3. Goroutine

graph LR
Stop-The-World --> 遍历allg切片
遍历allg切片 --> 输出创建g的堆栈
输出创建g的堆栈 --> Start-The-World

也就是在使用“咋瓦鲁多”后,遍历所有goroutine的列表并输出堆栈,最后恢复。注意这个采样是立刻触发的,记录所有goroutine的。

4. Block

graph TD
阻塞操作 --> |上报调用栈和消耗时间|Profiler
Profiler --> |采样|遍历阻塞记录
遍历阻塞记录 --> 统计阻塞次数和耗时
Profiler --> 时间未到阈值则丢弃

该采样只有当达到一个阈值时才会被记录,也就是阻塞时间达到一定时间就会进行采样,采样的内容是当时对应操作发生的调用者、次数和耗时。

三、实践练习例子

具体实践可以参考此文章golang pprof实战

四、课后个人总结

通过此次课程,我学会了不少有关Golang的高质量编程方法与性能调优工具以及技巧,对于高质量编程,最为重要的就是保证正确可靠简洁清晰。性能优化方面学习到了不少提高性能的方法,编写程序不是写好逻辑就算完成,写出高性能的代码是非常重要的。对于性能调优,pprof工具真的是非常强大,不仅能够控制台调试,还能够提供ui界面供查看。总的来说,这次课程收获不小。

五、引用参考