高质量编程 | 青训营笔记

88 阅读7分钟

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

GO高质量编程

在实际项目开发中还要注重Go语言程序优化(产出简洁的代码)和性能优化。

高质量编程介绍

编写的代码能够达到正确可靠、简洁清晰的目标可称为高质量代码,要求代码考虑完备各种边界条件、异常情况处理得当,确保稳定性、易读易维护。

在实际项目中,复杂的程序逻辑会让人害怕优化和重构,因为难以排查和定位问题并且无法预知调整造成的影响范围。 因此高质量的代码要求保证:

简单性:

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

可读性:

  • 代码是给人看的,而不是机器
  • 编写可维护代码的第一步是确保代码可读性,节约他人阅读你代码的时间

生产力:

  • 编程更多是团队间的合作,为了确保团队工作效率,Go语言通过工具强制统一所有代码格式,并遵循规范。

编码规范

编码规范包括:

  • 代码格式:gofmt自动化格式代码、goimports对依赖包进行管理(Goland自带功能)。
  • 注释:注释应该解释代码作用、代码如何做的、代码实现的原因、代码什么情况会出错。Good code has lots of comments, bad code requires lots of comments. 代码是最好的注释,且应该提供代码未表达出的上下文信息。
  • 命名规范:简洁胜于冗长、缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写(e.g. ServeHTTP instead of ServeHttp, XMLHTTPRequest instead of xmlHTTPRequest)、变量距离其被使用的地方越远,则需要携带越多的上下文信息(全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义)。
  • 控制流程:避免嵌套、保持正常代码路径为最小缩进、处理逻辑尽量走直线,避免复杂嵌套分支、提升代码可维护性和可读性、故障问题大多出现在复杂的条件语句和循环语句。 代码示例: image.png
  • 错误和异常处理:error尽可能提供简明的上下文信息链来定位问题、panic用于真正的异常情况、recover生效范围,只在当前goroutine的被defer的函数中生效。

性能优化建议

在确保正确性、简洁性等质量因素后可进行性能优化;性能优化是综合评估,有时候时间效率和空间效率可能对立(例如多花内存来提高性能),接下来会针对Go语言提出性能优化建议。

Benchmark

Benchmark是Go语言基准性能测试的工具,但是需要实际数据来衡量性能表现。

如何使用:以斐波那契数列为例,分成两个文件,fib.go编写函数代码,fib_test.go编写benchmark的逻辑,通过命令运行benchmark(go test -bench=. -benchmem)可以得到测试结果(-benchmem参数表示统计内存信息)。

运行结果:

image.png

Slice

Slice是Go中最常用的结构,性能优化的建议是预分配,尽可能使用make()来初始化切片并提供容量信息,下图中展现出两种追加切片时的性能表现,可以看出有预分配的slice性能明显优于无分配的slice:

image.png

原理:

Slice本质是一个数组片段的描述,包括数组指针、片段长度、片段容量(不改变内存分配情况下最大长度);slice的操作不会赋值切片指向的元素;创建一个新的切片会复用原来的切片底层数组。

image.png

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

image.png

优化陷阱:

当原切片由大量的元素构成,我们再在元切片的基础上切片,虽然只使用了很小的一段,但是底层数组在内存中占据了大量空间,且得不到释放。

示例:

分别用copy和re-slice来取slice的最后两位数,并同时统计输出内存占用信息。下图可以看到re-slice耗费了100MB,因为申请的100个1MB大小的内存未被回收,虽然只取了最后两个元素,但是与原来的切片引用了相同的底层数组,而且底层数组未得到释放。然而,copy方法只消耗了3MB的内存,因为copy方法指向的是新的底层数组,当原始数组不再被引用后,内存会被垃圾回收。

image.png

image.png

Map

Map也是Go常用结构,也同样有预分配的性能优化点:

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

image.png

字符串处理

Go中常见的字符串拼接方式有:直接'+'号、strings.Builder、bytes.Buffer。

image.png

image.png

对比后发现使用'+'拼接性能最差,Strings.Builder和bytes.Buffer性能相近。因为字符串在Go中不可修改,占用内存大小固定;使用'+'每次会重新分配内存(N个字符串生成一个新字符串会开辟新空间,空间大小是N个字符串大小之和);strings.Builder和bytes.Buffer底层都是[]byte数组;内存扩容策略不需要每次拼接重新分配内存。但strings.builder比bytes.buffer更快一些,是由于bytes.buffer转化为字符串的时候重新申请了一块内存;而strings.builder直接将底层的[]byte转换成了字符串类型返回。

image.png

image.png

字符串拼接和slice一样,都支持预分配,若预知字符串长度,可以进一步提升拼接性能,下图代码显示strings.builder只有一次内存分配,但bytes.buffer有两次。

image.png

空结构体

性能优化包括时间和空间的优化,上述提到的预分配是关于时间的优化,而对于空间的优化方法有使用空结构体节省内存。空结构体struct{}本身不占据内存空间,且本身具备很强的语义,不需要任何值,可以作为各种场景下的占位符使用来节约资源。

示例及对比结果:

image.png

image.png

可以看出使用空结构体的函数占用的内存及各指标均优于使用bool的函数,因为这个场景中只需要用到map的键,而不需要值,所以即使是将map的值设为bool也会多占据一个字节的空间。

atomic包

在多线程编程场景下,这里列举多线程共用计数器,为了保证线程安全,有不同的方式,比如atomic和sync.Mutex (Un)Lock。

image.png 对比后发现,atomic包使用效率优于sync.Mutex。是因为atomic包的锁实现是通过操作系统来实现的,属于系统调用;且atomic操作是通过硬件实现,效率比锁(Lock)高;sync.Mutex适用于保护一段逻辑而不是保护一个变量;对于非数值操作,可以使用atomic.Value,能承载一个interface{}。

总结

提高程序性能需建立在满足正确可靠、简洁清晰的代码基础上;避免常见性能陷阱可以保证大部分程序性能;普通应用代码不用一味追求程序性能;越高级的性能优化越容易产生问题。