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

203 阅读47分钟

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

本堂课重点内容:

  • 如何编写高质量的代码
  • 常用的一些 Go 语言程序优化手段
  • 熟悉运行 Go 程序性能分析工具 pprof
  • 了解工程中性能优化的原则和流程

1.高质量编程

1.1 简介

高质量——编写的代码能够达到正确可靠、简洁清晰的目标可称之为高质量代码

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

如何编写高质量的代码,实际应用场景千变万化,各种语言的特性和语法各不相同,有哪些通用的原则吗?

简单性

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

在实际工程项目中,复杂的程序逻辑会让人害怕重构和优化,因为无法明确预知调整造成的影响范围难以理解的逻辑,排查问题时也难以定位,不知道如何修复

可读性

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

在项目不断迭代的过程中,大部分工作是对已有功能的完善或扩展很少会完全下线某个功能,对应的功能代码实际会生存很长时间。已上线的代码在其生命周期内会被不同的人阅读几十上百次,难以理解的代码会占用后续每一个程序员的时间。

生产力

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

编程在当前更多是团队合作,因此团队整体的工作效率是非常重要的一方面为了降低新成员上手项目代码的成本,Go 语言甚至通过工具强制统一所有代码格式,

编码在整个项目开发链路中的一个节点,遵循规范,避免常见缺陷的代码能够降低后续联调、测试、验证、上线等各个节点的出现问题的概率,就算出现问题也能快速排查定位。

1.2 编码规范

1.2.1 代码格式

推荐使用 go fmt 自动格式化代码

gofmt

Go 语言官方提供的工具,能自动格式化 Go 语言代码为官方统一风格

goimports

也是 Go 语言官方提供的工具,实际等于 gofmt 加上依赖包管理自动增删依赖的包引用、将依赖包按字母序排序并分类

1.2.2 注释

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

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

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

———— Dave Thomas and Andrew Hunt

b.注释应该解释代码作用

适合注释公共符号

首先是注释应该解释代码作用,这种注释适合说明公共符号,比如对外提供的函数注释描述它的功能和用途,只有在函数的功能简单而明显时才能省略这些注(例如,简单的取值和设值函数)

image-20230117133917315.png

另外注释要避免啰嗦,不要对显而易见的内容进行说明.下面的代码中注释就没有必要加上,通过名称可以很容易的知道作用

image-20230117133544671.png

c.注释应该解释代码如何做的

适合注释实现过程

第二种注释是对代码中复杂的,并不明显的逻辑进行说明,适合注释实现过程

image-20230117133800625.png 上面这段代码是给新 url 加上最近的 referer 信息,并不是特别明显,所以注释说明了一下

下面的是一个反例,虽然是对过程注释,但是描述的是显而易见的流程,注意不要用自然语言直接翻译代码作为注释,信息冗余还好,有时候表述不一定和代码一致

image-20230117133846490.png

d.注释应该解释代码实现的原因

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

image-20230117134214309.png

示例中有一行 shouldRedirect = false 的语句,如果没有注释,无法清楚地明白为什么会设置 false,所以注释里提到了这么做的原因,给出了上下文说明

e.注释应该解释代码什么情况会出错

适合解释代码的限制条件

注释应该提醒使用者一些潜在的限制条件或者会无法处理的情况,例如函数的注释中可以说明是否存在性能隐患,输入的限制条件,可能存在哪些错误情况,让使用者无需了解实现细节

image-20230117134521488.png 示例介绍了解析时区字符串的流程,同时对可能遇到的不规范字符串处理进行了说明

f.公共符号始终要注释
  • 包中声明的每个公共的符号变量、常量、函数以及结构都需要添加注释
  • 任何既不明显也不简短的公共功能必须予以注释
  • 无论长度或复杂程度如何,对库中的任何函数都必须进行注释
  • 有一个例外,不需要注释实现接口的方法。具体不要像下面这样做

image-20230117133218227.png 例子:

image-20230117135010640.png

尽管 LimitedReader.Read 本身没有注释,但它紧跟 LimitedReader 结构的声明,明确它的作用

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

简洁清晰的代码对流程注释没有要求,但是对于为什么这么做,代码的相关背景,可以通过注释补充,提供有效信息,大家在以后工作中可以慢慢体会

1.2.3 命名规范

a. variable
  • 简洁胜于冗长

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

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

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

image-20230117135602160.png

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

如果索引的作用域扩展,在循环外也会用到的时候,可以考虑更符合需求的名称

image-20230117135744014.png

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

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

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

image-20230117140346060.png

倾向于第一种,在调用 http 包的 Server 方法时,代码是 http.Server,携带有 http 包名,所以函数名中无需添加包信息

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

以下规则尽量满足,以标准库包名为例

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

比函数更高一层的就是包,如何对包进行更好的命名也有一些经验

标准库有很多地方在使用,同时使用时需要指定别名,比较麻烦

需要用多个单词表达上下文的命名可以使用缩写,例如使用 strconv 而不是 stringconversion

