【Go】高质量编程与性能调优案例

420 阅读17分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第20天,点击查看活动详情

1.高质量编程

1.1 简介

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

编程原则

  • 简单性
    消除"多余的复杂性”,以简单清晰的逻辑编写代码
    在实际工程项目中,复杂的程序逻辑会让人害怕重构和优化,因为无法明确预知调整造成的影响范围难以理解的逻辑,排查问题时也难以定位,不知道如何修复

  • 可读性
    可读性很重要,因为代码是写给人看的,而不是机器

  • 生产力
    团队整体的工作效率非常重要
    为了降低新成员上手项目代码的成本,Go语言甚至通过工具强制统一所有代码格式
    编码在整个项目开发链路中的一个节点,遵循规范,避免常见缺陷的代码能够降低后续联调、测试、验证、上线等各个节点的出现问题的概率,就算出现问题也能快速排查定位

1.2 编码规范

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

1.2.1 代码格式

image.png

1.2.2 代码注释

  • 注释应该解释代码作用
  • 注释应该解释代码如何做的
  • 注释应该解释代码实现的原因
  • 注释应该解释代码什么情况会出错

解释作用

这种注释适合说明公共符号,比如对外提供的函数注释描述它的功能和用途
只有在函数的功能简单而明显时能省略这些注释(例如:简单的取值和设值函数)
另外注释要避免啰嗦,不要对显而易见的内容进行说明.
下面的代码中注释就没有必要加上,通过名称可以很容易的知道作用

image.png

解释代码如何做

第二种注释是对代码中复杂的,并不明显的逻辑进行说明,适合注释实现过程 上面这段代码是给新url加上最近的referer信息,并不是特别明显,所以注释说明了一下

image.png

第三条,注释可以解释代码的外部因素,这些因素脱离上下文后通常很难理解 示例中有一行shouldRedirect=false的语句,如果没有注释,无法清楚地明白为什么会设置false所以注释里提到了这么做的原因,给出了上下文说明

image.png 第四,注释应该提醒使用者一些潜在的限制条件或者会无法处理的情况 例如函数的注释中可以说明是否存在性能隐患,输入的限制条件,可能存在哪些错误情况,让使用者无需了解实现细节示例介绍了解析时区字符串的流程,同时对可能遇到的不规范字符串处理进行了说明

image.png

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

image.png

不用注释实现接口的方法,(即对函数抽象的实现)如下面可以删除

image.png

1.2.3 命名规范

变量

·简洁胜于冗长
·缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写
·例如使用ServeHTTP而不是ServeHttp
·使用XMLHTTPRequest或者xmlHTTPRequest
·变量距离其被使用的地方越远,则需要携带越多的上下文信息
·全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义

函数签名的变量

函数提供给外部调用时,签名的信息很重要,要将自己的功能准确表现出来,自动提示一般也会提示函数的方法签名,通过参数名更好的理解功能很有必要,节省时间

函数

·函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的函数名尽量简短
·当名为foo的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义
·当名为foo的包某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息
http包中创建服务的函数如何命名更好?
func Serve(l net.Listener,handler Handler) error func ServeHTTP(I net.Listener, handler Handler) error 第一个,因为调用时往往HTTP.Serve

package

只由小写字母组成。不包含大写字母和下划线等字符·简短并包含一定的上下文信息。例如schema. task 等·不要与标准库同名。例如不要使用sync或者strings
以下规则尽量满足,以标准库包名为例

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

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

1.2.4 控制流程

首先是避免嵌套 从最简单的一个if else条件开始,如果两个分支都包含return语句,则可以去除冗余的else方便后续维护,else一般是正常流程,如过需要在正常流程新增判断逻辑,避免分支嵌套

image.png

image.png

初步阅读代码时可以先忽略每一步的err情况,对整体流程有更清晰的了解如果后续想排查问题可以针对具体某一步的错误详细分析 如果后续正常流程新增操作,可以放心地在函数中添加新的代码 右面是go仓库中的代码示例,也是优先处理err情况,保持正常流程的统一 image.png

线性原理(参考资料里有详细说明),处理逻辑尽量走直线,避免复杂的嵌套分支 Go语言代码不是成功的路径越来越深地嵌套到右边,而是随着函数的执行,正常流程代码会沿着屏幕向下移动 一个功能如果可以通过多个功能的线性结合来实现,那它的结构就会非常简单,反过来,用条件分支控制/代码、毫无章法地增加吠态数等行为会让卡形变得难火理解。需要避总这些行为,提高保码的可读性.如果能让正常流程自上而下、简单清晰地进行处理,代码的可读性就会大幅提高,与此同时,可维护性也将提高,添加功能等改良工作将变得更喀易 故障问题大多出现在复杂的条件语句和循环语句中,在维护这种逻辑时,添加功能会变成高风险的操作,很容易遗漏部分条件导致问题

1.2.5 错误和异常处理

