打赏

golang 内存和cpu优化

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) 把计数器设置为nDone() 每次把计数器-1wait() 会阻塞代码的运行,直到计数器地值减为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:  拉起进程对应的命令

参考链接

map内存释放

timer的正确使用

defer的性能分析
go-test
其他

posted @ 2021-08-16 11:48  苍山落暮  阅读(1207)  评论(0编辑  收藏  举报