包名也涉及到项目代码结构的划分和层次安排,具体名称不同项目会有细微差异,实际保持项目内风格统一

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

总体来说,命名的核心在于降低阅读理解代码的成本,人们在阅读理解代码的时候会尝试模拟计算机运行程序,好的命名能让人把关注点留在主流程上,清晰地理解程序的功能,避免频繁切换到分支细节,增加理解成本 Dave有句话感觉很贴切 :

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

1.2.4 控制流程

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

image-20230117141433055.png

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

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

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

image-20230117141944454.png

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

调整后:

image-20230117142142155.png

调整后的代码从上到下就是正常流程的执行过程

初步阅读代码时可以先忽略每一步的 err 情况,对整体流程有更清晰的了解

如果后续想排查问题,可以针对具体某步的错误详细分析 如果后续正常流程新增操作,可以放心地在函数中添加新的代码

好的示例:

image-20230117142227350.png

优先处理 err 情况,保持正常流程的统一

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

Go 语言代码成功的路径不是越来越深地嵌套到右边,而是随着函数的执行,正常流程代码会沿着屏幕向下移动

一个功能如果可以通过多个功能的线性结合来实现,那它的结构就会非常简单。反过来,用条件分支控制代码、毫无章法地增加状态数等行为会让代码变得难以理解。需要避免这些行为,提高代码的可读性。

如果能让正常流程自上而下、简单清晰地进行处理,代码的可读性就会大幅提高,与此同时,可维护性也将提高,添加功能等改良工作将变得更加容易

故障问题大多出现在复杂的条件语句和循环语句中,在维护这种逻辑时,添加功能会变成高风险的操作,很容易遗漏部分条件导致问题

1.2.5 错误和异常处理

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

image-20230117143233627.png

定义简单错误,描述失败原因

b.错误的 Wrap 和 Unwrap

对于复杂的错误,有时候并不能简单描述,应该如何处理?

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

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

Tips:

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

image-20230117143808341.png

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

image-20230117144004392.png 那如果错误是一套错误链,我们该如何处理呢?

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

image-20230117144219002.png

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

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

image-20230117144726105.png

在 Go 中,比错误更严重的就是 panic,它的出现表示程序无法正常工作了,那么在使用时应该注意什么呢?

不建议在业务代码中使用 panic。因为 panic 发生后,会向上传播至调用栈顶,如果当前 goroutine 中所有 deferred 函数都不包含 recover 就会造成整个程序崩溃。

若问题可以被屏蔽或解决,建议使用 error 代替 panic

特殊地,当程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用 panic。因为在这种情况下,服务启动起来也不会有意义

比如示例是启动消息队列监听器的逻辑,在创建消费组失败的时候会 Panicf,实际打印日志,然后抛出panic

e. recover

recover 的生效条件:

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

image-20230117145118682.png 如果需要更多的上下文信息,可以 recover 后在 log 中记录当前的调用栈

image-20230117145157869.png

常见情况是记录 panic 的调用栈信息,出现问题时能够方便分析定位

示例中的 debug.Stack() 包含的调用堆栈信息,方便定位具体问题代码

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

因为错误和异常是不正常的情况,除了希望程序能兼容这些场景外,重要的也有记录问题的上下文信息,方便后续定位原因

在明确 panic recover 这些功能的作用范围的情况下,编写更可靠的程序

1.2.6 回顾

哪种命名方式更好?

image-20230117145833543.png

接下来有个问题来回顾下之前的内容

首先是 time,那个方法名更好,输入 now 或者 nowtime,提示下,我们看看实际调用时是什么情况 time 包的方法也是类似,Now 和 NowTime 返回的是 time.Time 类型,使用时没有必要写成 time.NowTime 来额外表示时间信息,使用 Now 更简洁

