golang 内存和cpu优化
背景介绍
在压力测试的过程中程序会发生内存和CPU飙升的情况,并且持续一段时间后,虽有所回落,但是内存还是没有及时回收,分析可能存在内存泄露的情况。
问题分析
(1.)在代码中加入性能分析的监控,具体如下:
import (
_ "net/http/pprof" // 引入 pprof 模块
_ "github.com/mkevac/debugcharts" // 可选,图形化插件
)
func main(){
// ...
// 内存分析
go func() {
http.ListenAndServe("0.0.0.0:8090", nil)
}()
// ...
}
(2.) 运行程序,由于程序运行在远端linux服务器,如需在本地查看还需要进行端口映射。当然也可以直接在远端linux服务器上通过命令行方式进行查看,但是追踪代码路径时可能找不到,需要指定代码源路径。
go tool pprof -http 172.0.0.88:8070 http://172.0.0.88:8090/debug/pprof/heap
// 浏览器访问
http://172.0.0.88:8070
(3.)通过jemter进行压力测试
(4.)查看top10的内存占用,分析top10的函数占用,这里可以看到addMap()函数占比较高,可着重分析。
参数说明:
列名 | 含义 |
---|---|
flat | 本函数的执行耗时 |
flat% | flat 占 CPU 总时间的比例。 |
sum% | 前面每一行的 flat 占比总和 |
cum | 累计量。指该函数加上该函数调用的函数总耗时 |
cum% | cum 占 CPU 总时间的比例 |
(5.)停掉jemter的压力测试,等待两分钟后(便于GC进行垃圾回收)查看仍然在占用中的内存。这里可以查询inuse_space和inuse_obj这两个参数。这里也可以通过peek查看具体代码的哪一行占用内存较高。
(6.)既然没有了用户操作,内存还被占用,没有释放,那必然存在问题,进一步查看这一块代码进行分析。
这里分析代码发现,addMap有一个递归操作,在调用该函数结束后,map仍然没有释放,这里需要说明的是go1.14一直存在map内存的问题,go1.17该问题已修复。这里我做了对该函数的性能测试,并打印了内存信息。
// 打印堆栈信息
func printMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v TotalAlloc = %v Just Freed = %v Sys = %v NumGC = %v\n",
m.Alloc/1024, m.TotalAlloc/1024, ((m.TotalAlloc-m.Alloc)-lastTotalFreed)/1024, m.Sys/1024, m.NumGC)
lastTotalFreed = m.TotalAlloc - m.Alloc
}
-------------------------------------------------------------------
参数说明:
Alloc:当前堆上对象占用的内存大小。
TotalAlloc:堆上总共分配出的内存大小。
Sys:程序从操作系统总共申请的内存大小。
NumGC:垃圾回收运行的次数。
// 基准测试
go test -bench=. -benchmem // 进行时间、内存的基准测试
go test -bench=. -run=none -benchmem -memprofile=mem.pprof
go test -bench=. -run=none -blockprofile=block.pprof
go test -bench=. -run=none -benchmem -memprofile=mem.pprof -cpuprofile=cpu.pprof
测试代码
import (
"testing"
)
func BenchmarAddMap(b *testing.B) {
// 运行 addMap 函数 b.N 次
for n := 0; n < b.N; n++ {
addMap()
printMemStats() // 打印内存信息
}
}
// 输出内存和CPU的信息
go test -bench=. -run=none -benchmem -memprofile=mem.pprof -cpuprofile=cpu.pprof -blockprofile=block.pprof
// 使用go tool进行分析
go tool pprof cpu.pprof
top10 -cum // 查看top10占用情况
list xxx // 查看具体某个函数的内存
go tool pprof -http=":8080" cpu.pprof // 使用web界面进行分析
经过对addMap()函数进行性能测试发现,申请的内存一直在增长,总的内存占比也在增长。
(7.)map内存释放
-
如果删除的元素是值类型,如int,float,bool,string以及数组和struct,map的内存不会自动释放
-
如果删除的元素是引用类型,如指针,slice,map,chan等,map的内存会自动释放,但释放的内存是子元素应用类型的内存占用
-
将map设置为nil后,内存被回收,map 不会收缩 “不再使用” 的空间。就算把所有键值删除,它依然保留内存空间以待后用。
综合以上三点结论,我们需要对所有频繁使用map的地方,进行手动释放map内存,即将
map=nil
。slice在用完后,最好也能手动置空
slice= slice[0:0]
,理由是:golang中slice是对数组的引用,底层实现实际上还是数组。对slice一定要谨慎使用append操作。如果cap未变化时,slice是对数组的引用,并且append会修改被引用数组的值。append操作导致cap变化后,会复制被引用的数组,然后切断引用关系。
(8.)修改完map后,继续分析,发现goroutine中wg使用也存在部分问题。
WaitGroup
对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait()
用来控制计数器的数量。Add(n)
把计数器设置为n
,Done()
每次把计数器-1
,wait()
会阻塞代码的运行,直到计数器地值减为0。 使用wg时计数器不能为负值,另外WaitGroup对象不是一个引用类型,在通过函数传值的时候需要使用地址。
// 错误示例:
func testGoroutine() {
wg := sync.WaitGroup{}
for i := 0; i < 10; i++ {
// wg.Add(1) // 正确用法
go func() {
wg.Add(1) // 注意:wg.Add需要放到goroutine外部,才能起到计数的作用
defer wg.Done()
fmt.Println("hello world")
}()
}
wg.Wait()
}
另外这里建议使用goroutine池来实现,防止因为启动过多的goutine而导致内存占用过多,需要控制goroutine数量, 可以使用sync waitGroup
+ 非阻塞channel
实现 代码如下:
package gopool
import "sync"
// goroutine pool
type GoroutinePool struct {
c chan struct{}
wg *sync.WaitGroup
}
// 采用有缓冲channel实现,当channel满的时候阻塞
func NewGoroutinePool(maxSize int) *GoroutinePool {
if maxSize <= 0 {
panic("max size too small")
}
return &GoroutinePool{
c: make(chan struct{}, maxSize),
wg: new(sync.WaitGroup),
}
}
// add
func (g *GoroutinePool) Add(delta int) {
g.wg.Add(delta)
for i := 0; i < delta; i++ {
g.c <- struct{}{}
}
}
// done
func (g *GoroutinePool) Done() {
<-g.c
g.wg.Done()
}
// wait
func (g *GoroutinePool) Wait() {
g.wg.Wait()
}
(9.)goroutine修改完后,再次测试效果又好了很多,再分析一下timer和ticker,毕竟这两个也很容易产生内存泄露,进一步完善一下代码。
sendTimer := time.NewTimer(time.Second)
for {
if !sendTimer.Stop() {
select {
case <-sendTimer.C:
default:
}
}
select {
case <-this.exit:
sendTimer.Stop()
return
case <-sendTimer.C:
// 发送
// doSomething()
sendTimer.Reset(time.Second)
}
}
(10.)尽可能的少用全局变量,因为全局变量只有在程序结束后,内存才能得到释放。尽量使用局部变量(栈上分配),多个局部变量合并一个大的结构体或数组,减少扫描对象的次数,一次回尽可能多的内存。
(11)defer虽好,但是也要适当使用。
当前代码中有许多地方为了打印日志方便,直接使用defer log.Printf("xxx"),建议直接在函数结尾处打印,或者发生错误的地方打印。defer设计之初,主要用于资源释放,锁的释放等场景。
defer的实现机制:编译器通过 runtime.deferproc “注册” 延迟调用,除目标函数地址外,还会复制相关参数(包括 receiver)。在函数返回前,执行 runtime.deferreturn 提取相关信息执行延迟调用。这其中的代价自然不是普通函数调用一条 CALL 指令所能比拟的。
(12)查看某程序内存占用,可以通过pidstat -r -p 13084 1
来查看。
minflt/s: 每秒次缺页错误次数(minor page faults),次缺页错误次数意即虚拟内存地址映射成物理内存地址产生的page fault次数
majflt/s: 每秒主缺页错误次数(major page faults),当虚拟内存地址映射成物理内存地址时,相应的page在swap中,这样的page fault为major page fault,一般在内存使用紧张时产生
VSZ: 该进程使用的虚拟内存(以kB为单位)
RSS: 该进程使用的物理内存(以kB为单位)
%MEM: 该进程使用内存的百分比
Command: 拉起进程对应的命令