Go语言性能调优 | 青训营笔记

264 阅读9分钟

简介

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记。课程主要是讲解了Go语言的性能调优以及里面的一些工具的使用。主要是两部分:高质量编程和性能调优实战



1. 高质量编程

1.1 什么是高质量

编写的代码能够达到正确可靠、简洁清晰。对于业务代码,需要达到以下的需求:

  • 边界考虑完备, 对于一个程序要考虑到一些边界条件的输入
  • 异常情况处理,并且稳定性高
  • 易读易维护

对于代码而言,编程的规则就是以业务为主,后序再进行优化,所以代码在写的时候就要保证了简单性可读性,这对于后面的代码重构和优化都有很大的作用。最后的要素就是团队合作的效率,遵循相关的规范、进行一些测试...这些方法都可以提高合作的效率,后序的问题也不会很多。


1.2 如何编写高质量代码

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

1.2.1 编码规范-代码格式

使用 gofmt 自动格式化代码


1.2.2 编码规范-注释

使用适当的代码注释能够保证后续的开发

  • 注释解释代码作用(这段代码是干什么的,比如函数的作用)
  • 注释解释代码实现原因(为什么需要这么做,有什么好处)
  • 注释解释代码如何做的(怎么做到的,实现过程的方法有什么用)
  • 注释解释代码什么情况会出错(对于抛异常的情况可以说明)

总之就是尽量写注释,能给别人讲清楚代码是干什么的


1.2.3 编程规范-命名规范

变量命令

  • 简洁比冗长好
  • 缩略词全大写,比如ServeHTTP
  • 变量距离使用的地方越远,需要携带更多的信息(全局信息)
//Bad
for index :=0; index < len(s); index++{
}

//Good
for i:= 0; i < len(s); i++{
}

//Good
func(c *Client) send(req *Request, deadline time.Time)

//Bad: deadline替换成t之后里面的一些含义就可能理解不了了
func(c *Client) send(req *Request, t time.Time)

函数命名:

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

比如http包下有一个Serve方法,那么这时候就不要把这个方法命名为ServeHTTP,因为已经是http包下了,所以使用http.Serve方法比http.ServeHTTP更好


包名:

  • 只由小写字母组成,不包含大写字母和下划线
  • 简短且包含一定的上下文信息,比如service,repository
  • 不要与标准库同名,比如 sync 或者 strings
  • 不要使用常用变量作为包名,比如使用 bufio 而不是 buf
  • 使用单数而不是复数
  • 谨慎使用缩写,那些公认的缩写比如fmt比format更好,但是非公认的需要小心使用

1.2.4 编码规范-控制流程

  • 避免嵌套,保持正常的流程
if a == b{
}else{
}

//改成
if a == b{
}

  • 优先处理错误情况,尽早返回或者继续循环来减少嵌套,尽量说判断完所有error的情况
  • 对于一些可以合并在一起判断的条件,需要合并
  • 尽量使用多层if的格式
if a == b || a == c{
}

if d == e || d == f{
}

1.2.5 编码规范-错误和异常处理

简单错误

  • 仅出现一次的错误,其他地方不需要捕获异常

错误的 Wrap 和 UnWrap

错误判定

  • 判定一个错误是否为特定的错误,使用errors.Is
  • 在错误链上获取特定种类的错误,使用errors.As

panic

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

recover

  • recover只能在defer中有效
  • 嵌套无法生效
  • 只能在当前goroutine生效
  • defer的语句是后进先出的(栈)
  • 如果需要更多的上下文信息,可以recover后在log中记录当前的调用栈

1.3 性能优化建议

1.3.1 性能优化建议 - Benchmark

这是go语言提供的一个方法,可以使用这个方法来看自己的一些方法的执行情况

func BenchmarkFib10(b *testing.B){
	for n :=0; n < b.N; n++{
		Fib(10)
	}
}

func Fib(n int) int{
	if n < 2{
		return n
	}
	return Fib(n-1) + Fib(n-2)
}

image.png


1.3.2 性能优化建议 - Slice