简单错误

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

image.png

错误的Wrap和Unwrap

错误的包装提供了一个error嵌套另一个error的能力,生成一个error的跟踪链,同时结合错误的判定方法来确认调用链中是否有关注的错误出现。这个能力的好处是每一层调用方可以补充自己对应的上下文,方便跟踪排查问题,确定问题的根本原因在哪里

在fmt.Errorf中使用:%w关键字来将一个错误wrap至其错误链中

注意:Go1.13在errors中新增了三个新API和一个新的format关键字,分别是errors.ls errors.As, errors.Unwrap以及fmt.Errorf 的%w。如果项目运行在小于Go1.13 的版本中,导入 golang.org/x/xerrors来使用

image.png

错误判定
·判定一个错误是否为特定错误,使用errors.ls
·不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误

image.png 在错误链上获取特定种类的错误,使用errors.As 它和is的区别在于as会提取出调用链中指定类型的错误,并将错误赋值给定义好的变量,方便后续处理,示例中是把问题的path打印出来了

image.png

在Go中,比错误更严重的就是panic,它的出现表示程序无法正常工作了,使用时应该注意:
不建议在业务代码中使用panic。因为panic发生后,会向上传播至调用栈顶,如果当前goroutine中所有defered函数都不包含recover就会造成整个程序崩溃。若问题可以被屏蔽或解决,建议使用error代替panic。
特殊地,当程序启动阶段发生不可逆转的错误时,可以在init或main函数中使用panic。因为在这种情况下,服务启动起来也不会有意义比如示例是启动消息队列监听器的逻辑,在创建消费组失败的时候会Panicf,实际打印日志,然后抛出panic

image.png

recover

  • 只能在被defer的函数中使用
  • 嵌套无法生效
  • 只在当前goroutine生效
  • defer 的语句是后进先出

image.png

1.3 性能优化

简介

  • 性能优化的前提是满足正确可靠、简洁清晰等质量因素
  • 性能优化是综合评估,有时候时间效率和空间效率可能对立
  • 针对Go 语言特性,介绍Go相关的性能优化建议

1.3.1 性能优化建议-Benchmark

Go语言提供了支持基准性能测试的benchmark 工具
运行的命令:

go test -bench=. -benchmen

以计算斐波拉契数列的函数为例,分两个文件,fi.go编写函数代码fib_testgo编写benchmark的逻辑,通过命令运行benchmark可以得到测试结果-benchmem表示也统计内存信息

image.png

image.png

1.3.2 性能优化建议-关于Slice切片

  • 预分配内存 尽可能在使用make()初始化切片时提供容量信息,预分配只有一次内存分配

image.png 原理解释:
切片本质是一个数组片段的描述包括数组指针、片段的长度、片段的容量(不改变内存分配情况下的最大长度)切片操作并不复制切片指向的元素
创建一个新的切片会复用原来切片的底层数组以切片的append为例, append时有两种场景:

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

陷阱:大内存未释放 ·在已有切片基础上创建切片,会引用原来的底层数组,不会创建新的底层数组 场景:

  • 原切片较大,代码在原切片基础上新建小切片
  • 原底层数组在内存中有引用,得不到释放可使用copy替代re-slice通过copy,指向了一个新的底层数组,当origin不再被引用后,内存会被垃圾回收

image.png

1.3.4 性能优化-关于字符串

有三种字符串拼接方式如下图

  • 符号+

image.png

  • 方法string.Builder

image.png

  • 方法bytes.Buffer

image.png

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

bytes.Buffer转化为字符串时重新申请了一块空间
strings.Builder直接将底层的byte转换成了字符串类型返回

2.性能调优

2.1 原则

  • 要依靠数据不是猜测。预期可能不一样,要有一套标准
  • 要定位最大瓶颈而不是细枝末节
  • 不要过早优化。因为产品在不断迭代
  • 不要过度优化

2.2 性能分析工具pprof

说明
希望知道应用在什么地方耗费了多少CPU、Memory· pprof是用于可视化和分析性能分析数据的工具
pprof功能简介
pprof 排查实战pprof的采样过程和原理

image.png

3. 课后作业

3.1 Go编码规范

C++的变量名提倡用一律小写加下划线格式,函数名一般每个单词都大写,与golang命名规范有所不同,golang基本都用驼峰法,且会被外部调用的首字母大写此外C++的大括号可以在任何地方,因为C++编译器不是按照行来解析代码的,而golang必须将左括号写在分支判断、函数名或循环头的同一行。C++和golang乃至其他众多编程语言,都提倡把注释写清楚、不冗杂,C++常在重要函数或重要类前面用/**@param **/写清楚各个需要使用者明白的细则,并且也有单行注释//,和golang相似。

3.2 代码自动化检测

