Go语言pprof性能调优实战| 青训营

Go语言高质量编程与性能调优

前言

面向用户

  • 掌握 Go 语言基础
  • 使用 Go 编写过应用

笔者环境

  • macos 10.15.7
  • Golang 1.18
  • GoLand 2022.01

读完本文可以获得

  • 如何编写更简洁清晰的代码,控制流要简洁,减少嵌套,处理错误要合理。

  • 常用 GO 语言程序优化手段,使用预分配内存,减少动态扩容带来的影响 。

  • 熟悉 GO 程序性能分析工具,使用pprof分析CPU、内存、阻塞、锁争用等情况。

一、高质量编程与编码规范

1.1 简介

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

  • 各种边界条件考虑完备
  • 异常情况处理,稳定性保证
  • 易读易维护

1.2 编码规范

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

1.2.1 代码格式

gofmt是 Go 官方提供的工具,推荐使用 gofmt 自动格式化代码为官方统一风格

  1. 使用 tab 缩进
  2. 大括号单独一行
  3. 运算符空格分隔
  4. 关键字后有空格
  5. 点号和括号紧邻
  6. 操作符对称格式化
  7. 参数左对齐或单行展开
  8. 代码间隔空行合理
  9. 变量函数名大小写区分导出
  10. 代码行简短灵活

使用快捷键command +option+L自动格式化代码。

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

1.2.2 注释

注释应该做哪些?
  • 解释代码作用

    适合注释在公共符号(变量、常量、函数以及结构)

    Screen Shot 2023-07-29 at 4.22.34 PM.png

    Screen Shot 2023-07-29 at 4.26.06 PM.png

  • 解释代码实现的原因

    解释代码的外部因素和提供额外上下文信息

    Screen Shot 2023-07-29 at 4.28.28 PM.png

  • 介绍代码什么情况下会出错

    适合解释代码的限制条件

    Screen Shot 2023-07-29 at 4.29.59 PM.png

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

1.2.3 命名规范

变量variable
  1. 简洁优于冗长
usrAge //好过 userAge 
  1. 缩略词全大写,除非不导出则小写
// 导出的缩写词
XMLReq 




// 不导出的缩写词  
xmlReq
  1. 全局变量需要更明确的上下文
// 局部变量
count 




// 全局变量
userCount 




// 用户缓存数量
userCacheCount 


// 文章阅读总数
articleReadTotal

函数function
  1. 函数名不携带包名上下文信息
package foo











import "fmt"




// 正确 - 对包内函数使用简短命名
func PrintUser(name string) {
  fmt.Println("User:", name) 
}


// 错误 - 不要重复包前缀
func foo.PrintUser(name string) {
  fmt.Println("User:", name)
}




func main() {
  // 正确 - 直接调用 PrintUser
  PrintUser("张三")
  
  // 错误 - 不要加包前缀 
  foo.PrintUser("李四") 
}

  1. 函数名保持简短
func getUser() // 而不是 getUserProfile()
  1. 返回包名相关类型可省略类型信息
// package foo











func Foo() Foo // 可省略为 func Foo()
  1. 返回非包名相关类型则加上类型信息
// package foo 











func GetId() int64 // 加上返回类型信息int64
包package
  • 只有小写字母组成。
  • 简短并包含一定的上下文信息。例如 schema、task等
  • 不要与标准库同名
  • 使用单数而不是复数
总结
  • 核心目标是降低阅读理解代码的成本
  • 重点考虑上下文信息,设计简洁清晰的名称

1.2.4 控制流程

避免嵌套
// 错误示例
func process(items []Item) {
  for _, item := range items {
    if item.IsValid() {
      if item.InStock() {
        if item.NeedProcess() {
          // 处理逻辑
        }
      }
    }
  }
}



// 改进示例
func process(items []Item) {

  // 过滤无效项
  validItems := filterValid(items)  


  // 过滤库存不足项
  inStockItems := filterInStock(validItems)

  // 过滤不需要处理项
  needProcessItems := filterNeedProcess(inStockItems)

  // 处理
  for _, item := range needProcessItems {
    // 处理逻辑
  }

}

