聊聊兩個Go即將過時的GC優化策略

轉載請聲明出處哦~,本篇文章發佈於luozhiyun的博客:https://www.luozhiyun.com

這篇文章本來是要講 Go Memory Ballast 以及 Go GC Tuner 來調整 GC 的策略,實現原理怎麼樣,效果如何。但是在寫的過程中,發現 Go 1.19版本出了,有個新特性讓這兩個優化終究成爲歷史。

概述

首先我們來簡單的看一下 Go GC中做了什麼事,以及它裏面比較耗時的地方是什麼,我們才能對它進行優化。

首先對於 GC 來說有這麼幾個階段:

  1. sweep termination(清理終止):會觸發 STW ,所有的 P(處理器) 都會進入 safe-point(安全點);
  2. the mark phase(標記階段):恢復程序執行,GC 執行根節點的標記,這包括掃描所有的棧、全局對象以及不在堆中的運行時數據結構;
  3. mark termination(標記終止):觸發 STW,扭轉 GC 狀態,關閉 GC 工作線程等;
  4. the sweep phase(清理階段):恢復程序執行,後臺併發清理所有的內存管理單元;

在這幾個階段中,由於標記階段是要從根節點對堆進行遍歷,對存活的對象進行着色標記,因此標記的時間和目前存活的對象有關,而不是與堆的大小有關,也就是堆上的垃圾對象並不會增加 GC 的標記時間。

並且對於現代操作系統來說釋放內存是一個非常快的操作,所以 Go 的 GC 時間很大程度上是由標記階段決定的,而不是清理階段。

在什麼時候會觸發 GC ?

我在這篇文章 https://www.luozhiyun.com/archives/475 做源碼分析的時候有詳細的講到過,我這裏就簡單的說下。

在 Go 中主要會在三個地方觸發 GC:

1、監控線程 runtime.sysmon 定時調用;

2、手動調用 runtime.GC 函數進行垃圾收集;

3、申請內存時 runtime.mallocgc 會根據堆大小判斷是否調用;

runtime.sysmon

Go 程序在啓動的時候會後臺運行一個線程定時執行 runtime.sysmon 函數,這個函數主要用來檢查死鎖、運行計時器、調度搶佔、以及 GC 等。

它會執行 runtime.gcTrigger中的 test 函數來判斷是否應該進行 GC。由於 GC 可能需要執行時間比較長,所以運行時會在應用程序啓動時在後臺開啓一個用於強制觸發垃圾收集的 Goroutine 執行 forcegchelper 函數。

不過 forcegchelper 函數在一般情況下會一直被 goparkunlock 函數一直掛起,直到 sysmon 觸發GC 校驗通過,纔會將該被掛起的 Goroutine 放轉身到全局調度隊列中等待被調度執行 GC。

runtime.GC

這個比較簡單,會獲取當前的 GC 循環次數,然後設值爲 gcTriggerCycle 模式調用 gcStart 進行循環。

runtime.mallocgc

我在內存分配 https://www.luozhiyun.com/archives/434 這一節講過,對象在進行內存分配的時候會按大小分成微對象、小對象和大對象三類分別執行 tiny malloc、small alloc、large alloc。

Go 的內存分配採用了池化的技術,類似 CPU 這樣的設計,分爲了三級緩存,分別是:每個線程單獨的緩存池mcache、中心緩存 mcentral 、堆頁 mheap 。

tiny malloc、small alloc 都會先去 mcache 中找空閒內存塊進行內存分配,如果 mcache 中分配不到內存,就要到 mcentral 或 mheap 中去申請內存,這個時候就會嘗試觸發 GC;而對於 large alloc 一定會嘗試觸發 GC 因爲它直接在堆頁上分配內存。

如何控制 GC 是否應該被執行?

上面這三個觸發 GC 的地方最終都會調用 gcStart 執行 GC,但是在執行 GC 之前一定會先判斷這次調用是否應該被執行,並不是每次調用都一定會執行 GC, 這個時候就要說一下 runtime.gcTrigger中的 test 函數,這個函數負責校驗本次 GC 是否應該被執行。