注意这里持续时间并不是 time 类型,使用 time.ParseDuration (返回的是 time.Duration 类型,这种情况在函数命名中体现是不冗余的,用 ParseDuration 更好

程序的输出是什么?

image-20230117145852571.png

  • defer 语句会在函数返回前调用
  • 多个 defer 语句是后进先出

最终输出 31

2.性能优化建议

2.1.0 简介

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

高质量的代码能够完成功能,但是在大规模程序部署的场景,仅仅支持正常功能还不够,我们还要尽可能的提升性能,节省资源成本,接下来就主要介绍性能相关的建议

高性能代码为了效率会用到许多技巧,没有相关背景的人难以理解,不过有些基础性能问题是和语言本身相关的,接下来主要介绍这类内容,对应的调整对可读性和可维护性影响不大

在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的性能

有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能

2.1.1 Benchmark

a.如何使用
  • 性能表现需要实际数据衡量
  • Go 语言提供了支持基准性能测试的 benchmark 工具
go test -bench=. -benchmem

image-20230117150845965.png

如何评估性能?

性能表现要用数据说话,实际情况和想象中的并不一定致,要用数据来验证我们写的代码是否真的有性能提升

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

b.结果说明

image-20230117203609684.png

可以看到实际执行的结果,上面简单介绍下各项的含义,后续就通过这种 benchmark 结果来对比分析不同代码的性能表现

GOMAXPROCS 1.5 版本后,默认值为 CPU 核数,pkg.go.dev/runtime#GOM…

2.1.2 slice

a. slice 预分配内存

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

image-20230117151739321.png

slice 是 go 中最常用的结构,也很方便,那么在使用过程中有哪些点需要注意呢?

第一条建议就是预分配,尽可能在使用 make() 初始化切片时提供容量信息,特别是在追加切片时

对比看下两种情况的性能表现,左边是没有提供初始化容量信息,右边是设置了容量大小

结果中可以看出执行时间相差很多,预分配只有一次内存分配

image-20230117152308557.png

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

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

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

image-20230117152328754.png

那么为什么会出现这么大的性能差异呢?

以切片的 append 为例,append 时有两种场景:

当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间。

当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组。

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

b.另一个陷阱:大内存未释放
  • 在已有切片基础上创建切片,不会创建新的底层数组

  • 场景

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

image-20230117153120089.png

image-20230117153144632.png

了解 sice 的基本结构之后,还有个问题需要注意

因为很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放,可使用 copy 替代 re-slice

两部分代码使用了不同的逻辑取 slice 的最后两位数创建新数组,同时统计输出了内存占用信息

结果差异非常明显,lastBySlice 耗费了 100.14MB 内存,也就是说,申请的 100 个 1MB 大小的内存没有被回收。因为切片虽然只使用了最后 2 个元素,但是因为与原来 1M 的切片引用了相同的底层数组,底层数组得不到释放,因此,最终 100MB 的内存始终得不到释放。

而 lastByCopy 仅消耗了 3.14MB 的内存。这是因为通过copy,指向了一个新的底层数组,当 origin 不再被引用后,内存会被垃圾回收

2.1.3 map

map 预分配内存

image-20230117153451026.png

和 slice 一样 ,map 也有预分配的性能优化点

分析

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

2.1.4 字符串处理

使用 strings.Builder

常见的字符串拼接方式

image-20230117154057565.png

image-20230117154024704.png

编程过程中除了 slice 和 map,平时很多编码功能都和字符串处理相关的,字符串处理也是高频操作,那么不同字符串处理方式的性能表现会有什么差异吗?

我们可以看到,使用 + 拼接性能最差,strings.Builder , bytes.Buffer 相近,strings.Buffer 更快

分析

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

当使用 + 拼接 2 个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和。拼接第三个字符串时,再开辟一段新空间,新空间大小是三个字符串大小之和,以此类推

为什么 strings.builder 会比 bytes.buffer更快一些,可以看看实际的代码

image-20230117154803741.png

image-20230117154820939.png

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

注意注释里也提到如果想用更高效的字符串构造方法,可以使用 strings.builder

联系刚刚之前讲到的内容,有没有办法再次提升字符串拼接的效率?关键字是什么?预分配

image-20230117155427057.png

字符串拼接和 slice 一样,同样支持预分配,在预知字符串长度的情况下,我们可以进一步提升拼接性能

注意这里能确认 stringbuiler 只有一次内存分配,bytebuffer 有两次

2.1.5 空结构体

使用空结构体节省内存

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

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

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

image-20230117160019139.png

性能优化有时是时间和空间的平衡,之前提到的都是提高时间效率的点,对于空间上是否有优化的手段呢? 空结构体是节省内存空间的一个手段

空结构体占用内存更少些,在元素更多的情况下会更明显 实际应用场景有哪些?容易想到的是 Set 实现

image-20230117160140673.png

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

一个开源实现: github.com/deckarep/go…

2.1.6 atomic 包

如何使用atomic包

image-20230117160712010.png 在工作中迟早会遇到多线程编程的场景,比如实现一个多线程共用的计数器,如何保证计数准确,线程安全,有不同的方式

使用atomic包

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

小结

总体来说,性能优化时要注意以下几点:

  • 避免常见的性能陷阱可以保证大部分程序的性能
  • 普通应用代码,不要一味地追求程序的性能
  • 越高级的性能优化手段越容易出现问题
  • 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能

3.性能调优实战

3.1 简介

性能调优原则

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

3.2 性能分析工具 pprof

既然性能调优前提是对应用程序性能表现有实际的数据指标,那么有什么工具能够获得这种数据呢? 对于 go 程序,有一个很方便的工具就是 pprof

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

3.2.1 功能简介

image-20230117163210702.png

分析部分有两种方式,网页和可视化终端

具体的工具:可以在 runtime/pprof 中找到源码,同时 Golang 的 http 标准库中也对 pprof 做了一些封装,能让你在 http 服务中直接使用它

采样部分:它可以采样程序运行时的 CPU、堆内存、goroutine、锁竞争、阻塞调用和系统线程的使用数据

展示:用户可以通过列表、调用图、火焰图、源码、反汇编等视图去展示采集到的性能指标,方便分析

3.2.2 排查实战

a.搭建 pprof 实践项目

我们的目标是熟悉 pprof 工具,能够排查性能问题,那么首先我们需要构造一个有问题的程序,看看如何用 pprof 来定位性能问题点

这里有个开源项目(github.com/wolfogre/go…),已经制造了一些问题代码,需要我们进行排查,大家用 pprof 示例命令实验下能否正常打开 pprof 页面,是否缺少 graphviz 的组件

前置准备

  • 下载项目代码,能的够编译运行
  • 会占用 1 CPU 核心和超过 1 GB 的内存

image-20230117164131793.png

我们来看看「炸弹]程序是怎么做的。图中代码是 main.go 中初始化 http 服务和 pprof 接口的代码,无关逻辑有所省略。可以看到,引入了 net/http/pprof 这个包,它会将 pprof 的入口注册到 /debug/pprof 这个路径下,我们可通过浏览器打开这个路径,来查看一些基本的性能统计。