// 工具函数
func filterValid(items []Item) []Item {
  // 过滤代码
}

// 其他过滤函数

主要改进:

  1. 提取出独立函数处理每个条件判断
  2. 主流程只保留一个for循环
  3. 避免多层if嵌套
保持正常代码路径为最小缩进

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

func process(data []byte) error {











  if len(data) == 0 {
    return errors.New("empty data")
  }




  if err := validate(data); err != nil {
    return err 
  }



  // 正常代码路径
  result := doSomething(data)


  if err := moreValidate(result); err != nil {
    return err
  }


  return nil
}

在这个例子中

  • 先处理错误情况:空数据和验证失败

  • 通过提前返回,避免增加缩进

  • 主路径只有一行正常逻辑

  • 后续再处理额外错误场景

相比之下,嵌套多个if会增加缩进层级。

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

1.2.5 错误和异常处理

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

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

    if len(data) == 0 {
      return errors.New("data is empty") 
    }
    
    
    
    
    
  • 需要格式化的错误信息,使用 fmt.Errorf

    err := fmt.Errorf("data %v is invalid", data)
    return err
    
错误的wrap和unwrap
  • wrap实际上是提供一个error嵌套另一个error的能力,从而生成一个error的跟踪链。wrap的目的是提供错误追踪链,把原错误保留下来。
  • 在fmt.Errorf可以通过%w关键字来将一个错误关联至错误链中
err1 := errors.New("origin error")











err2 := fmt.Errorf("read failed: %w", err1)

这里err2把err1包装起来,err1成为err2的原错误cause。

后续可以通过errors.Is等方式来检测和访问原错误

errors.Is(err2, err1) // true











errors.Unwrap(err2) // 返回 err1

所以错误wrap可以保留下层错误信息,方便调试分析。

错误判定- error.Is
  • 使用error.Is来判定错误链中是否包含特定类型错误
  • 不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的类型错误
err1 := fmt.Errorf("file not found")











err2 := fmt.Errorf("read file failed: %w", err1)




if errors.Is(err2, syscall.ENOENT) {
  // 文件未找到错误
}



if errors.Is(err2, err1) {
  // 读取文件失败并且文件不存在
}


这里通过errors.Is检测错误

  • 可以检查整个错误链
  • 不仅限于直接比较错误对象

与之相比,使用==判断就只能对比对象是否完全一致

if err2 == err1 {
  // 仅检查err1对象,如果是wrapped则无法匹配
}




错误判定- error.As

error.As用于在错误链上获取特定种类型的错误

err := fmt.Errorf("read file error: %w", fs.PathError{Path: "filename"})











// 1. errors.Is 检查错误链中是否包含某类型错误 
if errors.Is(err, fs.PathError) {
  fmt.Println("Is path error") 
}

// 2. errors.As 获取特定错误实例
var pathErr fs.PathError 
if errors.As(err, &pathErr) {
  fmt.Println("Filename:", pathErr.Path)  
}

errors.Is仅检查错误链中是否存在fs.PathError这种类型的错误。

errors.As在errors.Is的基础上,还会把该类型的错误实例保存到路径错误变量中,这样就可以获取到详细的错误信息。

总结一下主要区别:

  • errors.Is 检查类型,返回bool
  • errors.As 检查类型,返回第一个匹配的实例指针
error.As有多个匹配

如果错误链中存在多个相同类型的错误,那么如何使用errors.As获取特定的错误实例呢?

对于这种场景,我们可以通过errors.As逐个遍历错误链来获取所有指定类型的错误实例。基本方法如下

var err1, err2 MyError
for err := someError; errors.As(err, &err1); err = errors.Unwrap(err) {
  // 处理 err1实例
  // ...

  if errors.As(err, &err2) {
    // 处理 err2实例
  } 
}

