高质量编程和性能调优 | 青训营

104 阅读9分钟

高质量编程

高质量编程简介

高质量编程: 编写的代码能够达到正确可靠、简洁清晰、无性能隐患的目标就能称之为高质量代码。

  • 各种边界条件是否考虑完备
  • 异常情况处理,稳定性保证
  • 易读易维护

高质量编程需要注意以下原则:

  • 简单性
    • 消除“多余的复杂性”,以简单清晰的逻辑编写代码
    • 不理解的代码无法修复改进
  • 可读性
    • 代码是写给人看的,而不是机器
    • 编写可维护代码的第一步是确保代码可读
  • 生产力
    • 团队整体工作效率非常重要

编码规范

1. 代码格式

可以使用goland软件自带的自动格式化,保证写的代码与官方推荐格式相同。
Windows系统使用快捷键Ctrl+Alt+L对代码进行格式化。

2. 注释

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

那么都有什么情况下需要注释呢?我觉得就是别人看不懂的地方以及容易产生误解的地方需要加一下注释:

  • 公共符号需要注释
  • 一些实现过程复杂也需要加注释
  • 一些代码表达不出上下文的地方也可以加一些注释帮助理解
  • 一些限制条件也可以加上注释

(说不定自己写的代码过一段时间自己都不知道啥意思,所以写注释还有助于帮助自己理解自己之前写过的代码)

公共符号始终要注释

  • 包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释
  • 任何既不明显也不简短的公共功能必须予以注释
  • 无论长度或复杂程度如何,对库中的任何函数都必须进行注释

3. 命名规范

3.1 变量

  • 简洁胜于冗长
    • 比如大家都用的for循环,能用i就不要用index,效果一样的
    • 但是有特定含义的变量就比较特殊,比如deadline而不用t
  • 缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写
    • 比如使用ServerHTTP而不是ServerHttp
    • 位于开头就都可以,使用XMLHTTPRequest或者xmIHTTPRequest
  • 变量距离其被使用的地方越远,则需要携带越多的上下文信息
    • 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义

3.2 函数

  • 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
  • 函数名尽量简短
  • 当名为 foo 的包某个函数返回类型 Foo 时,可以省略类型信息而不导致歧义
  • 当名为 foo 的包某个函数返回类型 T 时(T 并不是 Foo),可以在函数名中加入类型信息

3.3 包

  • 只由小写字母组成。不包含大写字母和下划线等字符
  • 简短并包含一定的上下文信息。例如 schema、task 等
  • 不要与标准库同名。例如不要使用 sync 或者 strings

尽量满足以下规则:

  • 不使用常用变量名作为包名。例如使用 bufio 而不是 buf。
  • 使用单数而不是复数。例如使用encoding而不是encodings
  • 谨慎地使用缩写。例如使用fmt在不破坏上下文的情况下比 format更加简短

Good naming is like a good joke. If you have to explain it, it's not funny. 好的命名就像一个好笑话。如果你必须解释它,那就不好笑了 —Dave Cheney

4. 控制流程

4.1 避免嵌套,保持正常流程清晰

比如一些else可以删除

4.2 尽量保持正常代码路径为最小缩进

优先处理错误情况/特殊情况,尽早返回或继续循环来减少嵌套

在处理逻辑的时候写代码尽量走直线,避免复杂的嵌套,这样可以提高代码可读性,也方便自己查找问题所在

5. 错误和异常处理

5.1 简单错误

  • 简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误
  • 优先使用errors.New来创建匿名变量来直接表示简单错误
  • 如果有格式化的需求,使用fmt.Errorf

5.2 Wrap和Unwrap

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

5.3 错误判定

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

5.4 panic

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

5.5 recover

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

性能优化

1. 性能优化建议

性能优化前提就是保证代码的正确性,并且完成基本的功能,再想着去优化,正确是优化的基础。

1.1 Benchmark

首先自己编写一个程序,这个函数名字以Benchmark开头,测试代码的文件名字以_test.go结尾,参数为 *testing.B,通过命令行输入命令 go test -bench=. -benchmem可以得到结果如图

func Fib(n int) int {  
if n < 2 {  
return n  
}  
return Fib(n-1) + Fib(n-2)  
}  

func BenchmarkFib(b *testing.B) {  
for n := 0; n < b.N; n++ {  
Fib(10)  
}  
}

benchmarkans.png

在运行过程中出现了报错main redeclared in this block,是因为我把好几个带有package main的文件放在同一个目录下了。

1.2 slice 预分配内存

尽可能在使用make()初始化切片时提供容量信息