运行「炸弹」,并等待它运行稳定

b.浏览器查看指标

在浏览器中打开 http://localhost:6060/debug/pprof ,可以看到这样的页面

image-20230117164327558.png

这就是我们刚刚引入的 net/http/pprof 注入的入口了。

页面上展示了可用的程序运行采样数据,下面也有简单说明,分别是: allocs : 内存分配情况 blocks : 阻塞操作情况 cmdline : 程序启动命令 goroutine : 当前所有 goroutine 的堆栈信息 heap : 堆上内存使用情况(同 allocs) mutex : 锁竞争操作情况 profile : CPU 占用情况 threadcreate : 当前所有创建的系统线程的堆栈信息 trace : 程序运行跟踪信息

image-20230117164803863.png

看到的数据可读性很差,可以看出一些信息但很难阅读它,所以接下来我们会借助 pprof 工具帮我们「阅读」这些指标。

image-20230117165030949.png

cmdline 显示运行进程的命令 threadcreate 比较复杂,不透明, trace 需要另外的工具解析,暂不涉及

炸弹在 CPU,堆内存,goroutine,锁竞争和阻塞操作上埋了炸弹,可以使用 pprof 工具进行分析

CPU

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

pprof 的采样结果是将一段时间内的信息汇总输出到文件中,所以首先需要拿到这个 profile 文件。你可以直接使用暴露的接口链接下载文件后使用,也可以直接用 pprof 工具连接这个接口下载需要的数据。

这里我们使用 go tool pprof + 采样链接来启动采样。

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

链接中就是刚刚「炸弹】程序暴露出来的接口,链接结尾的 profile 代表采样的对象是 CPU 使用。如果你在浏览器里直接打开这个链接,会启动一个 60 秒的采样,并在结束后下载文件。这里我们加上 seconds = 10 的参数,让它采样十秒。

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

image-20230117165454505.png top 命令:

首先,输入 top ,查看 CPU 占用最高的函数

image-20230117165759002.png 这五列从左到右分别是: Flat : 当前函数的占用 Flat% : Flat 占总量的比例 Sum% : 上面所有行的 Flat% 总和 Cum(Cumulative) : 当前函数加上其调用函数的总占用 Cum% : Cum 占总量的比例

表格前面描述了采样的总体信息。默认会展示资源占用最高的 10 个函数,如果只需要查看最高的 N 个函数,可以输入 topN ,例如查看最高的 3 个调用,输入 top3 。

可以看到表格的第一行里,Tiger.Eat 函数本身占用 3.56 秒的 CPU 时间,占总时间的 95.44%,显然问题就是这里引起的

image-20230117170447458.png 大家在这张表中可以看到,Flat 和 Cum 有的是相等的,有的是不相等的,有的一边直接为零了,那么,

在什么情况下 Flat = Cum ? 在什么情况下 Flat = 0 ? 刚刚提到这几列是什么含义

解答:

Cum - Flat 得到的是函数中调用其他函数所消耗的资源,所以在函数中没有对其他函数进行调用时,Cum - Fla t= 0,也就是 Flat = Cum

相应地,函数中除了调用另外的函数,没有其他逻辑时,Flat = 0

答案1:

当函数中没有调用其他函数时, Flat = Cum 函数中只有调用其他函数的逻辑时,Flat = 0

list 命令 :

接着,输入 list Eat 查找这个函数,看看具体是哪里出了问题

List 命令会根据后面给定的正则表达式查找代码,并按行展示出每一行的占用。

image-20230117170752095.png 可以看到,第 24 行有一个 100 亿次的空循环,占用了 3.56 秒的 CPU 时间,问题就在这儿了,定位成功

web 命令 :

除了这两种视图之外,我们还可以输入 web 命令,生成一张调用关系图,默认会使用浏览器打开。

image-20230117171243513.png 图中除了每个节点的资源占用以外,还会将他们的调用关系穿起来。

图中最显的就是方框最红最大,线条最粗的 *Tiger.Eat 函数,是不是比 top 视图更直观些呢?