主要步骤

  1. 定义需要获取的错误实例变量err1、err2

  2. 通过errors.As逐层获取实例,并解包到下层错误

  3. 利用返回值判断是否获取实例成功

  4. 通过errors.Unwrap访问下层错误链继续获取

通过这种逐层遍历的方式,可以获取错误链中所有的指定类型的错误实例。

panic
  • 正常业务代码中应避免使用panic,这样可以避免程序崩溃。

  • 调用含有panic的函数需要用recover处理,否则会造成崩溃。

  • 如果有可处理的问题应该用error而不是panic。

panic更适合不可恢复的错误

if err != nil {

  panic("config file missing") 
}




但对于可处理的问题,应使用error

if err != nil {

  return err
}




panic会中断程序流程,应仅在严重错误时使用。error可以优雅地处理问题。

综上, 对可处理问题使用error,确保程序健壮性。

recover

recover()通过在defer中调用,用于捕获panic并恢复正常执行

  • recover只能在defer函数中使用

    当有panic发生时,可以通过recover来捕获panic,从而避免程序崩溃。

    func f() {
    
    
      recover() // 无效
    
    
    
    
      defer func() {
        recover() // 有效
      }()
    
    }
    
    
    
  • 嵌套recover无法捕获上层panic

    func f() {
    
    
      defer func() {
        func() {
          recover() // 无效
        }()
      }()
    
    }
    
    
    
  • 只对当前goroutine生效

    func f() {
    
    
      go func() {
        recover() // 对其他goroutine无效
      }()
    }
    
    
    
    
    
  • defer遵循后进先出

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

二、性能优化建议

2.1 简介

  • 性能调优的前提是满足正确可靠、简洁清晰等质量因素
  • 性能调优是综合评估,有时候时间效率和空间效率可能对立

2.2 Benchmark

  • 性能表现需要通过实际的数据来衡量和对比,而不是主观猜测。

  • Go语言通过testing包中的Benchmark功能支持编写基准测试用例。

  • 可以跑多次测试来计算平均时间和标准差。

  • 支持设置循环次数,使基准测试更稳定可靠。

  • 可以方便地比较优化前后的性能差异。

一个基本的基准测试样例

  1. 基准测试用例文件名以_test.go结尾

  2. 基准测试函数名以Benchmark开头

  3. 使用*testing.B作为参数

  4. 在循环体内调用需要测试的代码

  5. 运行go test -bench来执行

func BenchmarkFoo(b *testing.B) {
  for i := 0; i < b.N; i++ {


    // 测试目标代码
  }


}




只需要运行go test -bench,即可自动执行基准测试。

所以Go语言内置的benchmark测试可以非常方便地帮助我们进行代码性能测试。

基准测试结果结果说明

例如运行

go test -benchmem

其中在默认的基准测试中,并没有开启测量内存分配次数的选项,需要加上-benchmem参数开启。

输出如下

BenchmarkFoo-8    	2000000000	         0.46 ns/op	         0 B/op	         0 allocs/op

表示:

  • BenchmarkFoo:基准测试的名称
  • 8:GOMAXPROCS的值,机器的核心数,也是并发度
  • 2000000000:执行的总次数
  • 0.46 ns/op:每次操作的平均耗时
  • B/op:每次操作分配的内存字节
  • allocs/op:每次操作的内存分配次数

其中关键指标是ns/op,表示每次执行测试函数的平均时间。

其他说明

  • ns/op值越低表示执行越快
  • 高并发时需要足够大的运行次数才能稳定结果
  • 可以对比优化前后的结果

综上,ns/op是最关键的指,反映每个操作的执行耗时。执行的总次数说明了基准测试的稳定性。

所以测试时需要确保运行次数足够大,结果才具有参考价值。

2.3 Slice 预分配内存

2.3.1 slice扩容

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

  • 数组指针
  • 片段的长度
  • 片段的容量
type slice struct{
  array unsafe.Pointer
  len int
  cap int
}




