高质量编程与性能调优实战|青训营笔记

97 阅读14分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第3篇笔记。

image.png

高质量编程

简介

  • 编写的代码能够达到正确可靠、简洁清晰、无性能隐患的目标就能称之为高质量代码(边界条件完善、异常处理,稳定性保证、易读易维护)
  • 实际应用场景千变万化,各种语言的特性和语法各不相同,但是高质量编程遵循的原则是相通的
  • 高质量的编程需要注意以下原则:简单性、可读性、生产力(团队整体工作效率)

常见编码规范

代码格式

使用 gofmt 自动格式化代码,保证所有的 Go 代码与官方推荐格式保持一致

使用goimports,管理格式、管理依赖包

总结

提升可读性,风格一致的代码更容易维护、需要更少的学习成本、团队合作成本,同时可以降低 Review 成本

编码规范-注释

1️⃣注释应该解释代码作用

image-20220511135754373

2️⃣注释应该解释代码如何做的

image-20220511135823809

3️⃣注释应该解释代码实现的原因

  • 适合解释代码的外部原因
  • 提供额外上下文

image-20220511140002529

4️⃣注释应该解释代码什么情况会出错

image-20220511140220818

5️⃣公共符号始终要注释

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

总结

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

编码规范-命名规范

variable

  • 简洁胜于冗长

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

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

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

    举例子🌰:

    image-20220511141721826

    i和index的作用域范围仅限于for循环内部时,index的额外冗长没有增加对于程序的理解

    image-20220511103659790

    • 将 deadline 替换成 t 降低了变量名的信息量
    • t 常指任意时间
    • deadline 指截止时间,有特定含义

function

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

image-20220511103157262

package

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

以下规则尽量满足

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

总结

  • 核心目标是降低阅读理解代码的成本
  • 关于命名的大多数规范核心在于考虑上下文
  • 人们在阅读理解代码的时候也可以看成是计算机运行程序,好的命名能让人把关注点留在主流程上,清晰地理解程序的功能,避免频繁切换到分支细节,增加理解成本

编码规范-控制流程

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

  • 如果两个分支中都包含 return 语句,则可以去除冗余的 else

image-20220511142843420

尽量保持正常代码路径为最小缩进,优先处理错误情况/特殊情况,并尽早返回或继续循环来减少嵌套,增加可读性

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

    • 优化前:

    最常见的正常流程的路径被嵌套在两个if 条件内成功的退出条件是return nil,必须仔细匹配大括号来发现 函数最后一行返回一个错误,需要追溯到匹配的左括号,才能了解何时会触发错误如果后续正常流程需要增加一步操作,调用新的函数,则又会增加一层嵌套

    image-20220511143021734

    • 优化后

    image-20220511143104743

总结

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

错误和异常处理

简单错误处理

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

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

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

    image-20220511143753105

错误的 Wrap 和 Unwrap

  • 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的艰踪链

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

    image-20220511143930196

  • Go1.13 在 errors 中新增了三个新 API 和一个新的 format 关键字,分别是 errors.Is、errors.As 、errors.Unwrap 以及 fmt.Errorf 的 %w。如果项目运行在小于 Go1.13 的版本中,导入 golang.org/x/xerrors 来使用。以下语法均已 Go1.13 作为标准。

错误判定

  • 判定一个错误是否为特定错误,使用errors.ls

  • 不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误

  • image-20220511144024509

  • 在错误链上获取特定种类的错误,使用errors.As

    image-20220511144158402

panic

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

recover

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

总结

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

性能优化建议

简介

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

性能优化建议-Benchmark

  • 性能表现需要实际数据衡量
  • Go语言提供了支持基准性能测试的benchmark工具

如何使用

image-20220511150139714

image-20220511150216134

结果说明

性能优化建议-Slice

slice 预分配内存

  • 在尽可能的情况下,在使用 make() 初始化切片时提供容量信息,特别是在追加切片时

  • image-20220511150727488

    性能产生差异的原理

  • 切片本质是一个数组片段的描述

    • 包括数组指针
    • 片段的长度
    • 片段的容量(不改变内存分配情况下的最大长度)
    • image-20220511150911245
  • 切片操作并不复制切片指向的元素

  • 创建一个新的切片会复用原来切片的底层数组

  • 切片有三个属性,指针(ptr)、长度(len) 和容量(cap)。append 时有两种场景:

    • 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间
    • 当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组
  • image-20220511150850328

  • 因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够获得最好的性能

另一个陷阱:大内存未释放

  • 在已有切片基础上创建切片,不会创建新的底层数组

  • 场景

    • 原切片较大,代码在原切片基础上新建小切片
    • 原底层数组在内存中有引用,得不到释放
  • 可使用copy替代re-slice

  • image-20220511151750542

  • image-20220511151814450

性能优化建议-Map

map 预分配内存

image-20220511151953157

  • 原理

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

性能优化建议-字符串处理

使用 strings.Builder

  • 常见的字符串拼接方式

    • +image-20220511152156411
    • strings.Builderimage-20220511152245223
    • bytes.Bufferimage-20220511152259669
  • strings.Builder 最快,bytes.Buffer 较快,+ 最慢

  • 分析

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

  • bytes.buffer 转化为字符串时重新申请了一块空间

image-20220511152628279

  • strings.Builder 直接将底层的[]byte 转换成了字符串类型返回

image-20220511152528232

进行了预分配后

image-20220511152952119

性能优化建议-空结构体

空结构体

  • 空结构体struct实例不占据任何的内存空间

  • 可作为各种场景下的占位符使用

    • 节省资源
    • 空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符

image-20220511153105938

空结构体比bool类型更省内存