到这里,CPU 的炸弹已经定位完成,我们输入 q 退出终端。

为了方便后续的展示,我们先将这个「炸弹」拆除,注意这一部分我们的主要目的是展示如何定位问题,所以不会花太多时间在看代码上,我们将问题代码直接注释掉,重新打开「炸弹」程序。

Heap - 堆内存

image-20230117171826231.png 注释 CPU 问题代码,重新运行后打开活动监视器,可以发现进程的 CPU 已经降下来了,然而内存使用还是很高;接着我们来排查内存问题。

在刚刚排查 CPU 的过程中,我们使用的是 pprof 终端,这里我们介绍另种展示方式。通过 -http=:8080 参数,可以开启 pprof 自带的 Web UI,,性能指标会以网页的形式呈现。

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

再次启动 pprof 工具,注意这时的链接结尾是 heap

image-20230117172225595.png 等待采样完成后,浏览器会被自动打开,展示出熟悉的 web 视图,同时展示的资源使用从「CPU时间」变为了「内存占用」

可以明显看到,这里出问题的是 *Mouse.Steal() 函数,它占用了 1 GB 内存。在页面顶端的 View 菜单中,我们可以切换不同的视图。

image-20230117173102681.png 我们再切换到 Source 视图,可以看到页面上展示出了刚刚看到的四个调用和具体的源码视图。如果觉得内容太多,也可以在顶部的搜索框中输入 Steal 来使用正则表达式过滤需要的代码。

根据源码我们会发现,在 *Mouse.Steal() 这个函数会向固定的 Buffer 中不断追加 1 MB 内存,直到 Buffer 达到 1 GB 大小为止,和我们在 Graph 视图中发现的情况一致。

我们将这里的问题代码注释掉,至此,「炸弹」已被拔除了两个。

image-20230117173514396.png 重新运行「炸弹」程序,发现内存占用已经降到了 23.6MB ,刚才的解决方案起效果了

在采样中,也没有出现异常节点了(实际上没有任何节点了)不过,内存的问题真的被全部排除了吗?

大家在使用工具的过程中,有没有注意过右上角有个 unknown_inuse_space

我们打开 sample 菜单,会发现堆内存实际上提供了四种指标:

image-20230117173557740.png 在堆内存采样中,默认展示的是 inuse_space 视图,只展示当前持有的内存,但如果有的内存已经释放,这时 inuse 采样就不会展示了。我们切换到 alloc_space 指标。后续分析下 alloc 的内存问题

image-20230117174046255.png 马上就定位到了 *Dog.Run() 中还藏着一个「炸弹」,它会每次申请 16 MB大小的内存,并且已经累计申请了超过 3.5 GB 内存。在 Top 视图中还能看到这个函数被内联了。

看看定位到的函数,果然,这个函数在反复中请 16 MB 的内存,但因为是无意义的申请,分配结束之后会马上被 GC,所以在 inuse 采样中并不会体现出来。

我们将这一行问题代码注释掉,继续接下来的排查。至此,内存部分的「炸弹」已经被全部拆除。

goroutine -协程

goroutine 泄露也会导致内存泄露

Golang 是一门自带垃圾回收的语言,一般情况下内存泄露是没那么容易发生的。

但是有一种例外:goroutine 是很容易泄露的,进而会导致内存泄露。所以接下来,我们来看看 goroutine 的使用情况。

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

image-20230117174423894.png 输入:

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

image-20230117174749746.png 把链接末尾换成 goroutine,看看结果可以看到,pprof 生成了一张非常长的调用关系图,尽管最下方的问题用红色标了出来,不过这么多节点还是比较难以阅读的

这里我们介绍另一种更加直观的展示方式一火焰图。

  • 由上到下表示调用顺序
  • 每一块代表一个函数,越长代表占用 CPU 的时间更长
  • 火焰图是动态的,支持点击块进行分析

image-20230117175100739.png 打开 View 菜单,切换到 Flame Graph 视图,可以看到,刚才的节点被堆叠了起来。图中,自顶向下展示了各个调用,表示各个函数调用之问的层级关系,每一行中,条形越长代表消耗的资源占比越多。

显然,那些「又平又长」的节点是占用资源多的节点

可以看到,Wolf.Drink() 这个调用创建了超过 90% 的 goroutine,问题可能在这里

火焰图是非常常用的性能分析工具,在程序逻辑复杂的情况下很有用,可以重点熟悉

image-20230117175340623.png 到 Source 视图搜索 Drink,发现函数每次会发起 10 条无意义的 goroutine,每条等待 30 秒后才退出,导致了 goroutine 的泄露。

这里为了模拟泄漏场景,只等待了 30 秒就退出了;试想一下,如果发起的 goroutine 没有退出,同时不断有新的 goroutine 被启动,对应的内存占用持续增长,CPU 调度压力也不断增大,最终进程会被系统 kill 掉。

image-20230117175411069.png