Go语言中的slice在追加数据时,如果超过当前的容量,会触发扩容机制,主要过程是

Screen Shot 2023-07-29 at 7.19.34 PM.png

  1. 根据新的长度计算新的容量,通常为旧容量的2倍
  2. 用新的容量分配一块新的底层数组
  3. 将原来的数据拷贝到新的数组
  4. slice指向这个新数组,长度和容量更新(之前的底层数组还在,但不再被该slice使用,后面会被自动GC)

例如

arr := [4]int{1, 2, 3, 4} 
slice := arr[:2] // len=2, cap=4




newSlice := append(slice, 5) 

这里append导致扩容,具体过程是

  1. 计算新slice容量,比如新cap=8
  2. 新分配一个array,长度为新cap
  3. 拷贝原数据到新array
  4. 新slice引用新array

扩容需要数据拷贝,会带来性能损耗。

如果知道大概需要的容量,可以通过make(slice, len, cap)一次性分配足够大的容量,来避免多次扩容。

2.3.2 底层数组未释放的危害

从已有slice创建新slice,会共享底层数组,不会新分配内存。

如果原slice很大,即使新slice很小,大数组也不会被回收。

这很容易导致内存占用问题。

bigSlice := make([]byte, 1<<20)











smallSlice := bigSlice[:1<<10] 

这里smallSlice引用bigSlice底层1MB数组的前1024B。

这个1MB的大数组不会被回收,造成浪费。

解决方法是

  1. 及时归还不再需要的大slice
  2. 使用copy()拷贝slice数据
  3. 显示设置slice的容量(cap),会为这个新slice单独分配一块底层数组,而不是共享原数组。

2.3.3 指定cap VS 省略cap

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

下面使用benchmark比较省略和指定cap的性能。

func BenchmarkNoCap(b *testing.B) {

  for i := 0; i < b.N; i++ {


    s := make([]int, 1024) 
  }


}








func BenchmarkWithCap(b *testing.B) {

  for i := 0; i < b.N; i++ {

    s := make([]int, 1024, 1024)
  }  
}


测试结果

$ go test -benchmem -bench .
BenchmarkNoCap-8   	10000000	       153 ns/op	      64 B/op	       1 allocs/op
BenchmarkWithCap-8	        300000000	       5.33 ns/op	      0 B/op	       0 allocs/op

可以看到提前设置cap可以大幅提高性能,减少内存分配次数。

这个示例证明

  • 针对slice预分配内存非常重要
  • benchmark可以量化这种优化带来的收益

为什么给slice预分配容量可以带来这么大的性能提升

  1. 默认slice会按需增长容量,需要进行拷贝和分配内存。
  2. 而预分配容量可以避免多次增长和拷贝开销。
  3. 内存分配和GC压力也会减小。
  4. 初期分配容量的时候可以直接获取一大块内存。

通过预分配容量,可以减少slice扩容时的复制开销,也可以有效利用底层内存分配,这是性能提升的主要原因。

2.4 map 预分配内存

根据map预分配容量的原则,用benchmark比较省略和指定cap的性能。

func BenchmarkNoCap(b *testing.B) {

  for i := 0; i < b.N; i++ {


    m := make(map[int]int)
  }


}








func BenchmarkWithCap(b *testing.B) {

  for i := 0; i < b.N; i++ {

    m := make(map[int]int, 10000)
  }
}


测试结果:

BenchmarkNoCap-8   	 3000000	       449 ns/op	     192 B/op	       1 allocs/op
BenchmarkWithCap-8	         500000000	       3.59 ns/op	     0 B/op	       0 allocs/op

可以看出提前分配容量可以显著提高速度,减少内存分配。

所以,与slice类似。map也需要尽量预分配容量。

这可以有效避免map动态增长带来的重新hash开销。

