查看原文
其他

Golang 垃圾回收原理与优化

Go开发大全 2021-01-31

(给Go开发大全加星标)

来源:https://toolchain.dexscript.com/golang-garbage-collection.html

【导读】golang gc是后端服务压测中重要的关注点。本文详细地剖析了golang gc原理,通过实战样例分析来定位GC问题。



要解决的问题: 如何解决Golang垃圾的回收问题


解决方案: 对Golang垃圾回收原理进行剖析,并通过实战样例分析来定位GC问题,从而掌握优化思路。


Golang GC原理剖析


何时触发GC?

  1. 在申请内存的时候,检查当前当前已分配的内存是否大于上次GC后的内存的2倍,若是则触发(主GC线程为当前M)

  2. 监控线程发现上次GC的时间已经超过两分钟了,触发;将一个G任务放到全局G队列中去。(主GC线程为执行这个G任务的M)


垃圾回收流程


详细过程如下图: 


说明:

  • mark 有两个过程。

    • 从 root 开始遍历,标记为灰色。遍历灰色队列。

    • re-scan 全局指针和栈。因为 mark 和用户程序是并行的,所以在过程 1 的时候可能会有新的对象分配,这个时候就需要通过写屏障(write barrier)记录下来。re-scan 再完成检查一下。

  • Stop The World 有两个过程。

    • 第一个是 GC 将要开始的时候,这个时候主要是一些准备工作,比如 enable write barrier。

    • 第二个过程就是上面提到的 re-scan 过程。如果这个时候没有 stw,那么 mark 将无休止。

  • 回收过程目前是可以并行执行执行