可以看到 goroutine 数量已经到正常水平了,接下来我们分析下锁的问题

mutex - 锁

image-20230117175808107.png

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

修改链接后缀,改成 mutex,然后打开网页观察,发现存在 1 个锁操作,同样地,在 Graph 视图中定位到出问题的函数在 Wolf.Howl()

然后在 Source 视图中定位到具体哪一行发生了锁竞争

在这个函数中,goroutine 足足等待了 1 秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。

block-阻塞

我们开始排查最后一个阻塞问题。

image-20230117180108619.png 在程序中,除了锁的竞争会导致阻塞之外,还有很多逻辑(例如读取一个 channel)也会导致阻塞,在页面中可以看到阻塞操作还剩两个强调)。 链接地址末尾再换成 block

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

image-20230117180444069.png 和刚才一样,Graph 到 Source 的视图切换

可以看到,在 *Cat.Pee() 函数中读取了一个 time.After() 生成的 channel,这就导致了这个 goroutine 实际上阻塞了 1 秒钟,而不是等待了 1 秒钟。

image-20230117180912133.png 不过通过上面的分析,我们只定位到一个 block 的问题

可是刚刚的计数页面上有两个阻塞操作,但是实际上只有一个,那么另一个为什么没有展示呢?

提示:可以关注一下 pprof Top 视图中表格之外的部分,从框住的地方可以发现,有 4 个节点因为 cumulative 小于 1.41 秒被 drop 掉了,这就是另一个阻塞操作的节点,但他因为总用时小于总时长的千分之 5,所以被省略掉了。这样的过滤策略能够更加有效地突出问题所在,而省略相对没有问题的信息。

如果不作任何过滤全部展示的话,对于一个复杂的程序可能内容就会非常庞大了,不利于我们的问题定位。

image-20230117181423533.png 我们知道了另一个阻塞是确实存在的,接下来大家可以进一步猜测一下:没有展示的这个阻塞操作发生在哪里?需要点击哪个链接查看?

提示:程序除了在跑性能「炸弹」之外,还有什么工作呢?我们是怎样访问它的采样信息的?

尽管占用低的节点不会在 pprof 工具中展示出来,但实际上他是被记录下来的。我们还可以通过暴露出来的接口地址直接访问它。

所以,打开 Block 指标的页面,可以看到,第二个阻塞操作发生在了 http.handler 中,这个阻塞操作是符合预期的。

小结

image-20230117181539847.png

3.2.3 采样过程与原理

a. CPU

image-20230117182107178.png 首先来看 CPU,CPU 采样会记录所有的调用栈和它们的占用时间。

在采样时,进程会每秒暂停一百次,每次会记录当前的调用栈信息。汇总之后,根据调用栈在采样中出现的次数来推断函数的运行时间。

你需要手动地启动和停止采样。每秒 100 次的暂停频率也不能更改。

这个定时暂停机制在 unix 或类 unix系统上是依赖信号机制实现的。每次「暂停」都会接收到一个信号,通过系统计时器来保证这个信号是固定频率发送的。

接下来看看具体的流程。

image-20230117182350845.png 共有三个相关角色:进程本身、操作系统和写缓冲。

启动采样时,进程向 OS 注册一个定时器,OS 会每隔 10 ms 向进程发送一个 SIGPROF 信号,进程接收到信号后就会对当前的调用栈进行记录。

与此同时,进程会启动一个写缓冲的 goroutine,它会每隔 100 ms 从进程中读取已经记录的堆栈信息,并写入到输出流。

当采样停止时,进程向 OS 取消定时器,不再接收信号,写缓冲读取不到新的堆栈时,结束输出。

b. Heap -堆内存

image-20230117182904623.png 接下来看看堆内存采样。

提到内存指标的时候说的都是「堆内存」而不是「内存」,这是因为 pprof 的内存采样是有局限性的。

内存采样在实现上依赖了内存分配器的记录,所以它只能记录在堆上分配,且会参与 GC 的内存,一些其他的内存分配,例如调用结束就会回收的栈内存、一些更底层使用 cgo 调用分配的内存,是不会被内存采样记录的。

它的采样率是一个大小,默认每分配 512 KB内存会采样一次,采样率是可以在运行开头调整的,设为 1 则为每次分配都会记录。

与 CPU 和 goroutine 都不同的是,内存的采样是一个持续的过程,它会记录从程序运行起的所有分配或释放的内存大小和对象数量,并在采样时遍历这些结果进行汇总。

还记得刚才的例子中,堆内存采样的四种指标吗? alloc 的两项指标是从程序运行开始的累计指标,而 inuse 的两项指标是通过累计分配减去累计释放得到的程序当前持有的指标。你也可以通过比较两次 alloc 的差值来得到某一段时间程序分配的内存「大小和数量」

c. Goroutine-协程 & ThreadCreate-线程创建

接下来我们来看看 goroutine 和系统线程的采样。这两个采样指标在概念上和实现上都比校相似,所以在这里进行对比。