2.5 空结构体

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

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

    • 节省资源

      根据使用空结构体可以优化内存的原则,编写一个benchmark进行比较

      type Empty struct{}
      
      
      
      
      
      
      
      
      
      
      
      func BenchmarkWithData(b *testing.B) {
        data := struct{
          a int
          b string
          c float64
        }{}
        
        for i := 0; i < b.N; i++ {
          _ = data
        }
      }
      
      
      
      
      func BenchmarkEmpty(b *testing.B) {
        empty := Empty{}
        
        for i := 0; i < b.N; i++ {
          _ = empty 
        }
      }
      
      

      测试结果:

      BenchmarkWithData-8   	500000000	        3.46 ns/op	       0 B/op	       0 allocs/op
      BenchmarkEmpty-8      	1000000000	        0.28 ns/op	       0 B/op	       0 allocs/op
      

      可以看出,空结构体可以优化出较低的内存分配和更快速度。

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

      实现Set,可以考虑用map来替代,只用到map的键,如 mySet:=make(map[int]{})

      这里是一个使用map实现Set的示例

      type IntSet struct {
        data map[int]struct{} 
      }
      
      
      
      
      
      
      
      
      func NewIntSet() *IntSet {
        return &IntSet{
           data: make(map[int]struct{}),
        }
      }
      
      
      
      func (set *IntSet) Add(x int) {
        set.data[x] = struct{}{}
      } 
      
      
      
      func (set *IntSet) Contains(x int) bool {
        _, ok := set.data[x]
        return ok
      }
      
      
      func main() {
        s := NewIntSet()
        s.Add(1)
        s.Add(2)
      
        fmt.Println(s.Contains(1)) // true
        fmt.Println(s.Contains(3)) // false
      }
      

      这里我们只使用map的key来保存元素,value使用空结构体。

      相比使用切片,map可以快速判断元素是否存在。

2.6 atomic包

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

使用atomic进行计数比较非阻塞计数,编写benchmark进行比较

var i int64











func BenchmarkMutex(b *testing.B) {
  var m sync.Mutex
  for n := 0; n < b.N; n++ {
    m.Lock()
    i++
    m.Unlock()
  }

}

func BenchmarkAtomic(b *testing.B) {
  var a atomic.Int64
  for n := 0; n < b.N; n++ {
    atomic.AddInt64(&a, 1)
  }

}

比较结果:

BenchmarkMutex-8   	  5000000	         324 ns/op
BenchmarkAtomic-8  	  10000000	         184 ns/op

可以看出atomic的效率更高,因为

  • mutex需要线程在获取锁前进行等待和调度,这会降低性能。
  • 而atomic通过硬件级别的原子指令实现,可以直接修改值,无需等待。
  • atomic只需要一个CPU指令,mutex需要线程调度和上下文切换。
  • atomic还可以防止被优化和重排,mutex无法保证这一点。
  • 所以atomic避免了mutex的锁调度和上下文切换开销。

atomic提供了无锁的原子操作,对于高并发场景很有用。它可以规避复杂的同步问题,使代码更简洁。

2.7 小结

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

三、性能优化工具

3.1 性能调优原则

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

3.2 pprof

pprof是Go运行时提供的可视化性能分析工具,可以用来分析程序的CPU、内存等使用情况。

Screen Shot 2023-07-30 at 3.10.49 PM.png
主要功能有

  • CPU分析:找出程序占用CPU时间最多的部分

  • 内存分析:检测内存异常分配情况

  • 阻塞分析:识别造成阻塞的调用

  • 竞争检测:检测资源冲突情况

使用方式

  • 添加import _ “net/http/pprof”

  • 通过web接口访问/debug/pprof数据

  • 使用go tool pprof分析数据

    go tool pprof http://host:port/debug/pprof/heap # 获取最近的堆内存分析报告
    

3.3 搭建 pprof实践项目

3.3.1 前置准备

  1. clone github.com/wolfogre/go… ,该项目提前埋入一些炸弹代码,会产生可观测的性能问题
  2. 该项目运行后,会占用1个CPU核心和超过1GB的内存

Screen Shot 2023-07-30 at 3.29.32 PM.png