runtime.gcTrigger中的 test 函數最終會根據自己的三個策略,判斷是否應該執行GC:

gctest

  • gcTriggerHeap:按堆大小觸發,堆大小和上次 GC 時相比達到一定閾值則觸發;
  • gcTriggerTime:按時間觸發,如果超過 forcegcperiod(默認2分鐘) 時間沒有被 GC,那麼會執行GC;
  • gcTriggerCycle:沒有開啓垃圾收集,則觸發新的循環;

如果是 gcTriggerHeap 策略,那麼會根據 runtime.gcSetTriggerRatio 函數中計算的值來判斷是否要進行 GC,主要是由環境變量 GOGC(默認值爲100 ) 決定閾值是多少。

我們可以大致認爲,觸發 GC 的時機是由上次 GC 時的堆內存大小,和當前堆內存大小值對比的增長率來決定的,這個增長率就是環境變量 GOGC,默認是 100 ,計算公式可以大體理解爲:

hard_target = live_dataset + live_dataset * (GOGC / 100).

假設目前是 100M 內存佔用,那麼根據上面公式,會到 200M 的時候纔會觸發 GC。

觸發 GC 的時機其實並不只是 GOGC 單一變量決定的,在代碼 runtime.gcSetTriggerRatio 裏面我們可以看到它控制的是一個範圍:

func gcSetTriggerRatio(triggerRatio float64) { 
    // gcpercent 由環境變量 GOGC 決定
    if gcpercent >= 0 {
        // 默認是 1
        scalingFactor := float64(gcpercent) / 100 
        // 最大的 maxTriggerRatio 是 0.95
        maxTriggerRatio := 0.95 * scalingFactor
        if triggerRatio > maxTriggerRatio {
            triggerRatio = maxTriggerRatio
        }

        // 最大的 minTriggerRatio 是 0.6
        minTriggerRatio := 0.6 * scalingFactor
        if triggerRatio < minTriggerRatio {
            triggerRatio = minTriggerRatio
        }
    } else if triggerRatio < 0 { 
        triggerRatio = 0
    }
    memstats.triggerRatio = triggerRatio

    trigger := ^uint64(0)
    if gcpercent >= 0 {
        // 當前標記存活的大小乘以1+係數triggerRatio
        trigger = uint64(float64(memstats.heap_marked) * (1 + triggerRatio))
        ...
    }
    memstats.gc_trigger = trigger
    ...
}

具體閾值計算是比較複雜的,從 gcControllerState.endCycle 函數中可以看到執行 GC 的時機還要看以下幾個因素:

  • 當前 CPU 佔用率,GC 標記階段最高不能超過整個應用的 25%;
  • 輔助 GC 標記對象 CPU 佔用率;
  • 目標增長率(預估),該值等於:(下次 GC 完後堆大小 - 堆存活大小)/ 堆存活大小;
  • 堆實際增長率:堆總大小/上次標記完後存活大小-1;
  • 上次GC時觸發的堆增長率大小;

這些綜合因素計算之後得到的一個值就是本次的觸發 GC 堆增長率大小。這些都可以通過 GODEBUG=gctrace=1,gcpacertrace=1 打印出來。

下面我們看看一個具體的例子:

package main

import (
    "fmt"
)

func allocate() {
	_ = make([]byte, 1<<20)
}

func main() {
	fmt.Println("start.")

	fmt.Println("> loop.")
	for {
		allocate()
	}
	fmt.Println("< loop.")
}

使用 gctrace 跟蹤 GC 情況:

[root@localhost gotest]# go build main.go 
[root@localhost gotest]# GODEBUG=gctrace=1 ./main
start.
> loop.
...
gc 1409 @0.706s 14%: 0.009+0.22+0.076 ms clock, 0.15+0.060/0.053/0.033+1.2 ms cpu, 4->6->2 MB, 5 MB goal, 16 P
gc 1410 @0.706s 14%: 0.007+0.26+0.092 ms clock, 0.12+0.050/0.070/0.030+1.4 ms cpu, 4->7->3 MB, 5 MB goal, 16 P
gc 1411 @0.707s 14%: 0.007+0.36+0.059 ms clock, 0.12+0.047/0.092/0.017+0.94 ms cpu, 5->7->2 MB, 6 MB goal, 16 P
...
< loop.