Slice预分配内存

  • 尽可能在使用make() 初始化切片的时候提供容量信息
  • append底层就是一个数组,append长度超出了数组长度之后,就会创建一个新的切片返回,我们尽量设置初始值大小,减少在append过程中的切片重新创建
  • 创建一个切片会复制原来切片的底层数组

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

  • 在已有切片基础上创建切片,不会创建新的底层数组
  • 场景
    • 原来切片比较大,代码在原来切片上新建小切片
    • 原来底层数组在内存中引用,得不到释放 可以使用copy代替re-slice
func GetLastBySlice(orgin []int) []int {
	return orgin[len(orgin)-2:]
}

func GetLastByCopy(orgin []int) []int {
	result := make([]int, 2)
	copy(result, orgin[len(orgin)-2:])
	return result
}

func testGetLast(t *testing.T, f func([]int) []int) {
	result := make([][]int, 0)
	for k := 0; k < 100; k++ {
		orgin := generateWithCap(128 * 1024)
		append(result, f(orgin))
		return result
	}
}


1.3.3 性能优化建议 - Map

map预分配内存 data := make(map[int]int, size)

  • 和java一样,map达到一定程度大小就会发生扩容
  • 提前分配好空间可以减少内存拷贝和Rehash的消耗
  • 一般根据实际项目预估要用多大的空间

1.3.4 性能优化建议 - 使用strings.Builder

func Plus(n int, str string) string{
	s := ""
	for i:=0; i < n; i++{
		s += str
	}
	return s
}

//建议使用这种方式
func StrBuilder(n int, str string) string{
	var builder strings.Builder
	for i:=0; i < n; i++{
		builder.WriteString(str)
	}
	return builder.String()
}
  • 使用 + 拼接性能最差,strings.Builder, butes.Buffer 相近,strings.Buffer更快
  • 在java中,使用+拼接在字节码层面最终还是会创建StringBuilder进行拼接,所以效率不如直接使用StringBuilder
  • strings.Builder, bytes.Buffer底层都是[]byte数组
  • 内存扩容策略,不需要每次拼接重新分配内存

image.png


1.3.5 性能优化建议 - 空结构体

使用空结构体节省内存

  • 空结构体 struct{} 示例不占据任何的空间
  • 可作为各种场景下的占位符使用
    • 节省资源
    • 空结构体本身具有很强的语义,即这里不需要任何值,仅作为占位符