void runtime_gc_m(void) { a.start_time = (uint64)(g->m->scalararg[0]) | ((uint64)(g->m->scalararg[1]) << 32); a.eagersweep = g->m->scalararg[2]; gc(&a);}
static void gc(struct gc_args *args) { // 如果前次回收的清理操作未完成,那么先把这事结束了。 while (runtime·sweepone() != -1) runtime·sweep.npausesweep++; // 为回收操作准备相关环境状态。 runtime·mheap.gcspans = runtime·mheap.allspans; runtime·work.spans = runtime·mheap.allspans; runtime·work.nspan = runtime·mheap.nspan; runtime·work.nwait = 0; runtime·work.ndone = 0; runtime·work.nproc = runtime·gcprocs();
//初始化并⾏行标记状态对象 markfor。 //使⽤用 nproc 个线程执⾏行并⾏行标记任务。 //任务总数 = 固定内存段(RootCount) + 当前 goroutine G 的数量。标记函数 markroot。 runtime·parforsetup(runtime·work.markfor, runtime·work.nproc, RootCount + runtime·allglen, nil, false, markroot);
if (runtime·work.nproc > 1) { // 重置结束标记。 runtime·noteclear(&runtime·work.alldone); // 唤醒 nproc - 1 个线程准备执⾏行 markroot 函数,因为当前线程也会参与标记⼯工作。 runtime·helpgc(runtime·work.nproc); }
// 让当前线程也开始执⾏行标记任务。 gchelperstart(); runtime·parfordo(runtime·work.markfor); scanblock(nil, 0, nil); if (runtime·work.nproc > 1) // 休眠,等待标记全部结束。 runtime·notesleep(&runtime·work.alldone); // 收缩 stack 内存。    runtime·shrinkfinish();
// 更新所有 cache 统计参数。 cachestats();
// 计算上⼀一次回收后 heap_alloc ⼤大⼩小。 // 当前 next_gc = heap0 + heap0 * (gcpercent/100) // 那么 heap0 = next_gc / (1 + gcpercent/100) heap0 = mstats.next_gc*100/(runtime·gcpercent+100);
// 计算下⼀一次 next_gc 阈值。 // 这个值只是预估,会随着清理操作⽽而改变。 mstats.next_gc = mstats.heap_alloc + mstats.heap_alloc * runtime·gcpercent / 100; runtime·atomicstore64(&mstats.last_gc, runtime·unixnanotime()); // ⽬目标是 heap.allspans ⾥里的所有 span 对象。 runtime·mheap.gcspans = runtime·mheap.allspans; // GC 使⽤用递增的代龄来表⽰示 span 当前回收状态。 runtime·mheap.sweepgen += 2; runtime·mheap.sweepdone = false; runtime·work.spans = runtime·mheap.allspans; runtime·work.nspan = runtime·mheap.nspan; runtime·sweep.spanidx = 0;
// 并发清理 if (ConcurrentSweep && !args->eagersweep) { // 新建或唤醒⽤用于清理操作的 goroutine。 if (runtime·sweep.g == nil){ runtime·sweep.g = runtime·newproc1(&bgsweepv, nil, 0, 0, gc); }else if (runtime·sweep.parked) { runtime·sweep.parked = false; runtime·ready(runtime·sweep.g); // 唤醒 } } else { // 串⾏行回收 // ⽴立即执⾏行清理操作。while(runtime·sweepone() != -1) runtime·sweep.npausesweep++; } }}

如何确定哪些对象需要回收?


Golang GC 垃圾回收算法采用的是三色标记法,原理如下:

1. 初始所有对象都是白色

2. 从root(包含全局指针和goroutine栈上指针)出发扫描所有的可达对象,将可达对象标记为灰色,放入对处理队列
3. 从队列中取出所有的灰色对象,将这轮灰色对象所引用的对象标记为灰色放入队列,并将自己标记成黑色

4. 重复3,直到灰色队列为空。此时剩余的白色对象即为垃圾,执行回收。


(上面是动图)


三色标记法缺点,golang 怎么来解决这个问题?

  • 缺点:可能程序中的垃圾产生的速度会大于垃圾收集的速度,这样会导致程序中的垃圾越来越多无法被收集掉。

  • 解决思路:go 除了标准的三色收集以外,还有一个辅助回收功能,防止垃圾产生过快手机不过来的情况。这部分代码在 runtime.gcAssistAlloc 中


为什么做写屏障?对于和用户程序并发运行的垃圾回收算法,用户程序会一直修改内存,所以需要记录下来。


Golang GC 优化实战样例


gctrace 跟踪实时的gc:


  • 实验代码:

@/../golang-garbage-collection/pprof_study.go

  • 启动gctrace

GODEBUG=gctrace=1 go run pprof_study.go//GODEBUG=gctrace=1 ./main

效果如下图:

说明:

gc 5 @0.422s 0%: 0.009+18+0.037 ms clock, 0.039+0.15/18/24+0.14 ms cpu, 49->52->49 MB, 52 MB goal, 4 P
gc 5 代表第5次执行。@0.422s 这次GC之前程序已经运行的总时间0% 垃圾回收时间占用的百分比,0.009+18+0.037 ms clock垃圾回收的时间,几个时间依次是STW清扫的时间,并发标记和扫描的时间,STW标记时间0.039+0.15/18/24+0.14 ms cpu 垃圾回收占用cpu时间49->52->49 MB 堆的大小,gc后堆的大小,存活堆的大小52 MB goal 整体堆的大小4 P 使用的处理器数量

gctrace 能初步帮忙了解到gc执行的时间,次数,堆空间大小等宏观参数,但是无法帮助我们排查对于具体是那个方法, 哪个地方导致消耗大量内存以及造成无法回收,那该如何具体定位gc问题呢?


pprof 定位具体gc问题


  • 在你的程序加入以下代码

import ( _ "net/http/pprof" "net/http" "log")go func() {    //ip:port 依据自己情况而定 log.Println(http.ListenAndServe("localhost:8082", nil)) }()
  • 在浏览器中输入 -http://127.0.0.1:8082/debug/pprof/- : 


  • 也可以结合go tool 做更细致的操作

go tool pprof http://127.0.0.1:8082/debug/pprof/heap //查看堆的使用,即内存使用情况go tool pprof http://127.0.0.1:8082/debug/pprof/profile //查看cpu耗时,会详细列出每个函数的耗时go tool pprof  http://127.0.0.1:8082/debug/pprof/goroutine  //当前在运行的goroutine情况以及总数
go tool pprof http://127.0.0.1:8082/debug/pprof/heapFetching profile over HTTP from http://127.0.0.1:8082/debug/pprof/heapSaved profile in /Users/didi/pprof/pprof.alloc_objects.alloc_space.inuse_objects.inuse_space.013.pb.gzType: inuse_spaceTime: Mar 13, 2019 at 10:34am (CST)Entering interactive mode (type "help" for commands, "o" for options)(pprof) top20Showing nodes accounting for 8104.83kB, 100% of 8104.83kB totalShowing top 20 nodes out of 38 flat flat% sum% cum cum% 4978.91kB 61.43% 61.43% 4978.91kB 61.43% main.Add 1028kB 12.68% 74.12% 1028kB 12.68% bufio.NewReaderSize (inline) 1024.20kB 12.64% 86.75% 1024.20kB 12.64% bytes.(*Buffer).String 561.50kB 6.93% 93.68% 561.50kB 6.93% html.init 512.22kB 6.32% 100% 512.22kB 6.32% crypto/x509.parseCertificate 0 0% 100% 1028kB 12.68% bufio.NewReader (inline) 0 0% 100% 1028kB 12.68% bytes.(*Buffer).ReadFrom 0 0% 100% 1028kB 12.68% compress/gzip.(*Reader).Reset 0 0% 100% 1028kB 12.68% compress/gzip.NewReader 0 0% 100% 512.22kB 6.32% crypto/tls.(*Conn).Handshake 0 0% 100% 512.22kB 6.32% crypto/tls.(*Conn).clientHandshake 0 0% 100% 512.22kB 6.32% crypto/tls.(*clientHandshakeState).doFullHandshake 0 0% 100% 512.22kB 6.32% crypto/tls.(*clientHandshakeState).handshake 0 0% 100% 512.22kB 6.32% crypto/x509.(*CertPool).AppendCertsFromPEM 0 0% 100% 512.22kB 6.32% crypto/x509.(*Certificate).Verify 0 0% 100% 512.22kB 6.32% crypto/x509.ParseCertificate 0 0% 100% 512.22kB 6.32% crypto/x509.initSystemRoots 0 0% 100% 512.22kB 6.32% crypto/x509.loadSystemRoots 0 0% 100% 512.22kB 6.32% crypto/x509.systemRootsPool 0 0% 100% 561.50kB 6.93% html/template.init(pprof) web(pprof)

flat代表单个函数的运行时间,而cum则是累加的时间 使用web 就会将在浏览器上展示内存消耗调用栈svg矢量图,图中框颜色越深内存消耗越大


web命令 是生成一张svg图然后再浏览器中展示 效果如下图: 

注意,如果想结合图形查看则需要安装graphviz

- mac: brew install graphviz- centos: yum install graphviz

上图中 main.Add 中的颜色较深,我们用peek 来看下调用关系

(pprof) peek AddShowing nodes accounting for 8104.83kB, 100% of 8104.83kB total----------------------------------------------------------+------------- flat flat% sum% cum cum% calls calls% + context ----------------------------------------------------------+------------- 4978.91kB 100% | main.doTask (inline) 4978.91kB 61.43% 61.43% 4978.91kB 61.43% | main.Add----------------------------------------------------------+-------------

main.doTask 100%调用main.Add方法,并切内存百分比达到61.43% ,接下来我们用list 命令跟下main.doTask 和main.Add源码

(pprof) list main.doTaskTotal: 7.91MBROUTINE ======================== main.doTask in /Users/didi/mygo/src/github.com/agnees/studygo/pprof_study.go0 5.87MB (flat, cum) 74.12% of Total . . 48:func doTask(waitGroup *sync.WaitGroup) { . . 49: . . 50: for { . . 51: . . 52: for i:= 0 ;i<10000;i++{ . 2.86MB 53: Add("hello world ! just do it!") . 1MB 54: len := doHttpRequest() . . 55: . 2MB 56: Add(fmt.Sprintf("response length %v",len)) . . 57: } . . 58: . . 59: time.Sleep(3 * time.Second) . . 60: } . . 61: waitGroup.Done()
(pprof) list AddTotal: 7.91MBROUTINE ======================== main.Add in /Users/didi/mygo/src/github.com/agnees/studygo/pprof_study.go 4.86MB 4.86MB (flat, cum) 61.43% of Total . . 63: . . 64:var datas []string . . 65: . . 66:func Add(str string) string { . . 67: data := []byte(str) 3MB 3MB 68: dataStr := string(data) 1.86MB 1.86MB 69: datas = append(datas, dataStr) . . 70: return dataStr         .          .     71:}
list doHttpRequestTotal: 7.91MBROUTINE ======================== main.doHttpRequest in /Users/didi/mygo/src/github.com/agnees/studygo/pprof_study.go 0 1MB (flat, cum) 12.68% of Total . . 35: resp, err := http.Get("http://www.alibaba.com") . . 36: if err != nil { . . 37: . . 38: } . . 39: . 1MB 40: bytes, e := ioutil.ReadAll(resp.Body) . . 41: if e != nil { . . 42: . . 43: } . . 44: result = append(result,string(bytes)) . . 45: return len(bytes)(pprof) list ReadAll

可以看出 append 方法占用1.86MB string(data)占用了3MB,初步可以看来:

1. slice会频繁扩容
2. byte 数组与string转换导致


doHttpRequest 中ioutil.ReadAll为什么也会内存占用这么多?


  • 结合go build -gcflgs=-m 做逃逸分析

bogon:studygo didi$ go build --gcflags=-m pprof_study.go# command-line-arguments./pprof_study.go:66:6: can inline Add./pprof_study.go:53:7: inlining call to Add./pprof_study.go:56:7: inlining call to Add./pprof_study.go:40:33: resp.Body escapes to heap./pprof_study.go:44:31: string(bytes) escapes to heap./pprof_study.go:34:20: doHttpRequest []string literal does not escape./pprof_study.go:53:7: string(data) escapes to heap./pprof_study.go:56:20: len escapes to heap./pprof_study.go:56:7: string(data) escapes to heap./pprof_study.go:48:24: leaking param: waitGroup./pprof_study.go:53:7: doTask ([]byte)(str) does not escape./pprof_study.go:56:19: doTask ... argument does not escape./pprof_study.go:56:7: doTask ([]byte)(str) does not escape./pprof_study.go:16:11: waitGroup escapes to heap./pprof_study.go:15:2: moved to heap: waitGroup./pprof_study.go:19:13: &waitGroup escapes to heap./pprof_study.go:22:5: func literal escapes to heap./pprof_study.go:22:5: func literal escapes to heap./pprof_study.go:28:11: waitGroup escapes to heap./pprof_study.go:24:34: http.ListenAndServe("localhost:8082", nil) escapes to heap./pprof_study.go:24:14: main.func1 ... argument does not escape./pprof_study.go:68:19: string(data) escapes to heap./pprof_study.go:66:22: Add str does not escape./pprof_study.go:67:16: Add ([]byte)(str) does not escape

结果发现 resp.Body 被分配到堆上,可以初步定为可能是http body没有close掉导致频繁GC


我们再来看下profile中调用栈gc耗时时间 (部分截图)

果不其然确实是resp.Body未被关闭导致频繁GC


总结


高频请求下:

  • 函数尽量不要返回map, slice对象, 这种频繁调用的函数会给gc 带来压力。

  • 小对象要合并。

  • 函数频繁创建的简单的对象,直接返回对象,效果比返回指针效果要好。

  • 避不开,能用sync.Pool 就用,虽然有人说1.10 后不推荐使用sync.Pool,但是压测来看,确实还是用效果,堆累计分配大小能减少一半以上。

  • 避免反复创建slice。

  • http response body 需要被关闭


 - EOF -

推荐阅读(点击标题可打开)

1、Go 性能分析工具 pprof 入门

2、RedHat容器术语实用导论

3、图解 Goroutine 与抢占机制

如果觉得本文不错,欢迎转发推荐给更多人。



分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存