上面展示了 3 次 GC 的情況,下面我們看看:

gc 1410 @0.706s 14%: 0.007+0.26+0.092 ms clock, 0.12+0.050/0.070/0.030+1.4 ms cpu, 4->7->3 MB, 5 MB goal, 16 P

內存
4 MB:標記開始前堆佔用大小 (in-use before the Marking started)
7 MB:標記結束後堆佔用大小 (in-use after the Marking finished)
3 MB:標記完成後存活堆的大小 (marked as live after the Marking finished)
5 MB goal:標記完成後正在使用的堆內存的目標大小 (Collection goal)

可以看到這裏標記結束後堆佔用大小是7 MB,但是給出的目標預估值是 5 MB,你可以看到回收器超過了它設定的目標2 MB,所以它這個目標值也是不準確的。

在 1410 次 GC 中,最後標記完之後堆大小是 3 MB,所以我們可以大致根據 GOGC 推測下次 GC 時堆大小應該不超過 6MB,所以我們可以看看 1411 次GC:

gc 1411 @0.707s 14%: 0.007+0.36+0.059 ms clock, 0.12+0.047/0.092/0.017+0.94 ms cpu, 5->7->2 MB, 6 MB goal, 16 P

內存
5 MB:標記開始前堆佔用大小 (in-use before the Marking started)
7 MB:標記結束後堆佔用大小 (in-use after the Marking finished)
2 MB:標記完成後存活堆的大小 (marked as live after the Marking finished)
6 MB goal:標記完成後正在使用的堆內存的目標大小 (Collection goal)

可以看到在 1411 次GC啓動時堆大小是 5 MB 是在控制範圍之內。

說了這麼多 GC 的機制,那麼有沒有可能 GC 的速度趕不上製造垃圾的速度呢?這就引出了 GC 中的另一種機制:Mark assist。

如果收集器確定它需要減慢分配速度,它將招募應用程序 Goroutines 來協助標記工作。這稱爲 Mark assist 標記輔助。這也就是爲什麼在分配內存的時候還需要判斷要不要執行 mallocgc 進行 GC。

在進行 Mark assist 的時候 Goroutines 會暫停當前的工作,進行輔助標記工作,這會導致當前 Goroutines 工作的任務有一些延遲。

而我們的 GC 也會盡可能的消除 Mark assist ,所以會讓下次的 GC 時間更早一些,也就會讓 GC 更加頻繁的觸發。

我們可以通過 go tool trace 來觀察到 Mark assist 的情況:

image-20220612175510974

Go Memory Ballast

上面我們熟悉了 Go GC 的策略之後,我們來看看 Go Memory Ballast 是怎麼優化 GC 的。下面先看一個例子:

func allocate() {
	_ = make([]byte, 1<<20)
}