3.3.2 编译运行项目

进入 /debug/pprof/,在该浏览器查看各项指标

Screen Shot 2023-07-30 at 3.34.09 PM.png

3.4 pprof 排查实战

3.4.1 CPU

1. 查看资源占用

打开资源管理器查看测试项目的CPU占用

Screen Shot 2023-07-30 at 3.40.05 PM.png

2. 使用go tool pprof获取最近10秒的profiling数据
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10

默认情况下,pprof接口提供的分析结果是从程序启动一直累积汇总的 profiling数据。

但在大多数情况下,我们只需要最近一小段时间的分析,以方便查找突发的问题。

所以通过seconds参数可以限定只分析最近指定秒数的数据,而不是全部。

下面是该命令执行后返回的最近10秒内CPU的使用情况

Screen Shot 2023-07-30 at 3.43.19 PM.png

3.使用TopN 命令,查看占用资源最多的前N个函数

Screen Shot 2023-07-30 at 3.54.22 PM.png

参数含义

  • flat: 当前函数自身占用CPU的时间

  • flat%:当前函数占总CPU时间的百分比

  • sum%: 前若干函数的flat%之和

  • cum:当前函数及其调用链占用CPU总时间

  • cum%:当前函数及其调用链占总CPU时间的百分比

  • 当flat==cum时,表示该函数中没有调用其他函数

  • 当flat==0时,表示该函数里只有其他函数的调用

比较重要的是flat%和cum%。它们从不同层面反应函数的占用CPU时间。

因此,上图显示github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat该函数占用较大的CPU时间,因此我们要进行代码定位。

4. 使用命令list Regex,根据指定的正则表达式查找代码行
  1. 在pprof提示符下输入list 函数名 即可查看该函数实现
list Eat

Screen Shot 2023-07-30 at 4.11.17 PM.png

我们看到有一个空的for循环占用了4秒左右的CPU时间

    4.04s      4.12s     24:	for i := 0; i < loop; i++ {
         .          .     25:		// do nothing
         .          .     26:	}




  1. 可以添加行号范围来只查看部分实现
list Eat:1,10 

只会输出Eat函数行1-10的代码。

  1. 可以通过 Regex 来过滤函数名
list Eat.*

它会输出所有函数名匹配Eat.*正则表达式的函数实现代码,包括

  • EatApple
  • EatBanana
  • EatGrape
  • 等所有以Eat开头的函数
  1. list默认输出前10行,可以通过 -len=n 参数调整行数。

    list Eat -len=20
    

    这里通过 -len=20 将Eat函数的输出代码行数指定为20行

5. 使用web命令,查看可视化调用图

Screen Shot 2023-07-30 at 4.35.27 PM.png

该错误信息是因为go tool pprof 在使用web命令打开图形界面时,会使用Graphviz来生成调用图,而因为未安装Graphviz软件,将导致的无法执行dot命令。

通过Homebrew安装

brew install graphviz

6. 注释Eat函数的for循代码

通过list命令,我们获取了Eat函数的代码(默认前10行),在输出中,我们可以获取到Eat函数的的文件路径

 github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat in /Users/spaceqi/Desktop/ByteDanceCamp/code/ajust/go-pprof-practice-master/animal/felidae/tiger/tiger.go

Screen Shot 2023-07-30 at 4.47.03 PM.png

我们将for循环代码进行注释,然后再运行,再查看资源管理器

Screen Shot 2023-07-30 at 4.48.35 PM.png

我们发现CPU占用已经降低到了0.4%。

3.4.2 Head-堆内存

1. 查看堆内存

Screen Shot 2023-07-30 at 4.49.44 PM.png

我们发现内存占用高达7个GB,接着我们使用pprof帮助我们查看内存方面的问题

2. 通过可视化调用图查看内存占用情况

在控制台输入下列命令

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


打开浏览器进入localhost:8080/ui/

Screen Shot 2023-08-04 at 3.04.56 PM.png