使用slice注意可能会有大内存得不到释放的情况,因为slice在已有切片的基础上创建切片的话,那么原数组底层还是在内存存在引用的,所以得不到释放,所以这时候可以用copy代替re-slice

1.3 map 预分配内存

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

1.4 字符串处理

字符串拼接几种方式在性能上的对比:

+=

func Plus(n int, str string) string {  
s := ""  
for i := 0; i < n; i++ {  
s += str  
}  
return s  
}

Plus.png

strings.Builder

func StrBuilder(n int, str string) string {  
var builder strings.Builder  
for i := 0; i < n; i++{  
builder.WriteString(str)  
}  
return builder.String()  
}

StrBuilder.png

bytes.Buffer

func ByteBuffer(n int, str string) string {  
buf := new(bytes.Buffer)  
for i := 0; i < n; i++ {  
buf.WriteString(str)  
}  
return buf.String()  
}

ByteBuffer.png

结论: 使用+拼接性能最差,strings.Builder,bytes.Buffer 相近,strings.Buffer 更快
分析

  • 字符串在Go语言中是不可变类型,占用内存大小是固定的
  • 使用+每次都会重新分配内存
  • strings.Builder, bytes.Buffer底层都是[byte 数组]
  • 内存扩容策略,不需要每次拼接重新分配内存

而后两种方式的区别在哪里呢?
bytes.Buffer 转化为字符串时重新申请了一块空间 strings.Builder 直接将底层的[]byte 转换成了字符串类型返回

1.5 空结构体

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

1.6 atomic

  • 锁的实现是通过操作系统来实现,属于系统调用
  • atomic操作是通过硬件实现,效率比锁高
  • sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
  • 对于非数值操作,可以使用atomic.Value,能承载一个interface{}

2 性能优化工具

2.1 性能调优原则

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

2.2 性能分析工具pprof

性能调试工具,可以生成一个分析图更好观察性能。

2.2.1 功能简介

pprof功能简介.png

2.2.2 排查实战

后面单独写。

2.2.3 采样过程和原理

CPU

  • 采样对象:函数调用和它们占用的时间
  • 采样率:100次/秒,固定值
  • 采样时间:从手动启动到手动结束
  • 操作系统
    • 每10ms向进程发送一次SIGPROF信号
  • 进程
    • 每次接收到SIGPROF会记录调用堆栈
  • 写缓冲
    • 每100ms读取已经记录的调用栈并写入输出流

Heap-堆内存

  • 采样程序通过内存分配器在堆上分配和释放的内存,记录分配/释放的大小和数量
  • 采样率:每分配512KB记录一次,可在运行开头修改,1为每次分配均记录
  • 采样时间:从程序运行开始到采样时
  • 采样指标: alloc_space, alloc_objects,inuse_space,inuse_objects
  • 计算方式: inuse = alloc - free

Goroutine-协程& ThreadCreate-线程创建

  • Goroutine
    • 记录所有用户发起且在运行中的goroutine (即入口非runtime开头的)runtime.main的调用栈信息
  • ThreadCreate
    • 记录程序创建的所有系统线程的信息

Block-阻塞& Mutex-锁

  • 阻塞操作
    • 采样阻塞操作的次数和耗时
    • 采样率:阻塞耗时超过阈值的才会被记录,1为每次阻塞均记录
  • 锁竞争
    • 采样争抢锁的次数和耗时
    • 采样率:只记录固定比例的锁操作,1为每次加锁均记录

2.3 性能调优案例

2.3.1 业务服务优化

优化流程:

  1. 建立服务性能评估手段
  2. 分析性能数据,定位性能瓶颈
  3. 重点优化项改造
  4. 优化效果验证

服务性能评估手段,我们之前用到的benchmark已经不适用于这种复杂逻辑的分析了,并且业务的性能表现在不同负载情况下是不一样的。在请求的构造上不同的请求参数覆盖逻辑也是不同的,以及要考虑的压测机器情况,要测量的是单机的性能还是集群的性能,因此我们需要从多个方面来考虑性能的评估手段。

2.3.2 基础库优化

  • 分析基础库核心逻辑和性能瓶颈
    • 设计完善改造方案
    • 数据按需获取
    • 数据序列化协议优化
  • 内部压测验证
  • 推广业务服务落地验证

2.3.3 Go语言优化

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

性能调优总结

  • 性能调优原则
    • 要依靠数据不是猜测
  • 性能分析工具pprof
    • 熟练使用pprof工具排查性能问题并了解其基本原理
  • 性能调优
    • 保证正确性
    • 定位主要瓶颈