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

158 阅读9分钟

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

本文主要内容是对高质量编程与性能调优实战课程的内容整理及部分课后习题的解答

编码规范

错误和异常处理

  • 简单错误

    • 优先使用 errors.New 来创建匿名变量直接表示简单错误
    • 如果有格式化的需求,使用 fmt.Errof
  • 错误的 WrapUnwrap

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

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

性能优化

Benchmark

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

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

Slice

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

  • 陷阱:大内存未释放

    • 场景:

    1. 原切片较大,代码在原切片基础上新建小切片(在已有切片基础上创建切片,不会创建新的底层数组)

    2. 原底层数组在内存中有引用,得不到释放

    • 可使用 copy 替代 re-slice

Map

  • 预分配内存

    • 不断向 map 中添加元素会触发 map 的扩容
    • 提前分配好空间可以减少内存拷贝和 Rehash 的消耗

字符串处理

  • 常见的字符串拼接方式

    • 使用 + 拼接性能最差, strings.Builderbytes.Buffer 相近,strings.Builder 更快
    • 使用 + 拼接每次都会重新分配新的内存,大小是原来两个字符串大小之和
    • strings.Builderbytes.Buffer 底层都是 []byte 数组,不需要每次重新分配内存
    • bytes.Buffer 转化为字符串时重新申请了一块内存空间,strings.Builder 直接将底层的 []byte 转换成了字符串类型返回
    • strings.Builderbytes.Buffer 同样可以使用 Grow 方法实现内存预分配,进一步提升拼接性能

空结构体

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

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

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

atomic包

image-20220509223455242.png

  • 多线程编程环境下
  • 锁的实现通过操作系统实现,属于系统调用
  • atomic 操作是通过硬件实现,效率比锁高
  • sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量

性能分析工具——pprof

blog.wolfogre.com/posts/go-pp…

功能简介

image-20220509223756188.png

  • pprof 的采样结果是将一段时间内的信息汇总输出到文件中

CPU

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

  • top命令展示的参数:

    • flat:当前函数本身的执行耗时
    • flat%flat 占 CPU 总时间的比例
    • sum%:上面每一行的 flat% 总和
    • cum:指当前函数加上其调用函数的总耗时
    • cum%cum 占 CPU 总时间的比例
    • flat == cum 说明函数中没有调用其他函数
    • flat == 0 说明函数中只有其他函数的调用

Heap堆内存

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

这里使用另一种展示方法,通过 -http=:8080 参数,可以开启 pprof 自带的 Web UI,性能指标会以网页形式呈现

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

  • 默认展示的是 inuse_space 视图,只展示当前持有的内存,但如果有的内存已经释放,这时 inuse 采样就不会展示了

    • alloc_objects:程序累积申请的对象数
    • alloc_space:程序累积申请的内存大小
    • inuse_objects:程序当前持有的对象数
    • inuse_space:程序当前占用的内存大小
  • 切换到 alloc_space 视图,继续分析下 alloc 的内存问题

goroutine

  • goroutine 泄露也会导致内存泄露

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

  • 但是有一种例外:goroutine 是很容易泄露的,进而会导致内存泄露

  • 由于 pprof 生成了一张非常长的调用关系图,因此在此使用火焰图来更加直观地展示

    • 火焰图自顶向下展示了各个调用,表示各个函数调用之间的层级关系
    • 每一行中,条形越长代表消耗的资源占比越多
    • 火焰图是动态的,支持点击块进行分析

pprof采样过程和原理

CPU

image-20220510182020647.png

  • CPU采样会记录所有的调用栈和它们的占用时间
  • 进程会每秒暂停100次,每次记录当前的调用栈信息。汇总之后,根据调用栈在采样中出现的次数来推断函数的运行时间

image-20220510182143010.png

  • 启动采样时,进程向OS注册一个定时器,OS每隔10ms向进程发送一个 SIGPROF 信号,进程接收到信号后就会对当前的调用栈进行记录
  • 与此同时,进程会启动一个写缓冲的goroutine,它会每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流
  • 当采样停止时,进程向OS取消定时器,不再接收信号;写缓冲读取不到新的堆栈时,结束输出

Heap堆内存

image-20220510182432800.png

  • 内存采样在实现上依赖了内存分配器的记录,所以它只能记录在堆上分配,且会参与 GC 的内存