如果提示你Could not execute dot; may need to install graphviz.是因为go tool pprof 在使用web命令打开图形界面时,会使用Graphviz来生成调用图,而因为未安装Graphviz软件,将导致的无法执行dot命令。

通过Homebrew安装

brew install graphviz

3. 进入Top页面,查看占用资源最多的函数

Screen Shot 2023-08-04 at 3.06.52 PM.png

点击VIEW->Top,与排查CPU的占用时的TOPN命令类似

Screen Shot 2023-08-04 at 3.07.36 PM.png

从图中我们发现

  • Steal函数自身占用了768MB
  • 剩余3个函数的Flat属性为0,表示在他们的函数中调用了其他的函数,所以整个函数的调用链占用&=768MB,这里推测调用的函数是Steal
4. 进入Source页面,查看指定函数的代码行

点击VIEW->Source,与排查CPU的占用时的Source命令类似

Screen Shot 2023-08-04 at 3.22.51 PM.png

从图中,我们发现Live函数调用了Steal函数,而Steal函数中的for循环在不断的扩展切片,导致内存的增大

for len(m.buffer)*constant.Mi < max { 
m.buffer = append(m.buffer, [constant.Mi]byte{}) 
} 
5. 注释代码

因此,这里将注释for循环内的代码。根据Source页面提示的Steal函数的位置,当前目录下的animal/muridae/mouse.(*Mouse).Steal

Screen Shot 2023-08-04 at 3.28.10 PM.png

重新运行项目,然后再次输入

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


打开浏览器进入localhost:8080/ui/

Screen Shot 2023-08-04 at 3.30.55 PM.png

6. Sample指标说明

pprof工具的Web页面中的sample部分主要包含4个不同维度的内存分析数据

  • inuse_space 当前使用的内存大小(默认使用)

    它展示当前应用实时正在使用的内存大小,可以看到每个函数占用的内存情况。

  • alloc_space 累计分配的内存大小

    它展示从应用启动至当前,各函数累计申请过的内存总大小,包括之前已释放的内存。

  • inuse_objects 当前使用的对象数量

    它统计每个函数当前占用的对象数量,反映内存使用情况。

  • alloc_objects 累计分配的对象数量

    它统计每个函数累计申请过的对象总数,包含之前已释放的对象。

总结一下

  • inuse主要统计当前实时使用的内存/对象
  • alloc主要统计累计分配的内存/对象,包含之前释放过的
  • inuse更直观,alloc可以帮助发现内存使用不当的点。

Screen Shot 2023-08-04 at 3.31.40 PM.png

点击SAMPLE->alloc_space切换

Screen Shot 2023-08-04 at 3.43.30 PM.png

图中显示Dog.Run函数占用了累计分配的内存的91%,这是有问题的,进入VIEW->Source

Screen Shot 2023-08-04 at 3.46.27 PM.png

图中显示,下面的代码,一直在创建一个16MB大小的切片,且不使用,创建后就会被回收,会影响GC性能

_ = make([]byte, 16*constant.Mi)
7. 注释代码

将下列代码注释后

Screen Shot 2023-08-04 at 4.11.35 PM.png

重新运行项目,并重新运行go tool pprof命令,重新拉取最新的分析报告

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


刷新pprof的Web页面

Screen Shot 2023-08-04 at 3.52.44 PM.png

3.4.3 goroutine-协程

处理完堆内存后,我们再次进入 /debug/pprof/,在该浏览器查看各项指标,然后我们看到goroutine有16个(每台机器运行结果不一定相同),goroutine过多也会导致内存泄露。

Screen Shot 2023-08-04 at 4.17.30 PM.png

1. 进入web页面查看可视化调用图

使用pprof工具,获取最新的协程分析

go tool pprof -http=:8080 http://host:port/debug/pprof/goroutine

打开浏览器进入localhost:8080/ui/

Screen Shot 2023-08-04 at 3.57.23 PM.png

从图中发现Wolf.Drink函数间接占用了80%的协程