image-20230117183323154.png Goroutine 采样会记录所有用户发起,也就是入口不是 runtime 开头的 goroutine,,以及 main 函数所在 goroutine 的信息和创建这些 goroutine 的调用栈;

他们在实现上非常的相似,都是会在 STW 之后,遍历所有 goroutine 所有线程的列表(图中的 m 就是 GMP 模型中的 m,在 golang 中和线程一一为对应)并输出堆栈,最后 Start The World 继续运行。这个采样是立刻触发的全量记录,你可以通过比较两个时间点的差值来得到某一时间段的指标。

d. Block-阻塞 & Mutex-锁

最后是阻塞和锁竞争这两种采样这两个指标在流程和原理上也非常相似

image-20230117183708051.png 这两个采样记录的都是对应操作发生的调用栈、次数和耗时,不过这两个指标的采样率含义并不相同。

阻塞操作的采样率是一个「阈值」,消耗超过阈值时间的阻塞操作才会被记录,1 为每次操作都会记录。记得炸弹程序的 main 代码吗?里面设置了 rate = 1

锁竞争的采样率是一个「比例」运行时会通过随机数来只记录固定比例的锁操作,1 为每次操作都会记录

它们在实现上也是基本相同的。都是一个「主动上报」的过程。

在阻塞操作或锁操作发生时,会计算出消耗的时间,连同调用栈一起主动上报给采样器,采样器会根据采样率可能会丢弃一些记录。

在采样时,采样器会遍历已经记录的信息,统计出具体操作的次数、调用栈和总耗时。和堆内存一样,你可以对比两个时间点的差值计算出段时间内的操作指标。

小结

  • 掌握常用 pprof 工具功能
  • 灵活运用 pprof 工具分析解决性能问题
  • 了解 pprof 的采样过程和工作原理

3.3 性能调优案例

3.1.0 简介

介绍实际业务服务性能优化的案例 对逻辑相对复杂的程序如何进行性能调优

  • 业务服务优化
  • 基础库优化
  • Go 语言优化

在实际工作中,当服务规模比较小的时候,可能不会触发很多性能问题,同时性能优化带来的效果也不明显,很难体会到性能调优带来的收益

而当业务量逐渐增大,比如一个服务使用了几千台机器的时候,性能优化一个百分点,就能节省数百台机器,成本降低是非常可观的

接下来我们来了解下工程中进行性能调优的实际案例

程序从不同的应用层次上看,可以分为业务服务、基础库和 Go 语言本身三类,对应优化的适用范围也是越来越广。

  • 业务服务一般指直接提供功能的程序,比如专门处理用户评论操作的程序
  • 基础库一般指提供通用功能的程序,主要是针对业务服务提供功能,比如监控组件,负责收集业务服务的运行指标
  • 另外还有对Go语言本身进行的优化项

3.1.1 业务服务优化

a.基本概念

那么针对逻辑相对复杂的业务服务,它的性能调优流程是怎么样的呢?在介绍真正流程之前,可能有的同学对部分名词不太了解,先介绍一下

image-20230117184640719.png 右边是系统部署的简单示意图,客户端请求经过网关转发,由不同的业务服务处理,业务服务可能依赖其他的服务,也可能会依赖存储、消息队列等组件

接下来我们以业务服务优化为例,说明性能调优的流程,图中的 Service B 被 Service A 依赖,同时也依赖了存储和 Service D

b.流程
  • 建立服务性能评估手段
  • 分析性能数据,定位性能瓶颈
  • 重点优化项改造
  • 优化效果验证

那么接下来就来看一下业务服务优化的主要流程,主要分四步,这些流程也是性能调优相对通用的流程,可以适用其他场景

和上面评估代码优化效果的 benchmark 工具类似,对于服务的性能也需要一个评估手段和标准优化的核心是发现服务性能的瓶颈,这里主要也是用 pprof 采样性能数据,分析服务的表现

发现瓶颈后需要进行服务改造,重构代码,使用更高效的组件等,最后一步是优化效果验证,通过压测对比和正确性验证之后,服务可以上线进行实际收益评估

整体的流程可以循环并行执行,每个优化点可能不同,可以分别评估验证

c.建立服务性能评估手段

image-20230117185452845.png 之所以不用 benchmark 是因为实际服务逻辑比较复杂,希望从更高的层面分析服务的性能问题,同时机器在不同负载下的性能表现也会不同,右图是负载和单核 qps 的对应数据

另外因为逻辑复杂,不同的请求参数会走不同的处理逻辑,对应的性能表现也不相同,需要尽量模拟线上真实情况,分析真正的性能瓶颈

压测会录制线上的请求流量,通过控制回放速度来对服务进行测试,测试范围可以是单个实例,也可以是整个集群,同样性能采集也会区分单机和集群

image-20230117185758655.png 评估手段建立后,它的产出是什么呢?实际是一个服务的性能指标分析报告