goroutine协程 & ThreadCreate线程创建

image-20220510182649856.png

  • 两者的采样是立刻触发的全量记录

Block阻塞 & Mutex锁

image-20220510183122195.png

  • 两者在实现上是基本相同的,都是一个主动上报的过程

课后习题

1. 了解下其他语言的编码规范,是否和 Go 语言编码规范有相通之处,注重理解哪些共同点?

编程语言的共通编码规范有很多:

  • 在变量和函数的命名时需要满足最小化长度 && 最大化信息量原则:在尽量简洁的同时携带更多的上下文信息,让开发者能够轻易辨认出变量或者函数的含义
  • 注释层面应详细解释代码的具体作用,输入输出等关键参数的具体含义,代码在什么情况下会出错,甚至可以在注释部分给出代码的某些具体示例
  • 编码层面注意整体代码结构的清晰与可读性,避免冗余的嵌套,优先处理错误与特殊情况(尽早返回)

2. 编码规范或者性能优化建议大部分是通用的,有没有方式能够自动化对代码进行检测?

  • Go语言官方提供了 gofmtgoimports 等工具,前者可以自动格式化 Go 语言代码,使得团队开发过程中基本的代码风格能保持统一,便于阅读;后者提供了对于依赖包的管理,能够自动增删依赖的包引用
  • Go 语言标准库的 testing 包提供了功能性测试和压力测试常用的方法的框架,也可以非常方便地利用其进行自动化测试

3. 使用 Go 进行并发编程时有哪些性能陷阱或者优化手段?

  1. 在并发编程时可能会遇到并发访问共享数据的错误,可能同时存在多个 goroutine读取到了共享数据并对其进行操作,而造成了相互覆盖的情况。可以使用 Go 语言提供的 go run -race 来发现该问题

  2. 当我们使用 for-loop 创建并发起多个 goroutine时,整个程序可以分为三步:

    • 先创建:for-loop 循环创建 goroutine
    • 再调度:协程goroutine 开始调度执行
    • 才执行:开始执行 goroutine 内的逻辑

    因此创建 goroutine 与真正执行 goroutine 内的逻辑可能并不同步

4. Go 语言本身在持续更新迭代,每个版本在性能上有哪些重要的优化点?

版本主要更新
1.0修改 goroutine初始化方式:在初始化时就开始运行;新增 rune, error等类型;多线程调度器
1.1允许函数不一定有return 返回;任务窃取调度器(引入了处理器P,构成了目前的GMP模型)
1.2nil指针不允许访问内存地址;允许slice切片添加第三个参数 slice[a:b:c];基于协作的抢占式调度器
1.3允许有缓存的channel;更改对操作系统的支持;更改垃圾回收器;map的迭代改为随机性
1.4允许range不需要任何index和value;用 go 重写了 gc
1.5编译器和 runtime 不再有 c 代码;gc的算法发生变更;调度器的调度顺序发生变更;并发编译
1.6支持http/2;增加了并发 map 结果的错误探测
1.7明确语句的终止,为了方便 gc 和 gccgo
1.8struct之间的强转;优化并发 map 检测器,支持 map 迭代的并发写
1.9支持类型别名;新增 sync.Map,math/bits;runtime/pprof 支持添加 label
1.10默认的 GOROOT;优化 gc,降低内存分配的延迟
1.11新增 go modules;严格化 import path
1.12GO111MODULE=on的应用;降低 gc 的分配延迟;更加积极地释放内存
1.13go get 可以更新至 go modules;panic 包含数组下标越界
1.14允许 interface 嵌套;基于信号的异步抢占式调度器;页面分配器更加高效和更少的锁竞争
1.15优化链接器的资源使用量,速度以及代码质量;使用更高内核数的系统,分配的小对象要比之前版本快很多
1.16核心库引入 embed 包,可以将静态文件编译进Go的二进制执行文件中;默认开启 Go modules
1.17改进了编译器,采用了一种新的函数参数和结果传递方式,性能提升了大约 5%
1.18支持泛型;fuzzing(模糊测试)的实现;由于与支持泛型相关的编译器的更改,Go 1.18 的编译速度可能比 Go 1.17 的编译速度慢大约 15%