一个场景,我们要实现set,可以用map实现,只需要用到key就行。一个开源的实现是: `github.com/deckarep/go…


1.3.6 性能优化建议 - atomic包

底层应该是使用CAS,循环获取,避免锁耗时

  • 锁通过操作系统实现,属于系统调用
  • atomic操作通过硬件实现,效率闭锁高
  • sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
  • 对于非数值操作,可以使用atomic.Value, 能继承一个interface{}
type atomicCounter struct {
	i int32
}

func AtomicAddOne(c *atomicCounter) {
	atomic.AddInt32(&c.i, 1)
}

type mutexCoutner struct {
	i int32
	m sync.Mutex
}

func MutexAddOne(c *mutexCoutner){
	c.m.Lock()
	c.i++
	c.m.Unlock()
}


> 测试运行完成时间: 2022/5/8 21:21:43 <

Running tool: D:\go\golang\bin\go.exe test -benchmem -run=^$ -bench ^BenchmarkTestAdd1$ github.com/Moonlight-Zhao/go-project-example/attention

goos: windows
goarch: amd64
pkg: github.com/Moonlight-Zhao/go-project-example/attention
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkTestAdd1
BenchmarkTestAdd1-8     1000000000             0 B/op          0 allocs/op
PASS
ok      github.com/Moonlight-Zhao/go-project-example/attention  0.147s


> 测试运行完成时间: 2022/5/8 21:23:02 <

Running tool: D:\go\golang\bin\go.exe test -benchmem -run=^$ -bench ^BenchmarkTestAdd2$ github.com/Moonlight-Zhao/go-project-example/attention

goos: windows
goarch: amd64
pkg: github.com/Moonlight-Zhao/go-project-example/attention
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkTestAdd2
BenchmarkTestAdd2-8     1000000000             0 B/op          0 allocs/op
PASS
ok      github.com/Moonlight-Zhao/go-project-example/attention  0.158s



2. 性能调优实战

2.1 性能调优原则

  • 要依靠数据而不是猜测
  • 要定位最大瓶颈而不是细枝末节
  • 不要过早优化(如果后面有新的功能添加就有点难调整)
  • 不要过度优化

2.2 性能分析工具 pprof

  • 可以知道在什么地方耗费了多少的CPU、Memory
  • 基于可视化的性能调优工具

2.2.1 pprof介绍

2.2.2 排查实战

代码来自:github.com/wolfogre/go… 在这个项目里面提供了很多的炸弹,炸弹就是对于CPU损耗比较大的一些操作,比如一些死循环,不断创建对象导致的垃圾回收....都是一些性能上面的问题。这个项目里面也提供了具体的使用方法,在README.md里面提供了一个博客地址用于实战演示:blog.wolfogre.com/posts/go-pp…

首先运行起来:打开http://localhost:6060/debug/pprof/

image.png

可以看到里面的一些指标:allocs...这些代表了内存堆栈的一些指标


2.2.3 CPU占据

我们打开电脑的任务管理器

image.png


打开cmd,输入go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10 ,就可以采集10s之内的一些消耗

image.png


输入top就可以查看详细的信息:

image.png

  • flat: 当前函数本身的执行耗时
  • flat% flat占CPU总时间的比例
  • sum% 上面每一层的flat%的总和
  • cum 指当前函数本身加上调用函数的总耗时
  • cum% cum占CPU总时间的比例

当函数中没有调用其他函数的时候,Flat = Cum。Flat=0 表示函数中只有其他函数的调用


输入list就可以查看具体代码行,可以看到下面的代码中主要占用的大头就是tiger.Eat方法,我们注释掉这个方法就可以解决CPU占用高的问题,这个方法里面循环了10000000000次不断输出,占用CPU本来就高了

image.png


我们也可以输入web指令打开可视化文件:

image.png 同样可以定位到Eat方法中


注释之后再次运行,发现CPU占用下来了,但是内存占用还是没有下来。

image.png


2.2.4 堆内存

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap" 打开视图

image.png


使用 go tool pprof http://localhost:6060/debug/pprof/heap 打开pprof,输入top和list Steal定位

image.png

image.png

可以看到问题出现在Steal函数上面, 这个方法每次调用都追加1M的内存,所以就占堆比较高了

func (m *Mouse) Steal() {
	log.Println(m.Name(), "steal")
	max := constant.Gi
	for len(m.buffer) * constant.Mi < max {
		//追加1M的内存
		m.buffer = append(m.buffer, [constant.Mi]byte{})
	}
}

同样的方法,我们注释掉之后发现堆内存占用情况也下来了

image.png


2.2.5 协程goroutine

image.png


同样的方法,我们可以打开view直观图以及火山图
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine" http://localhost:8080/ui/flamegraph

image.png

image.png

可以直观看到是Drin方法造成goroutine泄露的情况,这个方法每次都会创建一个协程睡眠30秒

func (w *Wolf) Drink() {
	log.Println(w.Name(), "drink")
	for i := 0; i < 10; i++ {
		go func() {
			time.Sleep(30 * time.Second)
		}()
	}
}

同样的处理方法,我们注释掉这个方法

image.png


当然了goroutine还没有变为0,但是这种方法的效果已经出来了,可以继续这样排查,当然不为0的原因可能是主函数或者其他函数创建了协程。如果想要查看具体的方法,可以在View的Source下面查看具体代码:

image.png


2.2.6 mutex锁

同样的方法:go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/mutex"

image.png

可以定位到Wolf中的方法,我们也可以查看具体的方法代码,上面的例子定位到Source

image.png

func (w *Wolf) Howl() {
	log.Println(w.Name(), "howl")

	m := &sync.Mutex{}
	m.Lock()
	go func() {
		time.Sleep(time.Second)
		m.Unlock()
	}()
	m.Lock()
}

2.2.7 阻塞block

image.png

同样定位到具体的方法中:go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/block"

image.png

image.png

当然我们也可以看到一些其他的方法也会阻塞,只不过没有展示出来:

image.png


2.2.8 小结

image.png



最后总结下就是:通过这次课程学会了pprof的基本的使用以及了解了一些调优的案例,以及代码的一些规范,使用的包和方法的建议,对代码的编写更加有好处