实际的压测报告截图,会统计压测期间服务的各项监控指标,包括 qps,延迟等内容,同时在压测过程中,也可以采集服务的 pprof 数据,使用之前的方式分析性能问题

d.分析性能数据,定位性能瓶颈

1.使用库不规范

image-20230117190118201.png 有了服务优化前的性能报告和一些性能采样数据,我们可以进行性能瓶颈分析了

业务服务常见的性能问题可能是使用基础组件不规范

比如这里通过火焰图看出 json 的解析部分占用了较多的 CPU 资源,那么我们就能定位到具体的逻辑代码,是在每次使用配置时都会进行 json 解析,拿到配置项,实际组件内部提供了缓存机制,只有数据变更的时候才需要重新解析 json

image-20230117190529261.png 还有是日志库使用不规范,一部分是调试日志发布到线上,一部分是线上服务在不同的调用链路上数据有差别,测试场景日志量还好,但是到了真实线上全量场景,会导致日志量增加,影响性能

2.高并发场景优化不足

image-20230117190631870.png 另外常见的性能问题就是高并发场景的优化不足,左边是服务高峰期的火焰图,右边是低峰期的火焰图,可以发现 metrics ,即监控组件的 CPU 资源占用变化较大,主要原因是监控数据上报是同步请求,在请求量上涨,监控打点数据量增加时,达到性能瓶颈,造成阻塞,影响业务逻辑的处理,后续是改成异步上报的机制提升了性能

e.重点优化项改造

image-20230117191116307.png 定位到性能瓶颈后,我们也有了对应的修复手段,但是修改完后能直接发布上线吗?

性能优化的前提是保证正确性,所以在变动较大的性能优化上线之前,还需要进行正确性验证,因为线上的场景和流程太多,所以要借助自动化手段来保证优化后程序的正确性

同样是线上请求的录制,不过这里不仅包含请求参数录制,还会录制线上的返回内容,重放时对比线上的返回内容和优化后服务的返回内容进行正确性验证

比如图中作者信息相关的字段值在优化有有变化,需要进一步排查原因

f.优化效果验证

image-20230117191447954.png 改造完成后,可以进行优化效果验证了

验证分两部分,首先依然是用同样的数据对优化后的服务进行压测,可以看到现在的数据比优化前好很多,能够支持更多的 qps

正式上线的时候会逐步放量,记录真正的优化效果3

同时压测并不能保证和线上表现完全一致,有时还要通过线上的表现再进行分析改进,是个长期的过程

g.进一步优化,服务整体链路分析

image-20230117194345948.png 以上的内容是针对单个服务的优化过程,从更高的视角看,性能是不是还有优化空间?

在熟悉服务的整体部署情况后,可以针对具体的接口链路进行分析调优,比如 Service A 调用 Service B 是否存在重复调用的情况,调用 Service B服务 时,是否更小的结果数据集就能满足需求,接口是否一定要实时数据,能否在 Service A 层进行缓存,减轻调用压力

这种优化只使用与特定业务场景,适用范围窄,不过能更合理的利用资源

3.1.2 基础库优化

image-20230117194941136.png 适用范围更广的就是基础库的优化

比如在实际的业务服务中,为了评估某些功能上线后的效果,经常需要进行 AB 实验,看看不同策略对核心指标的影响,所以公司内部多数服务都会使用 AB 实验的 SDK,如果能优化 AB 组件库的性能,所有用到的服务都会有性能提升

类似业务服务的优化流程,也会先统计下各个服务中 AB 组件的资源占用情况,看看 AB 组件的哪些逻辑更耗费资源,提取公共问题进行重点优化

图中看到有部分性能耗费在序列化上,因为 AB 相关的数据量较大,所以在制定优化方案时会考虑优化数据序列化协议,同时进行按需加载,只处理服务需要的数据

完成改造和内部压测验证后,会逐步选择线上服务进行试点放量,发现潜在的正确性和使用上的问题,不断迭代后推广到更多服务

3.1.3 Go 语言优化

image-20230117195558930.png 接下来是适用范围最广的优化,就是针对 Go 本身进行的优化,会优化编译器和运行时的内存分配策略,构建更高效的 go 发行版本

这样的优化业务服务接入非常简单,只要调整编译配置即可,通用性很强,几乎对所有 go 的程序都会生效,比如右图中服务只是换用新的发行版本进行编译,CPU 占用降低 8% 。

总结

性能调优的流程很长,这里总结下重要的点

image-20230117195650267.png 我们性能评估要依靠数据,用实际的结果做决策

对于 pprof 工具,可以通过分析实际的程序熟悉相关功能,理解基本原理,后续能够更好地解决性能问题

在真正的服务性能调优流程中,链路会很长,重点是要保证正确性,不影响功能,同时定位主要问题

性能调优实战代码

GitHub: github.com/wolfogre/go…

个人总结

  • 编程时注意规范,编写高质量代码
  • 在高质量编程的前提下,设法提高程序的性能很关键
  • 性能分析和调优需要多加练习才有可能掌握,是重点也是难点