C++编写中可以要求必须遵守[Google C++ Style Guide](google.github.io),可以用clang-format工具,在构建项目时使用make format自动格式化代码,使用make check-lint、make check-clang-tidy等来检查代码是否符合Google C++ Style Guide。golang中最常用的就是go fmt和go import工具进行代码自动检测,例如每次显式保存后go import工具都会将冗杂的未使用module从import命令中删除。

3.3 sync包

在sync包下,有多个用于并发编程的并发工具类
①Mutex:互斥锁,在同一时间内只能有一个goroutine抢到锁,其他goroutine全部阻塞等待
②RWMutex:读写锁,一共有两把锁,分别是读锁和写锁,实现读写互斥,写写互斥,读读同时。
③WaitGroup:并发等待,使用它的goroutine必须等待直到所有预设的goroutine都执行结束了才能继续往下执行,防止主goroutine提前结束。
④Map:并发安全的map
⑤cond:多个goroutine等待或接受通知的集合地。
⑥once:只有一个Do函数,且只会执行一次,如果一个函数里面的一部分代码,希望在很多协程都执行的时候,只被执行一次,那么Once便起到了作用
⑦pool:临时对象池,用于存储和取出一些临时对象,是并发安全的
⑧runtime:对goroutine做操作

3.4 性能陷阱和优化手段

在使用 channel 进行通讯时,要注意有写入和有读取,而且写入完之后要记得将 channel 关掉,不然在读取的协程会一直被阻塞,若读写双方都在等待就会造成死锁 在利用协程对某些算法进行并发改进时(如分治算法),如果是每一次分治都新开一个协程,可能会导致算法的性能浪费在开协程,写入 channel 和读取 channel 上,反而降低了效率。应该是在第一次分治时确定好要使用的协程数,进行并发求解 在并发编程中,有时候会对某一变量执行写操作,这是就会涉及加锁,除了最常见的使用读写锁或互斥锁来实现写操作之外,还可与使用atomic包中的方法来达到并发安全的问题,相比手动加锁性能提升了,但是atomic包中只能提供对变量的写操作的并发安全按,这时候我们如果需要在访问临界区加上其他操作的话就不能使用atomic的方法,而是需要手动加锁,所以在这方面需要做一个权衡。如果需要在多个协程之间来实现一个前趋图的效果(即P1执行完后P2才执行),这时可以借助channel来实现协程通信,由于我们不关心channel的具体内容,因此可以使用空结构体来进行性能优化。

字符串的拼接,比较几种不同的拼接方法:直接+,fmt.Sprintf,strings.Builder,bytes.Buffer,[]byte。分别用benchmark测,会发现+和fmt.Sprintf效率最低,与其他方法性能相差约1000倍,并且消耗超过1000倍内存。其他方法性能相似,[]byte的方法性能最高,是因为一开始就分配好了内存,拼接过程中零拷贝,不需要分配新内存。
\

for和range迭代,对于一个包含很多属性的struct,如果用range迭代其kv对,则效率会是for的千分之一,如果一定要range,可以指迭代下标值,通过下标去访问迭代值,这样就和for没有区别了。也可以将切片/数组的元素改为指针,这时用range也不会影响性能。

Golang各版本关键更新

1.0: 保证与未来发布版本的兼容性,不会破坏已有程序,已经有go tool pprof和go vet(检查包中潜在的错误)。

1.1: 专注优化语言(编译器,gc,map,go调度器)和提升性能。

1.2: go tool cover测试代码覆盖率

1.3: 栈管理有了改进,可以申请连续的内存片段,提高了分配效率,使下个版本的栈空间降到2KB。 栈的频繁申请/释放栈片段回导致某些元素变慢,可申请连续段解决了这一问题。

1.4:对Android支持,有了更高效的gc,项目管理移到了github。

1.5 对gc进行了重新设计。归功于并发的回收,在回收期间等待时间大大减少。

1.6 使用HTTPS时默认支持HTTP/2,gc等待时间降低。

1.7 发布context包,提供处理超时和任务取消的方法。编译工具链也有优化。

1.8 gc停顿时间减少到1ms以下。改进了defer函数。

1.9 sync包新增并发写安全的Map类型。

1.10 test包引进了新的智能cache,运行测试后会缓存测试结果,如果没做任何修改,不需重复运行测试。

1.11 go modules,WebAssembly为开发者提供把go程序编译成一个可兼容四大主流Web浏览器的二进制格式的能力。

1.12 基于analysis包重写了go vet

1.13 改进sync中的Pool,gc运行时不会清除pool。

1.14 鼓励所有用户迁移到Module,性能有较大改进。goroutine支持异步抢占。

1.15 改进了对高核心数的小对象的分配。编译工具链优化,

1.16 GO111MODULE默认on,embed包可以在编译阶段将静态资源打包进编译好的程序中。

1.17 go modules支持修剪模块图go mod tidy -go=1.17

1.18 泛型,Fuzzing模糊测试,Workspaces工作区,20%的性能提升。