func main() {
	ballast := make([]byte, 200*1024*1024) // 200M 
	for i := 0; i < 10; i++ {
		go func() {
			fmt.Println("start.")

			fmt.Println("> loop.")
			for {
				allocate()
			}
			fmt.Println("< loop.")
		}()
	} 
	runtime.KeepAlive(ballast)

我們運行上面的代碼片段,然後我們對資源利用的情況進行簡單的統計:

gcfraction

從上面的結果我們可以直到,GC 的 CPU 利用率大約在 5.5 % 左右。

下面我們把 ballast 內存佔用去掉,看看會是多少:

gcfraction (1)

可以看到在沒有 ballast 的時候 GC 的 CPU佔用在 28% 左右。對 GC 的其他信息感興趣的朋友可以使用 runtime.Memstats 定期抓取 GC 的信息進行打印。

那麼爲什麼在申請了一個空的數組之後 CPU 佔用會低這麼多?首先我們在概述也講到了,GC 會根據環境變量 GOGC 來決定下次 GC 的執行時機,所以如果我們申請了200M的數組,那麼下次 GC 的時候大約會在 400M。由於我們上面的例子中,allocate 函數申請的對象都是臨時對象,在 GC 之後會被再次減少到 200M 左右,所以下次執行 GC 的時機會被再次設置到 400M 。

但是如果沒有 ballast 數組,感興趣的可以自行去測試一下,大約會在 4M 左右的時候會觸發 GC,這無疑對於臨時變量比較多的系統來說會造成相當頻繁的 GC。

總之,通過設置 ballast 數組我們達到了延遲 GC 的效果,但是這種效果只會在臨時變量比較多的系統中有用,對於全局變量多的系統,用處不大。

那麼還有一個問題,在系統中無故申請 200M 這麼大的內存會不會對內存造成浪費?畢竟內存這麼貴。其實不用擔心,只要我們沒有對 ballast 數組進行讀寫,是不會真正用到物理內存佔用的,我們可以用下面的例子看一下:

func main() {
	_ = make([]byte, 100<<20)
	<-time.After(time.Duration(math.MaxInt64))
}

$ ps -eo pmem,comm,pid,maj_flt,min_flt,rss,vsz --sort -rss | numfmt --header --to=iec --field 4-5 | numfmt --header --from-unit=1024 --to=iec --field 6-7 | column -t | egrep "[t]est|[P]ID"
%MEM  COMMAND     PID    MAJFL  MINFL  RSS   VSZ
0.0   test_alloc  31248  0      1.1K   7.4M  821M

可以看到虛擬內存VSZ佔用很大,但是RSS 進程分配的內存大小很小。

func main() {
	ballast := make([]byte, 100<<20)
	for i := 0; i < len(ballast)/2; i++ {
		ballast[i] = byte('A')
	}
	<-time.After(time.Duration(math.MaxInt64))
}

$ ps -eo pmem,comm,pid,maj_flt,min_flt,rss,vsz --sort -rss | numfmt --header --to=iec --field 4-5 | numfmt --header --from-unit=1024 --to=iec --field 6-7 | column -t | egrep "[t]est|[P]ID"
%MEM  COMMAND     PID    MAJFL  MINFL  RSS   VSZ
0.4   test_alloc  31692  0      774    60M   821M

但是如果我們要對它進行寫入操作,RSS 進程分配的內存大小就會變大,剩下的可以自己去驗證。

對於 Go Ballast 的討論其實很早就有人提過 issue ,其實官方只需要加一個最小堆大小的參數即可,但是一直沒有得到實現。相比之下 Java 就好很多GC 的調優參數,InitialHeapSize 就可以設置堆的初始值。

這也導致了很多對性能要求比較高的項目如: tidbcortex 都在代碼里加了一個這樣的空數組實現。

Go GC Tuner

這個方法其實是來自 uber 的這篇文章裏面介紹的。根本問題還是因爲 Go 的 GC 太頻繁了,導致標記佔用了很高的 CPU,但是 Go 也提供了 GOGC 來調整 GC 的時機,那麼有沒有一種辦法可以動態的根據當前的內存調整 GOGC 的值,由此來控制 GC 的頻率呢?

在 Go 中其實提供了 runtime.SetFinalizer 函數,它會在對象被 GC 的時候最後回調一下。在 Go 中 它是這麼定義的:

type any = interface{}

func SetFinalizer(obj any, finalizer any)

obj 一般來說是一個對象的指針;finalizer 是一個函數,它接受單個可以直接用 obj 類型值賦值的參數。也就是說 SetFinalizer 的作用就是將 obj 對象的析構函數設置爲 finalizer,當垃圾收集器發現 obj 不能再直接或間接訪問時,它會清理 obj 並調用 finalizer。

所以我們可以通過它來設置一個鉤子,每次 GC 完之後檢查一下內存情況,然後設置 GOGC 值:

type finalizer struct {
	ref *finalizerRef
}

type finalizerRef struct {
	parent *finalizer
}

func finalizerHandler(f *finalizerRef) {
	// 爲 GOGC 動態設值
	getCurrentPercentAndChangeGOGC()
  // 重新設置回去,否則會被真的清理
	runtime.SetFinalizer(f, finalizerHandler)
}

func NewTuner(options ...OptFunc) *finalizer {
  // 處理傳入的參數
  ...
  
  f := &finalizer{}
	f.ref = &finalizerRef{parent: f}
	runtime.SetFinalizer(f.ref, finalizerHandler)
  // 設置爲 nil,讓 GC 認爲原 f.ref 函數是垃圾,以便觸發 finalizerHandler 調用
	f.ref = nil
  return f
}

上面的這段代碼就利用了 finalizer 特性,在 GC 的時候會調用 getCurrentPercentAndChangeGOGC 重新設置 GOGC 值,由於 finalizer 會延長一次對象的生命週期,所以我們可以在 finalizerHandler 中設置完 GOGC 之後再次調用 SetFinalizer 將對象重新綁定在 Finalizer 上。

這樣構成一個循環,每次 GC 都會有一個 finalizerRef 對象在動態的根據當前內存情況改變 GOGC 值,從而達到調整 GC 次數,節約資源的目的。

上面我們也提到過,GC 基本上根據本次 GC 之後的堆大小來計算下次 GC 的時機:

hard_target = live_dataset + live_dataset * (GOGC / 100).

比如本次 GC 完之後堆大小 live_dataset 是 100 M,對於 GOGC 默認值 100 來說會在堆大小 200M 的時候觸發 GC。

爲了達到最大化利用內存,減少 GC 次數的目的,那麼我們可以將 GOGC 設置爲:

(可使用內存最大百分比 - 當前佔內存百分比)/當前佔內存百分比 * 100

也就是說如果有一臺機器,全部內存都給我們應用使用,應用當前佔用 10%,也就是 100M,那麼:

 GOGC = (100%-10%)/10% * 100 = 900

然後根據上面 hard_target 計算公式可以得知,應用將在堆佔用達到 1G 的時候開始 GC。當然我們生產當中不可能那麼極限,具體的最大可使用內存最大百分比還需要根據當前情況進行調整。

那麼換算成代碼,我們的 getCurrentPercentAndChangeGOGC 就可以這麼寫:

var memoryLimitInPercent float64 = 100

func getCurrentPercentAndChangeGOGC() {
	p, _ := process.NewProcess(int32(os.Getpid()))
  // 獲取當前應用佔用百分比
  memPercent, _ := p.MemoryPercent()
  // 計算 GOGC 值
  newgogc := (memoryLimitInPercent - float64(memPercent)) / memPercent * 100.0
  // 設置 GOGC 值
  debug.SetGCPercent(int(newgogc))
}

上面這段代碼我省去了很多異常處理,默認處理,以及 memoryLimitInPercent 寫成了一個固定值,在真正使用的時候,代碼還需要再完善一下。

寫到這裏,上面 Go Memory Ballast 和 Go GC Tuner 已經達到了我們的優化目的,但是在我即將提稿的時候,曹春暉大佬發了一篇文章中,說到最新的 Go 版本中 1.19 beta1版本中新加了一個 debug.SetMemoryLimit 函數。

Soft Memory Limit

這一個優化來自 issue#48409,在 Go 1.19 版本中被加入,優化原理實際上和上面差不多,通過內置的 debug.SetMemoryLimit 函數我們可以調整觸發 GC 的堆內存目標值,從而減少 GC 次數,降低GC 時 CPU 佔用的目的。

在上面我們也講了,Go 實現了三種策略觸發 GC ,其中一種是 gcTriggerHeap,它會根據堆的大小設定下次執行 GC 的堆目標值。 1.19 版的代碼正是對 gcTriggerHeap 策略做了修改。

通過代碼調用我們可以知道在 gcControllerState。heapGoalInternal 計算 HeapGoal 的時候使用了兩種方式,一種是通過 GOGC 值計算,另一種是通過 memoryLimit 值計算,然後取它們兩個中小的值作爲 HeapGoal。

func (c *gcControllerState) heapGoalInternal() (goal, minTrigger uint64) {
	// Start with the goal calculated for gcPercent.
	goal = c.gcPercentHeapGoal.Load() //通過 GOGC 計算 heapGoal

	// 通過 memoryLimit 計算 heapGoal,並和 goal 比較大小,取小的
	if newGoal := c.memoryLimitHeapGoal(); go119MemoryLimitSupport && newGoal < goal {
		goal = newGoal
	} else {
		...
	}
	return
}

gcPercentHeapGoal 的計算方式如下:

func (c *gcControllerState) commit(isSweepDone bool) {
	...
	gcPercentHeapGoal := ^uint64(0)
	if gcPercent := c.gcPercent.Load(); gcPercent >= 0 {
		// HeapGoal = 存活堆大小 + (存活堆大小+棧大小+全局變量大小)* GOGC/100
		gcPercentHeapGoal = c.heapMarked + (c.heapMarked+atomic.Load64(&c.lastStackScan)+atomic.Load64(&c.globalsScan))*uint64(gcPercent)/100
	}
	c.gcPercentHeapGoal.Store(gcPercentHeapGoal)
	...
}

和我們上面提到的 hard_target 計算差別不大,可以理解爲:

HeapGoal = live_dataset + (live_dataset+棧大小+全局變量大小)* GOGC/100

我們再看看memoryLimitHeapGoal計算:

func (c *gcControllerState) memoryLimitHeapGoal() uint64 { 
	var heapFree, heapAlloc, mappedReady uint64
	heapFree = c.heapFree.load()                          
	heapAlloc = c.totalAlloc.Load() - c.totalFree.Load()  
	mappedReady = c.mappedReady.Load()                   
	
	memoryLimit := uint64(c.memoryLimit.Load())
 
	nonHeapMemory := mappedReady - heapFree - heapAlloc 
	...
	goal := memoryLimit - nonHeapMemory

	...
	return goal
}

上面這段代碼基本上可以理解爲:

goal = memoryLimit - 非堆內存

所以正因爲 Go GC 的觸發是取上面兩者計算結果較小的值,那麼原本我們使用 GOGC 填的太大怕導致 OOM,現在我們可以加上 memoryLimit 參數限制一下;或者直接 GOGC = off ,然後設置 memoryLimit 參數,通過它來調配我們的 GC。

總結

我們這篇主要通過講解 Go GC 的觸發機制,然後引出利用這個機制可以比較 hack 的方式減少 GC 次數,從而達到減少 GC 消耗。

Go Memory Ballast 主要是通過預設一個大數組,讓 Go 在啓動的時候提升 Go 下次觸發 GC 的堆內存閾值,從而避免在內存夠用,但是應用內臨時變量較多時不斷 GC 所產生的不必要的消耗。

Go GC Tuner 主要時通過 Go 提供的 GC 鉤子,設置 Finalizer 在 GC 完之後通過當前的內存使用情況動態設置 GOGC,從而達到減少 GC 的目的。

Soft Memory Limit 是1.19版本的新特性,通過內置的方式實現了 GC 的控制,通過設置 memoryLimit 控制 GC 內存觸發閾值達到減少 GC 的目的,原理其實和上面兩種方式沒有本質區別,但是由於內置在 GC 環節,可以更精細化的檢查當前的非堆內存佔用情況,從而實現更精準控制。

Reference

https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/

https://github.com/golang/go/issues/23044

https://www.cnblogs.com/457220157-FTD/p/15567442.html

https://github.com/golang/go/issues/42430

https://eng.uber.com/how-we-saved-70k-cores-across-30-mission-critical-services/

https://xargin.com/dynamic-gogc/

https://github.com/cch123/gogctuner

https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/pacing/

https://medium.com/a-journey-with-go/go-finalizers-786df8e17687

https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector

https://xargin.com/the-new-api-for-heap-limit/

https://pkg.go.dev/runtime/debug@master#SetMemoryLimit

https://tip.golang.org/doc/go1.19

https://github.com/golang/go/issues/48409

掃碼_搜索聯合傳播樣式-白色版 1

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章