image-20220511153131374

  • 实现Set,可以考虑用map来代替
  • 对于这个场景,只需要用到map的键,而不需要值
  • 即使是将map的值设置为bool类型,也会多占据1个字节空间

性能优化建议-atomic包

atomic包VS锁

image-20220511153344022

  • 原理

    • 锁的实现是通过操作系统来实观,属于系统调用
    • atomic操作是通过硬件实现效率比锁高
    • sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
    • 对于非数值操作,可以使用atomic.Value,能承载一个interfacet
总结
  • 避免常见的性能陷阱可以保证大部分程序的性能
  • 普通应用代码,不要一味地追求程序的性能
  • 越高级的性能优化手段越容易出现问题
  • 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能

性能调优实战

性能调优简介

  • 性能调优原则

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

性能分析工具 pprof

说明

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

性能分析工具 pprof-功能简介

image-20220511153808118

性能分析工具 pprof-排查实战

搭建 pprof 实践项目

GitHub(来自Wolfogre )github.com/wolfogre/go… 项目提前埋入了一些炸弹代码,产生可观测的性能问题

前置准备

下载项目代码,能够编译运行 会占用1CPU核心和超过1GB的内存 浏览器查看指标 浏览器输入 http://localhost:6060/debug/pprof/

image-20220511155443023

CPU

我们先从CPU问题排查开始,不同的操作系统工具可能不同,我们首先使用自己熟悉的工具看看程序进程的资源占用,CPU占用了58%,显然这里是有问题的

image-20220511155522066

go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"

这里我们使用go tool pprof +采样链接来启动采样。 连接中就是刚刚「|炸弹」程序暴露出来的接回,连接结尾的tporief表采样的对金是CU使用。如果你在流说克器里直接打开这个钳接,会定动一个60s的采祥,并在纯束后下数文件。这里我和们加上scndses = 10的参势数,让他采样10s.

稍等片刻,我们需要的采样数据已经记录和下载完成,并展示出pprof终端

命令:top 查看占用资源最多的函数

image-20220511155657774

image-20220511155846992

命令:list

根据指定的正则表达式查找代码行

输入:list Eat

image-20220511155925471

命令:web 调用关系可视化

image-20220511160240053

以上命令可以定位[炸弹]的位置,用q退出终端。

接下来就是将[炸弹]删除:将/animal/felidae/tiget/tiget.go文件下的 23-26行注释,重新运行

heap-堆内存

cpu降下来了,但内存还是很高

终端输入:

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"

这里浏览器打开的是[内存占用]

点击view按钮切换到top,点击路径

image-20220511160732327

点击view按钮切换到source

image-20220511160751374

根据top图路径打开mouse.go,在steal()这个函数会向固定的Buffer中不断追加1MG内存,直到Buffer达到1GB大小位置,和我们再Graph视图中发现的情况一致。我们将代码注释掉。

至此,[炸弹]已经拆除两个了

重新运行程序,内存降低了

我们注意到右上角有个 unknown inuse_space。

我们打开sample菜单,会发现最内存实际上提供了四种指标。 在堆内存采样中,默认展示的是inuse_space视图,只展示当前持有的内存,但如果有的内存已经释放,这时inuse采样就不会展示了。我们切换到alloc_ space指标。后续分析下alloc的内存问题

image-20220511160859254

点击 alloc_space,鼠标点击一下dog

在进去 source,然后根据路径找到dog.go,将128M这行代码注释掉

至此,内存部分的[炸弹]已经被全部拆除。

goroutine-协程

goroutine 泄露也会导致内存泄露 Golang是一门自带垃圾回收的语言,一般情况下内存泄露是没那么容易发生的。 但是有一种例外: goroutine是很容易泄露的,进而会导致内存泄露。

打开http;//ocalhost6060/debug/pprof/,发现「炸弹」程序已经有65条goroutine在运行了,这个量级并不是很大,但对于一个简单的小程序来说还是很不正常的。

终端输入:

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine" 1 就会出现一张非常长的调用关系图,但是这种比较不好阅读,推荐使用火焰图

火焰图:

由上到下表示调用顺序 每一块代表一个函数,越长代表占用CPU的时问更长 火焰图是动态的,支持点击块进行分析 打开view菜单,切换到flame graph视图,可以看到刚刚的节点被堆叠起来。

图中自顶向下展示了各个调用,表示各个函数之间的层级关系,每一行中,条形越长代表消耗的资源占比越多,可以看到wolfy资源占比95.24%,所以我们打开source。

支持搜索,在Source视图下搜索 wolf

发现每次都会发起十次无意义的goroutine,每次等待30秒菜退出,导致goroutine泄露。这里为了模拟泄漏场景,只设置了30秒,如果发起的goroutine没有退出,不断有新的goroutine被启动,内存占比持续增长,cpu调度压力不断增大,最终进程会被系统kill掉。

我们注释掉代码,重启,可以看到goroutine恢复正常水平

mutex-锁

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/mutex" 1 修改链接后缀,改成mutex,然后打开网页观察,发现存在1个锁操作同样地,在Graph视图中定位到出问题的函数在Wolf.Howl() 然后在Source视图*中定位到具体哪—行发生了锁竞争 在这个函数中,goroutine足足等待了1秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。

block-阻塞

重启后,可以看到页面中block还剩两个

连接地址结尾换成block

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/block" 1

和刚才一样,graph到source视图切换,可以看到,在*Cat.Pee()函数中读取了一个time.Afterl)生成的channel这就导致了这个goroutine实际上阻塞了1秒钟,而不是等待了1秒钟。我们注释掉,不用重启

两个blocak为什么只展示了一个 打开block页面,可以看到第二个阻塞操作发生在http handle中,这个操作是符合预期的,不用管。

剩下的以后再写!