2. 火山图

点击进入VIEW->Flame Graph

Screen Shot 2023-08-04 at 4.01.01 PM.png

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

从火焰图也能看出,Wolf.Drink函数占用了最长的CPU时间

3. 进入Source页面,查看指定函数的代码行

进入VIEW->Source,搜索wolf

Screen Shot 2023-08-04 at 4.04.35 PM.png

从图中发现,每创建一个协程,将睡眠30秒,这也意味着一个协程的生命周期至少30秒以上,且长时间处于阻塞状态会占用内存和CPU资源

go func() { 
time.Sleep(30 * time.Second) 
}() 
4. 注释代码

将下列代码注释后

Screen Shot 2023-08-04 at 4.12.25 PM.png

重新运行项目,再次进入 /debug/pprof/

Screen Shot 2023-08-04 at 4.18.40 PM.png

3.4.4 mutex-锁

1. 进入web页面查看可视化调用图

在控制台输入下列命令

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

打开浏览器进入localhost:8080/ui/

Screen Shot 2023-08-04 at 4.22.05 PM.png

图中显示Wolf.Howl函数中有一个匿名函数func1,该函数中包含一个延迟1秒,且包含一个锁

2. 进入Source页面,查看指定函数的代码行

进入VIEW->Source,搜索wolf

Screen Shot 2023-08-04 at 4.31.47 PM.png

上图显示,这个延迟发生在wolf结构体的Howl方法中,这个方法创建了一个互斥锁m,并在一个匿名函数func1中释放它。然而,这个匿名函数在释放锁之前,先睡眠了一秒,这就造成了Howl方法无法立即获取锁,从而导致延迟。

3. 注释代码

将下列代码注释,然后重新运行项目

Screen Shot 2023-08-04 at 5.08.41 PM.png

运行pprof工具

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

刷新Web页面

Screen Shot 2023-08-04 at 5.10.23 PM.png

3.4.5 block-阻塞

1. 进入web页面查看可视化调用图

在控制台输入下列命令

go tool pprof -http=:8080 http://localhoust:6060/debug/pprof/block

打开浏览器进入localhost:8080/ui/

Screen Shot 2023-08-04 at 5.11.53 PM.png

显示了一个运行时的chanrecv1函数,它在1秒内占用了100%的CPU时间。该函数是由Cat.Pee函数直接调用的。

2. 进入Source页面,查看指定函数的代码行

进入VIEW->Source

Screen Shot 2023-08-04 at 5.15.25 PM.png

上图显示time.After它会创建一个定时channel,在指定时间后发送一个值,通过 <- channel接收这个定时值,会阻塞等待定时完成,这样就会使Pee()方法至少执行1秒钟。

4. 注释代码

将下列代码注释,然后重新运行项目

Screen Shot 2023-08-04 at 5.28.23 PM.png

运行pprof工具

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

刷新Web页面

Screen Shot 2023-08-04 at 5.37.53 PM.png

小结

Screen Shot 2023-07-30 at 8.45.17 PM.png

四、总结

  1. 高质量编程要点
  • 遵循代码规范,格式化代码,添加注释
  • 命名要规范,表达变量、函数的语义
  • 控制流要简洁,减少嵌套,处理错误要合理
  • 避免panic,使用error处理可恢复问题
  1. 性能调优建议
  • 使用基准测试比较优化前后效果
  • 预分配内存,减少动态扩容带来的影响
  • 使用atomic避免锁的开销
  • 调优要遵循依靠数据,定位主要瓶颈的原则
  1. 性能调优工具
  • pprof可以分析CPU、内存、阻塞、锁争用等情况
  • 基于pprof的数据进行火焰图分析,定位热点函数
  • 注释或优化热点代码,解决性能问题
  1. 调优要在保证程序正确性、清晰性的前提下进行,避免过早或过度优化。

  2. 合理利用Go语言的并发、标准库和工具可以使程序高效简洁。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYXLOOvi' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片